From 48a040defbf42cd1329e7bc4ecd61034004a92e9 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Fri, 12 Jun 2026 20:26:37 +0200 Subject: [PATCH 01/13] feat(genspec-tui): interactive TUI that live-renders a spec from source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bubbletea front-end in a separate module (cmd/genspec-tui) that scans a Go package set and renders the Swagger spec live as the source changes: source-tree, spec (JSON/YAML with search) and diagnostics panes, an fsnotify-debounced whole-scope rescan, a scanner-options popup, a read-only navigable file viewer with an opt-in editor, mouse focus/scroll and clipboard copy. Position-backed cross-reference navigation, uniform on `f` across panes: spec node ↔ Go source, source line ↔ spec node, and diagnostic → source. Each is a persistent auto-follow mode — the driver pane keeps focus and the follower mirrors on every cursor move, with both sides highlighting the linked node. Built on a caller-owned SpecIndex (rendered line ↔ JSON pointer) and SourceIndex (pointer ↔ token.Position, nearest-ancestor both ways); the diagnostics pane is navigable and follows the position carried on each grammar.Diagnostic. The spec-side index uses encoding/json/jsontext, so the module REQUIRES GOEXPERIMENT=jsonv2 to build and test. Kept out of the lean library via its own go.mod (replace → ../..); bubbletea deps stay off the library. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Frederic BIDON --- cmd/genspec-tui/go.mod | 53 + cmd/genspec-tui/go.sum | 100 ++ .../internal/ux/diagnostics_render.go | 118 ++ .../internal/ux/diagnostics_render_test.go | 139 ++ .../internal/ux/gadgets/clipboard.go | 86 ++ cmd/genspec-tui/internal/ux/key/bindings.go | 50 + cmd/genspec-tui/internal/ux/model.go | 1126 +++++++++++++++++ .../internal/ux/model_diag_test.go | 105 ++ .../internal/ux/model_follow_test.go | 104 ++ .../internal/ux/panels/diagnostics.go | 62 + .../internal/ux/panels/fileview.go | 224 ++++ .../internal/ux/panels/fileview_test.go | 78 ++ cmd/genspec-tui/internal/ux/panels/spec.go | 213 ++++ .../internal/ux/panels/spec_test.go | 51 + cmd/genspec-tui/internal/ux/panels/tree.go | 301 +++++ cmd/genspec-tui/internal/ux/scan.go | 94 ++ cmd/genspec-tui/internal/ux/sourceindex.go | 114 ++ .../internal/ux/sourceindex_test.go | 130 ++ cmd/genspec-tui/internal/ux/specindex.go | 193 +++ cmd/genspec-tui/internal/ux/specindex_test.go | 148 +++ cmd/genspec-tui/internal/ux/theme/theme.go | 90 ++ cmd/genspec-tui/internal/ux/watcher.go | 98 ++ cmd/genspec-tui/main.go | 70 + 23 files changed, 3747 insertions(+) create mode 100644 cmd/genspec-tui/go.mod create mode 100644 cmd/genspec-tui/go.sum create mode 100644 cmd/genspec-tui/internal/ux/diagnostics_render.go create mode 100644 cmd/genspec-tui/internal/ux/diagnostics_render_test.go create mode 100644 cmd/genspec-tui/internal/ux/gadgets/clipboard.go create mode 100644 cmd/genspec-tui/internal/ux/key/bindings.go create mode 100644 cmd/genspec-tui/internal/ux/model.go create mode 100644 cmd/genspec-tui/internal/ux/model_diag_test.go create mode 100644 cmd/genspec-tui/internal/ux/model_follow_test.go create mode 100644 cmd/genspec-tui/internal/ux/panels/diagnostics.go create mode 100644 cmd/genspec-tui/internal/ux/panels/fileview.go create mode 100644 cmd/genspec-tui/internal/ux/panels/fileview_test.go create mode 100644 cmd/genspec-tui/internal/ux/panels/spec.go create mode 100644 cmd/genspec-tui/internal/ux/panels/spec_test.go create mode 100644 cmd/genspec-tui/internal/ux/panels/tree.go create mode 100644 cmd/genspec-tui/internal/ux/scan.go create mode 100644 cmd/genspec-tui/internal/ux/sourceindex.go create mode 100644 cmd/genspec-tui/internal/ux/sourceindex_test.go create mode 100644 cmd/genspec-tui/internal/ux/specindex.go create mode 100644 cmd/genspec-tui/internal/ux/specindex_test.go create mode 100644 cmd/genspec-tui/internal/ux/theme/theme.go create mode 100644 cmd/genspec-tui/internal/ux/watcher.go create mode 100644 cmd/genspec-tui/main.go diff --git a/cmd/genspec-tui/go.mod b/cmd/genspec-tui/go.mod new file mode 100644 index 00000000..b09a9ada --- /dev/null +++ b/cmd/genspec-tui/go.mod @@ -0,0 +1,53 @@ +module github.com/go-openapi/codescan/cmd/genspec-tui + +go 1.25.0 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/fsnotify/fsnotify v1.10.1 + github.com/go-openapi/codescan v0.34.0 + go.yaml.in/yaml/v3 v3.0.4 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/loads v0.23.3 // indirect + github.com/go-openapi/spec v0.22.4 // indirect + github.com/go-openapi/swag/conv v0.25.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-openapi/swag/jsonutils v0.25.5 // indirect + github.com/go-openapi/swag/loading v0.25.5 // indirect + github.com/go-openapi/swag/mangling v0.26.0 // indirect + github.com/go-openapi/swag/stringutils v0.25.5 // indirect + github.com/go-openapi/swag/typeutils v0.25.5 // indirect + github.com/go-openapi/swag/yamlutils v0.25.5 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.45.0 // indirect +) + +replace github.com/go-openapi/codescan => ../.. diff --git a/cmd/genspec-tui/go.sum b/cmd/genspec-tui/go.sum new file mode 100644 index 00000000..7230b55a --- /dev/null +++ b/cmd/genspec-tui/go.sum @@ -0,0 +1,100 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +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/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= +github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= +github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= +github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= +github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= +github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= +github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= +github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= +github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= +github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= +github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= +github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= +github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= +github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= +github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= +github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.1 h1:NZOrZmIb6PTv5LTFxr5/mKV/FjbUzGE7E6gLz7vFoOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.1/go.mod h1:r7dwsujEHawapMsxA69i+XMGZrQ5tRauhLAjV/sxg3Q= +github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= +github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/cmd/genspec-tui/internal/ux/diagnostics_render.go b/cmd/genspec-tui/internal/ux/diagnostics_render.go new file mode 100644 index 00000000..5d12b86c --- /dev/null +++ b/cmd/genspec-tui/internal/ux/diagnostics_render.go @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" + "github.com/go-openapi/codescan/internal/parsers/grammar" +) + +// renderDiagnostics composes the diagnostics-pane body for one scan outcome and +// reports the 0-based content line of the selected diagnostic (-1 when none). +// +// A hard error from codescan.Run is shown first — it aborts the whole spec, so +// it dwarfs everything else. Soft diagnostics follow a one-line severity tally, +// one per row in source order, colored by severity; the selected row gets the +// whole-line highlight (for diagnostic→source navigation). Paths are trimmed to +// the work dir to keep rows short. An empty, error-free scan shows the rest +// state. The selected line is counted as the body is built, so it stays correct +// even when a diagnostic message spans multiple lines. +func renderDiagnostics(workdir string, scanErr error, diags []grammar.Diagnostic, selected int) (string, int) { + var b strings.Builder + selectedLine := -1 + + if scanErr != nil { + b.WriteString(theme.SevError().Render("scan failed: ") + scanErr.Error()) + if len(diags) == 0 { + return b.String(), -1 + } + b.WriteString("\n\n") + } + + if len(diags) == 0 { + return "(no diagnostics)", -1 + } + + b.WriteString(theme.Status().Render(diagnosticTally(diags))) + for i, d := range diags { + b.WriteString("\n") + row := formatDiagnostic(workdir, d) + if i == selected { + selectedLine = strings.Count(b.String(), "\n") // 0-based line of this row + row = theme.Selected().Render(row) + } + b.WriteString(row) + } + return b.String(), selectedLine +} + +// diagnosticTally summarizes a diagnostic slice as "N diagnostics (E errors, W +// warnings, H hints)", omitting any zero buckets. +func diagnosticTally(diags []grammar.Diagnostic) string { + var e, w, h int + for _, d := range diags { + switch d.Severity { + case grammar.SeverityError: + e++ + case grammar.SeverityWarning: + w++ + default: + h++ + } + } + + var parts []string + for _, p := range []struct { + n int + one string + }{{e, "error"}, {w, "warning"}, {h, "hint"}} { + if p.n > 0 { + parts = append(parts, fmt.Sprintf("%d %s%s", p.n, p.one, plural(p.n))) + } + } + + noun := "diagnostic" + plural(len(diags)) + if len(parts) == 0 { + return fmt.Sprintf("%d %s", len(diags), noun) + } + return fmt.Sprintf("%d %s (%s)", len(diags), noun, strings.Join(parts, ", ")) +} + +// formatDiagnostic renders one diagnostic as "path:line:col severity: message +// [code]", with the severity label colored and the path made relative to +// workdir when it sits inside the scanned tree. +func formatDiagnostic(workdir string, d grammar.Diagnostic) string { + loc := d.Pos.String() // absolute "file:line:col" (or "-" when unknown) + if rel, err := filepath.Rel(workdir, d.Pos.Filename); err == nil && !strings.HasPrefix(rel, "..") { + loc = fmt.Sprintf("%s:%d:%d", rel, d.Pos.Line, d.Pos.Column) + } + sev := severityStyle(d.Severity).Render(d.Severity.String()) + return fmt.Sprintf("%s %s: %s [%s]", loc, sev, d.Message, d.Code) +} + +// severityStyle maps a grammar.Severity to its diagnostics-pane style. +func severityStyle(s grammar.Severity) lipgloss.Style { + switch s { + case grammar.SeverityError: + return theme.SevError() + case grammar.SeverityWarning: + return theme.SevWarn() + default: + return theme.SevHint() + } +} + +// plural returns "s" unless n is exactly 1. +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} diff --git a/cmd/genspec-tui/internal/ux/diagnostics_render_test.go b/cmd/genspec-tui/internal/ux/diagnostics_render_test.go new file mode 100644 index 00000000..75b3da92 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/diagnostics_render_test.go @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "go/token" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/parsers/grammar" +) + +// badMaximumFixture is the minimal diagnostic trigger: a swagger:model whose +// field carries a non-numeric `maximum:`. The parser drops the keyword from the +// spec and emits grammar.CodeInvalidNumber — exactly the soft-diagnostic shape +// the pane is built to surface. Kept inline so the test owns its input and the +// lean TUI module needs no root test-fixture dependency. +const badMaximumFixture = `package diagfixture + +// BadMaximum has an invalid maximum: value. +// +// swagger:model BadMaximum +type BadMaximum struct { + // Count holds an arbitrary count. + // + // maximum: notanumber + Count int ` + "`json:\"count\"`" + ` +} +` + +// TestDoScanCollectsDiagnostics is the end-to-end wiring proof: a malformed +// numeric validation surfaces the parser's CodeInvalidNumber through +// Options.OnDiagnostic into scanResultMsg.diags, without failing the scan +// (diagnostics never abort the build). +func TestDoScanCollectsDiagnostics(t *testing.T) { + dir := writeModule(t, map[string]string{ + "go.mod": "module diagfixture\n\ngo 1.25\n", + "types.go": badMaximumFixture, + }) + + res := doScan(codescan.Options{ + WorkDir: dir, + Packages: []string{"."}, + ScanModels: true, + }) + + if res.err != nil { + t.Fatalf("scan should not hard-fail on soft diagnostics: %v", res.err) + } + if len(res.diags) == 0 { + t.Fatal("expected at least one diagnostic from the malformed fixture") + } + + found := false + for _, d := range res.diags { + if d.Code == grammar.CodeInvalidNumber { + found = true + break + } + } + if !found { + t.Errorf("expected a %s diagnostic; got %v", grammar.CodeInvalidNumber, codes(res.diags)) + } +} + +// TestRenderDiagnostics checks the three render states: clean, hard-error, and +// a soft-diagnostic list with a severity tally and relative paths. +func TestRenderDiagnostics(t *testing.T) { + t.Run("clean", func(t *testing.T) { + if got, _ := renderDiagnostics("/work", nil, nil, 0); got != "(no diagnostics)" { + t.Errorf("clean scan: got %q", got) + } + }) + + t.Run("hard error", func(t *testing.T) { + got, _ := renderDiagnostics("/work", codescan.ErrCodeScan, nil, 0) + if !strings.Contains(got, "scan failed") || !strings.Contains(got, codescan.ErrCodeScan.Error()) { + t.Errorf("hard error not surfaced: %q", got) + } + }) + + t.Run("soft diagnostics", func(t *testing.T) { + diags := []grammar.Diagnostic{ + grammar.Errorf(pos("/work/models/a.go", 12, 3), grammar.CodeInvalidNumber, "bad maximum"), + grammar.Warnf(pos("/work/models/a.go", 20, 5), grammar.CodeAmbiguousEmbed, "ambiguous"), + } + got, _ := renderDiagnostics("/work", nil, diags, 0) + + for _, want := range []string{ + "2 diagnostics (1 error, 1 warning)", + "models/a.go:12:3", // path trimmed to workdir + "bad maximum", + string(grammar.CodeInvalidNumber), + "error", + "warning", + } { + if !strings.Contains(got, want) { + t.Errorf("rendered diagnostics missing %q in:\n%s", want, got) + } + } + if strings.Contains(got, "/work/models") { + t.Errorf("absolute path leaked into rendered diagnostics:\n%s", got) + } + }) +} + +// writeModule materializes files (relative path → content) under a fresh temp +// dir and returns it. codescan scans it as a standalone module (it forces +// GOWORK=off), so no go.sum or workspace entry is needed for a stdlib-only tree. +func writeModule(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for rel, content := range files { + path := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir for %s: %v", rel, err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + return dir +} + +func pos(file string, line, col int) token.Position { + return token.Position{Filename: file, Line: line, Column: col} +} + +func codes(diags []grammar.Diagnostic) []grammar.Code { + out := make([]grammar.Code, 0, len(diags)) + for _, d := range diags { + out = append(out, d.Code) + } + return out +} diff --git a/cmd/genspec-tui/internal/ux/gadgets/clipboard.go b/cmd/genspec-tui/internal/ux/gadgets/clipboard.go new file mode 100644 index 00000000..a3a982ab --- /dev/null +++ b/cmd/genspec-tui/internal/ux/gadgets/clipboard.go @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package gadgets holds small, self-contained TUI helpers. The clipboard +// helper is ported from fredbi/git-janitor: it copies text reliably across +// terminals by trying real clipboard tools first (which report success), then +// falling back to OSC 52 escape sequences (which work over SSH and in modern +// terminals without any external tool), with tmux passthrough wrapping. +package gadgets + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +// CopyToClipboard copies text to the system clipboard. +// +// It tries command-line tools first — they give reliable feedback (OSC 52 is +// fire-and-forget, so we can't tell whether the terminal honored it) — then +// falls back to OSC 52. +func CopyToClipboard(ctx context.Context, text string) error { + if err := clipboardViaTool(ctx, text); err == nil { + return nil + } + + return osc52Copy(text) +} + +// clipboardViaTool tries xclip, xsel, then wl-copy, in order. +func clipboardViaTool(ctx context.Context, text string) error { + tools := []struct { + name string + args []string + }{ + {"xclip", []string{"-selection", "clipboard"}}, + {"xsel", []string{"--clipboard", "--input"}}, + {"wl-copy", nil}, + } + + for _, t := range tools { + path, err := exec.LookPath(t.name) + if err != nil { + continue + } + + cmd := exec.CommandContext(ctx, path, t.args...) + cmd.Stdin = strings.NewReader(text) + + if err := cmd.Run(); err == nil { + return nil + } + } + + return errors.New("no clipboard tool available (tried xclip, xsel, wl-copy)") +} + +// osc52Copy writes an OSC 52 escape sequence to stderr, instructing the +// terminal emulator to copy text to the system clipboard. Works on kitty, +// alacritty, wezterm, iTerm2, Windows Terminal, foot, etc.; not on +// gnome-terminal or some older terminals. Stderr is used so the sequence +// bypasses bubbletea's stdout render buffer. +func osc52Copy(text string) error { + b64 := base64.StdEncoding.EncodeToString([]byte(text)) + + // OSC 52 ; c ; BEL + seq := fmt.Sprintf("\x1b]52;c;%s\x07", b64) + + // Detect tmux and wrap in passthrough DCS. + if isTmux() { + seq = fmt.Sprintf("\x1bPtmux;\x1b%s\x1b\\", seq) + } + + _, err := fmt.Fprint(os.Stderr, seq) + + return err +} + +func isTmux() bool { + return strings.HasPrefix(os.Getenv("TERM_PROGRAM"), "tmux") || + os.Getenv("TMUX") != "" +} diff --git a/cmd/genspec-tui/internal/ux/key/bindings.go b/cmd/genspec-tui/internal/ux/key/bindings.go new file mode 100644 index 00000000..666ffe22 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/key/bindings.go @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package key normalizes tea.KeyMsg values into a small enum of named +// bindings, so the model dispatches on a plain string switch rather than a +// key-binding library. Mirrors the convention in fredbi/git-janitor. +package key + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// Binding is a lowercased key descriptor (e.g. "ctrl+j", "tab", "k"). +type Binding string + +const ( + CtrlC Binding = "ctrl+c" + CtrlQ Binding = "ctrl+q" + CtrlJ Binding = "ctrl+j" + CtrlY Binding = "ctrl+y" + Tab Binding = "tab" + ShiftTab Binding = "shift+tab" + Up Binding = "up" + Down Binding = "down" + Left Binding = "left" + Right Binding = "right" + J Binding = "j" + K Binding = "k" + H Binding = "h" + L Binding = "l" + C Binding = "c" + R Binding = "r" + O Binding = "o" + G Binding = "g" + F Binding = "f" + I Binding = "i" + Space Binding = " " + Esc Binding = "esc" + Enter Binding = "enter" +) + +// MsgBinding normalizes a key message to a Binding. +func MsgBinding(msg tea.KeyMsg) Binding { + return Binding(strings.ToLower(msg.String())) +} + +// Quit reports whether the binding requests application exit. +func (b Binding) Quit() bool { return b == CtrlC || b == CtrlQ } diff --git a/cmd/genspec-tui/internal/ux/model.go b/cmd/genspec-tui/internal/ux/model.go new file mode 100644 index 00000000..0ee86af9 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model.go @@ -0,0 +1,1126 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package ux is the bubbletea front-end for genspec-tui: a single root Model +// composing a header line, three panels (source tree, spec, diagnostics), and +// a status/help line. Structure borrows from fredbi/git-janitor — one root +// model owning panel values, an enum-based key dispatch, mouse focus/scroll, +// and a recalcLayout that distributes the terminal size across panels. +package ux + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/gadgets" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/key" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" + "github.com/go-openapi/codescan/internal/parsers/grammar" +) + +// headerH / statusH are the single-line chrome rows reserved top and bottom. +const ( + headerH = 1 + statusH = 1 +) + +// noticeTTL is how long a transient status notice (e.g. "copied to +// clipboard") stays on the status line before it clears. +const noticeTTL = 2 * time.Second + +// debounceDelay coalesces a burst of file-change events (an editor save often +// fires several) into a single rescan. +const debounceDelay = 300 * time.Millisecond + +// copyResultMsg is delivered after an async clipboard copy completes. +type copyResultMsg struct{ err error } + +// clearNoticeMsg clears the transient status notice. +type clearNoticeMsg struct{} + +// fsEventMsg signals that the watcher saw a relevant source change. +type fsEventMsg struct{} + +// debounceMsg fires after the quiet period; gen guards against stale timers. +type debounceMsg struct{ gen int } + +type pane int + +const ( + paneTree pane = iota + paneSpec + paneDiag + paneCount +) + +// leftMode is what the left pane shows: the source tree or a file's content. +type leftMode int + +const ( + modeBrowse leftMode = iota + modeView +) + +// followMode is the cross-ref auto-follow state: off, or one pane driving the +// other. The driver keeps focus; the follower mirrors on every cursor move +// (syncFollowIfActive runs after each key/scroll). `f` toggles it; any focus +// change or edit exits it. +type followMode int + +const ( + followOff followMode = iota + followSpec // spec drives, the source pane follows + followSource // the source pane drives, the spec follows + followDiag // the diagnostics pane drives, the source pane follows +) + +// optToggle binds an options-popup row to a boolean field of the scan config, +// with a short human description (the field names alone are cryptic). +type optToggle struct { + label string + desc string + ptr *bool +} + +// Model is the root bubbletea model. +type Model struct { + cfg codescan.Options + width, height int + ready bool + focused pane + notice string + + scanning bool + spin spinner.Model + numPaths int + numDefs int + lastElapsed time.Duration + + searching bool + searchInput textinput.Model + + optionsOpen bool + optCursor int + optDirty bool + optToggles []optToggle + + specJSON string + specYAML string + specIndex *SpecIndex // rendered-line ↔ JSON-pointer map for the active format + srcIndex *SourceIndex // JSON-pointer ↔ Go source position (cross-ref linker) + diags []grammar.Diagnostic + scanErr error // hard error from the last codescan.Run, shown in the diag pane + diagCursor int // selected diagnostic, for diagnostic→source navigation + + watch *watcher + watchCh <-chan struct{} + debounceGen int + + // layout regions, recomputed by recalcLayout and reused for hit-testing. + leftW, topH, diagH int + + leftMode leftMode + currentFile string + + follow followMode + followTarget string // human-readable resolved target, for the nav status badge + + tree panels.Tree + fileView panels.FileView + spec panels.Spec + diag panels.Diagnostics +} + +// New builds the root model. workdir/packages/scanModels map directly onto +// codescan.Options; the source tree browses workdir. A file watcher is started +// best-effort — if it can't initialize, live reload is simply unavailable and +// the user falls back to `r` (manual rescan). +func New(workdir string, packages []string, scanModels bool) *Model { + sp := spinner.New() + sp.Spinner = spinner.Dot + + si := textinput.New() + si.Prompt = "/" + si.Placeholder = "search spec" + + m := &Model{ + cfg: codescan.Options{WorkDir: workdir, Packages: packages, ScanModels: scanModels}, + focused: paneTree, + spin: sp, + searchInput: si, + tree: panels.NewTree(workdir), + fileView: panels.NewFileView(), + spec: panels.NewSpec(), + diag: panels.NewDiagnostics(), + } + // Options-popup rows bind to the scan-config booleans (pointers into + // m.cfg stay valid — m is heap-allocated). + m.optToggles = []optToggle{ + {"ScanModels", "also emit definitions for swagger:model types", &m.cfg.ScanModels}, + {"SkipExtensions", "omit x-go-* vendor extensions from the spec", &m.cfg.SkipExtensions}, + {"SetXNullableForPointers", "mark pointer fields as x-nullable: true", &m.cfg.SetXNullableForPointers}, + {"RefAliases", "emit $ref for type aliases instead of expanding them", &m.cfg.RefAliases}, + {"TransparentAliases", "treat aliases as transparent (never define them)", &m.cfg.TransparentAliases}, + {"DescWithRef", "keep a field description beside its $ref (allOf wrap)", &m.cfg.DescWithRef}, + } + if w, err := newWatcher(workdir); err == nil { + m.watch = w + m.watchCh = w.events + } + return m +} + +// Close releases the file watcher. Call after the program exits. +func (m *Model) Close() { + if m.watch != nil { + _ = m.watch.Close() + } +} + +// Init implements tea.Model: kick off the initial whole-scope scan and, if a +// watcher is available, begin listening for source changes. +func (m *Model) Init() tea.Cmd { + cmds := []tea.Cmd{m.startScan()} + if m.watchCh != nil { + cmds = append(cmds, waitForFS(m.watchCh)) + } + return tea.Batch(cmds...) +} + +// startScan marks a scan in flight and returns the scan command, starting the +// spinner only when one isn't already running (avoids stacking tick loops). +func (m *Model) startScan() tea.Cmd { + already := m.scanning + m.scanning = true + scan := runScan(m.cfg) + if already { + return scan + } + return tea.Batch(scan, m.spin.Tick) +} + +// Update implements tea.Model. +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.ready = true + m.recalcLayout() + return m, nil + + case tea.KeyMsg: + model, cmd := m.handleKey(msg) + m.syncFollowIfActive() // re-mirror the follower after a driver move + return model, cmd + + case tea.MouseMsg: + model, cmd := m.handleMouse(msg) + m.syncFollowIfActive() + return model, cmd + + case spinner.TickMsg: + if !m.scanning { + return m, nil + } + var cmd tea.Cmd + m.spin, cmd = m.spin.Update(msg) + return m, cmd + + case scanResultMsg: + m.scanning = false + m.specJSON, m.specYAML = msg.json, msg.yaml + m.numPaths, m.numDefs = msg.paths, msg.defs + m.lastElapsed = msg.elapsed + m.diags = msg.diags + m.scanErr = msg.err + m.diagCursor = clampInt(m.diagCursor, 0, max(len(m.diags)-1, 0)) + m.srcIndex = BuildSourceIndex(msg.provenance) + m.applyScan() + m.syncFollowIfActive() // refresh the follower against the rebuilt spec + return m, nil + + case fsEventMsg: + // A change arrived: start (restart) the debounce window and keep + // listening for the next event. + m.debounceGen++ + return m, tea.Batch(debounceCmd(m.debounceGen), waitForFS(m.watchCh)) + + case debounceMsg: + // Rescan only if no newer change arrived during the quiet period. + if msg.gen == m.debounceGen { + return m, m.startScan() + } + return m, nil + + case copyResultMsg: + if msg.err != nil { + m.notice = "clipboard error: " + msg.err.Error() + } else { + m.notice = "copied to clipboard" + } + return m, clearNoticeAfter(noticeTTL) + + case clearNoticeMsg: + m.notice = "" + return m, nil + } + + return m, m.updateFocused(msg) +} + +func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Modal/input modes capture all keys until dismissed. + if m.optionsOpen { + return m.handleOptionsKey(msg) + } + if m.searching { + return m.handleSearchKey(msg) + } + // The open file pane: the read-only viewer navigates; the editor captures + // input (except a few app keys). + if m.leftMode == modeView && m.focused == paneTree { + if m.fileView.Editing() { + return m.handleEditKey(msg) + } + return m.handleViewerKey(msg) + } + + // Diagnostics pane: select a diagnostic and follow it to source. Unhandled + // keys fall through to the global bindings below. + if m.focused == paneDiag { + if cmd, handled := m.handleDiagNav(msg); handled { + return m, cmd + } + } + + if mdl, cmd, handled := m.handleSearchControl(msg); handled { + return mdl, cmd + } + + switch key.MsgBinding(msg) { + case key.CtrlC, key.CtrlQ: + return m, tea.Quit + case key.Tab: + m.focused = (m.focused + 1) % paneCount + return m, m.syncEditFocus() + case key.ShiftTab: + m.focused = (m.focused + paneCount - 1) % paneCount + return m, m.syncEditFocus() + case key.CtrlJ: + m.spec.SetFormat("JSON") + m.refreshSpec() + return m, nil + case key.CtrlY: + m.spec.SetFormat("YAML") + m.refreshSpec() + return m, nil + case key.R: + return m, m.startScan() + case key.O: + m.exitFollow() + m.optionsOpen = true + m.optDirty = false + return m, nil + case key.Enter: + // Enter on a file (in browse mode) opens it in the editor. + // Dirs fall through to the tree (expand/collapse). + if m.focused == paneTree && m.leftMode == modeBrowse { + if path, isDir, ok := m.tree.Selection(); ok && !isDir { + return m, m.openFile(path) + } + } + return m, m.updateFocused(msg) + case key.G: + // Jump the spec to the first node the selected source file produced + // (position-backed locate). + if path, isDir, ok := m.tree.Selection(); ok && !isDir { + return m, m.locateInSpec(path) + } + return m, nil + case key.F: + // Toggle spec-driven follow mode: as the spec scrolls, the source pane + // mirrors the node at the top of the viewport (spec→source). + if m.focused == paneSpec { + m.toggleFollow(followSpec) + } + return m, nil + case key.C: + return m, m.copyFocused() + case key.Esc: + if m.follow != followOff { + m.exitFollow() + return m, nil + } + m.spec.ClearSearch() + m.spec.ClearHighlight() + return m, nil + } + + return m, m.updateFocused(msg) +} + +// handleSearchControl handles the case-sensitive search keys (`/` opens search, +// `n`/`N` step matches) that MsgBinding would lowercase. Returns handled=false +// for anything else. +func (m *Model) handleSearchControl(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { + switch msg.String() { + case "/": + mdl, cmd := m.enterSearch() + return mdl, cmd, true + case "n": + if _, total := m.spec.MatchInfo(); total > 0 { + m.spec.Step(+1) + return m, nil, true + } + case "N": + if _, total := m.spec.MatchInfo(); total > 0 { + m.spec.Step(-1) + return m, nil, true + } + } + return m, nil, false +} + +// handleDiagNav handles diagnostics-pane selection and follow. Returns +// handled=false for keys it doesn't own, so global bindings still apply. +func (m *Model) handleDiagNav(msg tea.KeyMsg) (tea.Cmd, bool) { + switch key.MsgBinding(msg) { + case key.Up, key.K: + m.moveDiagCursor(-1) + return nil, true + case key.Down, key.J: + m.moveDiagCursor(+1) + return nil, true + case key.F: + // Toggle diagnostics-driven follow mode: as the selection moves, the + // source pane mirrors the selected diagnostic's position. + m.toggleFollow(followDiag) + return nil, true + } + return nil, false +} + +// moveDiagCursor moves the diagnostics selection by delta (clamped) and +// re-renders the pane to highlight and scroll to it. In follow mode the Update +// loop re-mirrors the source pane afterward (syncFollowIfActive). +func (m *Model) moveDiagCursor(delta int) { + if len(m.diags) == 0 { + return + } + m.diagCursor = clampInt(m.diagCursor+delta, 0, len(m.diags)-1) + m.refreshDiagnostics() +} + +// driveDiagToSource mirrors the source follower to the selected diagnostic's +// position, WITHOUT moving focus (the diag pane stays the driver). Returns a +// human-readable target for the status badge; the position rides on the +// diagnostic itself, so no index lookup is needed. +func (m *Model) driveDiagToSource() string { + if len(m.diags) == 0 { + return "(no diagnostics)" + } + d := m.diags[m.diagCursor] + if !d.Pos.IsValid() || d.Pos.Filename == "" { + return "(no source)" + } + if m.currentFile != d.Pos.Filename { + m.loadFileQuietly(d.Pos.Filename) + } + m.fileView.GotoLine(d.Pos.Line - 1) // follower scrolls; not focused + return fmt.Sprintf("%s:%d", relTo(m.cfg.WorkDir, d.Pos.Filename), d.Pos.Line) +} + +// handleViewerKey drives the read-only file viewer: move the highlighted nav +// line, follow it to the spec node it produced (`f`), enter the editor (`i`/ +// Enter), or leave back to the tree (Esc). +func (m *Model) handleViewerKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch key.MsgBinding(msg) { + case key.Up, key.K: + m.fileView.NavUp() + return m, nil + case key.Down, key.J: + m.fileView.NavDown() + return m, nil + case key.F: + // Toggle source-driven follow mode: as the nav line moves, the spec + // mirrors the node it produced (source→spec). + m.toggleFollow(followSource) + return m, nil + case key.I, key.Enter: + return m, m.fileView.StartEdit() + case key.Esc: + if m.follow != followOff { + m.exitFollow() + return m, nil + } + m.leftMode = modeBrowse + return m, nil + case key.Tab: + m.focused = (m.focused + 1) % paneCount + return m, m.syncEditFocus() + case key.ShiftTab: + m.focused = (m.focused + paneCount - 1) % paneCount + return m, m.syncEditFocus() + case key.C: + return m, m.copyFocused() + case key.CtrlQ, key.CtrlC: + return m, tea.Quit + } + return m, nil +} + +// handleEditKey routes keys to the file editor while it is focused. A few app +// keys still work: Esc returns to the read-only viewer, Ctrl-S saves, Ctrl-F +// follows the cursor line to the spec, Ctrl-Q quits, Tab moves focus. Everything +// else edits the buffer. +func (m *Model) handleEditKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.fileView.StopEdit() + return m, nil + case "ctrl+f": + // One-shot jump from the cursor's source line to the spec node it + // produced, focusing the spec. ctrl+f rather than f because the editor + // owns plain f for typing; follow mode proper runs from the read-only + // viewer. + if desc, ok := m.linkSourceToSpec(); ok { + m.fileView.Blur() + m.focused = paneSpec + m.notice = "→ " + desc + } else { + m.notice = "no spec node anchored at or above this line" + } + return m, clearNoticeAfter(noticeTTL) + case "ctrl+s": + return m, m.saveFile() + case "ctrl+q": + return m, tea.Quit + case "tab": + m.focused = (m.focused + 1) % paneCount + return m, m.syncEditFocus() + case "shift+tab": + m.focused = (m.focused + paneCount - 1) % paneCount + return m, m.syncEditFocus() + } + return m, m.fileView.Update(msg) +} + +// loadFileQuietly loads path into the read-only viewer and switches the left +// pane to view mode WITHOUT changing focus — used by spec-driven follow, where +// the spec keeps focus while the source pane mirrors. A read error is shown in +// the buffer. +func (m *Model) loadFileQuietly(path string) { + m.currentFile = path + if content, err := os.ReadFile(path); err != nil { + m.fileView.SetFile(filepath.Base(path), "error reading file: "+err.Error()) + } else { + m.fileView.SetFile(relTo(m.cfg.WorkDir, path), string(content)) + } + m.leftMode = modeView +} + +// openFile loads path into the read-only viewer and focuses it. The viewer is +// navigable immediately; `i` enters the editor. +func (m *Model) openFile(path string) tea.Cmd { + m.loadFileQuietly(path) + m.focused = paneTree + return nil +} + +// saveFile writes the editor buffer back to disk. The watcher then triggers a +// rescan, so the spec reflects the edit. +func (m *Model) saveFile() tea.Cmd { + if m.currentFile == "" { + return nil + } + if err := os.WriteFile(m.currentFile, []byte(m.fileView.Value()), 0o644); err != nil { //nolint:gosec // user's own source tree + m.notice = "save failed: " + err.Error() + return clearNoticeAfter(noticeTTL) + } + m.fileView.MarkClean() + m.notice = "saved " + relTo(m.cfg.WorkDir, m.currentFile) + return clearNoticeAfter(noticeTTL) +} + +// syncEditFocus focuses the editor only when the left pane is focused in view +// mode AND editing; the read-only viewer needs no textarea focus. Blurs it +// otherwise so a backgrounded editor doesn't keep capturing input. +func (m *Model) syncEditFocus() tea.Cmd { + if m.leftMode == modeView && m.focused == paneTree && m.fileView.Editing() { + return m.fileView.Focus() + } + m.fileView.Blur() + return nil +} + +// relTo renders path relative to base when possible, else the base name. +func relTo(base, path string) string { + if rel, err := filepath.Rel(base, path); err == nil && !strings.HasPrefix(rel, "..") { + return rel + } + return filepath.Base(path) +} + +// handleOptionsKey drives the scanner-options modal: move the cursor, toggle a +// boolean with space/enter, and apply-on-close (rescan only if something +// changed). +func (m *Model) handleOptionsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch key.MsgBinding(msg) { + case key.Up, key.K: + if m.optCursor > 0 { + m.optCursor-- + } + case key.Down, key.J: + if m.optCursor < len(m.optToggles)-1 { + m.optCursor++ + } + case key.Space, key.Enter: + t := m.optToggles[m.optCursor] + *t.ptr = !*t.ptr + m.optDirty = true + case key.Esc, key.O, key.CtrlQ, key.CtrlC: + m.optionsOpen = false + if m.optDirty { + return m, m.startScan() + } + } + return m, nil +} + +// enterSearch opens the search input over the status line, focusing the spec. +func (m *Model) enterSearch() (tea.Model, tea.Cmd) { + m.exitFollow() + m.searching = true + m.focused = paneSpec + m.searchInput.SetValue("") + return m, m.searchInput.Focus() +} + +// handleSearchKey routes keys to the search input; Enter runs the search, Esc +// cancels and clears highlights. +func (m *Model) handleSearchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + m.searching = false + m.searchInput.Blur() + q := m.searchInput.Value() + if q == "" { + m.spec.ClearSearch() + return m, nil + } + if n := m.spec.Search(q); n == 0 { + m.notice = "no matches: " + q + return m, clearNoticeAfter(noticeTTL) + } + return m, nil + case tea.KeyEsc: + m.searching = false + m.searchInput.Blur() + m.spec.ClearSearch() + return m, nil + } + + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + return m, cmd +} + +// handleMouse focuses the pane under a left-click and scrolls the pane under +// the wheel — no Tab required. +func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + p, ok := m.paneAt(msg.X, msg.Y) + if !ok { + return m, nil + } + + switch msg.Button { + case tea.MouseButtonWheelUp: + return m, m.scrollPane(p, msg, -1) + case tea.MouseButtonWheelDown: + return m, m.scrollPane(p, msg, +1) + case tea.MouseButtonLeft: + if msg.Action == tea.MouseActionPress { + m.focused = p + return m, m.syncEditFocus() + } + } + return m, nil +} + +// scrollPane scrolls the given pane: the tree moves its cursor; the viewport +// panes handle the wheel event natively. +func (m *Model) scrollPane(p pane, msg tea.MouseMsg, delta int) tea.Cmd { + switch p { + case paneTree: + if m.leftMode == modeView { + if m.fileView.Editing() { + return m.fileView.Update(msg) // textarea handles its own scroll + } + m.fileView.ScrollBy(delta) // read-only viewer moves its nav line + return nil + } + m.tree.ScrollBy(delta) + return nil + case paneSpec: + return m.spec.Update(msg) + case paneDiag: + return m.diag.Update(msg) + } + return nil +} + +// paneAt maps terminal coordinates to a pane, using the regions recalcLayout +// stored. Returns false for the header/status chrome rows. +func (m *Model) paneAt(x, y int) (pane, bool) { + topStart := headerH + topEnd := topStart + m.topH + switch { + case y >= topStart && y < topEnd: + if x < m.leftW { + return paneTree, true + } + return paneSpec, true + case y >= topEnd && y < topEnd+m.diagH: + return paneDiag, true + } + return 0, false +} + +// applyScan updates the spec and diagnostics panes from the latest scan. +func (m *Model) applyScan() { + m.refreshSpec() + m.refreshDiagnostics() +} + +// refreshDiagnostics re-renders the diagnostics pane from the stored diagnostics +// and the selection cursor, scrolling the selected diagnostic into view. The +// pane shows any hard error from codescan.Run first, then every +// grammar.Diagnostic the build emitted (colored by severity, paths relative to +// the work dir, the selected row highlighted); a clean scan with no diagnostics +// shows the empty state. +func (m *Model) refreshDiagnostics() { + content, line := renderDiagnostics(m.cfg.WorkDir, m.scanErr, m.diags, m.diagCursor) + m.diag.SetContent(content) + if line >= 0 { + m.diag.ScrollToLine(line) + } +} + +// clampInt clamps v to [lo, hi]. +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// refreshSpec renders the spec pane from the stored JSON/YAML per the active +// format toggle, and rebuilds the line↔pointer index for the active format +// (the spec-side half of the cross-ref linker, design §4 / build LX-spec-0). +func (m *Model) refreshSpec() { + yamlFmt := m.spec.Format() == "YAML" + body := m.specJSON + if yamlFmt { + body = m.specYAML + } + if body == "" { + m.specIndex = nil + m.spec.SetContent("(no spec generated yet)") + return + } + if yamlFmt { + m.specIndex = BuildYAMLIndex([]byte(body)) + } else { + m.specIndex = BuildJSONIndex([]byte(body)) + } + m.spec.SetContent(body) +} + +// locateInSpec jumps the spec pane to the first node produced by the given +// source file (position-backed, via the SourceIndex), highlighting it and +// focusing the spec. The exact replacement for the retired name-matching linker. +func (m *Model) locateInSpec(path string) tea.Cmd { + ptr, ok := m.srcIndex.FirstAnchor(path) + if !ok { + m.notice = "no spec node produced by " + filepath.Base(path) + return clearNoticeAfter(noticeTTL) + } + specLine, ok := m.specIndex.LineForPointer(ptr) + if !ok { + m.notice = "node not in the current spec view: " + ptr + return clearNoticeAfter(noticeTTL) + } + m.spec.HighlightLine(specLine) + m.focused = paneSpec + m.notice = "→ " + ptr + return clearNoticeAfter(noticeTTL) +} + +// toggleFollow turns the given follow mode on (driving from the current pane) +// or off if it is already active, doing an immediate first sync on entry. +func (m *Model) toggleFollow(mode followMode) { + if m.follow == mode { + m.exitFollow() + return + } + m.follow = mode + m.syncFollowIfActive() +} + +// exitFollow leaves follow mode and drops the spec follower highlight (the +// source nav line is the viewer's own cursor, so it stays). +func (m *Model) exitFollow() { + if m.follow == followOff { + return + } + m.follow = followOff + m.followTarget = "" + m.spec.ClearHighlight() +} + +// syncFollowIfActive re-mirrors the follower pane from the driver's current +// position. Runs after every key/scroll. A focus change away from the driver +// (or starting to edit) exits follow mode rather than mirroring stale state. +func (m *Model) syncFollowIfActive() { + switch m.follow { + case followSpec: + if m.focused != paneSpec { + m.exitFollow() + return + } + m.followTarget = m.driveSpecToSource() + case followSource: + if m.focused != paneTree || m.leftMode != modeView || m.fileView.Editing() { + m.exitFollow() + return + } + if desc, ok := m.linkSourceToSpec(); ok { + m.followTarget = desc + } else { + m.followTarget = "(no spec node)" + } + case followDiag: + if m.focused != paneDiag { + m.exitFollow() + return + } + m.followTarget = m.driveDiagToSource() + case followOff: + } +} + +// driveSpecToSource mirrors the source follower to the spec node at the top of +// the viewport, WITHOUT moving focus or the spec scroll (the user drives it). +// Returns a human-readable target for the status badge. +func (m *Model) driveSpecToSource() string { + ptr, ok := m.specIndex.PointerAt(m.spec.TopLine()) + if !ok { + return "(no node)" + } + if specLine, found := m.specIndex.LineForPointer(ptr); found { + m.spec.MarkLine(specLine) // mark the mapped node, no scroll + } + pos, ok := m.srcIndex.PositionFor(ptr) + if !ok { + return ptr + " (no source)" + } + if m.currentFile != pos.Filename { + m.loadFileQuietly(pos.Filename) + } + m.fileView.GotoLine(pos.Line - 1) // follower scrolls; not focused + return fmt.Sprintf("%s → %s:%d", ptr, relTo(m.cfg.WorkDir, pos.Filename), pos.Line) +} + +// linkSourceToSpec highlights (and scrolls to) the spec node produced by the +// file viewer's current line. No focus change. Returns a status description and +// whether the node was found in the current spec render. +func (m *Model) linkSourceToSpec() (string, bool) { + if m.currentFile == "" { + return "", false + } + line := m.fileView.CurrentLine() + 1 // pane rows are 0-based; source lines 1-based + ptr, ok := m.srcIndex.PointerAt(m.currentFile, line) + if !ok { + return "", false + } + specLine, ok := m.specIndex.LineForPointer(ptr) + if !ok { + return ptr + " (not in view)", false + } + m.spec.HighlightLine(specLine) // follower scrolls + highlights + return ptr, true +} + +// copyFocused copies the focused panel's raw content to the clipboard, +// asynchronously (the clipboard tool exec must not block the event loop). +// Returns nil when the focused panel has nothing to copy. +func (m *Model) copyFocused() tea.Cmd { + text := m.focusedContent() + if text == "" { + return nil + } + + return func() tea.Msg { + return copyResultMsg{err: gadgets.CopyToClipboard(context.Background(), text)} + } +} + +func (m *Model) focusedContent() string { + switch m.focused { + case paneTree: + if m.leftMode == modeView { + return m.fileView.Content() + } + return m.tree.Content() + case paneSpec: + return m.spec.Content() + case paneDiag: + return m.diag.Content() + } + return "" +} + +// clearNoticeAfter returns a command that emits clearNoticeMsg after d. +func clearNoticeAfter(d time.Duration) tea.Cmd { + return tea.Tick(d, func(time.Time) tea.Msg { return clearNoticeMsg{} }) +} + +// waitForFS blocks on the watcher channel and emits one fsEventMsg per change. +// It is re-issued after each event to form the listen loop; a closed channel +// ends the loop quietly. +func waitForFS(ch <-chan struct{}) tea.Cmd { + return func() tea.Msg { + if _, ok := <-ch; !ok { + return nil + } + return fsEventMsg{} + } +} + +// debounceCmd emits a debounceMsg for gen after the quiet period. +func debounceCmd(gen int) tea.Cmd { + return tea.Tick(debounceDelay, func(time.Time) tea.Msg { return debounceMsg{gen: gen} }) +} + +// updateFocused forwards a message to the currently focused panel (the left +// pane is the tree or the file viewer depending on leftMode). +func (m *Model) updateFocused(msg tea.Msg) tea.Cmd { + switch m.focused { + case paneTree: + if m.leftMode == modeView { + return m.fileView.Update(msg) + } + return m.tree.Update(msg) + case paneSpec: + return m.spec.Update(msg) + case paneDiag: + return m.diag.Update(msg) + } + return nil +} + +// recalcLayout distributes the terminal size: a header line, a top row with the +// source tree (1/3 width) beside the spec, a diagnostics strip, and a status +// line. The regions are stored for mouse hit-testing. +func (m *Model) recalcLayout() { + m.diagH = max(m.height/4, 5) + m.topH = max(m.height-headerH-statusH-m.diagH, 3) + m.leftW = max(min(m.width/3, m.width), 1) + rightW := max(m.width-m.leftW, 1) + + m.tree.SetSize(m.leftW, m.topH) + m.fileView.SetSize(m.leftW, m.topH) + m.spec.SetSize(rightW, m.topH) + m.diag.SetSize(m.width, m.diagH) +} + +// leftView renders whichever the left pane currently shows. The file viewer +// highlights its nav line when focused or when it is the active follower in +// spec-driven follow mode (where the spec keeps focus). +func (m *Model) leftView(focused bool) string { + if m.leftMode == modeView { + // The source pane is the active follower in spec- and diag-driven follow. + navActive := focused || m.follow == followSpec || m.follow == followDiag + return m.fileView.View(focused, navActive) + } + return m.tree.View(focused) +} + +// View implements tea.Model. +func (m *Model) View() string { + if !m.ready { + return "loading…" + } + + if m.optionsOpen { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.optionsView()) + } + + top := lipgloss.JoinHorizontal( + lipgloss.Top, + m.leftView(m.focused == paneTree), + m.spec.View(m.focused == paneSpec), + ) + + return m.headerLine() + "\n" + + top + "\n" + + m.diag.View(m.focused == paneDiag) + "\n" + + m.statusLine() +} + +// optionsView renders the scanner-options modal: a bordered list of boolean +// toggles with checkboxes and a cursor caret. +func (m *Model) optionsView() string { + labelW := 0 + for _, t := range m.optToggles { + labelW = max(labelW, len(t.label)) + } + + var b strings.Builder + b.WriteString(theme.Accent().Render("Scanner options")) + b.WriteString("\n\n") + + for i, t := range m.optToggles { + caret := " " + if i == m.optCursor { + caret = "▸ " + } + box := "[ ]" + if *t.ptr { + box = "[x]" + } + label := fmt.Sprintf("%-*s", labelW, t.label) + if i == m.optCursor { + // highlight the whole row, description included + b.WriteString(theme.Selected().Render(fmt.Sprintf("%s%s %s %s", caret, box, label, t.desc))) + } else { + b.WriteString(fmt.Sprintf("%s%s %s ", caret, box, label)) + b.WriteString(theme.Status().Render(t.desc)) + } + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(theme.Status().Render("space: toggle · esc/o: apply & close")) + + return theme.Modal().Render(b.String()) +} + +// headerLine shows the app name, the (shortened) workdir, the active format, +// spec stats, and a scan spinner / ready indicator. +func (m *Model) headerLine() string { + wd := shortenPath(m.cfg.WorkDir, max(m.width-44, 12)) + stats := fmt.Sprintf("%d paths · %d defs", m.numPaths, m.numDefs) + + if cur, total := m.spec.MatchInfo(); total > 0 { + stats += fmt.Sprintf(" · match %d/%d", cur, total) + } + + left := theme.Accent().Render("genspec-tui") + mid := theme.Status().Render(fmt.Sprintf(" · %s · %s · %s · ", wd, m.spec.Format(), stats)) + + tail := theme.Status().Render("ready") + switch { + case m.scanning: + tail = m.spin.View() + theme.Status().Render("scanning") + case m.lastElapsed > 0: + tail = theme.Status().Render("ready (" + humanDuration(m.lastElapsed) + ")") + } + return left + mid + tail +} + +// humanDuration renders d compactly: "947ms", "3s", "1m 3s" (minute form drops +// a zero-second remainder, e.g. "2m"). +func humanDuration(d time.Duration) string { + switch { + case d < time.Second: + return fmt.Sprintf("%dms", d.Milliseconds()) + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Round(time.Second).Seconds())) + default: + d = d.Round(time.Second) + mins := int(d / time.Minute) + secs := int((d % time.Minute) / time.Second) + if secs == 0 { + return fmt.Sprintf("%dm", mins) + } + return fmt.Sprintf("%dm %ds", mins, secs) + } +} + +func (m *Model) statusLine() string { + if m.searching { + return m.searchInput.View() + } + if m.follow != followOff { + return m.followBadge() + } + if m.notice != "" { + return theme.Status().Render(m.notice) + } + if m.focused == paneTree && m.leftMode == modeView { + if m.fileView.Editing() { + return theme.Status().Render( + "editing · ctrl+f: jump → spec · esc: stop editing · ctrl+s: save · ctrl+q: quit") + } + return theme.Status().Render( + "viewing · ↑↓/jk: line · f: follow mode · i: edit · esc: tree · tab: focus · c: copy") + } + if m.focused == paneDiag && len(m.diags) > 0 { + return theme.Status().Render(fmt.Sprintf( + "diagnostic %d/%d · ↑↓/jk: select · f: follow mode · tab: focus · c: copy", + m.diagCursor+1, len(m.diags))) + } + if m.focused == paneSpec && m.specIndex.Len() > 0 { + if ptr, ok := m.specIndex.PointerAt(m.spec.TopLine()); ok { + return theme.Status().Render("node " + ptr + " · f: follow mode · /: search · tab: focus · c: copy") + } + } + return theme.Status().Render( + "tab/click: focus · enter: open file · g: locate · /: search · n/N: next/prev · o: options · c: copy · r: rescan · ctrl+q: quit") +} + +// followBadge renders the auto-follow status line: which pane drives, the +// resolved target, and how to exit. The accent label makes the mode obvious. +func (m *Model) followBadge() string { + label := "SPEC ▸ SOURCE" + switch m.follow { + case followSource: + label = "SOURCE ▸ SPEC" + case followDiag: + label = "DIAG ▸ SOURCE" + case followSpec, followOff: + } + target := m.followTarget + if target == "" { + target = "(move the cursor)" + } + return theme.Accent().Render(" "+label+" ") + + theme.Status().Render(" "+target+" · esc / f: exit follow") +} + +// shortenPath trims a path from the left with an ellipsis so it fits maxLen. +func shortenPath(p string, maxLen int) string { + if maxLen < 4 { + maxLen = 4 + } + r := []rune(p) + if len(r) <= maxLen { + return p + } + return "…" + string(r[len(r)-maxLen+1:]) +} diff --git a/cmd/genspec-tui/internal/ux/model_diag_test.go b/cmd/genspec-tui/internal/ux/model_diag_test.go new file mode 100644 index 00000000..06b7f6ae --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_diag_test.go @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "os" + "path/filepath" + "testing" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestDiag_MoveCursorClamps(t *testing.T) { + m := &Model{diag: panels.NewDiagnostics()} + m.diag.SetSize(60, 8) + m.diags = make([]grammar.Diagnostic, 3) + + m.moveDiagCursor(+1) + assert.Equal(t, 1, m.diagCursor) + m.moveDiagCursor(+5) + assert.Equal(t, 2, m.diagCursor, "clamped at the last diagnostic") + m.moveDiagCursor(-9) + assert.Equal(t, 0, m.diagCursor, "clamped at the first") + + // No diagnostics: a no-op, no panic. + empty := &Model{diag: panels.NewDiagnostics()} + empty.moveDiagCursor(+1) + assert.Equal(t, 0, empty.diagCursor) +} + +func TestDiag_FollowModeTracksSelection(t *testing.T) { + dir := t.TempDir() + a := filepath.Join(dir, "a.go") + b := filepath.Join(dir, "b.go") + require.NoError(t, os.WriteFile(a, []byte("package p\n\ntype X struct{}\n"), 0o600)) + require.NoError(t, os.WriteFile(b, []byte("package p\n\n\n\ntype Y struct{}\n"), 0o600)) + + m := &Model{fileView: panels.NewFileView(), diag: panels.NewDiagnostics()} + m.cfg.WorkDir = dir + m.fileView.SetSize(40, 12) + m.diag.SetSize(60, 8) + m.diags = []grammar.Diagnostic{ + grammar.Warnf(pos(a, 3, 1), grammar.CodeInvalidNumber, "one"), + grammar.Warnf(pos(b, 5, 1), grammar.CodeInvalidNumber, "two"), + } + m.focused = paneDiag + + // Entering follow mode mirrors the first diagnostic; the driver keeps focus. + m.toggleFollow(followDiag) + assert.Equal(t, followDiag, m.follow) + assert.Equal(t, paneDiag, m.focused, "the diagnostics pane stays the driver") + assert.Equal(t, a, m.currentFile) + assert.Equal(t, modeView, m.leftMode) + assert.Equal(t, 2, m.fileView.CurrentLine(), "line 3 → row 2") + + // Moving the selection auto-tracks the source pane (the Update loop re-syncs). + m.moveDiagCursor(+1) + m.syncFollowIfActive() + assert.Equal(t, b, m.currentFile, "source follows to the second diagnostic's file") + assert.Equal(t, 4, m.fileView.CurrentLine(), "line 5 → row 4") + + // A second `f` toggles off. + m.toggleFollow(followDiag) + assert.Equal(t, followOff, m.follow) +} + +func TestDiag_FollowExitsOnFocusChange(t *testing.T) { + m := &Model{fileView: panels.NewFileView(), diag: panels.NewDiagnostics()} + m.fileView.SetSize(40, 12) + m.diag.SetSize(60, 8) + m.diags = make([]grammar.Diagnostic, 2) + m.focused = paneDiag + m.follow = followDiag + + m.focused = paneSpec // tab/click away from the driver + m.syncFollowIfActive() + assert.Equal(t, followOff, m.follow, "leaving the driver pane exits follow") +} + +func TestDiag_FollowNoPosition(t *testing.T) { + m := &Model{fileView: panels.NewFileView(), diag: panels.NewDiagnostics()} + m.fileView.SetSize(40, 10) + m.diag.SetSize(60, 8) + m.diags = []grammar.Diagnostic{{Message: "no position"}} // zero Pos is invalid + m.focused = paneDiag + + m.toggleFollow(followDiag) + assert.Equal(t, followDiag, m.follow) + assert.Empty(t, m.currentFile, "nothing opened when the diagnostic has no source") + assert.Equal(t, "(no source)", m.followTarget) +} + +func TestRenderDiagnostics_SelectedLine(t *testing.T) { + diags := []grammar.Diagnostic{ + grammar.Warnf(pos("/w/a.go", 1, 1), grammar.CodeInvalidNumber, "one"), + grammar.Warnf(pos("/w/a.go", 2, 1), grammar.CodeInvalidNumber, "two"), + } + // tally on line 0, first diagnostic on line 1, second on line 2. + _, line := renderDiagnostics("/w", nil, diags, 1) + assert.Equal(t, 2, line) +} diff --git a/cmd/genspec-tui/internal/ux/model_follow_test.go b/cmd/genspec-tui/internal/ux/model_follow_test.go new file mode 100644 index 00000000..db5c75c3 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_follow_test.go @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "go/token" + "os" + "path/filepath" + "testing" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/codescan/internal/scanner" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// followFixture builds a Model wired with a known spec/source index pair, ready +// to drive follow mode without a real scan. +func followFixture(t *testing.T) *Model { + t.Helper() + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.spec.SetSize(60, 20) + m.fileView.SetSize(60, 20) + m.spec.SetContent("line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8") + m.specIndex = newSpecIndex( + map[int]string{7: "/definitions/User/properties/email"}, + map[string]int{"/definitions/User/properties/email": 7}, + ) + return m +} + +func TestFollow_SourceDriven(t *testing.T) { + m := followFixture(t) + m.srcIndex = BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User/properties/email", Pos: token.Position{Filename: "user.go", Line: 5}}, + }) + m.currentFile = "user.go" + m.fileView.SetFile("user.go", "a\nb\nc\nd\ne\nf") + m.fileView.GotoLine(4) // 0-based row 4 == source line 5 + m.focused = paneTree + m.leftMode = modeView + + m.toggleFollow(followSource) + assert.Equal(t, followSource, m.follow, "f enters source-driven follow") + assert.Equal(t, "/definitions/User/properties/email", m.followTarget) + + // A line with no anchor at or above reports honestly. + m.fileView.GotoLine(0) // source line 1, before the first anchor (line 5) + m.syncFollowIfActive() + assert.Equal(t, "(no spec node)", m.followTarget) + + // Moving focus off the driver exits follow. + m.focused = paneSpec + m.syncFollowIfActive() + assert.Equal(t, followOff, m.follow, "leaving the driver pane exits follow") +} + +func TestFollow_SpecDriven(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "user.go") + require.NoError(t, os.WriteFile(src, []byte("package p\n\ntype User struct{}\n"), 0o600)) + + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.cfg.WorkDir = dir + m.spec.SetSize(60, 20) + m.fileView.SetSize(60, 20) + m.spec.SetContent("{\n \"definitions\": {}\n}") + // The node maps to the top line of the viewport (YOffset 0). + m.specIndex = newSpecIndex( + map[int]string{0: "/definitions/User"}, + map[string]int{"/definitions/User": 0}, + ) + m.srcIndex = BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User", Pos: token.Position{Filename: src, Line: 3}}, + }) + m.focused = paneSpec + + m.toggleFollow(followSpec) + assert.Equal(t, followSpec, m.follow) + assert.Equal(t, src, m.currentFile, "the source follower loads the producing file") + assert.Equal(t, paneSpec, m.focused, "the driver keeps focus") + assert.Contains(t, m.followTarget, "/definitions/User") + assert.Contains(t, m.followTarget, "user.go:3") + + // f again toggles off. + m.toggleFollow(followSpec) + assert.Equal(t, followOff, m.follow) + assert.Empty(t, m.followTarget) +} + +func TestFollow_ExitClearsState(t *testing.T) { + m := followFixture(t) + m.srcIndex = BuildSourceIndex(nil) + m.follow = followSpec + m.followTarget = "something" + + m.exitFollow() + assert.Equal(t, followOff, m.follow) + assert.Empty(t, m.followTarget) + // Idempotent. + m.exitFollow() + assert.Equal(t, followOff, m.follow) +} diff --git a/cmd/genspec-tui/internal/ux/panels/diagnostics.go b/cmd/genspec-tui/internal/ux/panels/diagnostics.go new file mode 100644 index 00000000..e5976401 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/diagnostics.go @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" +) + +// Diagnostics is the bottom diagnostics panel: a scrollable viewport whose +// content is composed by the model from the scan's grammar.Diagnostic slice +// (see renderDiagnostics). It stays presentation-only — the model owns the +// diagnostic data and formatting; the panel just displays and scrolls it. +type Diagnostics struct { + vp viewport.Model + w, h int + content string +} + +// NewDiagnostics returns a Diagnostics panel with placeholder content. +func NewDiagnostics() Diagnostics { + const placeholder = "(no diagnostics)" + vp := viewport.New(0, 0) + vp.SetContent(placeholder) + return Diagnostics{vp: vp, content: placeholder} +} + +// SetSize fits the panel to outer dimensions w×h (border + title reserved). +func (p *Diagnostics) SetSize(w, h int) { + p.w, p.h = w, h + p.vp.Width = max(w-2, 0) + p.vp.Height = max(h-3, 0) +} + +// SetContent replaces the rendered diagnostics text. +func (p *Diagnostics) SetContent(s string) { + p.content = s + p.vp.SetContent(s) +} + +// Content returns the raw (unwrapped) panel text, for clipboard copy. +func (p *Diagnostics) Content() string { return p.content } + +// ScrollToLine scrolls so the 0-based content line sits near the top (with a +// little context above). Used to keep the selected diagnostic in view. +func (p *Diagnostics) ScrollToLine(line int) { p.vp.SetYOffset(max(line-scrollContext, 0)) } + +// Update forwards a message to the underlying viewport (scrolling). +func (p *Diagnostics) Update(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + p.vp, cmd = p.vp.Update(msg) + return cmd +} + +// View renders the bordered panel; focused brightens the border/title. +func (p *Diagnostics) View(focused bool) string { + title := theme.Title(focused).Render("diagnostics") + return theme.Panel(p.w, p.h, focused).Render(title + "\n" + p.vp.View()) +} diff --git a/cmd/genspec-tui/internal/ux/panels/fileview.go b/cmd/genspec-tui/internal/ux/panels/fileview.go new file mode 100644 index 00000000..6e68608d --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/fileview.go @@ -0,0 +1,224 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" +) + +// FileView is the left pane's file display. It opens READ-ONLY and navigable — +// a highlighted line you move with the cursor keys and follow to the spec — and +// switches to an editable textarea on demand (`i`), returning to the viewer on +// Esc. Disk is the source of truth; saving writes back and the watcher drives +// the rescan. A VIM/VS-Code integration is the eventual full editor. +type FileView struct { + ta textarea.Model + w, h int + title string + loaded string // content as loaded/saved, for the dirty check + editing bool + navLine int // 0-based highlighted line in read-only mode + offset int // 0-based top visible line in read-only mode +} + +// NewFileView returns an empty viewer. +func NewFileView() FileView { + ta := textarea.New() + ta.ShowLineNumbers = true + ta.CharLimit = 0 // no limit; whole files + ta.Prompt = "" // line numbers are the gutter; no extra prompt + return FileView{ta: ta} +} + +// SetSize fits the panel to outer dimensions w×h (border + title reserved). +func (p *FileView) SetSize(w, h int) { + p.w, p.h = w, h + p.ta.SetWidth(max(w-2, 0)) + p.ta.SetHeight(max(h-3, 0)) + p.clampOffset() +} + +// SetFile loads name/content read-only at the top and marks the buffer clean. +func (p *FileView) SetFile(name, content string) { + p.title = name + p.ta.SetValue(content) + p.loaded = content + p.editing = false + p.navLine = 0 + p.offset = 0 +} + +// Title returns the current file's display name. +func (p *FileView) Title() string { return p.title } + +// Value returns the current (possibly edited) buffer text. +func (p *FileView) Value() string { return p.ta.Value() } + +// Content returns the buffer text, for clipboard copy. +func (p *FileView) Content() string { return p.ta.Value() } + +// Dirty reports whether the buffer has unsaved edits. +func (p *FileView) Dirty() bool { return p.ta.Value() != p.loaded } + +// MarkClean records the current buffer as the saved baseline. +func (p *FileView) MarkClean() { p.loaded = p.ta.Value() } + +// Editing reports whether the pane is in editable mode (vs the read-only viewer). +func (p *FileView) Editing() bool { return p.editing } + +// CurrentLine returns the 0-based "current" line: the editor cursor row while +// editing, else the read-only nav line. Used by source→spec cross-ref nav. +func (p *FileView) CurrentLine() int { + if p.editing { + return p.ta.Line() + } + return p.navLine +} + +// NavUp / NavDown move the read-only nav line by one, keeping it visible. +func (p *FileView) NavUp() { p.gotoNav(p.navLine - 1) } +func (p *FileView) NavDown() { p.gotoNav(p.navLine + 1) } + +// ScrollBy moves the nav line by delta (mouse wheel in read-only mode). +func (p *FileView) ScrollBy(delta int) { p.gotoNav(p.navLine + delta) } + +// GotoLine parks the read-only nav line on the 0-based line, scrolling it into +// view. Used by cross-ref navigation to land on a node's source line. The editor +// cursor is synced lazily by StartEdit, so this stays cheap when called on every +// follow-mode scroll. +func (p *FileView) GotoLine(line int) { p.gotoNav(line) } + +// StartEdit switches to the editable textarea at the current nav line. +func (p *FileView) StartEdit() tea.Cmd { + p.syncEditorCursor() + p.editing = true + return p.ta.Focus() +} + +// StopEdit leaves edit mode, parking the nav line where the cursor was. +func (p *FileView) StopEdit() { + p.navLine = p.ta.Line() + p.editing = false + p.ta.Blur() + p.clampOffset() +} + +// Focus focuses the editor when in edit mode (the read-only viewer needs no +// textarea focus). Retained for the model's focus plumbing. +func (p *FileView) Focus() tea.Cmd { + if p.editing { + return p.ta.Focus() + } + return nil +} + +// Blur removes editor focus. +func (p *FileView) Blur() { p.ta.Blur() } + +// Update forwards a message to the textarea (edit mode only; the read-only +// viewer is driven by the model's nav keys). +func (p *FileView) Update(msg tea.Msg) tea.Cmd { + if !p.editing { + return nil + } + var cmd tea.Cmd + p.ta, cmd = p.ta.Update(msg) + return cmd +} + +// View renders the bordered panel: the textarea in edit mode, a line-numbered +// read-only viewer with the nav line highlighted otherwise. A "●" marks unsaved +// edits. focused drives the border/title brightness; navActive drives the +// nav-line highlight (true when focused OR mirroring as a follow follower). +func (p *FileView) View(focused, navActive bool) string { + name := p.title + if name == "" { + name = "(no file)" + } + if p.Dirty() { + name += " ●" + } + mode := "view" + body := p.viewerBody(navActive) + if p.editing { + mode = "edit" + body = p.ta.View() + } + title := theme.Title(focused).Render("file · " + name + " · " + mode) + return theme.Panel(p.w, p.h, focused).Render(title + "\n" + body) +} + +// viewerBody renders the read-only window: a right-aligned line-number gutter +// and the file text, with the nav line highlighted when navActive (the pane is +// focused, or mirroring the followed line as a follow follower). +func (p *FileView) viewerBody(navActive bool) string { + inner := max(p.w-2, 0) + visible := max(p.h-3, 0) + lines := strings.Split(p.ta.Value(), "\n") + total := len(lines) + numW := len(strconv.Itoa(total)) + textW := max(inner-(numW+1), 0) + + var b strings.Builder + end := min(p.offset+visible, total) + for i := p.offset; i < end; i++ { + row := fmt.Sprintf("%*d ", numW, i+1) + fit(lines[i], textW) + if i == p.navLine && navActive { + row = theme.Selected().Render(row) + } + b.WriteString(row) + if i < end-1 { + b.WriteString("\n") + } + } + return b.String() +} + +// gotoNav clamps and sets the nav line, then re-clamps the scroll offset. +func (p *FileView) gotoNav(line int) { + p.navLine = clamp(line, 0, max(p.lineCount()-1, 0)) + p.clampOffset() +} + +// syncEditorCursor steps the textarea cursor to navLine (textarea has no +// row-setter), so toggling into edit mode keeps the line. +func (p *FileView) syncEditorCursor() { + for p.ta.Line() < p.navLine { + before := p.ta.Line() + p.ta.CursorDown() + if p.ta.Line() == before { + break + } + } + for p.ta.Line() > p.navLine { + before := p.ta.Line() + p.ta.CursorUp() + if p.ta.Line() == before { + break + } + } +} + +func (p *FileView) lineCount() int { return strings.Count(p.ta.Value(), "\n") + 1 } + +// clampOffset keeps the nav line within the visible read-only window. +func (p *FileView) clampOffset() { + visible := max(p.h-3, 1) + if p.navLine < p.offset { + p.offset = p.navLine + } + if p.navLine >= p.offset+visible { + p.offset = p.navLine - visible + 1 + } + if p.offset < 0 { + p.offset = 0 + } +} diff --git a/cmd/genspec-tui/internal/ux/panels/fileview_test.go b/cmd/genspec-tui/internal/ux/panels/fileview_test.go new file mode 100644 index 00000000..f51bf324 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/fileview_test.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" +) + +func newLoadedFileView() FileView { + fv := NewFileView() + fv.SetSize(40, 10) + fv.SetFile("x.go", "line1\nline2\nline3\nline4") + return fv +} + +func TestFileView_ReadOnlyNav(t *testing.T) { + fv := newLoadedFileView() + + assert.False(t, fv.Editing(), "opens read-only") + assert.Equal(t, 0, fv.CurrentLine(), "starts at the top") + + fv.NavDown() + fv.NavDown() + assert.Equal(t, 2, fv.CurrentLine()) + + fv.NavUp() + assert.Equal(t, 1, fv.CurrentLine()) + + // Clamped at both ends. + fv.NavUp() + fv.NavUp() + assert.Equal(t, 0, fv.CurrentLine(), "clamped at the first line") + + for range 10 { + fv.NavDown() + } + assert.Equal(t, 3, fv.CurrentLine(), "clamped at the last line (4 lines, 0-based)") +} + +func TestFileView_GotoLine(t *testing.T) { + fv := newLoadedFileView() + fv.GotoLine(2) + assert.Equal(t, 2, fv.CurrentLine()) + + // Out-of-range is clamped, not an error. + fv.GotoLine(99) + assert.Equal(t, 3, fv.CurrentLine()) + fv.GotoLine(-5) + assert.Equal(t, 0, fv.CurrentLine()) +} + +func TestFileView_EditToggle(t *testing.T) { + fv := newLoadedFileView() + fv.GotoLine(2) + + _ = fv.StartEdit() + assert.True(t, fv.Editing(), "i enters edit mode") + assert.Equal(t, 2, fv.CurrentLine(), "editor cursor lands on the nav line") + + fv.StopEdit() + assert.False(t, fv.Editing(), "esc leaves edit mode") + assert.Equal(t, 2, fv.CurrentLine(), "nav line parks where the cursor was") +} + +func TestFileView_ViewRendersGutterAndHighlight(t *testing.T) { + fv := newLoadedFileView() + fv.GotoLine(1) + + out := fv.View(true, true) + assert.Contains(t, out, "line2", "shows file content") + assert.Contains(t, out, "view", "title marks read-only mode") + + _ = fv.StartEdit() + assert.Contains(t, fv.View(true, true), "edit", "title marks edit mode") +} diff --git a/cmd/genspec-tui/internal/ux/panels/spec.go b/cmd/genspec-tui/internal/ux/panels/spec.go new file mode 100644 index 00000000..95c0fd43 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/spec.go @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" +) + +// Spec is the right-hand generated-spec panel. It tracks the active render +// format (JSON/YAML) and an optional case-insensitive search that highlights +// matching lines and scrolls between them. +type Spec struct { + vp viewport.Model + w, h int + format string + content string // raw, unhighlighted spec text (also what Content() copies) + + query string + matches []int // indices of content lines containing the query + matchIdx int + + xrefLine int // content line highlighted by cross-ref follow, -1 for none +} + +// NewSpec returns a Spec defaulting to JSON with placeholder content. +func NewSpec() Spec { + const placeholder = "(no spec generated yet)" + vp := viewport.New(0, 0) + vp.SetContent(placeholder) + return Spec{vp: vp, format: "JSON", content: placeholder, xrefLine: -1} +} + +// SetSize fits the panel to outer dimensions w×h (border + title reserved). +func (p *Spec) SetSize(w, h int) { + p.w, p.h = w, h + p.vp.Width = max(w-2, 0) + p.vp.Height = max(h-3, 0) +} + +// SetFormat sets the title's format label ("JSON" or "YAML"). +func (p *Spec) SetFormat(f string) { p.format = f } + +// Format returns the active render format label. +func (p *Spec) Format() string { return p.format } + +// SetContent replaces the raw spec text, re-applying any active search. A new +// spec invalidates any cross-ref highlight (the line may have moved). +func (p *Spec) SetContent(s string) { + p.content = s + p.xrefLine = -1 + p.render() +} + +// Content returns the raw (unhighlighted) panel text, for clipboard copy. +func (p *Spec) Content() string { return p.content } + +// Search sets the query, highlights matching lines, scrolls to the first +// match, and returns the match count. A search supersedes any cross-ref +// highlight. +func (p *Spec) Search(query string) int { + p.query = query + p.matchIdx = 0 + p.xrefLine = -1 + p.render() + if len(p.matches) > 0 { + p.scrollToMatch() + } + return len(p.matches) +} + +// Step moves to the next (dir +1) or previous (dir -1) match, wrapping around. +func (p *Spec) Step(dir int) { + if len(p.matches) == 0 { + return + } + p.matchIdx = (p.matchIdx + dir + len(p.matches)) % len(p.matches) + p.scrollToMatch() +} + +// ClearSearch drops the query and re-renders the plain spec. +func (p *Spec) ClearSearch() { + p.query = "" + p.matches = nil + p.matchIdx = 0 + p.render() +} + +// MatchInfo returns the 1-based current match and the total (0,0 when none). +func (p *Spec) MatchInfo() (cur, total int) { + if len(p.matches) == 0 { + return 0, 0 + } + return p.matchIdx + 1, len(p.matches) +} + +// TopLine returns the 0-based index of the top visible content line, for +// mapping the current scroll position to a spec node via the SpecIndex. +func (p *Spec) TopLine() int { return p.vp.YOffset } + +// scrollContext is how many lines of context to keep above a scrolled-to target. +const scrollContext = 2 + +// MarkLine marks the 0-based content line as the cross-ref target (same style +// the source pane uses for its nav line) WITHOUT scrolling. Used when the spec +// is the driver pane (the user controls its scroll) and only the node mapping +// should be shown. +func (p *Spec) MarkLine(line int) { + if p.xrefLine == line { + return // already marked; avoid a re-render on every driver scroll + } + p.xrefLine = line + p.render() +} + +// HighlightLine marks the line and scrolls it into view with a little context +// above. Used when the spec is the follower (source→spec navigation). +func (p *Spec) HighlightLine(line int) { + p.vp.SetYOffset(max(line-scrollContext, 0)) + p.MarkLine(line) +} + +// ClearHighlight drops the cross-ref highlight, if any. +func (p *Spec) ClearHighlight() { + if p.xrefLine == -1 { + return + } + p.xrefLine = -1 + p.render() +} + +// Update forwards a message to the underlying viewport (scrolling). +func (p *Spec) Update(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + p.vp, cmd = p.vp.Update(msg) + return cmd +} + +// View renders the bordered panel; focused brightens the border/title. +func (p *Spec) View(focused bool) string { + title := theme.Title(focused).Render("spec · " + p.format) + return theme.Panel(p.w, p.h, focused).Render(title + "\n" + p.vp.View()) +} + +// render rebuilds the viewport content from the raw text, applying the active +// search highlight (per-substring) and the cross-ref highlight (whole line). +// The cross-ref line takes the whole-line style; search matches are still +// counted on it so n/N stays consistent. +func (p *Spec) render() { + if p.query == "" && p.xrefLine == -1 { + p.matches = nil + p.vp.SetContent(p.content) + return + } + + needle := "" + if p.query != "" { + needle = strings.ToLower(p.query) + } + lines := strings.Split(p.content, "\n") + p.matches = p.matches[:0] + for i, ln := range lines { + isMatch := needle != "" && strings.Contains(strings.ToLower(ln), needle) + if isMatch { + p.matches = append(p.matches, i) + } + switch { + case i == p.xrefLine: + lines[i] = theme.Selected().Render(ln) + case isMatch: + lines[i] = highlightAll(ln, p.query) + } + } + p.vp.SetContent(strings.Join(lines, "\n")) +} + +func (p *Spec) scrollToMatch() { + if len(p.matches) == 0 { + return + } + // keep a little context above the match + p.vp.SetYOffset(max(p.matches[p.matchIdx]-2, 0)) +} + +// highlightAll wraps every case-insensitive occurrence of query in line with +// the match style, preserving the original casing of the matched text. +func highlightAll(line, query string) string { + if query == "" { + return line + } + style := theme.Match() + lower := strings.ToLower(line) + lq := strings.ToLower(query) + + var b strings.Builder + for { + i := strings.Index(lower, lq) + if i < 0 { + b.WriteString(line) + break + } + b.WriteString(line[:i]) + b.WriteString(style.Render(line[i : i+len(query)])) + line = line[i+len(query):] + lower = lower[i+len(query):] + } + return b.String() +} diff --git a/cmd/genspec-tui/internal/ux/panels/spec_test.go b/cmd/genspec-tui/internal/ux/panels/spec_test.go new file mode 100644 index 00000000..929ecf12 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/spec_test.go @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" +) + +func newLoadedSpec() Spec { + sp := NewSpec() + sp.SetSize(40, 12) + sp.SetContent("{\n \"a\": 1,\n \"b\": 2,\n \"c\": 3\n}") + return sp +} + +func TestSpec_HighlightLine(t *testing.T) { + sp := newLoadedSpec() + assert.Equal(t, -1, sp.xrefLine, "no highlight on a fresh spec") + + sp.HighlightLine(2) + assert.Equal(t, 2, sp.xrefLine, "cross-ref highlight set") + assert.Contains(t, sp.Content(), "\"b\": 2", "raw content is unchanged (highlight is view-only)") + + sp.ClearHighlight() + assert.Equal(t, -1, sp.xrefLine, "highlight cleared") +} + +func TestSpec_HighlightInvalidatedBySearchAndContent(t *testing.T) { + sp := newLoadedSpec() + + sp.HighlightLine(1) + sp.Search("b") + assert.Equal(t, -1, sp.xrefLine, "a search supersedes the cross-ref highlight") + + sp.HighlightLine(3) + sp.SetContent("{\n \"x\": 9\n}") + assert.Equal(t, -1, sp.xrefLine, "new content invalidates the highlight") +} + +func TestSpec_RenderPreservesContent(t *testing.T) { + sp := newLoadedSpec() + sp.HighlightLine(2) // forces the styled render path + // The viewport render must still show every source line. + view := sp.vp.View() + for _, want := range []string{"\"a\": 1", "\"b\": 2", "\"c\": 3"} { + assert.Contains(t, view, want) + } +} diff --git a/cmd/genspec-tui/internal/ux/panels/tree.go b/cmd/genspec-tui/internal/ux/panels/tree.go new file mode 100644 index 00000000..4506be7b --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/tree.go @@ -0,0 +1,301 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package panels holds the three scrollable sub-panels of the genspec-tui +// layout: the source tree (left), the generated spec (right) and the +// diagnostics (bottom). +package panels + +import ( + "os" + "path/filepath" + "sort" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/key" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" +) + +// node is a file or directory in the source tree. Directories list only the +// descendants that (transitively) contain Go files; everything else is pruned +// at build time. +type node struct { + name string + path string + isDir bool + expanded bool + depth int + children []*node +} + +// Tree is the left-hand source-tree explorer. It owns a cursor and a scroll +// offset (the git-janitor Base idiom) and renders a flattened view of the +// expanded nodes inside a bordered, titled box. +type Tree struct { + root *node + flat []*node // visible rows, recomputed on expand/collapse + cursor int + offset int + w, h int +} + +// NewTree builds the explorer rooted at root, pruned to Go-bearing paths. +func NewTree(root string) Tree { + t := Tree{root: buildTree(root)} + t.rebuild() + return t +} + +// SetSize fits the panel to outer dimensions w×h (border + title reserved). +func (p *Tree) SetSize(w, h int) { + p.w, p.h = w, h + p.clampOffset() +} + +// Selection returns the path of the node under the cursor, whether it is a +// directory, and false when the tree is empty. Used by the model to react to +// the user's current focus (locating diagnostics, opening a file for edit). +func (p *Tree) Selection() (path string, isDir bool, ok bool) { + n := p.current() + if n == nil { + return "", false, false + } + return n.path, n.isDir, true +} + +// Content returns the flattened tree as indented text, for clipboard copy. +func (p *Tree) Content() string { + var b strings.Builder + for i, n := range p.flat { + if i > 0 { + b.WriteString("\n") + } + b.WriteString(strings.Repeat(" ", n.depth)) + b.WriteString(n.name) + if n.isDir { + b.WriteString("/") + } + } + return b.String() +} + +// Update handles cursor movement and expand/collapse. +func (p *Tree) Update(msg tea.Msg) tea.Cmd { + km, ok := msg.(tea.KeyMsg) + if !ok { + return nil + } + + switch key.MsgBinding(km) { + case key.Up, key.K: + p.move(-1) + case key.Down, key.J: + p.move(1) + case key.Right, key.L: + if n := p.current(); n != nil && n.isDir && !n.expanded { + n.expanded = true + p.rebuild() + } + case key.Left, key.H: + p.collapseOrParent() + case key.Enter: + if n := p.current(); n != nil && n.isDir { + n.expanded = !n.expanded + p.rebuild() + } + } + return nil +} + +// View renders the bordered panel; focused brightens the border/title and +// shows the cursor highlight. +func (p *Tree) View(focused bool) string { + title := theme.Title(focused).Render("source") + inner := max(p.w-2, 0) + visible := max(p.h-3, 0) + + var b strings.Builder + if len(p.flat) <= 1 && (p.root == nil || len(p.root.children) == 0) { + b.WriteString(theme.Status().Render(fit("(no .go files under root)", inner))) + } else { + end := min(p.offset+visible, len(p.flat)) + for i := p.offset; i < end; i++ { + row := p.renderRow(p.flat[i], inner) + switch { + case i == p.cursor && focused: + row = theme.Selected().Render(row) + case p.flat[i].isDir: + row = theme.Dir().Render(row) + } + b.WriteString(row) + if i < end-1 { + b.WriteString("\n") + } + } + } + + return theme.Panel(p.w, p.h, focused).Render(title + "\n" + b.String()) +} + +func (p *Tree) renderRow(n *node, width int) string { + marker := " " + if n.isDir { + if n.expanded { + marker = "▾ " + } else { + marker = "▸ " + } + } + + label := strings.Repeat(" ", n.depth) + marker + n.name + if n.isDir { + label += "/" + } + return fit(label, width) +} + +func (p *Tree) move(d int) { + if len(p.flat) == 0 { + return + } + p.cursor = clamp(p.cursor+d, 0, len(p.flat)-1) + p.clampOffset() +} + +// ScrollBy moves the cursor by delta rows (used for mouse-wheel scrolling). +func (p *Tree) ScrollBy(delta int) { p.move(delta) } + +// collapseOrParent collapses an expanded directory, otherwise jumps to the +// parent row. +func (p *Tree) collapseOrParent() { + n := p.current() + if n == nil { + return + } + if n.isDir && n.expanded { + n.expanded = false + p.rebuild() + return + } + for i := p.cursor - 1; i >= 0; i-- { + if p.flat[i].depth == n.depth-1 { + p.cursor = i + p.clampOffset() + return + } + } +} + +func (p *Tree) current() *node { + if p.cursor < 0 || p.cursor >= len(p.flat) { + return nil + } + return p.flat[p.cursor] +} + +// rebuild recomputes the flattened visible-row slice and re-clamps the cursor. +func (p *Tree) rebuild() { + p.flat = p.flat[:0] + if p.root != nil { + flatten(p.root, &p.flat) + } + p.cursor = clamp(p.cursor, 0, max(len(p.flat)-1, 0)) + p.clampOffset() +} + +func (p *Tree) clampOffset() { + visible := max(p.h-3, 1) + if p.cursor < p.offset { + p.offset = p.cursor + } + if p.cursor >= p.offset+visible { + p.offset = p.cursor - visible + 1 + } + if p.offset < 0 { + p.offset = 0 + } +} + +// buildTree walks root, returning its node with Go-bearing descendants +// populated. The root node is always returned (expanded) even when empty. +func buildTree(root string) *node { + rn := &node{name: filepath.Base(root), path: root, isDir: true, expanded: true} + if info, err := os.Stat(root); err == nil && info.IsDir() { + rn.children = readDir(root, 1) + } + return rn +} + +// readDir returns the directory's child nodes: subdirectories that contain Go +// files (transitively) and *.go files, directories first, each sorted by name. +func readDir(dir string, depth int) []*node { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + + var dirs, files []*node + for _, e := range entries { + name := e.Name() + if strings.HasPrefix(name, ".") { + continue + } + + if e.IsDir() { + if name == "vendor" || name == "node_modules" { + continue + } + child := &node{name: name, path: filepath.Join(dir, name), isDir: true, depth: depth} + child.children = readDir(child.path, depth+1) + if len(child.children) > 0 { // prune dirs with no Go content + dirs = append(dirs, child) + } + continue + } + + if strings.HasSuffix(name, ".go") { + files = append(files, &node{name: name, path: filepath.Join(dir, name), depth: depth}) + } + } + + sort.Slice(dirs, func(i, j int) bool { return dirs[i].name < dirs[j].name }) + sort.Slice(files, func(i, j int) bool { return files[i].name < files[j].name }) + return append(dirs, files...) +} + +func flatten(n *node, out *[]*node) { + *out = append(*out, n) + if n.isDir && n.expanded { + for _, c := range n.children { + flatten(c, out) + } + } +} + +// fit truncates s to width with an ellipsis, or right-pads it with spaces so +// the cursor highlight spans the full inner width. +func fit(s string, width int) string { + if width <= 0 { + return "" + } + r := []rune(s) + if len(r) > width { + if width == 1 { + return "…" + } + return string(r[:width-1]) + "…" + } + return s + strings.Repeat(" ", width-len(r)) +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} diff --git a/cmd/genspec-tui/internal/ux/scan.go b/cmd/genspec-tui/internal/ux/scan.go new file mode 100644 index 00000000..0e203aa9 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/scan.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "encoding/json" + "time" + + tea "github.com/charmbracelet/bubbletea" + yaml "go.yaml.in/yaml/v3" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/scanner" +) + +// scanResultMsg carries the outcome of a whole-scope scan: the spec rendered +// as both JSON and YAML, path/definition counts for the header, how long the +// scan took, every grammar.Diagnostic the build emitted (in source order), plus +// any hard error from codescan.Run. +type scanResultMsg struct { + json string + yaml string + paths int + defs int + elapsed time.Duration + diags []grammar.Diagnostic + provenance []scanner.Provenance + err error +} + +// runScan runs codescan over the whole scope (the decision-C model: one spec +// for the whole scanned set) and renders it, timing the work. It runs in a +// tea.Cmd goroutine so packages.Load latency never blocks the event loop. cfg +// is taken by value so the goroutine has a stable snapshot even if the model +// mutates its options. +func runScan(cfg codescan.Options) tea.Cmd { + return func() tea.Msg { + start := time.Now() + res := doScan(cfg) + res.elapsed = time.Since(start) + return res + } +} + +// doScan performs the scan and rendering, returning the result without timing +// (runScan stamps the elapsed time around it). +func doScan(cfg codescan.Options) scanResultMsg { + // OnDiagnostic fires synchronously inside codescan.Run, on this same + // goroutine, so a plain append is race-free. Diagnostics collected before a + // hard error are still worth surfacing, so we carry them on every return. + var diags []grammar.Diagnostic + cfg.OnDiagnostic = func(d grammar.Diagnostic) { + diags = append(diags, d) + } + // OnProvenance also fires synchronously inside codescan.Run, so a plain + // append is race-free. This is the source-side half of the cross-ref linker + // (pointer → source position); the model turns it into a SourceIndex. + var provs []scanner.Provenance + cfg.OnProvenance = func(p scanner.Provenance) { + provs = append(provs, p) + } + + sw, err := codescan.Run(&cfg) + if err != nil { + return scanResultMsg{diags: diags, provenance: provs, err: err} + } + + jb, err := json.MarshalIndent(sw, "", " ") + if err != nil { + return scanResultMsg{diags: diags, provenance: provs, err: err} + } + + res := scanResultMsg{json: string(jb), defs: len(sw.Definitions), diags: diags, provenance: provs} + if sw.Paths != nil { + res.paths = len(sw.Paths.Paths) + } + if yb, yerr := jsonToYAML(jb); yerr == nil { + res.yaml = string(yb) + } + return res +} + +// jsonToYAML reserializes ordered JSON bytes as YAML. Map keys come out +// alphabetically (yaml v3's deterministic order), which is good enough for a +// human-readable viewer. +func jsonToYAML(jb []byte) ([]byte, error) { + var v any + if err := json.Unmarshal(jb, &v); err != nil { + return nil, err + } + return yaml.Marshal(v) +} diff --git a/cmd/genspec-tui/internal/ux/sourceindex.go b/cmd/genspec-tui/internal/ux/sourceindex.go new file mode 100644 index 00000000..c28edfdf --- /dev/null +++ b/cmd/genspec-tui/internal/ux/sourceindex.go @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "go/token" + "sort" + "strings" + + "github.com/go-openapi/codescan/internal/scanner" +) + +// SourceIndex is the caller-owned source-side half of the cross-ref linker +// (design §3): it maps the RFC 6901 JSON pointers codescan emits via +// OnProvenance to the Go source position that produced them, and back. codescan +// anchors only code-detail nodes, so this is NOT a bijection — a finer pointer +// resolves to its nearest anchored ancestor (PositionFor), and a source line +// resolves to its nearest enclosing anchor (PointerAt). +type SourceIndex struct { + fwd map[string]token.Position // pointer → source position + byFile map[string][]lineAnchor // absolute file → anchors sorted by line +} + +// lineAnchor is one (1-based source line → pointer) entry within a file, for the +// reverse source→spec lookup. +type lineAnchor struct { + line int + ptr string +} + +// BuildSourceIndex builds the index from the provenance records collected during +// a scan (one OnProvenance call each). Later records win on a duplicate pointer +// (upsert / last-wins), matching codescan's build, where a node may be rewritten. +func BuildSourceIndex(provs []scanner.Provenance) *SourceIndex { + x := &SourceIndex{ + fwd: make(map[string]token.Position, len(provs)), + byFile: make(map[string][]lineAnchor), + } + for _, p := range provs { + x.fwd[p.Pointer] = p.Pos + if p.Pos.Filename != "" { + x.byFile[p.Pos.Filename] = append(x.byFile[p.Pos.Filename], lineAnchor{line: p.Pos.Line, ptr: p.Pointer}) + } + } + for f := range x.byFile { + anchors := x.byFile[f] + sort.Slice(anchors, func(i, j int) bool { return anchors[i].line < anchors[j].line }) + } + return x +} + +// Len reports how many anchored pointers the index holds (0 for a nil index). +func (x *SourceIndex) Len() int { + if x == nil { + return 0 + } + return len(x.fwd) +} + +// PositionFor returns the source position anchored to ptr, or — when ptr itself +// is a finer node with no anchor of its own — the position of its nearest +// anchored ancestor. The walk trims one pointer segment at a time (a zero-alloc +// suffix shrink), so /definitions/User/properties/x/items resolves to +// /definitions/User/properties/x, then /definitions/User, … until a hit. +func (x *SourceIndex) PositionFor(ptr string) (token.Position, bool) { + if x == nil { + return token.Position{}, false + } + for ptr != "" { + if pos, ok := x.fwd[ptr]; ok { + return pos, true + } + i := strings.LastIndexByte(ptr, '/') + if i < 0 { + break + } + ptr = ptr[:i] + } + return token.Position{}, false +} + +// FirstAnchor returns the pointer of the earliest (lowest-line) anchor recorded +// in file, or ok=false when the file produced no spec node. Backs the tree's +// "locate this file in the spec" jump. +func (x *SourceIndex) FirstAnchor(file string) (string, bool) { + if x == nil { + return "", false + } + anchors := x.byFile[file] + if len(anchors) == 0 { + return "", false + } + return anchors[0].ptr, true // byFile is sorted by line +} + +// PointerAt returns the pointer of the nearest anchor at or above (file, line) — +// the spec node enclosing that source line. line is 1-based (token.Position.Line). +// The bool is false when the file holds no anchors or line precedes the first. +func (x *SourceIndex) PointerAt(file string, line int) (string, bool) { + if x == nil { + return "", false + } + anchors := x.byFile[file] + if len(anchors) == 0 { + return "", false + } + // greatest anchor line <= line + i := sort.Search(len(anchors), func(i int) bool { return anchors[i].line > line }) - 1 + if i < 0 { + return "", false + } + return anchors[i].ptr, true +} diff --git a/cmd/genspec-tui/internal/ux/sourceindex_test.go b/cmd/genspec-tui/internal/ux/sourceindex_test.go new file mode 100644 index 00000000..73906e1f --- /dev/null +++ b/cmd/genspec-tui/internal/ux/sourceindex_test.go @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "go/token" + "testing" + + "github.com/go-openapi/codescan/internal/scanner" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func srcPos(file string, line int) token.Position { + return token.Position{Filename: file, Line: line} +} + +func TestSourceIndex_PositionFor(t *testing.T) { + idx := BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User", Pos: srcPos("user.go", 10)}, + {Pointer: "/definitions/User/properties/email", Pos: srcPos("user.go", 14)}, + {Pointer: "/paths/~1pets/get", Pos: srcPos("api.go", 3)}, + }) + + t.Run("exact hit", func(t *testing.T) { + p, ok := idx.PositionFor("/definitions/User/properties/email") + require.True(t, ok) + assert.Equal(t, "user.go", p.Filename) + assert.Equal(t, 14, p.Line) + }) + + t.Run("nearest anchored ancestor", func(t *testing.T) { + // A finer node with no anchor of its own resolves to the closest + // anchored ancestor — here the property, then the definition. + p, ok := idx.PositionFor("/definitions/User/properties/email/format") + require.True(t, ok) + assert.Equal(t, 14, p.Line, "should resolve up to the property anchor") + + p, ok = idx.PositionFor("/definitions/User/required/0") + require.True(t, ok) + assert.Equal(t, 10, p.Line, "should resolve up to the definition anchor") + }) + + t.Run("no anchor at or above", func(t *testing.T) { + _, ok := idx.PositionFor("/swagger") + assert.False(t, ok) + _, ok = idx.PositionFor("") + assert.False(t, ok) + }) + + t.Run("nil index", func(t *testing.T) { + var nilIdx *SourceIndex + _, ok := nilIdx.PositionFor("/definitions/User") + assert.False(t, ok) + assert.Equal(t, 0, nilIdx.Len()) + }) +} + +func TestSourceIndex_PointerAt(t *testing.T) { + idx := BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User", Pos: srcPos("user.go", 10)}, + {Pointer: "/definitions/User/properties/email", Pos: srcPos("user.go", 14)}, + {Pointer: "/definitions/User/properties/name", Pos: srcPos("user.go", 12)}, + {Pointer: "/paths/~1pets/get", Pos: srcPos("api.go", 3)}, + }) + + t.Run("nearest enclosing anchor", func(t *testing.T) { + // A line inside the email field (anchored at 14) but below it resolves + // to that field; a line between name (12) and email (14) resolves to name. + ptr, ok := idx.PointerAt("user.go", 15) + require.True(t, ok) + assert.Equal(t, "/definitions/User/properties/email", ptr) + + ptr, ok = idx.PointerAt("user.go", 13) + require.True(t, ok) + assert.Equal(t, "/definitions/User/properties/name", ptr) + + ptr, ok = idx.PointerAt("user.go", 10) + require.True(t, ok) + assert.Equal(t, "/definitions/User", ptr, "exact line lands on the definition") + }) + + t.Run("line above the first anchor", func(t *testing.T) { + _, ok := idx.PointerAt("user.go", 1) + assert.False(t, ok) + }) + + t.Run("unknown file", func(t *testing.T) { + _, ok := idx.PointerAt("other.go", 5) + assert.False(t, ok) + }) + + t.Run("per-file isolation", func(t *testing.T) { + ptr, ok := idx.PointerAt("api.go", 9) + require.True(t, ok) + assert.Equal(t, "/paths/~1pets/get", ptr) + }) +} + +func TestSourceIndex_FirstAnchor(t *testing.T) { + idx := BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User/properties/email", Pos: srcPos("user.go", 14)}, + {Pointer: "/definitions/User", Pos: srcPos("user.go", 10)}, + {Pointer: "/paths/~1pets/get", Pos: srcPos("api.go", 3)}, + }) + + ptr, ok := idx.FirstAnchor("user.go") + require.True(t, ok) + assert.Equal(t, "/definitions/User", ptr, "earliest line in the file wins") + + _, ok = idx.FirstAnchor("missing.go") + assert.False(t, ok) + + var nilIdx *SourceIndex + _, ok = nilIdx.FirstAnchor("user.go") + assert.False(t, ok) +} + +func TestSourceIndex_LastWins(t *testing.T) { + // A pointer recorded twice (node rewritten during the build) keeps the last + // position — upsert semantics. + idx := BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/X", Pos: srcPos("a.go", 1)}, + {Pointer: "/definitions/X", Pos: srcPos("a.go", 7)}, + }) + p, ok := idx.PositionFor("/definitions/X") + require.True(t, ok) + assert.Equal(t, 7, p.Line) +} diff --git a/cmd/genspec-tui/internal/ux/specindex.go b/cmd/genspec-tui/internal/ux/specindex.go new file mode 100644 index 00000000..8d1d2b16 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/specindex.go @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "bytes" + "encoding/json/jsontext" + "sort" + "strconv" + "strings" + + yaml "go.yaml.in/yaml/v3" +) + +// SpecIndex maps between rendered-spec lines and the RFC 6901 JSON pointer of +// the spec node shown on each line. It is the spec-side half of the cross-ref +// linker (design §4): line ↔ pointer, built fresh from the exact bytes the spec +// pane displays. Lines are 0-based (matching the viewport's strings.Split +// addressing); the same structure also anchors spec remarks. +type SpecIndex struct { + line2ptr map[int]string + ptr2line map[string]int + lines []int // sorted keys of line2ptr, for nearest-preceding lookup +} + +// PointerAt returns the JSON pointer of the node at line, or the nearest member +// line above it when line itself carries no pointer (e.g. a closing brace). The +// bool is false only for an empty index or a line above the first member. +func (x *SpecIndex) PointerAt(line int) (string, bool) { + if x == nil || len(x.lines) == 0 { + return "", false + } + if p, ok := x.line2ptr[line]; ok { + return p, true + } + // greatest indexed line <= line + i := sort.SearchInts(x.lines, line+1) - 1 + if i < 0 { + return "", false + } + return x.line2ptr[x.lines[i]], true +} + +// LineForPointer returns the 0-based line where pointer is rendered. +func (x *SpecIndex) LineForPointer(ptr string) (int, bool) { + if x == nil { + return 0, false + } + l, ok := x.ptr2line[ptr] + return l, ok +} + +// Len reports how many pointers the index holds (0 for a nil index). +func (x *SpecIndex) Len() int { + if x == nil { + return 0 + } + return len(x.ptr2line) +} + +// newSpecIndex finalizes the maps into a SpecIndex with sorted line keys. +func newSpecIndex(line2ptr map[int]string, ptr2line map[string]int) *SpecIndex { + lines := make([]int, 0, len(line2ptr)) + for l := range line2ptr { + lines = append(lines, l) + } + sort.Ints(lines) + return &SpecIndex{line2ptr: line2ptr, ptr2line: ptr2line, lines: lines} +} + +// BuildJSONIndex builds a SpecIndex from indented JSON bytes using the +// jsontext.Decoder token stream: after each token, StackPointer() names the +// current node, and InputOffset() locates it. The first occurrence of a pointer +// is the member's declaration line; later repeats (its value, its close) are +// ignored. Best-effort — on a decode error it returns what it has so far (the +// rendered bytes are always valid JSON, so this is just defensive). +// +// Requires GOEXPERIMENT=jsonv2 (see plan §4 / build §A0). +func BuildJSONIndex(b []byte) *SpecIndex { + lt := newLineTable(b) + line2ptr := make(map[int]string) + ptr2line := make(map[string]int) + + d := jsontext.NewDecoder(bytes.NewReader(b)) + for { + // Any error (io.EOF at end, or a malformed-input error that can't occur + // on our own freshly-rendered bytes) ends the scan — best-effort. + if _, err := d.ReadToken(); err != nil { + break + } + p := string(d.StackPointer()) + if p == "" { + continue + } + if _, seen := ptr2line[p]; seen { + continue + } + line := lt.lineAt(d.InputOffset()) + ptr2line[p] = line + line2ptr[line] = p + } + return newSpecIndex(line2ptr, ptr2line) +} + +// BuildYAMLIndex builds a SpecIndex from YAML bytes by walking the yaml.Node +// tree, which carries a 1-based .Line per node. Pointers use the same RFC 6901 +// escaping as the JSON side, so an index built from either render is +// interchangeable. Best-effort — a parse error yields an empty index. +func BuildYAMLIndex(b []byte) *SpecIndex { + var root yaml.Node + if err := yaml.Unmarshal(b, &root); err != nil { + return newSpecIndex(map[int]string{}, map[string]int{}) + } + line2ptr := make(map[int]string) + ptr2line := make(map[string]int) + // A document node wraps a single content node. + for _, doc := range docContents(&root) { + walkYAML(doc, "", line2ptr, ptr2line) + } + return newSpecIndex(line2ptr, ptr2line) +} + +func docContents(n *yaml.Node) []*yaml.Node { + if n.Kind == yaml.DocumentNode { + return n.Content + } + return []*yaml.Node{n} +} + +// walkYAML records a pointer→line for each mapping member and sequence element, +// recursing into nested mappings/sequences. Scalars are leaves (their line is +// recorded by the parent member/element). +func walkYAML(n *yaml.Node, prefix string, line2ptr map[int]string, ptr2line map[string]int) { + switch n.Kind { + case yaml.MappingNode: + for i := 0; i+1 < len(n.Content); i += 2 { + key, val := n.Content[i], n.Content[i+1] + ptr := prefix + "/" + escapePointer(key.Value) + record(key.Line-1, ptr, line2ptr, ptr2line) + walkYAML(val, ptr, line2ptr, ptr2line) + } + case yaml.SequenceNode: + for i, val := range n.Content { + ptr := prefix + "/" + strconv.Itoa(i) + record(val.Line-1, ptr, line2ptr, ptr2line) + walkYAML(val, ptr, line2ptr, ptr2line) + } + default: + // Scalars and aliases are leaves; their line was recorded by the + // enclosing member/element. DocumentNode is unwrapped in docContents. + } +} + +func record(line int, ptr string, line2ptr map[int]string, ptr2line map[string]int) { + if _, seen := ptr2line[ptr]; seen { + return + } + ptr2line[ptr] = line + if _, taken := line2ptr[line]; !taken { + line2ptr[line] = ptr + } +} + +// escapePointer applies RFC 6901 token escaping (~ → ~0, / → ~1), matching what +// jsontext.Pointer produces on the JSON side. +func escapePointer(tok string) string { + tok = strings.ReplaceAll(tok, "~", "~0") + tok = strings.ReplaceAll(tok, "/", "~1") + return tok +} + +// itoa is a tiny strconv.Itoa avoiding the import churn for one call site. +// lineTable maps byte offsets to 0-based line numbers via the positions of '\n'. +type lineTable struct { + nlOffsets []int64 // sorted offsets of each '\n' +} + +func newLineTable(b []byte) lineTable { + var nl []int64 + for i, c := range b { + if c == '\n' { + nl = append(nl, int64(i)) + } + } + return lineTable{nlOffsets: nl} +} + +// lineAt returns the 0-based line containing the byte at off (= number of +// newlines strictly before off). +func (lt lineTable) lineAt(off int64) int { + return sort.Search(len(lt.nlOffsets), func(i int) bool { return lt.nlOffsets[i] >= off }) +} diff --git a/cmd/genspec-tui/internal/ux/specindex_test.go b/cmd/genspec-tui/internal/ux/specindex_test.go new file mode 100644 index 00000000..156a0560 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/specindex_test.go @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import "testing" + +// specJSON is a small rendered-spec sample exercising the cases that matter: +// nested objects, an array element, and a key needing RFC 6901 escaping +// (`/pets` → `~1pets`). Indented exactly as json.MarshalIndent renders. +const specJSON = `{ + "definitions": { + "User": { + "properties": { + "email": { + "type": "string" + } + }, + "required": [ + "email" + ] + } + }, + "paths": { + "/pets": { + "get": { + "operationId": "listPets" + } + } + } +}` + +func TestBuildJSONIndex(t *testing.T) { + idx := BuildJSONIndex([]byte(specJSON)) + + // pointer → 0-based line (count lines in specJSON above). + want := map[string]int{ + "/definitions": 1, + "/definitions/User": 2, + "/definitions/User/properties": 3, + "/definitions/User/properties/email": 4, + "/definitions/User/properties/email/type": 5, + "/definitions/User/required": 8, + "/definitions/User/required/0": 9, + "/paths": 13, + "/paths/~1pets": 14, // escaped key + "/paths/~1pets/get": 15, + "/paths/~1pets/get/operationId": 16, + } + for ptr, line := range want { + got, ok := idx.LineForPointer(ptr) + if !ok { + t.Errorf("pointer %q missing from index", ptr) + continue + } + if got != line { + t.Errorf("pointer %q: line = %d, want %d", ptr, got, line) + } + } + + // line → pointer round-trips for a representative member line. + if p, ok := idx.PointerAt(4); !ok || p != "/definitions/User/properties/email" { + t.Errorf("PointerAt(4) = %q,%v; want the email property", p, ok) + } + + // A closing-brace line (7: ` },`) carries no member; PointerAt resolves + // to the nearest preceding member line (the email type at 5). + if p, ok := idx.PointerAt(7); !ok || p != "/definitions/User/properties/email/type" { + t.Errorf("PointerAt(7) nearest-preceding = %q,%v", p, ok) + } +} + +// specYAML mirrors specJSON's shape (keys in a fixed order so line numbers are +// stable); the index must produce the same pointers as the JSON side. +const specYAML = `definitions: + User: + properties: + email: + type: string + required: + - email +paths: + /pets: + get: + operationId: listPets +` + +func TestBuildYAMLIndex(t *testing.T) { + idx := BuildYAMLIndex([]byte(specYAML)) + + want := map[string]int{ + "/definitions": 0, + "/definitions/User": 1, + "/definitions/User/properties": 2, + "/definitions/User/properties/email": 3, + "/definitions/User/properties/email/type": 4, + "/definitions/User/required": 5, + "/definitions/User/required/0": 6, + "/paths": 7, + "/paths/~1pets": 8, // escaped key, same as JSON + "/paths/~1pets/get": 9, + "/paths/~1pets/get/operationId": 10, + } + for ptr, line := range want { + got, ok := idx.LineForPointer(ptr) + if !ok { + t.Errorf("pointer %q missing from YAML index", ptr) + continue + } + if got != line { + t.Errorf("pointer %q: line = %d, want %d", ptr, got, line) + } + } +} + +func TestSpecIndexEmptyAndNil(t *testing.T) { + var nilIdx *SpecIndex + if _, ok := nilIdx.PointerAt(3); ok { + t.Error("nil index PointerAt should report not-found") + } + if nilIdx.Len() != 0 { + t.Error("nil index Len should be 0") + } + + empty := BuildJSONIndex([]byte(`{}`)) + if _, ok := empty.PointerAt(0); ok { + t.Error("empty object should index no pointers") + } +} + +func TestLineTable(t *testing.T) { + lt := newLineTable([]byte("a\nbb\n\nc")) + cases := []struct { + off int64 + line int + }{ + {0, 0}, // 'a' + {1, 0}, // '\n' after a still line 0 + {2, 1}, // 'b' + {5, 2}, // '\n' (the empty line's newline) → line 2 + {6, 3}, // 'c' + } + for _, c := range cases { + if got := lt.lineAt(c.off); got != c.line { + t.Errorf("lineAt(%d) = %d, want %d", c.off, got, c.line) + } + } +} diff --git a/cmd/genspec-tui/internal/ux/theme/theme.go b/cmd/genspec-tui/internal/ux/theme/theme.go new file mode 100644 index 00000000..ec9ae8b3 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/theme/theme.go @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package theme holds the lipgloss styles shared by the model and its panels: +// a rounded-border panel box (bright when focused, dim otherwise), a panel +// title, and the status line. Kept tiny and dependency-free so both ux and +// panels can import it without a cycle. +package theme + +import "github.com/charmbracelet/lipgloss" + +var ( + colorActive = lipgloss.Color("170") + colorInactive = lipgloss.Color("240") + colorTitle = lipgloss.Color("213") + colorDim = lipgloss.Color("245") + colorError = lipgloss.Color("203") + colorWarn = lipgloss.Color("214") + colorHint = lipgloss.Color("110") +) + +// Panel returns a rounded-border box style whose OUTER dimensions are w×h +// (the border consumes one cell on each side). The border is bright when +// focused and dim otherwise. +func Panel(w, h int, focused bool) lipgloss.Style { + s := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Width(max(w-2, 0)). + Height(max(h-2, 0)) + if focused { + return s.BorderForeground(colorActive) + } + return s.BorderForeground(colorInactive) +} + +// Title styles a panel's header line. +func Title(focused bool) lipgloss.Style { + s := lipgloss.NewStyle().Bold(true) + if focused { + return s.Foreground(colorTitle) + } + return s.Foreground(colorDim) +} + +// Status styles the bottom status/help line. +func Status() lipgloss.Style { + return lipgloss.NewStyle().Foreground(colorDim) +} + +// Accent styles the app name / emphasised header text. +func Accent() lipgloss.Style { + return lipgloss.NewStyle().Foreground(colorActive).Bold(true) +} + +// Match styles a search hit in the spec pane. +func Match() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("16")). + Background(lipgloss.Color("226")) +} + +// Modal styles a centered popup box (e.g. the scanner-options dialog). +func Modal() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorActive). + Padding(1, 3) +} + +// SevError, SevWarn, and SevHint style a diagnostic's severity label in the +// diagnostics pane (red / amber / blue), matching grammar.Severity order. +func SevError() lipgloss.Style { return lipgloss.NewStyle().Foreground(colorError).Bold(true) } + +// SevWarn styles a warning-severity diagnostic label. +func SevWarn() lipgloss.Style { return lipgloss.NewStyle().Foreground(colorWarn) } + +// SevHint styles a hint-severity diagnostic label. +func SevHint() lipgloss.Style { return lipgloss.NewStyle().Foreground(colorHint) } + +// Dir styles a directory row in the source tree. +func Dir() lipgloss.Style { + return lipgloss.NewStyle().Foreground(colorTitle) +} + +// Selected styles the cursor row in a navigable panel. +func Selected() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("231")). + Background(colorActive) +} diff --git a/cmd/genspec-tui/internal/ux/watcher.go b/cmd/genspec-tui/internal/ux/watcher.go new file mode 100644 index 00000000..750ff5ad --- /dev/null +++ b/cmd/genspec-tui/internal/ux/watcher.go @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "os" + "path/filepath" + "strings" + + "github.com/fsnotify/fsnotify" +) + +// watcher reports Go-source changes under a directory tree as a coalesced +// stream of signals. It watches every (non-vendored, non-hidden) directory in +// the tree — fsnotify is not recursive — and re-adds directories created at +// runtime so new packages are picked up. Bursts collapse into a single pending +// signal; the model debounces further before rescanning. +type watcher struct { + fs *fsnotify.Watcher + events chan struct{} +} + +func newWatcher(root string) (*watcher, error) { + fw, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + w := &watcher{fs: fw, events: make(chan struct{}, 1)} + w.addRecursive(root) + go w.loop() + return w, nil +} + +// addRecursive adds every directory under root to the watch set, pruning the +// same noise the source tree prunes (hidden dirs, vendor, node_modules). +func (w *watcher) addRecursive(root string) { + _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil || !d.IsDir() { + return nil //nolint:nilerr // skip unreadable entries, keep walking + } + if name := d.Name(); path != root && (strings.HasPrefix(name, ".") || name == "vendor" || name == "node_modules") { + return filepath.SkipDir + } + _ = w.fs.Add(path) // best effort; ignore per-dir watch-limit errors + return nil + }) +} + +func (w *watcher) loop() { + for { + select { + case ev, ok := <-w.fs.Events: + if !ok { + close(w.events) + return + } + if w.relevant(ev) { + w.signal() + } + case _, ok := <-w.fs.Errors: + if !ok { + close(w.events) + return + } + } + } +} + +// relevant reports whether an event should trigger a rescan: any *.go change, +// or a directory create/remove/rename (which can add or drop packages). Newly +// created directories are added to the watch set so their files are seen. +func (w *watcher) relevant(ev fsnotify.Event) bool { + if strings.HasSuffix(ev.Name, ".go") { + return true + } + if ev.Op&(fsnotify.Create|fsnotify.Remove|fsnotify.Rename) != 0 { + if fi, err := os.Stat(ev.Name); err == nil && fi.IsDir() { + if ev.Op&fsnotify.Create != 0 { + w.addRecursive(ev.Name) + } + return true + } + } + return false +} + +// signal posts a coalesced change notification (non-blocking: a pending signal +// already covers this change). +func (w *watcher) signal() { + select { + case w.events <- struct{}{}: + default: + } +} + +// Close stops watching and tears down the goroutine. +func (w *watcher) Close() error { return w.fs.Close() } diff --git a/cmd/genspec-tui/main.go b/cmd/genspec-tui/main.go new file mode 100644 index 00000000..67f96b48 --- /dev/null +++ b/cmd/genspec-tui/main.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Command genspec-tui is an interactive terminal front-end for the codescan +// Swagger-spec generator: a source-tree browser (left), the generated spec +// (right, JSON/YAML), and diagnostics (bottom). It regenerates the whole-scope +// spec on any file change. +// +// This is the scaffold: an empty three-panel UX shell. Scanner wiring, +// file-watching, and diagnostics rendering land in later increments. +package main + +import ( + "flag" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux" +) + +func main() { + // Mute the scanner's logging. codescan writes warnings (unsupported type + // kinds, skipped builtins, …) through the standard log package, whose + // default sink is stderr — which paints over bubbletea's alt-screen and + // corrupts the TUI. Discard it globally for the lifetime of the program. + // (Reflection: codescan should accept an injected sink / route these + // through OnDiagnostic instead of the global logger — see plan.) + log.SetOutput(io.Discard) + + workdir := flag.String("workdir", ".", "module directory where scanning runs (codescan WorkDir)") + packages := flag.String("packages", "./...", "comma-separated package patterns to scan, relative to -workdir") + scanModels := flag.Bool("scan-models", true, "also emit definitions for swagger:model types") + flag.Parse() + + dir, err := filepath.Abs(*workdir) + if err != nil { + fmt.Fprintln(os.Stderr, "genspec-tui:", err) + os.Exit(1) + } + + model := ux.New(dir, splitPatterns(*packages), *scanModels) + defer model.Close() + + p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()) + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, "genspec-tui:", err) + os.Exit(1) + } +} + +// splitPatterns parses the comma-separated -packages flag into non-empty, +// trimmed patterns, falling back to "./..." when nothing usable is given. +func splitPatterns(s string) []string { + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + if len(out) == 0 { + return []string{"./..."} + } + return out +} From c2ac06ccc944023c94694facd77d6b84b0c3e021 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Fri, 12 Jun 2026 20:26:47 +0200 Subject: [PATCH 02/13] build(genspec-tui): monorepo workspace and CI Tie cmd/genspec-tui into the repo as a second module via go.work, and add its CI: a monorepo test workflow, release/tag automation and dependabot, plus the lint config and .gitignore (go.work.sum is generated, not committed). The TUI module builds under GOEXPERIMENT=jsonv2 (jsontext); official binaries/wasm are pinned to go1.26 with the flag, and users building their own are warned. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Frederic BIDON --- .codecov.yml | 7 ++- .github/workflows/tui-test.yml | 63 ++++++++++++++++++++ .gitignore | 3 + .golangci.yml | 1 + cmd/genspec-tui/go.mod | 49 ++++++++------- cmd/genspec-tui/go.sum | 105 ++++++++++++++++----------------- go.work | 12 ++++ 7 files changed, 157 insertions(+), 83 deletions(-) create mode 100644 .github/workflows/tui-test.yml create mode 100644 go.work diff --git a/.codecov.yml b/.codecov.yml index c8edfe2a..a5ba8e96 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,8 +1,9 @@ +codecov: + notify: + after_n_builds: 2 + coverage: status: patch: default: target: 80% -ignore: - - internal/scantest - - fixtures diff --git a/.github/workflows/tui-test.yml b/.github/workflows/tui-test.yml new file mode 100644 index 00000000..a90296cc --- /dev/null +++ b/.github/workflows/tui-test.yml @@ -0,0 +1,63 @@ +name: tui-test + +permissions: + pull-requests: read + contents: read + +on: + push: + branches: + - master + + pull_request: + +jobs: + tui-test: + runs-on: ubuntu-latest + + strategy: + matrix: + go-version: [stable] + + steps: + - + name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - + name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ matrix.go-version }} + - + name: golangci-lint + uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 + env: + GOEXPERIMENT: jsonv2 + with: + version: latest + only-new-issues: true + working-directory: cmd/genspec-tui + - + name: Test the TUI CLI + env: + GOEXPERIMENT: jsonv2 + run: > + cd cmd/genspec-tui + go test -race -count=1 + -coverprofile='genspec-tui.coverage.ubuntu-latest-${{ matrix.go-version }}.out' + -covermode=atomic + -coverpkg='github.com/go-openapi/codescan' + ./... + - + name: Upload coverage artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + path: 'genspec-tui.coverage.ubuntu-latest-${{ matrix.go-version }}.out' + name: 'genspec-tui.coverage.ubuntu-latest-${{ matrix.go-version }}' + retention-days: 1 + + collect-coverage: + needs: [tui-test] + if: ${{ !cancelled() && needs.tui-test.result == 'success' }} + uses: go-openapi/ci-workflows/.github/workflows/collect-coverage.yml@f00f5763ddb0c59105de5f565da8cac323fce2bf # v0.3.5 + secrets: inherit diff --git a/.gitignore b/.gitignore index 24693b02..4528f287 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ profile.cov # Dependency directories (remove the comment below to include it) # vendor/ +# Go workspace: commit go.work, ignore the generated checksum file +go.work.sum + # env file .env diff --git a/.golangci.yml b/.golangci.yml index 1515b84f..344ac75f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -7,6 +7,7 @@ linters: - gomoddirectives # mono-repo, multi-modules (docs/examples): local replace directives are needed for proper releasing - goconst # disabled, perhaps temporarily as this linter has become way too pick and noisy - godox + - gomoddirectives - gomodguard - gomodguard_v2 - exhaustruct diff --git a/cmd/genspec-tui/go.mod b/cmd/genspec-tui/go.mod index b09a9ada..aec7574e 100644 --- a/cmd/genspec-tui/go.mod +++ b/cmd/genspec-tui/go.mod @@ -8,46 +8,45 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/fsnotify/fsnotify v1.10.1 github.com/go-openapi/codescan v0.34.0 + github.com/go-openapi/testify/v2 v2.5.1 go.yaml.in/yaml/v3 v3.0.4 ) require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/go-openapi/jsonpointer v0.22.5 // indirect - github.com/go-openapi/jsonreference v0.21.5 // indirect - github.com/go-openapi/loads v0.23.3 // indirect - github.com/go-openapi/spec v0.22.4 // indirect - github.com/go-openapi/swag/conv v0.25.5 // indirect - github.com/go-openapi/swag/jsonname v0.25.5 // indirect - github.com/go-openapi/swag/jsonutils v0.25.5 // indirect - github.com/go-openapi/swag/loading v0.25.5 // indirect - github.com/go-openapi/swag/mangling v0.26.0 // indirect - github.com/go-openapi/swag/stringutils v0.25.5 // indirect - github.com/go-openapi/swag/typeutils v0.25.5 // indirect - github.com/go-openapi/swag/yamlutils v0.25.5 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.6 // indirect + github.com/go-openapi/spec v0.22.6 // indirect + github.com/go-openapi/swag/conv v0.26.1 // indirect + github.com/go-openapi/swag/jsonname v0.26.1 // indirect + github.com/go-openapi/swag/jsonutils v0.26.1 // indirect + github.com/go-openapi/swag/loading v0.26.1 // indirect + github.com/go-openapi/swag/mangling v0.26.1 // indirect + github.com/go-openapi/swag/stringutils v0.26.1 // indirect + github.com/go-openapi/swag/typeutils v0.26.1 // indirect + github.com/go-openapi/swag/yamlutils v0.26.1 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.34.0 // indirect - golang.org/x/tools v0.45.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/tools v0.46.0 // indirect ) replace github.com/go-openapi/codescan => ../.. diff --git a/cmd/genspec-tui/go.sum b/cmd/genspec-tui/go.sum index 7230b55a..78daac70 100644 --- a/cmd/genspec-tui/go.sum +++ b/cmd/genspec-tui/go.sum @@ -10,66 +10,62 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= -github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= -github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= -github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= -github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= -github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= -github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= -github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= -github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= -github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= -github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= -github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= -github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= -github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= -github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= -github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= -github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= -github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= -github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= -github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= -github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= -github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= -github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= -github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.1 h1:NZOrZmIb6PTv5LTFxr5/mKV/FjbUzGE7E6gLz7vFoOQ= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.1/go.mod h1:r7dwsujEHawapMsxA69i+XMGZrQ5tRauhLAjV/sxg3Q= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= +github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= +github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= +github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= +github.com/go-openapi/swag/conv v0.26.1 h1:slr5FVkg9Wc3Y5zcwenD8Sd/PQ94b2I/QJI7N7KTBpg= +github.com/go-openapi/swag/conv v0.26.1/go.mod h1:mvQXgPptZk9GTrFgGwWvT4q+dN+zQej9JfmGwnipz1A= +github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY= +github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1 h1:1CD7NiLLb/TXl3tOnFYU4b+mNfb5rtgHkaA+q7RMYYQ= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1/go.mod h1:ZWafc8nMdYzTE3uYY6W86f0n46+IF0g4uUyRhJw/kXc= +github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w= +github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E= +github.com/go-openapi/swag/mangling v0.26.1 h1:gpYI4WuPKFJJVjV5cDLGlDVJhFIxYjQc7yN5eEb4CqM= +github.com/go-openapi/swag/mangling v0.26.1/go.mod h1:POETDH01hqAdASXfw7ISEd9bCOE6xBHOt8NHmGZRmYM= +github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU= +github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk= +github.com/go-openapi/swag/typeutils v0.26.1 h1:yg42FgMzRR6PVQ3M3qHz1s+Y6/P4HoJ3cBarXa3OVnU= +github.com/go-openapi/swag/typeutils v0.26.1/go.mod h1:VfnV+oUtSP2vCSCn2aJgnr8OevUYemyIzzS1VOzS10o= +github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk= +github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -84,17 +80,16 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/go.work b/go.work new file mode 100644 index 00000000..00629d1c --- /dev/null +++ b/go.work @@ -0,0 +1,12 @@ +go 1.25.0 + +// Workspace for the codescan monorepo: the main library module (.) and the +// genspec-tui front-end module (./cmd/genspec-tui), kept in separate go.mod +// files so the TUI's bubbletea dependency tree never pollutes the lean library. +// +// Dev-only: `go install .../cmd/genspec-tui@latest` ignores this file, so the +// TUI module's own go.mod carries the real `require` on the library once wired. +use ( + . + ./docs/examples +) From 671faf95086480947e5a25b463325daa8766ad3c Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Thu, 30 Jul 2026 12:10:43 +0200 Subject: [PATCH 03/13] feat(tui): removed the need for goexperiment flag. * json & yaml parsing now relies on lexers provided by go-openapi/core/json to scan & identifies json and yaml. * this package builds with go1.25.8, no go debug flag required any longer * simplifies the spec index construction, since token pointer evaluation is provided out-of-the-box by the lexer Signed-off-by: Frederic BIDON --- .github/workflows/tui-test.yml | 4 - cmd/genspec-tui/go.mod | 46 ++--- cmd/genspec-tui/go.sum | 60 ++++++- .../internal/ux/{ => index}/sourceindex.go | 2 +- .../ux/{ => index}/sourceindex_test.go | 2 +- .../internal/ux/{ => index}/specindex.go | 159 ++++++------------ .../internal/ux/{ => index}/specindex_test.go | 2 +- cmd/genspec-tui/internal/ux/model.go | 15 +- .../internal/ux/model_follow_test.go | 11 +- docs/examples/go.mod | 2 +- fixtures/go.mod | 2 +- go.mod | 2 +- go.work | 3 +- 13 files changed, 155 insertions(+), 155 deletions(-) rename cmd/genspec-tui/internal/ux/{ => index}/sourceindex.go (99%) rename cmd/genspec-tui/internal/ux/{ => index}/sourceindex_test.go (99%) rename cmd/genspec-tui/internal/ux/{ => index}/specindex.go (58%) rename cmd/genspec-tui/internal/ux/{ => index}/specindex_test.go (99%) diff --git a/.github/workflows/tui-test.yml b/.github/workflows/tui-test.yml index a90296cc..f7b7e676 100644 --- a/.github/workflows/tui-test.yml +++ b/.github/workflows/tui-test.yml @@ -31,16 +31,12 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 - env: - GOEXPERIMENT: jsonv2 with: version: latest only-new-issues: true working-directory: cmd/genspec-tui - name: Test the TUI CLI - env: - GOEXPERIMENT: jsonv2 run: > cd cmd/genspec-tui go test -race -count=1 diff --git a/cmd/genspec-tui/go.mod b/cmd/genspec-tui/go.mod index aec7574e..c62193cf 100644 --- a/cmd/genspec-tui/go.mod +++ b/cmd/genspec-tui/go.mod @@ -1,6 +1,6 @@ module github.com/go-openapi/codescan/cmd/genspec-tui -go 1.25.0 +go 1.25.8 require ( github.com/charmbracelet/bubbles v1.0.0 @@ -8,8 +8,10 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/fsnotify/fsnotify v1.10.1 github.com/go-openapi/codescan v0.34.0 - github.com/go-openapi/testify/v2 v2.5.1 - go.yaml.in/yaml/v3 v3.0.4 + github.com/go-openapi/core/json v0.0.2 + github.com/go-openapi/core/json/lexers/yaml-lexer v0.0.2 + github.com/go-openapi/testify/v2 v2.6.0 + go.yaml.in/yaml/v3 v3.0.5 ) require ( @@ -22,31 +24,33 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/go-openapi/jsonpointer v0.23.1 // indirect - github.com/go-openapi/jsonreference v0.21.6 // indirect - github.com/go-openapi/spec v0.22.6 // indirect - github.com/go-openapi/swag/conv v0.26.1 // indirect - github.com/go-openapi/swag/jsonname v0.26.1 // indirect - github.com/go-openapi/swag/jsonutils v0.26.1 // indirect - github.com/go-openapi/swag/loading v0.26.1 // indirect - github.com/go-openapi/swag/mangling v0.26.1 // indirect - github.com/go-openapi/swag/stringutils v0.26.1 // indirect - github.com/go-openapi/swag/typeutils v0.26.1 // indirect - github.com/go-openapi/swag/yamlutils v0.26.1 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/spec v0.22.9 // indirect + github.com/go-openapi/swag/conv v0.28.0 // indirect + github.com/go-openapi/swag/jsonname v0.28.0 // indirect + github.com/go-openapi/swag/jsonutils v0.28.0 // indirect + github.com/go-openapi/swag/loading v0.28.0 // indirect + github.com/go-openapi/swag/mangling v0.28.0 // indirect + github.com/go-openapi/swag/pools v0.28.0 // indirect + github.com/go-openapi/swag/stringutils v0.28.0 // indirect + github.com/go-openapi/swag/typeutils v0.28.0 // indirect + github.com/go-openapi/swag/yamlutils v0.28.0 // indirect + github.com/goccy/go-yaml v1.19.2 // 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.24 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/mattn/go-runewidth v0.0.27 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect - golang.org/x/tools v0.46.0 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect ) replace github.com/go-openapi/codescan => ../.. diff --git a/cmd/genspec-tui/go.sum b/cmd/genspec-tui/go.sum index 78daac70..774c0818 100644 --- a/cmd/genspec-tui/go.sum +++ b/cmd/genspec-tui/go.sum @@ -28,44 +28,76 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/go-openapi/core/json v0.0.2 h1:RACr1Kjs6U8Rzpu0JLnJ2tw5TZ86+5ROXFPNQg1L9I0= +github.com/go-openapi/core/json v0.0.2/go.mod h1:vEcP/Wkw1ImzIAmGt7lmY+dJ8Ilf0TtvV8vPe8HtVA4= +github.com/go-openapi/core/json/lexers/yaml-lexer v0.0.2 h1:aJC6mspwBIPzxJoduTagX416RvVRMZL6yygngyITHoM= +github.com/go-openapi/core/json/lexers/yaml-lexer v0.0.2/go.mod h1:p4x5CYKYZecVZLy22fOMap8EGW5Rkb4yPxpIzWuCYr4= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= -github.com/go-openapi/swag/conv v0.26.1 h1:slr5FVkg9Wc3Y5zcwenD8Sd/PQ94b2I/QJI7N7KTBpg= -github.com/go-openapi/swag/conv v0.26.1/go.mod h1:mvQXgPptZk9GTrFgGwWvT4q+dN+zQej9JfmGwnipz1A= +github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= +github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= +github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8= +github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k= github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-openapi/swag/jsonname v0.28.0 h1:IteWYOZSFQVvBZEMPAddN5AI/KSaJ9DVcFyVeMeza4Q= +github.com/go-openapi/swag/jsonname v0.28.0/go.mod h1:rtHNjjwBhdavc6eybmd5Fj60cIgstqQHcToaK/+4WwQ= github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY= github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ= +github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= +github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg= github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1 h1:1CD7NiLLb/TXl3tOnFYU4b+mNfb5rtgHkaA+q7RMYYQ= github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1/go.mod h1:ZWafc8nMdYzTE3uYY6W86f0n46+IF0g4uUyRhJw/kXc= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0= github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w= github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E= +github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU= +github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k= github.com/go-openapi/swag/mangling v0.26.1 h1:gpYI4WuPKFJJVjV5cDLGlDVJhFIxYjQc7yN5eEb4CqM= github.com/go-openapi/swag/mangling v0.26.1/go.mod h1:POETDH01hqAdASXfw7ISEd9bCOE6xBHOt8NHmGZRmYM= +github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM= +github.com/go-openapi/swag/mangling v0.28.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= +github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU= github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk= -github.com/go-openapi/swag/typeutils v0.26.1 h1:yg42FgMzRR6PVQ3M3qHz1s+Y6/P4HoJ3cBarXa3OVnU= -github.com/go-openapi/swag/typeutils v0.26.1/go.mod h1:VfnV+oUtSP2vCSCn2aJgnr8OevUYemyIzzS1VOzS10o= +github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU= +github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY= +github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk= github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M= +github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY= +github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs= github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= -github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= -github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -78,18 +110,30 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= -golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= -golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/cmd/genspec-tui/internal/ux/sourceindex.go b/cmd/genspec-tui/internal/ux/index/sourceindex.go similarity index 99% rename from cmd/genspec-tui/internal/ux/sourceindex.go rename to cmd/genspec-tui/internal/ux/index/sourceindex.go index c28edfdf..0925d66c 100644 --- a/cmd/genspec-tui/internal/ux/sourceindex.go +++ b/cmd/genspec-tui/internal/ux/index/sourceindex.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package ux +package index import ( "go/token" diff --git a/cmd/genspec-tui/internal/ux/sourceindex_test.go b/cmd/genspec-tui/internal/ux/index/sourceindex_test.go similarity index 99% rename from cmd/genspec-tui/internal/ux/sourceindex_test.go rename to cmd/genspec-tui/internal/ux/index/sourceindex_test.go index 73906e1f..d97c9eb5 100644 --- a/cmd/genspec-tui/internal/ux/sourceindex_test.go +++ b/cmd/genspec-tui/internal/ux/index/sourceindex_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package ux +package index import ( "go/token" diff --git a/cmd/genspec-tui/internal/ux/specindex.go b/cmd/genspec-tui/internal/ux/index/specindex.go similarity index 58% rename from cmd/genspec-tui/internal/ux/specindex.go rename to cmd/genspec-tui/internal/ux/index/specindex.go index 8d1d2b16..86ab96cd 100644 --- a/cmd/genspec-tui/internal/ux/specindex.go +++ b/cmd/genspec-tui/internal/ux/index/specindex.go @@ -1,16 +1,13 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package ux +package index import ( - "bytes" - "encoding/json/jsontext" "sort" - "strconv" - "strings" - yaml "go.yaml.in/yaml/v3" + lexer "github.com/go-openapi/core/json/lexers/default-lexer" + yamllexer "github.com/go-openapi/core/json/lexers/yaml-lexer" ) // SpecIndex maps between rendered-spec lines and the RFC 6901 JSON pointer of @@ -24,43 +21,8 @@ type SpecIndex struct { lines []int // sorted keys of line2ptr, for nearest-preceding lookup } -// PointerAt returns the JSON pointer of the node at line, or the nearest member -// line above it when line itself carries no pointer (e.g. a closing brace). The -// bool is false only for an empty index or a line above the first member. -func (x *SpecIndex) PointerAt(line int) (string, bool) { - if x == nil || len(x.lines) == 0 { - return "", false - } - if p, ok := x.line2ptr[line]; ok { - return p, true - } - // greatest indexed line <= line - i := sort.SearchInts(x.lines, line+1) - 1 - if i < 0 { - return "", false - } - return x.line2ptr[x.lines[i]], true -} - -// LineForPointer returns the 0-based line where pointer is rendered. -func (x *SpecIndex) LineForPointer(ptr string) (int, bool) { - if x == nil { - return 0, false - } - l, ok := x.ptr2line[ptr] - return l, ok -} - -// Len reports how many pointers the index holds (0 for a nil index). -func (x *SpecIndex) Len() int { - if x == nil { - return 0 - } - return len(x.ptr2line) -} - -// newSpecIndex finalizes the maps into a SpecIndex with sorted line keys. -func newSpecIndex(line2ptr map[int]string, ptr2line map[string]int) *SpecIndex { +// NewSpecIndex finalizes the maps into a SpecIndex with sorted line keys. +func NewSpecIndex(line2ptr map[int]string, ptr2line map[string]int) *SpecIndex { lines := make([]int, 0, len(line2ptr)) for l := range line2ptr { lines = append(lines, l) @@ -76,31 +38,29 @@ func newSpecIndex(line2ptr map[int]string, ptr2line map[string]int) *SpecIndex { // ignored. Best-effort — on a decode error it returns what it has so far (the // rendered bytes are always valid JSON, so this is just defensive). // -// Requires GOEXPERIMENT=jsonv2 (see plan §4 / build §A0). +// When using standard lib, this requires GOEXPERIMENT=jsonv2. For now, we are +// still relying on github.com/go-json-experiment/json. +// We are actively developing an alternative json lexer (part go-openapi/core/json) +// to avoid these complicated choices in the near future. func BuildJSONIndex(b []byte) *SpecIndex { - lt := newLineTable(b) line2ptr := make(map[int]string) ptr2line := make(map[string]int) - d := jsontext.NewDecoder(bytes.NewReader(b)) - for { - // Any error (io.EOF at end, or a malformed-input error that can't occur - // on our own freshly-rendered bytes) ends the scan — best-effort. - if _, err := d.ReadToken(); err != nil { - break - } - p := string(d.StackPointer()) + lex := lexer.NewVerbatimWithBytes(b, lexer.WithJSONPointer(true)) + for range lex.Tokens() { + line := lex.Line() - 1 + p := lex.JSONPointer().String() // current jsonpath of the token if p == "" { continue } if _, seen := ptr2line[p]; seen { continue } - line := lt.lineAt(d.InputOffset()) ptr2line[p] = line line2ptr[line] = p } - return newSpecIndex(line2ptr, ptr2line) + + return NewSpecIndex(line2ptr, ptr2line) } // BuildYAMLIndex builds a SpecIndex from YAML bytes by walking the yaml.Node @@ -108,66 +68,59 @@ func BuildJSONIndex(b []byte) *SpecIndex { // escaping as the JSON side, so an index built from either render is // interchangeable. Best-effort — a parse error yields an empty index. func BuildYAMLIndex(b []byte) *SpecIndex { - var root yaml.Node - if err := yaml.Unmarshal(b, &root); err != nil { - return newSpecIndex(map[int]string{}, map[string]int{}) - } line2ptr := make(map[int]string) ptr2line := make(map[string]int) - // A document node wraps a single content node. - for _, doc := range docContents(&root) { - walkYAML(doc, "", line2ptr, ptr2line) - } - return newSpecIndex(line2ptr, ptr2line) -} -func docContents(n *yaml.Node) []*yaml.Node { - if n.Kind == yaml.DocumentNode { - return n.Content - } - return []*yaml.Node{n} -} - -// walkYAML records a pointer→line for each mapping member and sequence element, -// recursing into nested mappings/sequences. Scalars are leaves (their line is -// recorded by the parent member/element). -func walkYAML(n *yaml.Node, prefix string, line2ptr map[int]string, ptr2line map[string]int) { - switch n.Kind { - case yaml.MappingNode: - for i := 0; i+1 < len(n.Content); i += 2 { - key, val := n.Content[i], n.Content[i+1] - ptr := prefix + "/" + escapePointer(key.Value) - record(key.Line-1, ptr, line2ptr, ptr2line) - walkYAML(val, ptr, line2ptr, ptr2line) + lex := yamllexer.NewWithBytes(b, yamllexer.WithJSONPointer(true)) + for range lex.Tokens() { + line := lex.Line() - 1 + p := lex.JSONPointer().String() // current jsonpath of the token + if p == "" { + continue } - case yaml.SequenceNode: - for i, val := range n.Content { - ptr := prefix + "/" + strconv.Itoa(i) - record(val.Line-1, ptr, line2ptr, ptr2line) - walkYAML(val, ptr, line2ptr, ptr2line) + if _, seen := ptr2line[p]; seen { + continue } - default: - // Scalars and aliases are leaves; their line was recorded by the - // enclosing member/element. DocumentNode is unwrapped in docContents. + ptr2line[p] = line + line2ptr[line] = p } + + return NewSpecIndex(line2ptr, ptr2line) } -func record(line int, ptr string, line2ptr map[int]string, ptr2line map[string]int) { - if _, seen := ptr2line[ptr]; seen { - return +// PointerAt returns the JSON pointer of the node at line, or the nearest member +// line above it when line itself carries no pointer (e.g. a closing brace). The +// bool is false only for an empty index or a line above the first member. +func (x *SpecIndex) PointerAt(line int) (string, bool) { + if x == nil || len(x.lines) == 0 { + return "", false } - ptr2line[ptr] = line - if _, taken := line2ptr[line]; !taken { - line2ptr[line] = ptr + if p, ok := x.line2ptr[line]; ok { + return p, true } + // greatest indexed line <= line + i := sort.SearchInts(x.lines, line+1) - 1 + if i < 0 { + return "", false + } + return x.line2ptr[x.lines[i]], true } -// escapePointer applies RFC 6901 token escaping (~ → ~0, / → ~1), matching what -// jsontext.Pointer produces on the JSON side. -func escapePointer(tok string) string { - tok = strings.ReplaceAll(tok, "~", "~0") - tok = strings.ReplaceAll(tok, "/", "~1") - return tok +// LineForPointer returns the 0-based line where pointer is rendered. +func (x *SpecIndex) LineForPointer(ptr string) (int, bool) { + if x == nil { + return 0, false + } + l, ok := x.ptr2line[ptr] + return l, ok +} + +// Len reports how many pointers the index holds (0 for a nil index). +func (x *SpecIndex) Len() int { + if x == nil { + return 0 + } + return len(x.ptr2line) } // itoa is a tiny strconv.Itoa avoiding the import churn for one call site. diff --git a/cmd/genspec-tui/internal/ux/specindex_test.go b/cmd/genspec-tui/internal/ux/index/specindex_test.go similarity index 99% rename from cmd/genspec-tui/internal/ux/specindex_test.go rename to cmd/genspec-tui/internal/ux/index/specindex_test.go index 156a0560..050ede11 100644 --- a/cmd/genspec-tui/internal/ux/specindex_test.go +++ b/cmd/genspec-tui/internal/ux/index/specindex_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package ux +package index import "testing" diff --git a/cmd/genspec-tui/internal/ux/model.go b/cmd/genspec-tui/internal/ux/model.go index 0ee86af9..3388b48b 100644 --- a/cmd/genspec-tui/internal/ux/model.go +++ b/cmd/genspec-tui/internal/ux/model.go @@ -23,6 +23,7 @@ import ( "github.com/go-openapi/codescan" "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/gadgets" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/index" "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/key" "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" @@ -117,8 +118,8 @@ type Model struct { specJSON string specYAML string - specIndex *SpecIndex // rendered-line ↔ JSON-pointer map for the active format - srcIndex *SourceIndex // JSON-pointer ↔ Go source position (cross-ref linker) + specIndex *index.SpecIndex // rendered-line ↔ JSON-pointer map for the active format + srcIndex *index.SourceIndex // JSON-pointer ↔ Go source position (cross-ref linker) diags []grammar.Diagnostic scanErr error // hard error from the last codescan.Run, shown in the diag pane diagCursor int // selected diagnostic, for diagnostic→source navigation @@ -172,7 +173,7 @@ func New(workdir string, packages []string, scanModels bool) *Model { {"SetXNullableForPointers", "mark pointer fields as x-nullable: true", &m.cfg.SetXNullableForPointers}, {"RefAliases", "emit $ref for type aliases instead of expanding them", &m.cfg.RefAliases}, {"TransparentAliases", "treat aliases as transparent (never define them)", &m.cfg.TransparentAliases}, - {"DescWithRef", "keep a field description beside its $ref (allOf wrap)", &m.cfg.DescWithRef}, + {"EmitRefSiblings", "keep a field description beside its $ref (allOf wrap)", &m.cfg.EmitRefSiblings}, } if w, err := newWatcher(workdir); err == nil { m.watch = w @@ -245,7 +246,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.diags = msg.diags m.scanErr = msg.err m.diagCursor = clampInt(m.diagCursor, 0, max(len(m.diags)-1, 0)) - m.srcIndex = BuildSourceIndex(msg.provenance) + m.srcIndex = index.BuildSourceIndex(msg.provenance) m.applyScan() m.syncFollowIfActive() // refresh the follower against the rebuilt spec return m, nil @@ -743,9 +744,9 @@ func (m *Model) refreshSpec() { return } if yamlFmt { - m.specIndex = BuildYAMLIndex([]byte(body)) + m.specIndex = index.BuildYAMLIndex([]byte(body)) } else { - m.specIndex = BuildJSONIndex([]byte(body)) + m.specIndex = index.BuildJSONIndex([]byte(body)) } m.spec.SetContent(body) } @@ -1008,7 +1009,7 @@ func (m *Model) optionsView() string { // highlight the whole row, description included b.WriteString(theme.Selected().Render(fmt.Sprintf("%s%s %s %s", caret, box, label, t.desc))) } else { - b.WriteString(fmt.Sprintf("%s%s %s ", caret, box, label)) + fmt.Fprintf(&b, "%s%s %s ", caret, box, label) b.WriteString(theme.Status().Render(t.desc)) } b.WriteString("\n") diff --git a/cmd/genspec-tui/internal/ux/model_follow_test.go b/cmd/genspec-tui/internal/ux/model_follow_test.go index db5c75c3..ab9f13ef 100644 --- a/cmd/genspec-tui/internal/ux/model_follow_test.go +++ b/cmd/genspec-tui/internal/ux/model_follow_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "testing" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/index" "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" "github.com/go-openapi/codescan/internal/scanner" "github.com/go-openapi/testify/v2/assert" @@ -23,7 +24,7 @@ func followFixture(t *testing.T) *Model { m.spec.SetSize(60, 20) m.fileView.SetSize(60, 20) m.spec.SetContent("line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8") - m.specIndex = newSpecIndex( + m.specIndex = index.NewSpecIndex( map[int]string{7: "/definitions/User/properties/email"}, map[string]int{"/definitions/User/properties/email": 7}, ) @@ -32,7 +33,7 @@ func followFixture(t *testing.T) *Model { func TestFollow_SourceDriven(t *testing.T) { m := followFixture(t) - m.srcIndex = BuildSourceIndex([]scanner.Provenance{ + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ {Pointer: "/definitions/User/properties/email", Pos: token.Position{Filename: "user.go", Line: 5}}, }) m.currentFile = "user.go" @@ -67,11 +68,11 @@ func TestFollow_SpecDriven(t *testing.T) { m.fileView.SetSize(60, 20) m.spec.SetContent("{\n \"definitions\": {}\n}") // The node maps to the top line of the viewport (YOffset 0). - m.specIndex = newSpecIndex( + m.specIndex = index.NewSpecIndex( map[int]string{0: "/definitions/User"}, map[string]int{"/definitions/User": 0}, ) - m.srcIndex = BuildSourceIndex([]scanner.Provenance{ + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ {Pointer: "/definitions/User", Pos: token.Position{Filename: src, Line: 3}}, }) m.focused = paneSpec @@ -91,7 +92,7 @@ func TestFollow_SpecDriven(t *testing.T) { func TestFollow_ExitClearsState(t *testing.T) { m := followFixture(t) - m.srcIndex = BuildSourceIndex(nil) + m.srcIndex = index.BuildSourceIndex(nil) m.follow = followSpec m.followTarget = "something" diff --git a/docs/examples/go.mod b/docs/examples/go.mod index 945c4482..7e91eb8f 100644 --- a/docs/examples/go.mod +++ b/docs/examples/go.mod @@ -4,7 +4,7 @@ // codescan consumers. module github.com/go-openapi/codescan/docs/examples -go 1.25.0 +go 1.25.8 require ( github.com/go-openapi/codescan v0.0.0 diff --git a/fixtures/go.mod b/fixtures/go.mod index 99df9cd0..38ac93f7 100644 --- a/fixtures/go.mod +++ b/fixtures/go.mod @@ -1,6 +1,6 @@ module github.com/go-openapi/codescan/fixtures -go 1.25.0 +go 1.25.8 require ( github.com/go-openapi/runtime v0.29.3 diff --git a/go.mod b/go.mod index f9f5c84e..fd8ff9e2 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/go-openapi/codescan -go 1.25.0 +go 1.25.8 toolchain go1.26.1 diff --git a/go.work b/go.work index 00629d1c..2b3afe54 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.25.0 +go 1.26 // Workspace for the codescan monorepo: the main library module (.) and the // genspec-tui front-end module (./cmd/genspec-tui), kept in separate go.mod @@ -8,5 +8,6 @@ go 1.25.0 // TUI module's own go.mod carries the real `require` on the library once wired. use ( . + ./cmd/genspec-tui ./docs/examples ) From 5ca1ab07ee840b1985bdac6464182f647c88c195 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Thu, 30 Jul 2026 14:30:39 +0200 Subject: [PATCH 04/13] feat(tui): honest cross-ref edges (S7) Closes the C7-tail edge cases the follow loop glossed over, and sweeps the fallout from the ca0653c lexer swap. Format toggle preserved the LINE, not the node. ctrl+j/ctrl+y called refreshSpec() under an unchanged viewport YOffset, so the rebuilt index addressed a different render at the same offset. Since the YAML form is roughly half the height of the JSON one, toggling reliably dumped the user on an unrelated node. setSpecFormat now captures PointerAt(TopLine()), swaps, rebuilds, and re-locates via LineForPointer -> Spec.ScrollTo. A failed link now says WHICH failure it was. "no file open", "no provenance from the last scan", "no spec node anchored at or above this line" and " - not rendered in this view" are four different answers, and only the last two are about the node at hand; collapsing them sent the reader hunting for a bug that wasn't there. linkSourceToSpec already computed the honest "not in view" text and both callers discarded it - syncFollowIfActive flattened it to "(no spec node)", handleEditKey to a hardcoded string. The helper now always returns a meaningful description; the bool reports only whether the follower moved. A node with no anchored ancestor was never produced from code (design 3.8, the InputSpec overlay case), so the follower holds position rather than mis-jumping. driveDiagToSource's "(no source)" likewise became "(diagnostic carries no position)". Provenance is a snapshot of the last scan, so an unsaved edit shifts every anchor below it. A STALE chip on the follow badge says so; nav keeps working and simply stops claiming to be exact. Save -> watcher -> rescan clears it. lineTable/newLineTable/lineAt are dead since the lexers report Line() directly; removed, along with the orphaned "// itoa" comment left above the type. Tests: new model_edges_test.go covers format-toggle preservation (helper, ctrl+y binding, round-trip, same-format no-op, nil index), one subtest per miss kind for both link directions asserting the follower holds, and the STALE badge across clean -> dirty -> saved. The toggle test is mutation-verified: it fails when the fix is reverted. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../internal/ux/index/specindex.go | 22 -- .../internal/ux/index/specindex_test.go | 19 -- cmd/genspec-tui/internal/ux/model.go | 100 ++++-- .../internal/ux/model_diag_test.go | 3 +- .../internal/ux/model_edges_test.go | 284 ++++++++++++++++++ .../internal/ux/model_follow_test.go | 5 +- cmd/genspec-tui/internal/ux/panels/spec.go | 5 + cmd/genspec-tui/internal/ux/theme/theme.go | 9 + 8 files changed, 382 insertions(+), 65 deletions(-) create mode 100644 cmd/genspec-tui/internal/ux/model_edges_test.go diff --git a/cmd/genspec-tui/internal/ux/index/specindex.go b/cmd/genspec-tui/internal/ux/index/specindex.go index 86ab96cd..1f511514 100644 --- a/cmd/genspec-tui/internal/ux/index/specindex.go +++ b/cmd/genspec-tui/internal/ux/index/specindex.go @@ -122,25 +122,3 @@ func (x *SpecIndex) Len() int { } return len(x.ptr2line) } - -// itoa is a tiny strconv.Itoa avoiding the import churn for one call site. -// lineTable maps byte offsets to 0-based line numbers via the positions of '\n'. -type lineTable struct { - nlOffsets []int64 // sorted offsets of each '\n' -} - -func newLineTable(b []byte) lineTable { - var nl []int64 - for i, c := range b { - if c == '\n' { - nl = append(nl, int64(i)) - } - } - return lineTable{nlOffsets: nl} -} - -// lineAt returns the 0-based line containing the byte at off (= number of -// newlines strictly before off). -func (lt lineTable) lineAt(off int64) int { - return sort.Search(len(lt.nlOffsets), func(i int) bool { return lt.nlOffsets[i] >= off }) -} diff --git a/cmd/genspec-tui/internal/ux/index/specindex_test.go b/cmd/genspec-tui/internal/ux/index/specindex_test.go index 050ede11..e78b55c6 100644 --- a/cmd/genspec-tui/internal/ux/index/specindex_test.go +++ b/cmd/genspec-tui/internal/ux/index/specindex_test.go @@ -127,22 +127,3 @@ func TestSpecIndexEmptyAndNil(t *testing.T) { t.Error("empty object should index no pointers") } } - -func TestLineTable(t *testing.T) { - lt := newLineTable([]byte("a\nbb\n\nc")) - cases := []struct { - off int64 - line int - }{ - {0, 0}, // 'a' - {1, 0}, // '\n' after a still line 0 - {2, 1}, // 'b' - {5, 2}, // '\n' (the empty line's newline) → line 2 - {6, 3}, // 'c' - } - for _, c := range cases { - if got := lt.lineAt(c.off); got != c.line { - t.Errorf("lineAt(%d) = %d, want %d", c.off, got, c.line) - } - } -} diff --git a/cmd/genspec-tui/internal/ux/model.go b/cmd/genspec-tui/internal/ux/model.go index 3388b48b..89d60aee 100644 --- a/cmd/genspec-tui/internal/ux/model.go +++ b/cmd/genspec-tui/internal/ux/model.go @@ -86,6 +86,22 @@ const ( followDiag // the diagnostics pane drives, the source pane follows ) +// Cross-ref outcome descriptions. A link can fail for genuinely different +// reasons, and conflating them sends the user hunting for a bug that isn't +// there: a node with no anchored ancestor was never produced from code (design +// §3.8 — an InputSpec overlay node legitimately has no origin), whereas a node +// that resolved but isn't rendered is simply outside the active JSON/YAML view. +// Both are first-class answers, not errors, so every link helper returns one of +// these whether or not the follower moved. +const ( + noNodeDesc = "(no node here)" + noFileDesc = "(no file open)" + noAnchorDesc = "no spec node anchored at or above this line" + noProvenanceDesc = "no provenance from the last scan" + noSourceSuffix = " · no source (not produced from code)" + notRenderedSuffix = " · not rendered in this view" +) + // optToggle binds an options-popup row to a boolean field of the scan config, // with a short human description (the field names alone are cryptic). type optToggle struct { @@ -319,12 +335,10 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.focused = (m.focused + paneCount - 1) % paneCount return m, m.syncEditFocus() case key.CtrlJ: - m.spec.SetFormat("JSON") - m.refreshSpec() + m.setSpecFormat("JSON") return m, nil case key.CtrlY: - m.spec.SetFormat("YAML") - m.refreshSpec() + m.setSpecFormat("YAML") return m, nil case key.R: return m, m.startScan() @@ -433,7 +447,7 @@ func (m *Model) driveDiagToSource() string { } d := m.diags[m.diagCursor] if !d.Pos.IsValid() || d.Pos.Filename == "" { - return "(no source)" + return "(diagnostic carries no position)" } if m.currentFile != d.Pos.Filename { m.loadFileQuietly(d.Pos.Filename) @@ -495,12 +509,13 @@ func (m *Model) handleEditKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // produced, focusing the spec. ctrl+f rather than f because the editor // owns plain f for typing; follow mode proper runs from the read-only // viewer. - if desc, ok := m.linkSourceToSpec(); ok { + desc, moved := m.linkSourceToSpec() + if moved { m.fileView.Blur() m.focused = paneSpec m.notice = "→ " + desc } else { - m.notice = "no spec node anchored at or above this line" + m.notice = desc } return m, clearNoticeAfter(noticeTTL) case "ctrl+s": @@ -751,6 +766,28 @@ func (m *Model) refreshSpec() { m.spec.SetContent(body) } +// setSpecFormat switches the spec render between JSON and YAML, preserving the +// NODE at the top of the viewport rather than the raw line number. The two +// renders index the same pointers at different lines, so carrying the scroll +// offset across silently lands the user on an unrelated node (design §6.4). +// A no-op when the format is already active. +func (m *Model) setSpecFormat(format string) { + if m.spec.Format() == format { + return + } + anchor, anchored := m.specIndex.PointerAt(m.spec.TopLine()) + + m.spec.SetFormat(format) + m.refreshSpec() // rebuilds m.specIndex for the new render + + if !anchored { + return + } + if line, ok := m.specIndex.LineForPointer(anchor); ok { + m.spec.ScrollTo(line) + } +} + // locateInSpec jumps the spec pane to the first node produced by the given // source file (position-backed, via the SourceIndex), highlighting it and // focusing the spec. The exact replacement for the retired name-matching linker. @@ -809,11 +846,9 @@ func (m *Model) syncFollowIfActive() { m.exitFollow() return } - if desc, ok := m.linkSourceToSpec(); ok { - m.followTarget = desc - } else { - m.followTarget = "(no spec node)" - } + // The description is meaningful on both outcomes — show it either way + // rather than flattening every miss to one opaque message. + m.followTarget, _ = m.linkSourceToSpec() case followDiag: if m.focused != paneDiag { m.exitFollow() @@ -830,14 +865,19 @@ func (m *Model) syncFollowIfActive() { func (m *Model) driveSpecToSource() string { ptr, ok := m.specIndex.PointerAt(m.spec.TopLine()) if !ok { - return "(no node)" + return noNodeDesc } if specLine, found := m.specIndex.LineForPointer(ptr); found { m.spec.MarkLine(specLine) // mark the mapped node, no scroll } pos, ok := m.srcIndex.PositionFor(ptr) if !ok { - return ptr + " (no source)" + // Hold the follower where it is rather than jumping somewhere wrong + // (design §6.4), and name which of the two misses this is. + if m.srcIndex.Len() == 0 { + return ptr + " · " + noProvenanceDesc + } + return ptr + noSourceSuffix } if m.currentFile != pos.Filename { m.loadFileQuietly(pos.Filename) @@ -847,20 +887,26 @@ func (m *Model) driveSpecToSource() string { } // linkSourceToSpec highlights (and scrolls to) the spec node produced by the -// file viewer's current line. No focus change. Returns a status description and -// whether the node was found in the current spec render. +// file viewer's current line. No focus change. The description is ALWAYS +// meaningful — callers show it whether or not the follower moved, because +// "this line produced nothing", "nothing was anchored at all" and "the node +// exists but isn't rendered here" are three different answers the user needs +// to tell apart. The bool reports only whether the follower actually moved. func (m *Model) linkSourceToSpec() (string, bool) { if m.currentFile == "" { - return "", false + return noFileDesc, false } line := m.fileView.CurrentLine() + 1 // pane rows are 0-based; source lines 1-based ptr, ok := m.srcIndex.PointerAt(m.currentFile, line) if !ok { - return "", false + if m.srcIndex.Len() == 0 { + return noProvenanceDesc, false + } + return noAnchorDesc, false } specLine, ok := m.specIndex.LineForPointer(ptr) if !ok { - return ptr + " (not in view)", false + return ptr + notRenderedSuffix, false } m.spec.HighlightLine(specLine) // follower scrolls + highlights return ptr, true @@ -1110,10 +1156,22 @@ func (m *Model) followBadge() string { if target == "" { target = "(move the cursor)" } - return theme.Accent().Render(" "+label+" ") + - theme.Status().Render(" "+target+" · esc / f: exit follow") + badge := theme.Accent().Render(" " + label + " ") + if m.stale() { + badge += theme.Stale().Render(" STALE ") + } + return badge + theme.Status().Render(" "+target+" · esc / f: exit follow") } +// stale reports whether the cross-ref positions are out of date with respect to +// what the user is looking at. Provenance is a snapshot of the LAST scan, so an +// unsaved edit shifts every anchor below it in that file: the follower can land +// N lines off until Ctrl-S → watcher → rescan refreshes the index. Design §6.4 +// leaves the choice open between suppressing reverse-nav and badging it; a badge +// is the non-destructive read — nav keeps working, it just stops pretending to +// be exact. +func (m *Model) stale() bool { return m.fileView.Dirty() } + // shortenPath trims a path from the left with an ellipsis so it fits maxLen. func shortenPath(p string, maxLen int) string { if maxLen < 4 { diff --git a/cmd/genspec-tui/internal/ux/model_diag_test.go b/cmd/genspec-tui/internal/ux/model_diag_test.go index 06b7f6ae..b3dca7aa 100644 --- a/cmd/genspec-tui/internal/ux/model_diag_test.go +++ b/cmd/genspec-tui/internal/ux/model_diag_test.go @@ -91,7 +91,8 @@ func TestDiag_FollowNoPosition(t *testing.T) { m.toggleFollow(followDiag) assert.Equal(t, followDiag, m.follow) assert.Empty(t, m.currentFile, "nothing opened when the diagnostic has no source") - assert.Equal(t, "(no source)", m.followTarget) + assert.Equal(t, "(diagnostic carries no position)", m.followTarget, + "a positionless diagnostic is a different miss from an unanchored spec node") } func TestRenderDiagnostics_SelectedLine(t *testing.T) { diff --git a/cmd/genspec-tui/internal/ux/model_edges_test.go b/cmd/genspec-tui/internal/ux/model_edges_test.go new file mode 100644 index 00000000..ff99f4aa --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_edges_test.go @@ -0,0 +1,284 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "go/token" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/index" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/codescan/internal/scanner" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// The two renders of the same spec, indexing the same pointers at DIFFERENT +// lines — the whole point of preserving the pointer rather than the line +// across a format toggle. Both renders are deliberately taller than the test +// viewport (below) so neither clamps: YAML is roughly half the height of JSON, +// which is exactly why carrying the raw line number across is wrong. +const ( + toggleJSON = `{ + "definitions": { + "Address": { + "properties": { + "city": { + "type": "string" + }, + "zip": { + "type": "string" + } + } + }, + "User": { + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } +}` + + toggleYAML = `definitions: + Address: + properties: + city: + type: string + zip: + type: string + User: + properties: + email: + type: string + name: + type: string +` +) + +// The email property's line in each render — the node the toggle must preserve. +const ( + emailPtr = "/definitions/User/properties/email" + emailJSONLine = 14 + emailYAMLLine = 9 +) + +// toggleFixture builds a model holding both renders of the same spec, with the +// spec pane focused and the JSON index live. The pane is short on purpose (a +// 5-line viewport) so both renders can actually scroll to the target node. +func toggleFixture(t *testing.T) *Model { + t.Helper() + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.spec.SetSize(60, 8) + m.fileView.SetSize(60, 8) + m.specJSON, m.specYAML = toggleJSON, toggleYAML + m.focused = paneSpec + m.refreshSpec() + return m +} + +func TestSpecFormatToggle_PreservesPointerNotLine(t *testing.T) { + m := toggleFixture(t) + + // Park the viewport on the `email` property. + jsonLine, ok := m.specIndex.LineForPointer(emailPtr) + require.True(t, ok, "the email property must be indexed in the JSON render") + require.Equal(t, emailJSONLine, jsonLine) + m.spec.ScrollTo(jsonLine) + require.Equal(t, jsonLine, m.spec.TopLine()) + + m.setSpecFormat("YAML") + + require.Equal(t, "YAML", m.spec.Format()) + yamlLine, ok := m.specIndex.LineForPointer(emailPtr) + require.True(t, ok, "the same pointer must be indexed in the YAML render") + require.Equal(t, emailYAMLLine, yamlLine, "the two renders put the node on different lines") + + assert.Equal(t, yamlLine, m.spec.TopLine(), + "the toggle must land on the same NODE, not the same line number") + + // Round-tripping back restores the JSON line for the same node. + m.setSpecFormat("JSON") + assert.Equal(t, jsonLine, m.spec.TopLine()) +} + +func TestSpecFormatToggle_SameFormatIsNoop(t *testing.T) { + m := toggleFixture(t) + m.spec.ScrollTo(emailJSONLine) + + m.setSpecFormat("JSON") + + assert.Equal(t, emailJSONLine, m.spec.TopLine(), + "re-selecting the active format must not move the viewport") +} + +func TestSpecFormatToggle_UnindexedTopLine(t *testing.T) { + m := toggleFixture(t) + m.specIndex = nil // no index: nothing to preserve, but nothing may panic either + + m.setSpecFormat("YAML") + + assert.Equal(t, "YAML", m.spec.Format()) +} + +// TestSpecFormatToggle_ViaKey checks the binding actually routes through the +// pointer-preserving path (the bug was in the key handler, not the helper). +func TestSpecFormatToggle_ViaKey(t *testing.T) { + m := toggleFixture(t) + m.spec.ScrollTo(emailJSONLine) + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyCtrlY}) + + assert.Equal(t, "YAML", m.spec.Format()) + assert.Equal(t, emailYAMLLine, m.spec.TopLine(), + "ctrl+y must preserve the node under the viewport top") +} + +func TestLinkSourceToSpec_NamesEachMiss(t *testing.T) { + const ptr = "/definitions/User" + + t.Run("no file open", func(t *testing.T) { + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + desc, moved := m.linkSourceToSpec() + assert.False(t, moved) + assert.Equal(t, noFileDesc, desc) + }) + + t.Run("no provenance at all", func(t *testing.T) { + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.currentFile = "user.go" + m.fileView.SetFile("user.go", "a\nb\nc") + m.srcIndex = index.BuildSourceIndex(nil) + + desc, moved := m.linkSourceToSpec() + assert.False(t, moved) + assert.Equal(t, noProvenanceDesc, desc, + "an empty index means nothing was anchored — not that this line is special") + }) + + t.Run("anchored file but line above the first anchor", func(t *testing.T) { + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.currentFile = "user.go" + m.fileView.SetFile("user.go", "a\nb\nc") + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ + {Pointer: ptr, Pos: token.Position{Filename: "user.go", Line: 3}}, + }) + m.fileView.GotoLine(0) // source line 1 + + desc, moved := m.linkSourceToSpec() + assert.False(t, moved) + assert.Equal(t, noAnchorDesc, desc) + }) + + t.Run("anchored but not rendered in this view", func(t *testing.T) { + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.spec.SetSize(60, 10) + m.currentFile = "user.go" + m.fileView.SetFile("user.go", "a\nb\nc") + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ + {Pointer: ptr, Pos: token.Position{Filename: "user.go", Line: 1}}, + }) + // The spec index knows a different node entirely, so the pointer + // resolves on the source side but has nowhere to land. + m.specIndex = index.NewSpecIndex( + map[int]string{0: "/definitions/Other"}, + map[string]int{"/definitions/Other": 0}, + ) + m.fileView.GotoLine(0) + + desc, moved := m.linkSourceToSpec() + assert.False(t, moved) + assert.Equal(t, ptr+notRenderedSuffix, desc, + "a node that exists but isn't in this render is a different answer from no source") + }) +} + +func TestDriveSpecToSource_NamesEachMiss(t *testing.T) { + newModel := func() *Model { + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.spec.SetSize(60, 10) + m.spec.SetContent("line0\nline1\nline2") + m.specIndex = index.NewSpecIndex( + map[int]string{0: "/definitions/User"}, + map[string]int{"/definitions/User": 0}, + ) + return m + } + + t.Run("no provenance at all", func(t *testing.T) { + m := newModel() + m.srcIndex = index.BuildSourceIndex(nil) + + desc := m.driveSpecToSource() + assert.Contains(t, desc, noProvenanceDesc) + assert.Empty(t, m.currentFile, "the follower holds rather than jumping") + }) + + t.Run("spec-only node", func(t *testing.T) { + m := newModel() + // Some other node is anchored, so the index is populated — this node + // simply wasn't produced from code (the InputSpec overlay case, §3.8). + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/Other", Pos: token.Position{Filename: "other.go", Line: 1}}, + }) + + desc := m.driveSpecToSource() + assert.Equal(t, "/definitions/User"+noSourceSuffix, desc) + assert.Empty(t, m.currentFile, "the follower holds rather than jumping") + }) + + t.Run("no node under the viewport top", func(t *testing.T) { + m := newModel() + m.specIndex = nil + m.srcIndex = index.BuildSourceIndex(nil) + + assert.Equal(t, noNodeDesc, m.driveSpecToSource()) + }) +} + +func TestFollowBadge_StaleWhileDirty(t *testing.T) { + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.fileView.SetFile("user.go", "package p\n") + m.follow = followSource + m.followTarget = "/definitions/User" + + require.False(t, m.stale(), "a freshly loaded buffer matches the last scan") + assert.NotContains(t, stripANSI(m.followBadge()), "STALE") + + // An unsaved edit shifts every anchor below it: positions are now older + // than what is on screen. + m.fileView.StartEdit() + _ = m.fileView.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + + require.True(t, m.stale(), "an edited buffer invalidates the recorded positions") + assert.Contains(t, stripANSI(m.followBadge()), "STALE") + + // Saving (MarkClean is what saveFile does) clears it again. + m.fileView.MarkClean() + assert.False(t, m.stale()) + assert.NotContains(t, stripANSI(m.followBadge()), "STALE") +} + +// stripANSI removes the SGR escape sequences lipgloss emits, so assertions can +// look at the text a user reads rather than the styling around it. +func stripANSI(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] == 0x1b { + for i < len(s) && s[i] != 'm' { + i++ + } + continue + } + b.WriteByte(s[i]) + } + return b.String() +} diff --git a/cmd/genspec-tui/internal/ux/model_follow_test.go b/cmd/genspec-tui/internal/ux/model_follow_test.go index ab9f13ef..249a6ea4 100644 --- a/cmd/genspec-tui/internal/ux/model_follow_test.go +++ b/cmd/genspec-tui/internal/ux/model_follow_test.go @@ -46,10 +46,11 @@ func TestFollow_SourceDriven(t *testing.T) { assert.Equal(t, followSource, m.follow, "f enters source-driven follow") assert.Equal(t, "/definitions/User/properties/email", m.followTarget) - // A line with no anchor at or above reports honestly. + // A line with no anchor at or above reports honestly — and names the cause, + // rather than flattening every miss into one opaque message. m.fileView.GotoLine(0) // source line 1, before the first anchor (line 5) m.syncFollowIfActive() - assert.Equal(t, "(no spec node)", m.followTarget) + assert.Equal(t, noAnchorDesc, m.followTarget) // Moving focus off the driver exits follow. m.focused = paneSpec diff --git a/cmd/genspec-tui/internal/ux/panels/spec.go b/cmd/genspec-tui/internal/ux/panels/spec.go index 95c0fd43..90c69f2e 100644 --- a/cmd/genspec-tui/internal/ux/panels/spec.go +++ b/cmd/genspec-tui/internal/ux/panels/spec.go @@ -125,6 +125,11 @@ func (p *Spec) HighlightLine(line int) { p.MarkLine(line) } +// ScrollTo puts the 0-based content line at the TOP of the viewport without +// marking it. Used by the format toggle to restore the node the user was +// looking at, whose line number differs between the JSON and YAML renders. +func (p *Spec) ScrollTo(line int) { p.vp.SetYOffset(line) } + // ClearHighlight drops the cross-ref highlight, if any. func (p *Spec) ClearHighlight() { if p.xrefLine == -1 { diff --git a/cmd/genspec-tui/internal/ux/theme/theme.go b/cmd/genspec-tui/internal/ux/theme/theme.go index ec9ae8b3..2920a734 100644 --- a/cmd/genspec-tui/internal/ux/theme/theme.go +++ b/cmd/genspec-tui/internal/ux/theme/theme.go @@ -88,3 +88,12 @@ func Selected() lipgloss.Style { Foreground(lipgloss.Color("231")). Background(colorActive) } + +// Stale styles the follow-mode badge shown while the source buffer has unsaved +// edits, i.e. while cross-ref positions are older than what is on screen. +func Stale() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("16")). + Background(colorWarn). + Bold(true) +} From 01e952e658c125c5f23b0de5bc20bdf56345cb2a Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Thu, 30 Jul 2026 15:10:27 +0200 Subject: [PATCH 05/13] feat(tui): distinguish driver from follower, centre jump targets (S8) Design 6.5 asks for the driver line and the follower target to be visually distinct, so it is obvious at a glance which pane leads. Both painted with theme.Selected(), so they were indistinguishable. Adds theme.Follower(), a muted tint of the same hue. No role parameter needed to be threaded anywhere: the design's own invariant is that the driver keeps focus (6.1), so "focused" already means "this pane drives". Spec.xrefStyle() and panels.navStyle() each read that one bit. Spec bakes the style into its viewport content at render time, so Spec.View re-renders on a focus TRANSITION only - the spec can run to thousands of lines and View runs on every message. Jump targets now land in the vertical centre of the viewport rather than two lines from its top. In follow mode the target moves continuously, and a top bias pins it against whichever edge it entered from, so it jitters instead of sitting still while its surroundings slide past. Rather than adding CenterLine/CenterOn alongside the existing methods - which would have left HighlightLine and GotoLine, the only reachable ones, dead in production - the jump primitives themselves centre. The resulting rule is simpler than two parallel APIs: a jump centres, incremental cursor movement scrolls as little as possible. Nav keys keep minimal scrolling, which is what you want walking line by line; search keeps its own top bias, since there you want the following matches visible below. Tests need a forced colour profile to mean anything: lipgloss degrades to plain text off a TTY, which go test never is, so driver and follower lines rendered byte-identically and the assertions would have passed against a panel applying no style at all. TestMain pins termenv.TrueColor for the package. The FileView assertion compares the nav line's own SGR prefix rather than whole views, which are confounded by the border and title also changing with focus - the whole-view form passed under mutation. Both wirings are mutation-verified. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- cmd/genspec-tui/go.mod | 3 +- cmd/genspec-tui/go.sum | 44 +---- cmd/genspec-tui/internal/ux/model.go | 4 +- .../internal/ux/panels/fileview.go | 44 ++++- .../internal/ux/panels/main_test.go | 24 +++ .../internal/ux/panels/navvisuals_test.go | 156 ++++++++++++++++++ cmd/genspec-tui/internal/ux/panels/spec.go | 39 ++++- cmd/genspec-tui/internal/ux/theme/theme.go | 13 +- 8 files changed, 266 insertions(+), 61 deletions(-) create mode 100644 cmd/genspec-tui/internal/ux/panels/main_test.go create mode 100644 cmd/genspec-tui/internal/ux/panels/navvisuals_test.go diff --git a/cmd/genspec-tui/go.mod b/cmd/genspec-tui/go.mod index c62193cf..c2cd20ce 100644 --- a/cmd/genspec-tui/go.mod +++ b/cmd/genspec-tui/go.mod @@ -11,6 +11,7 @@ require ( github.com/go-openapi/core/json v0.0.2 github.com/go-openapi/core/json/lexers/yaml-lexer v0.0.2 github.com/go-openapi/testify/v2 v2.6.0 + github.com/muesli/termenv v0.16.0 go.yaml.in/yaml/v3 v3.0.5 ) @@ -28,7 +29,6 @@ require ( github.com/go-openapi/jsonreference v1.0.0 // indirect github.com/go-openapi/spec v0.22.9 // indirect github.com/go-openapi/swag/conv v0.28.0 // indirect - github.com/go-openapi/swag/jsonname v0.28.0 // indirect github.com/go-openapi/swag/jsonutils v0.28.0 // indirect github.com/go-openapi/swag/loading v0.28.0 // indirect github.com/go-openapi/swag/mangling v0.28.0 // indirect @@ -43,7 +43,6 @@ require ( github.com/mattn/go-runewidth v0.0.27 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/mod v0.38.0 // indirect diff --git a/cmd/genspec-tui/go.sum b/cmd/genspec-tui/go.sum index 774c0818..39b8e154 100644 --- a/cmd/genspec-tui/go.sum +++ b/cmd/genspec-tui/go.sum @@ -32,54 +32,32 @@ github.com/go-openapi/core/json v0.0.2 h1:RACr1Kjs6U8Rzpu0JLnJ2tw5TZ86+5ROXFPNQg github.com/go-openapi/core/json v0.0.2/go.mod h1:vEcP/Wkw1ImzIAmGt7lmY+dJ8Ilf0TtvV8vPe8HtVA4= github.com/go-openapi/core/json/lexers/yaml-lexer v0.0.2 h1:aJC6mspwBIPzxJoduTagX416RvVRMZL6yygngyITHoM= github.com/go-openapi/core/json/lexers/yaml-lexer v0.0.2/go.mod h1:p4x5CYKYZecVZLy22fOMap8EGW5Rkb4yPxpIzWuCYr4= -github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= -github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= -github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= -github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= -github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= -github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8= github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k= -github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= -github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= -github.com/go-openapi/swag/jsonname v0.28.0 h1:IteWYOZSFQVvBZEMPAddN5AI/KSaJ9DVcFyVeMeza4Q= -github.com/go-openapi/swag/jsonname v0.28.0/go.mod h1:rtHNjjwBhdavc6eybmd5Fj60cIgstqQHcToaK/+4WwQ= -github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY= -github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ= github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1 h1:1CD7NiLLb/TXl3tOnFYU4b+mNfb5rtgHkaA+q7RMYYQ= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1/go.mod h1:ZWafc8nMdYzTE3uYY6W86f0n46+IF0g4uUyRhJw/kXc= github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0= -github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w= -github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU= github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k= -github.com/go-openapi/swag/mangling v0.26.1 h1:gpYI4WuPKFJJVjV5cDLGlDVJhFIxYjQc7yN5eEb4CqM= -github.com/go-openapi/swag/mangling v0.26.1/go.mod h1:POETDH01hqAdASXfw7ISEd9bCOE6xBHOt8NHmGZRmYM= github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM= github.com/go-openapi/swag/mangling v0.28.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= -github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU= -github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk= github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU= github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY= github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= -github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk= -github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M= github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY= github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs= -github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= -github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= @@ -88,14 +66,10 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= -github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -108,32 +82,18 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/cmd/genspec-tui/internal/ux/model.go b/cmd/genspec-tui/internal/ux/model.go index 89d60aee..14813357 100644 --- a/cmd/genspec-tui/internal/ux/model.go +++ b/cmd/genspec-tui/internal/ux/model.go @@ -452,7 +452,7 @@ func (m *Model) driveDiagToSource() string { if m.currentFile != d.Pos.Filename { m.loadFileQuietly(d.Pos.Filename) } - m.fileView.GotoLine(d.Pos.Line - 1) // follower scrolls; not focused + m.fileView.GotoLine(d.Pos.Line - 1) // follower centres on the target; not focused return fmt.Sprintf("%s:%d", relTo(m.cfg.WorkDir, d.Pos.Filename), d.Pos.Line) } @@ -882,7 +882,7 @@ func (m *Model) driveSpecToSource() string { if m.currentFile != pos.Filename { m.loadFileQuietly(pos.Filename) } - m.fileView.GotoLine(pos.Line - 1) // follower scrolls; not focused + m.fileView.GotoLine(pos.Line - 1) // follower centres on the target; not focused return fmt.Sprintf("%s → %s:%d", ptr, relTo(m.cfg.WorkDir, pos.Filename), pos.Line) } diff --git a/cmd/genspec-tui/internal/ux/panels/fileview.go b/cmd/genspec-tui/internal/ux/panels/fileview.go index 6e68608d..f9ac2de1 100644 --- a/cmd/genspec-tui/internal/ux/panels/fileview.go +++ b/cmd/genspec-tui/internal/ux/panels/fileview.go @@ -10,6 +10,7 @@ import ( "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" ) @@ -90,11 +91,22 @@ func (p *FileView) NavDown() { p.gotoNav(p.navLine + 1) } // ScrollBy moves the nav line by delta (mouse wheel in read-only mode). func (p *FileView) ScrollBy(delta int) { p.gotoNav(p.navLine + delta) } -// GotoLine parks the read-only nav line on the 0-based line, scrolling it into -// view. Used by cross-ref navigation to land on a node's source line. The editor -// cursor is synced lazily by StartEdit, so this stays cheap when called on every -// follow-mode scroll. -func (p *FileView) GotoLine(line int) { p.gotoNav(line) } +// GotoLine parks the read-only nav line on the 0-based line and scrolls it to +// the VERTICAL CENTRE, clamped at the edges (design §6.1). This is the JUMP +// primitive — cross-ref landings and follow-mode mirroring — as opposed to the +// nav keys, which move the cursor one line and scroll as little as possible. +// +// The distinction matters: a follower target moves continuously as the driver +// scrolls, and minimal scrolling would pin it to whichever edge it entered from, +// whereas centring keeps it still while its context slides past. +// +// The editor cursor is synced lazily by StartEdit, so this stays cheap when +// called on every follow-mode move. +func (p *FileView) GotoLine(line int) { + p.navLine = clamp(line, 0, max(p.lineCount()-1, 0)) + visible := max(p.h-3, 1) + p.offset = clamp(p.navLine-visible/2, 0, max(p.lineCount()-visible, 0)) +} // StartEdit switches to the editable textarea at the current nav line. func (p *FileView) StartEdit() tea.Cmd { @@ -147,7 +159,7 @@ func (p *FileView) View(focused, navActive bool) string { name += " ●" } mode := "view" - body := p.viewerBody(navActive) + body := p.viewerBody(focused, navActive) if p.editing { mode = "edit" body = p.ta.View() @@ -158,8 +170,10 @@ func (p *FileView) View(focused, navActive bool) string { // viewerBody renders the read-only window: a right-aligned line-number gutter // and the file text, with the nav line highlighted when navActive (the pane is -// focused, or mirroring the followed line as a follow follower). -func (p *FileView) viewerBody(navActive bool) string { +// focused, or mirroring the followed line as a follow follower). The driver pane +// keeps focus in follow mode, so focused doubles as "this is the driver line" — +// strong bar when it is, muted tint when this pane is only mirroring (§6.5). +func (p *FileView) viewerBody(focused, navActive bool) string { inner := max(p.w-2, 0) visible := max(p.h-3, 0) lines := strings.Split(p.ta.Value(), "\n") @@ -167,12 +181,14 @@ func (p *FileView) viewerBody(navActive bool) string { numW := len(strconv.Itoa(total)) textW := max(inner-(numW+1), 0) + style := navStyle(focused) + var b strings.Builder end := min(p.offset+visible, total) for i := p.offset; i < end; i++ { row := fmt.Sprintf("%*d ", numW, i+1) + fit(lines[i], textW) if i == p.navLine && navActive { - row = theme.Selected().Render(row) + row = style.Render(row) } b.WriteString(row) if i < end-1 { @@ -182,6 +198,16 @@ func (p *FileView) viewerBody(navActive bool) string { return b.String() } +// navStyle is the whole-line style for the highlighted nav line: the strong bar +// when this pane drives (the driver keeps focus in follow mode, design §6.1), a +// muted tint when it is only mirroring another pane's cursor (§6.5). +func navStyle(focused bool) lipgloss.Style { + if focused { + return theme.Selected() + } + return theme.Follower() +} + // gotoNav clamps and sets the nav line, then re-clamps the scroll offset. func (p *FileView) gotoNav(line int) { p.navLine = clamp(line, 0, max(p.lineCount()-1, 0)) diff --git a/cmd/genspec-tui/internal/ux/panels/main_test.go b/cmd/genspec-tui/internal/ux/panels/main_test.go new file mode 100644 index 00000000..68351e77 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/main_test.go @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "os" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +// TestMain forces a colour profile for the whole package's tests. +// +// lipgloss degrades to plain text when stdout is not a TTY, which `go test` +// never is — so without this every style renders identically and the panels' +// visual contracts (driver bar vs follower tint, §6.5) would be unfalsifiable: +// the assertions would pass just as happily against a panel that applied no +// style at all. +func TestMain(m *testing.M) { + lipgloss.SetColorProfile(termenv.TrueColor) + os.Exit(m.Run()) +} diff --git a/cmd/genspec-tui/internal/ux/panels/navvisuals_test.go b/cmd/genspec-tui/internal/ux/panels/navvisuals_test.go new file mode 100644 index 00000000..30d1fb9f --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/navvisuals_test.go @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "strconv" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// These tests check both halves of the §6.5 contract: that the panels CHOOSE +// the right style for their role, and that the choice actually reaches the +// rendered output. The latter needs a forced colour profile — see TestMain. + +// numberedContent returns n uniquely identifiable lines. +func numberedContent(n int) string { + rows := make([]string, n) + for i := range rows { + rows[i] = "row" + strconv.Itoa(i) + } + return strings.Join(rows, "\n") +} + +func TestTheme_DriverAndFollowerAreDistinct(t *testing.T) { + assert.NotEqual(t, + theme.Selected().GetBackground(), theme.Follower().GetBackground(), + "the driver bar and the follower tint must be visually distinct (§6.5)") +} + +func TestSpec_XrefStyleFollowsFocus(t *testing.T) { + sp := NewSpec() + sp.SetSize(40, 12) + sp.SetContent("aaa\nbbb\nccc\nddd") + sp.MarkLine(1) + + // The driver pane keeps focus in follow mode, so focused == drives. + _ = sp.View(true) + assert.Equal(t, theme.Selected().GetBackground(), sp.xrefStyle().GetBackground(), + "a focused spec pane paints its xref line as the driver") + + _ = sp.View(false) + assert.Equal(t, theme.Follower().GetBackground(), sp.xrefStyle().GetBackground(), + "an unfocused spec pane is mirroring, so its xref line is a follower") +} + +// A focus change must actually REPAINT: the style is baked into the viewport +// content at render time, so a missed re-render would leave the previous role's +// colour on screen even though xrefStyle() reports the new one. +func TestSpec_FocusChangeRepaints(t *testing.T) { + sp := NewSpec() + sp.SetSize(40, 12) + sp.SetContent("aaa\nbbb\nccc\nddd") + sp.MarkLine(1) + + driverView := sp.View(true) + followerView := sp.View(false) + + require.Contains(t, driverView, theme.Selected().Render("bbb"), + "the driver's xref line reaches the rendered output") + require.Contains(t, followerView, theme.Follower().Render("bbb"), + "the follower's xref line reaches the rendered output") + assert.NotEqual(t, driverView, followerView, "the two roles must render differently") +} + +// stylePrefix returns the SGR escape sequence a style emits, isolated from any +// text. Asserting on it pins WHICH style painted a line — comparing whole views +// would not, because the border and title also change with focus and would mask +// a nav line that never changed at all. +func stylePrefix(st lipgloss.Style) string { + const sentinel = "\x00sentinel\x00" + prefix, _, _ := strings.Cut(st.Render(sentinel), sentinel) + return prefix +} + +// The same for the source viewer: the nav line's style must reach the output, +// not merely be selected. +func TestFileView_NavStyleReachesOutput(t *testing.T) { + fv := NewFileView() + fv.SetSize(40, 10) + fv.SetFile("x.go", "line1\nline2\nline3\nline4") + fv.GotoLine(1) + + driver, follower := stylePrefix(theme.Selected()), stylePrefix(theme.Follower()) + require.NotEqual(t, driver, follower, "precondition: the two styles emit different escapes") + + driverView := fv.View(true, true) + assert.Contains(t, driverView, driver, "a focused viewer paints its nav line as the driver") + assert.NotContains(t, driverView, follower) + + followerView := fv.View(false, true) + assert.Contains(t, followerView, follower, + "a mirroring viewer must not look like the pane the user is driving") + assert.NotContains(t, followerView, driver) + + // With navActive false nothing is highlighted at all, so neither shows. + plain := fv.View(false, false) + assert.NotContains(t, plain, driver) + assert.NotContains(t, plain, follower) +} + +func TestSpec_HighlightLineCenters(t *testing.T) { + sp := NewSpec() + sp.SetSize(40, 13) // viewport height = 10 + sp.SetContent(numberedContent(60)) + + sp.HighlightLine(30) + assert.Equal(t, 25, sp.TopLine(), "target - height/2") + + sp.HighlightLine(2) + assert.Equal(t, 0, sp.TopLine(), "clamped at the top rather than scrolling negative") +} + +func TestFileView_NavStyleFollowsFocus(t *testing.T) { + assert.Equal(t, theme.Selected().GetBackground(), navStyle(true).GetBackground(), + "a focused viewer paints its nav line as the driver") + assert.Equal(t, theme.Follower().GetBackground(), navStyle(false).GetBackground(), + "an unfocused-but-mirroring viewer paints its nav line as a follower") +} + +func TestFileView_GotoLineCenters(t *testing.T) { + fv := NewFileView() + fv.SetSize(40, 13) // visible = 10 + fv.SetFile("x.go", numberedContent(60)) + + fv.GotoLine(30) + assert.Equal(t, 30, fv.CurrentLine()) + assert.Equal(t, 25, fv.offset, "a jump centres its target, it does not merely reveal it") + + fv.GotoLine(1) + assert.Equal(t, 0, fv.offset, "clamped at the top") + fv.GotoLine(59) + assert.Equal(t, 50, fv.offset, "clamped at the bottom (60 lines - 10 visible)") +} + +// The nav keys must NOT centre: moving the cursor one line should scroll as +// little as possible, or the view lurches on every keypress. +func TestFileView_NavKeysScrollMinimally(t *testing.T) { + fv := NewFileView() + fv.SetSize(40, 13) // visible = 10 + fv.SetFile("x.go", numberedContent(60)) + + for range 10 { + fv.NavDown() + } + + assert.Equal(t, 10, fv.CurrentLine()) + assert.Equal(t, 1, fv.offset, + "the cursor stepped one line past the window, so the view scrolled by exactly one") +} diff --git a/cmd/genspec-tui/internal/ux/panels/spec.go b/cmd/genspec-tui/internal/ux/panels/spec.go index 90c69f2e..5faa3758 100644 --- a/cmd/genspec-tui/internal/ux/panels/spec.go +++ b/cmd/genspec-tui/internal/ux/panels/spec.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" ) @@ -25,7 +26,8 @@ type Spec struct { matches []int // indices of content lines containing the query matchIdx int - xrefLine int // content line highlighted by cross-ref follow, -1 for none + xrefLine int // content line highlighted by cross-ref follow, -1 for none + focused bool // last focus state seen by View; picks the xref line's style } // NewSpec returns a Spec defaulting to JSON with placeholder content. @@ -118,10 +120,17 @@ func (p *Spec) MarkLine(line int) { p.render() } -// HighlightLine marks the line and scrolls it into view with a little context -// above. Used when the spec is the follower (source→spec navigation). +// HighlightLine marks the line and scrolls it to the VERTICAL CENTRE of the +// viewport, clamped at the edges (design §6.1). Every cross-ref landing comes +// through here — follow-mode mirroring, `g` locate, the ctrl+f jump. +// +// Centring rather than the top-biased scroll search uses: in follow mode the +// target moves continuously, so a top bias pins it against whichever edge it +// entered from and makes it jitter, instead of letting it sit still while its +// surroundings slide past. For a one-shot jump it simply shows context on both +// sides of the destination. func (p *Spec) HighlightLine(line int) { - p.vp.SetYOffset(max(line-scrollContext, 0)) + p.vp.SetYOffset(max(line-p.vp.Height/2, 0)) p.MarkLine(line) } @@ -147,11 +156,31 @@ func (p *Spec) Update(msg tea.Msg) tea.Cmd { } // View renders the bordered panel; focused brightens the border/title. +// +// Focus also decides how the cross-ref line is painted: the driver pane keeps +// focus in follow mode (design §6.1), so "focused" and "is the driver" are the +// same bit. Re-render only on a focus TRANSITION — the spec can be thousands of +// lines and View runs on every message. func (p *Spec) View(focused bool) string { + if p.focused != focused { + p.focused = focused + if p.xrefLine >= 0 { + p.render() + } + } title := theme.Title(focused).Render("spec · " + p.format) return theme.Panel(p.w, p.h, focused).Render(title + "\n" + p.vp.View()) } +// xrefStyle is the whole-line style for the cross-ref line: the strong bar when +// this pane drives, a muted tint when it is mirroring another pane. +func (p *Spec) xrefStyle() lipgloss.Style { + if p.focused { + return theme.Selected() + } + return theme.Follower() +} + // render rebuilds the viewport content from the raw text, applying the active // search highlight (per-substring) and the cross-ref highlight (whole line). // The cross-ref line takes the whole-line style; search matches are still @@ -176,7 +205,7 @@ func (p *Spec) render() { } switch { case i == p.xrefLine: - lines[i] = theme.Selected().Render(ln) + lines[i] = p.xrefStyle().Render(ln) case isMatch: lines[i] = highlightAll(ln, p.query) } diff --git a/cmd/genspec-tui/internal/ux/theme/theme.go b/cmd/genspec-tui/internal/ux/theme/theme.go index 2920a734..90c36cdd 100644 --- a/cmd/genspec-tui/internal/ux/theme/theme.go +++ b/cmd/genspec-tui/internal/ux/theme/theme.go @@ -17,6 +17,7 @@ var ( colorError = lipgloss.Color("203") colorWarn = lipgloss.Color("214") colorHint = lipgloss.Color("110") + colorFollower = lipgloss.Color("53") // muted shade of colorActive, for mirrored lines ) // Panel returns a rounded-border box style whose OUTER dimensions are w×h @@ -82,13 +83,23 @@ func Dir() lipgloss.Style { return lipgloss.NewStyle().Foreground(colorTitle) } -// Selected styles the cursor row in a navigable panel. +// Selected styles the cursor row in a navigable panel — the DRIVER line, i.e. +// the one the user is actually moving. Strong reverse-video bar (design §6.5). func Selected() lipgloss.Style { return lipgloss.NewStyle(). Foreground(lipgloss.Color("231")). Background(colorActive) } +// Follower styles a cross-ref line in the pane that is MIRRORING the driver. +// A muted tint of Selected, so at a glance it is obvious which pane leads and +// which one is being dragged along (design §6.5). +func Follower() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")). + Background(colorFollower) +} + // Stale styles the follow-mode badge shown while the source buffer has unsaved // edits, i.e. while cross-ref positions are older than what is on screen. func Stale() lipgloss.Style { From 7deda032edd9ba7d21af172c80c2511276d24490 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Thu, 30 Jul 2026 15:22:43 +0200 Subject: [PATCH 06/13] test(tui): model-level tests for the spec<->source join (S9) Both indexes had unit tests; nothing showed that the MODEL wires them together. These do, by synthesizing a scan the way one actually arrives - a rendered spec body plus the []scanner.Provenance a scan emits - building the indexes with the real builders, writing the Go sources to a temp dir so loadFileQuietly reads real files, and asserting where the follower actually lands rather than only what the status line reports. The provenance set anchors definitions and properties only, mirroring codescan's anchors-only emission (design 3.4). That is the point: it leaves ".../email/type" and a struct's closing brace unanchored, so nearest-ancestor and nearest-enclosing resolution become observable instead of incidental. Both are mutation-verified - disabling PositionFor's ancestor walk fails the nearest-ancestor test, disabling PointerAt's nearest-enclosing search fails two more. Covered: exact-anchor landing both ways; nearest-ancestor and nearest-enclosing through the model; file switching when the target moves between files; the follower tracking a walking driver in both directions; holding position above the first anchor; follow surviving a rescan, with a definition inserted ABOVE the followed node so a stale line number would land wrong; and the exits (search, options, edit, second f, focus change). TestJoin_SpecIndexMatchesFixture guards the hand-counted line constants the rest of the file depends on. TestJoin_ViewerSwallowsGlobalKeys pins current behaviour rather than endorsing it: handleViewerKey ends in a bare return, so with a file open in the left pane "/", o, r, g, ctrl+j and ctrl+y all silently do nothing. That may be deliberate - bare keys in a text pane are the ambiguity the read-only/edit split exists to avoid - but it also means you cannot toggle JSON/YAML or rescan while reading source. Left as a backlog decision; the test makes any change to it deliberate. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../internal/ux/model_join_test.go | 461 ++++++++++++++++++ 1 file changed, 461 insertions(+) create mode 100644 cmd/genspec-tui/internal/ux/model_join_test.go diff --git a/cmd/genspec-tui/internal/ux/model_join_test.go b/cmd/genspec-tui/internal/ux/model_join_test.go new file mode 100644 index 00000000..37162ca2 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_join_test.go @@ -0,0 +1,461 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/index" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/codescan/internal/scanner" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// C8 — the JOIN, exercised through the model. +// +// The two indexes have their own unit tests; what those cannot show is whether +// the model wires them together correctly. So these tests synthesize a scan — +// a rendered spec body plus the []scanner.Provenance a real scan would emit — +// build the indexes with the REAL builders, put the source files on disk, and +// then assert where the follower actually lands. +// +// The provenance set deliberately anchors only definitions and properties, the +// way codescan does (anchors-only emission, design §3.4): no anchor on +// `…/email/type`, none on a struct's closing brace. That is what makes +// nearest-ancestor (spec→source) and nearest-enclosing (source→spec) resolution +// observable rather than incidental. + +// joinSpecJSON is what the spec pane renders. Line numbers are load-bearing — +// see joinLine* below. +const joinSpecJSON = `{ + "definitions": { + "Address": { + "properties": { + "city": { + "type": "string" + } + } + }, + "User": { + "properties": { + "email": { + "type": "string" + }, + "manager": { + "$ref": "#/definitions/User" + } + } + } + } +}` + +// 0-based rendered lines of the nodes these tests navigate to. +const ( + joinLineAddress = 2 + joinLineCity = 4 + joinLineUser = 9 + joinLineEmail = 11 + joinLineEmailType = 12 // NOT anchored — resolves up to the email property + joinLineManager = 14 + joinLineManagerRef = 15 // NOT anchored — resolves up to the manager property + joinSpecTotalLines = 21 + joinViewportHeight = 7 // panel height 10 minus border+title + joinSpecMaxYOffset = joinSpecTotalLines - joinViewportHeight + joinFollowerContext = joinViewportHeight / 2 // HighlightLine centres (S8) +) + +// The synthesized source. Anchors point at the 1-based lines noted alongside. +const ( + joinUserGo = `package models + +// User is a user. +type User struct { + Email string + Manager *User +} +` + joinAddressGo = `package models + +// Address is an address. +type Address struct { + City string +} +` +) + +// 1-based source lines, matching the files above. +const ( + joinSrcUserDecl = 4 + joinSrcEmail = 5 + joinSrcManager = 6 + joinSrcUserClose = 7 // NOT anchored — resolves back to the manager field + joinSrcAddressDecl = 4 + joinSrcCity = 5 +) + +type joinFixture struct { + m *Model + userGo string + addrGo string + specLine func(ptr string) int +} + +// newJoinFixture writes the source files to a temp dir, builds the real spec +// index from the rendered bytes, and installs the provenance a scan would emit. +func newJoinFixture(t *testing.T) joinFixture { + t.Helper() + + dir := t.TempDir() + userGo := filepath.Join(dir, "user.go") + addrGo := filepath.Join(dir, "address.go") + require.NoError(t, os.WriteFile(userGo, []byte(joinUserGo), 0o600)) + require.NoError(t, os.WriteFile(addrGo, []byte(joinAddressGo), 0o600)) + + // searchInput must be a real textinput: a zero-value one panics on Focus, + // and production always builds it in New(). + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView(), searchInput: textinput.New()} + m.cfg.WorkDir = dir + m.spec.SetSize(60, 10) + m.fileView.SetSize(60, 10) + m.specJSON = joinSpecJSON + m.refreshSpec() // builds the real SpecIndex from the rendered bytes + + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User", Pos: token.Position{Filename: userGo, Line: joinSrcUserDecl}}, + {Pointer: "/definitions/User/properties/email", Pos: token.Position{Filename: userGo, Line: joinSrcEmail}}, + {Pointer: "/definitions/User/properties/manager", Pos: token.Position{Filename: userGo, Line: joinSrcManager}}, + {Pointer: "/definitions/Address", Pos: token.Position{Filename: addrGo, Line: joinSrcAddressDecl}}, + {Pointer: "/definitions/Address/properties/city", Pos: token.Position{Filename: addrGo, Line: joinSrcCity}}, + }) + + return joinFixture{ + m: m, userGo: userGo, addrGo: addrGo, + specLine: func(ptr string) int { + line, ok := m.specIndex.LineForPointer(ptr) + require.True(t, ok, "pointer %q must be in the rendered spec", ptr) + return line + }, + } +} + +// wantFollowerTop is where the spec viewport ends up once it has centred on +// line (S8: a jump centres, clamped at both edges). +func wantFollowerTop(line int) int { + return wantFollowerTopIn(line, joinSpecTotalLines) +} + +// wantFollowerTopIn is the same for a render of a different height — the bottom +// clamp depends on the total line count, which changes when the spec does. +func wantFollowerTopIn(line, totalLines int) int { + return clampInt(line-joinFollowerContext, 0, max(totalLines-joinViewportHeight, 0)) +} + +// driveSpec parks the spec viewport so that `line` is the top-of-viewport node +// (what driveSpecToSource reads) and re-mirrors the follower. +func (f joinFixture) driveSpec(line int) { + f.m.spec.ScrollTo(line) + f.m.syncFollowIfActive() +} + +// driveSource moves the source nav cursor to a 1-based source line and +// re-mirrors the follower. +func (f joinFixture) driveSource(srcLine int) { + f.m.fileView.GotoLine(srcLine - 1) + f.m.syncFollowIfActive() +} + +func TestJoin_SpecIndexMatchesFixture(t *testing.T) { + f := newJoinFixture(t) + + // Guards the hand-counted line constants the rest of the file relies on. + for ptr, want := range map[string]int{ + "/definitions/Address": joinLineAddress, + "/definitions/Address/properties/city": joinLineCity, + "/definitions/User": joinLineUser, + "/definitions/User/properties/email": joinLineEmail, + "/definitions/User/properties/email/type": joinLineEmailType, + "/definitions/User/properties/manager": joinLineManager, + "/definitions/User/properties/manager/$ref": joinLineManagerRef, + } { + assert.Equal(t, want, f.specLine(ptr), "rendered line of %s", ptr) + } +} + +func TestJoin_SpecToSource_LandsOnTheAnchoredLine(t *testing.T) { + f := newJoinFixture(t) + f.m.focused = paneSpec + f.m.spec.ScrollTo(joinLineEmail) + + f.m.toggleFollow(followSpec) + + require.Equal(t, followSpec, f.m.follow) + assert.Equal(t, f.userGo, f.m.currentFile, "the follower opened the producing file") + assert.Equal(t, joinSrcEmail-1, f.m.fileView.CurrentLine(), + "the follower parked on the email field's source line") + assert.Equal(t, paneSpec, f.m.focused, "the driver keeps focus") + assert.Contains(t, f.m.followTarget, "user.go:"+strconv.Itoa(joinSrcEmail)) +} + +// The spec has far more nodes than codescan anchors, so most lines resolve via +// nearest-ancestor. `…/email/type` has no anchor of its own. +func TestJoin_SpecToSource_NearestAncestor(t *testing.T) { + f := newJoinFixture(t) + f.m.focused = paneSpec + f.m.toggleFollow(followSpec) + + f.driveSpec(joinLineEmailType) + + assert.Equal(t, joinSrcEmail-1, f.m.fileView.CurrentLine(), + "an unanchored child resolves to its nearest anchored ancestor") + assert.Contains(t, f.m.followTarget, "/definitions/User/properties/email/type", + "the status names the node under the cursor, not the ancestor it resolved through") + assert.Contains(t, f.m.followTarget, "user.go:"+strconv.Itoa(joinSrcEmail)) +} + +func TestJoin_SpecToSource_SwitchesFileWhenTheTargetMoves(t *testing.T) { + f := newJoinFixture(t) + f.m.focused = paneSpec + f.m.spec.ScrollTo(joinLineEmail) + f.m.toggleFollow(followSpec) + require.Equal(t, f.userGo, f.m.currentFile) + + f.driveSpec(joinLineCity) + + assert.Equal(t, f.addrGo, f.m.currentFile, "the follower reopened the other file") + assert.Equal(t, joinSrcCity-1, f.m.fileView.CurrentLine()) +} + +// Walking the driver must re-mirror the follower each time, not just on entry. +func TestJoin_SpecToSource_TracksTheDriver(t *testing.T) { + f := newJoinFixture(t) + f.m.focused = paneSpec + f.m.toggleFollow(followSpec) + + for _, step := range []struct { + specLine int + wantSrcLine int + wantFile string + }{ + {joinLineAddress, joinSrcAddressDecl, f.addrGo}, + {joinLineCity, joinSrcCity, f.addrGo}, + {joinLineUser, joinSrcUserDecl, f.userGo}, + {joinLineEmail, joinSrcEmail, f.userGo}, + {joinLineManager, joinSrcManager, f.userGo}, + {joinLineManagerRef, joinSrcManager, f.userGo}, // unanchored → manager + } { + f.driveSpec(step.specLine) + assert.Equal(t, step.wantFile, f.m.currentFile, "spec line %d", step.specLine) + assert.Equal(t, step.wantSrcLine-1, f.m.fileView.CurrentLine(), "spec line %d", step.specLine) + } +} + +func TestJoin_SourceToSpec_LandsOnTheProducedNode(t *testing.T) { + f := newJoinFixture(t) + f.m.loadFileQuietly(f.userGo) + f.m.focused, f.m.leftMode = paneTree, modeView + f.m.fileView.GotoLine(joinSrcEmail - 1) + + f.m.toggleFollow(followSource) + + require.Equal(t, followSource, f.m.follow) + assert.Equal(t, "/definitions/User/properties/email", f.m.followTarget) + assert.Equal(t, wantFollowerTop(joinLineEmail), f.m.spec.TopLine(), + "the spec follower centred on the produced node") +} + +// A source line between anchors resolves to the nearest anchor at or above it. +func TestJoin_SourceToSpec_NearestEnclosing(t *testing.T) { + f := newJoinFixture(t) + f.m.loadFileQuietly(f.userGo) + f.m.focused, f.m.leftMode = paneTree, modeView + f.m.toggleFollow(followSource) + + f.driveSource(joinSrcUserClose) // the struct's closing brace: no anchor + + assert.Equal(t, "/definitions/User/properties/manager", f.m.followTarget, + "an unanchored line resolves to the nearest enclosing anchor") + assert.Equal(t, wantFollowerTop(joinLineManager), f.m.spec.TopLine()) +} + +func TestJoin_SourceToSpec_TracksTheDriver(t *testing.T) { + f := newJoinFixture(t) + f.m.loadFileQuietly(f.userGo) + f.m.focused, f.m.leftMode = paneTree, modeView + f.m.toggleFollow(followSource) + + for _, step := range []struct { + srcLine int + wantPtr string + wantSpec int + }{ + {joinSrcUserDecl, "/definitions/User", joinLineUser}, + {joinSrcEmail, "/definitions/User/properties/email", joinLineEmail}, + {joinSrcManager, "/definitions/User/properties/manager", joinLineManager}, + {joinSrcUserClose, "/definitions/User/properties/manager", joinLineManager}, + } { + f.driveSource(step.srcLine) + assert.Equal(t, step.wantPtr, f.m.followTarget, "source line %d", step.srcLine) + assert.Equal(t, wantFollowerTop(step.wantSpec), f.m.spec.TopLine(), "source line %d", step.srcLine) + } +} + +// A source line ABOVE every anchor in the file has no enclosing node. The +// follower must hold rather than snap to something arbitrary. +func TestJoin_SourceToSpec_AboveFirstAnchorHolds(t *testing.T) { + f := newJoinFixture(t) + f.m.loadFileQuietly(f.userGo) + f.m.focused, f.m.leftMode = paneTree, modeView + f.m.fileView.GotoLine(joinSrcEmail - 1) + f.m.toggleFollow(followSource) + before := f.m.spec.TopLine() + + f.driveSource(1) // `package models`, above the first anchor + + assert.Equal(t, noAnchorDesc, f.m.followTarget) + assert.Equal(t, before, f.m.spec.TopLine(), "the follower held its position") +} + +// A rescan rebuilds both indexes; follow must re-resolve against the NEW spec +// rather than keep pointing at a line number from the old render. +func TestJoin_FollowSurvivesRescan(t *testing.T) { + f := newJoinFixture(t) + f.m.loadFileQuietly(f.userGo) + f.m.focused, f.m.leftMode = paneTree, modeView + f.m.fileView.GotoLine(joinSrcEmail - 1) + f.m.toggleFollow(followSource) + require.Equal(t, wantFollowerTop(joinLineEmail), f.m.spec.TopLine()) + + // The same spec with a definition inserted ABOVE User, so every User node + // shifts down. A stale line number would now point at the wrong node. + grown := `{ + "definitions": { + "AAA": { + "properties": { + "zzz": { + "type": "string" + } + } + }, + "Address": { + "properties": { + "city": { + "type": "string" + } + } + }, + "User": { + "properties": { + "email": { + "type": "string" + }, + "manager": { + "$ref": "#/definitions/User" + } + } + } + } +}` + + _, _ = f.m.Update(scanResultMsg{ + json: grown, + provenance: []scanner.Provenance{ + {Pointer: "/definitions/User/properties/email", Pos: token.Position{Filename: f.userGo, Line: joinSrcEmail}}, + }, + }) + + newLine, ok := f.m.specIndex.LineForPointer("/definitions/User/properties/email") + require.True(t, ok) + require.NotEqual(t, joinLineEmail, newLine, "precondition: the node moved in the new render") + + assert.Equal(t, "/definitions/User/properties/email", f.m.followTarget) + assert.Equal(t, wantFollowerTopIn(newLine, strings.Count(grown, "\n")+1), f.m.spec.TopLine(), + "follow re-resolved against the rebuilt index") +} + +func TestJoin_FollowExits(t *testing.T) { + // Source-driven: the file viewer is the driver. + sourceDriven := func(t *testing.T) joinFixture { + t.Helper() + f := newJoinFixture(t) + f.m.loadFileQuietly(f.userGo) + f.m.focused, f.m.leftMode = paneTree, modeView + f.m.fileView.GotoLine(joinSrcEmail - 1) + f.m.toggleFollow(followSource) + require.Equal(t, followSource, f.m.follow) + return f + } + + // Spec-driven, with the left pane back on the tree. `/` and `o` are only + // reachable from here: handleViewerKey swallows every key it does not own, + // so neither reaches the global bindings while a file is open. + specDriven := func(t *testing.T) joinFixture { + t.Helper() + f := newJoinFixture(t) + f.m.focused, f.m.leftMode = paneSpec, modeBrowse + f.m.spec.ScrollTo(joinLineEmail) + f.m.toggleFollow(followSpec) + require.Equal(t, followSpec, f.m.follow) + return f + } + + t.Run("opening search", func(t *testing.T) { + f := specDriven(t) + _, _, handled := f.m.handleSearchControl(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + require.True(t, handled) + assert.Equal(t, followOff, f.m.follow, "search takes over the spec pane") + }) + + t.Run("opening options", func(t *testing.T) { + f := specDriven(t) + _, _ = f.m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) + require.True(t, f.m.optionsOpen) + assert.Equal(t, followOff, f.m.follow, "a rescan is about to invalidate the indexes") + }) + + t.Run("starting to edit", func(t *testing.T) { + f := sourceDriven(t) + _ = f.m.fileView.StartEdit() + f.m.syncFollowIfActive() + assert.Equal(t, followOff, f.m.follow, "positions go stale once the buffer is edited") + }) + + t.Run("second f", func(t *testing.T) { + f := sourceDriven(t) + f.m.toggleFollow(followSource) + assert.Equal(t, followOff, f.m.follow) + assert.Empty(t, f.m.followTarget) + }) + + t.Run("focus change", func(t *testing.T) { + f := sourceDriven(t) + f.m.focused = paneDiag + f.m.syncFollowIfActive() + assert.Equal(t, followOff, f.m.follow, "the driver pane lost focus") + }) +} + +// The read-only viewer swallows every key it does not own, so the global +// bindings are unreachable while a file is open. Pinning the CURRENT behaviour +// (not endorsing it) — see the UX-polish note in the build plan. +func TestJoin_ViewerSwallowsGlobalKeys(t *testing.T) { + for _, k := range []rune{'/', 'o', 'r', 'g'} { + f := newJoinFixture(t) + f.m.loadFileQuietly(f.userGo) + f.m.focused, f.m.leftMode = paneTree, modeView + + _, _ = f.m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{k}}) + + assert.False(t, f.m.optionsOpen, "%q reached the options popup", k) + assert.False(t, f.m.searching, "%q reached search", k) + } +} From dc6d0e8f276d070cf6dd88a59962d3d7ed241429 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Thu, 30 Jul 2026 15:40:23 +0200 Subject: [PATCH 07/13] feat(tui): $ref site index for find-references (S10) Phase D needs to answer "where is this definition used?". Provenance cannot: a referencing site is anchored to its own field, never to the type it references (design 3.4), so the question is only answerable by resolving $refs at RENDER time. This adds that as a per-render index, built in the same lexer pass that already produces the SpecIndex - one walk, two indexes, no extra cost per render. BuildJSONIndex/BuildYAMLIndex now return (*SpecIndex, *RefIndex) and share an indexAccum. The detection rule came from probing the lexers rather than guessing: both report the $ref key AND its value under the same ".../$ref" pointer, and Value() yields the target already unquoted (YAML single quotes stripped too), so "a non-key scalar whose pointer ends in /$ref" is the whole rule, identical on both sides. A RefSite names the node HOLDING the ref, with the /$ref segment trimmed - that is what the user navigates to, not the $ref member. Sites come back ordered by rendered line, which is the order F3 will step through them. Scope is a SITE index, not a resolver. Local "#/..." fragments resolve; sibling files, URLs, bare filenames and plain-name fragments are recorded verbatim and flagged non-local, so the UI can say where they point without pretending it can follow them. Ref-to-ref chains, $ref inside allOf and the ignored-siblings rule stay documented rather than chased. Fragments are percent-decoded. go-openapi/spec marshals refs through net/url, so a definition name with a space emits as "#/definitions/A%20B", while SpecIndex keys on the document's own unescaped key text; decoding is what keeps the two comparable. A malformed escape falls back to the verbatim fragment rather than dropping the ref. Unreachable with Go-identifier names today, but a silent mismatch is the worst failure mode for a nav feature. RFC 6901 ~0/~1 escaping is deliberately NOT decoded - both sides carry it, so it matches verbatim, as the path-key test shows. Model.refIndex is wired in refreshSpec; S11 consumes it. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- cmd/genspec-tui/internal/ux/index/refindex.go | 173 ++++++++++++++++ .../internal/ux/index/refindex_test.go | 189 ++++++++++++++++++ .../internal/ux/index/specindex.go | 64 ++---- .../internal/ux/index/specindex_test.go | 6 +- cmd/genspec-tui/internal/ux/model.go | 5 +- 5 files changed, 388 insertions(+), 49 deletions(-) create mode 100644 cmd/genspec-tui/internal/ux/index/refindex.go create mode 100644 cmd/genspec-tui/internal/ux/index/refindex_test.go diff --git a/cmd/genspec-tui/internal/ux/index/refindex.go b/cmd/genspec-tui/internal/ux/index/refindex.go new file mode 100644 index 00000000..708b8968 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/index/refindex.go @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package index + +import ( + "net/url" + "sort" + "strings" +) + +// refSuffix is the pointer tail a $ref member carries. Both lexers report the +// member's own pointer (…/boss/$ref) for the key AND for its value token. +const refSuffix = "/$ref" + +// RefTarget is a parsed $ref value. +// +// Local refs point inside the document being rendered and can therefore be +// followed with the SpecIndex; anything else (a sibling file, a URL, a bare +// filename) is recorded honestly but is not resolvable here — the TUI renders +// one spec, it is not a $ref resolver. +type RefTarget struct { + Raw string // exactly as written in the document + Pointer string // the RFC 6901 pointer for a local ref; "" otherwise + Local bool // whether the ref points inside this document +} + +// RefSite is one place in the rendered spec where a $ref appears. +type RefSite struct { + Pointer string // the node HOLDING the $ref (the /$ref segment trimmed) + Line int // 0-based rendered line of the $ref + Target RefTarget +} + +// RefIndex records every $ref in a rendered spec, keyed by what it points at. +// +// This is the "find references" half of Phase D (design §3.4): a decl is +// anchored to its own field, never to the type it references, so answering +// "where is this definition used?" means resolving $refs at RENDER time. That +// makes the index per-render, exactly like SpecIndex — and it is built in the +// same lexer pass, so it costs no extra walk. +// +// Scope, deliberately: this is a SITE index, not a JSON-Schema resolver. It +// records where each $ref token sits and what string it holds. It does not +// follow ref-to-ref chains, does not reason about $refs nested in allOf, and +// does not apply the "sibling keywords are ignored" rule — the §3.4 quirks stay +// documented rather than chased. +type RefIndex struct { + byTarget map[string][]RefSite // local target pointer → sites, ordered by line + byLine map[int]RefSite // rendered line → the $ref on it + total int +} + +// ParseRefTarget parses a raw $ref value. +// +// A local ref is a bare fragment ("#/definitions/User"). The fragment is +// percent-DECODED, because go-openapi/spec marshals refs through net/url and +// will escape a definition name containing e.g. a space — while the SpecIndex +// keys on the document's own key text, which is not escaped. Decoding here is +// what makes the two sides comparable. A malformed escape falls back to the +// verbatim fragment rather than dropping the ref. +func ParseRefTarget(raw string) RefTarget { + t := RefTarget{Raw: raw} + if !strings.HasPrefix(raw, "#") { + return t // another document, a URL, or a bare filename + } + frag := strings.TrimPrefix(raw, "#") + if frag != "" && !strings.HasPrefix(frag, "/") { + return t // "#Foo" — a plain-name fragment, not a JSON pointer + } + decoded, err := url.PathUnescape(frag) + if err != nil { + decoded = frag + } + t.Pointer, t.Local = decoded, true + + return t +} + +// RefsToPointer returns every site referencing the node at ptr, ordered by +// rendered line. ptr is a plain JSON pointer as the SpecIndex reports it +// (e.g. "/definitions/User") — the leading "#" of the $ref is not included. +func (x *RefIndex) RefsToPointer(ptr string) []RefSite { + if x == nil { + return nil + } + + return x.byTarget[ptr] +} + +// RefAt returns the $ref rendered on the given 0-based line, if any. Backs +// go-to-definition: the user puts the cursor on a $ref and follows it. +func (x *RefIndex) RefAt(line int) (RefSite, bool) { + if x == nil { + return RefSite{}, false + } + site, ok := x.byLine[line] + + return site, ok +} + +// Len reports how many $ref sites the index holds (0 for a nil index), +// including non-local ones. +func (x *RefIndex) Len() int { + if x == nil { + return 0 + } + + return x.total +} + +// indexAccum accumulates both per-render indexes during one lexer walk. The two +// lexers (JSON and YAML) emit different concrete types but the same logical +// stream, so each drives this from its own loop. +type indexAccum struct { + line2ptr map[int]string + ptr2line map[string]int + byTarget map[string][]RefSite + byLine map[int]RefSite + total int +} + +func newIndexAccum() *indexAccum { + return &indexAccum{ + line2ptr: make(map[int]string), + ptr2line: make(map[string]int), + byTarget: make(map[string][]RefSite), + byLine: make(map[int]RefSite), + } +} + +// add folds one token into both indexes. ptr is the token's JSON pointer, line +// its 0-based rendered line, isKey whether it is an object key, and value its +// raw scalar text (empty for delimiters). +func (a *indexAccum) add(ptr string, line int, isKey bool, value []byte) { + if ptr == "" { + return + } + + // Spec index: the FIRST token to report a pointer is the line that pointer + // is declared on; later repeats (its value, its closing delimiter) are the + // same node seen again. + if _, seen := a.ptr2line[ptr]; !seen { + a.ptr2line[ptr] = line + a.line2ptr[line] = ptr + } + + // Ref index: the VALUE token under a …/$ref pointer carries the target. + // The key token shares that pointer, hence the isKey guard. + if isKey || len(value) == 0 || !strings.HasSuffix(ptr, refSuffix) { + return + } + site := RefSite{ + Pointer: strings.TrimSuffix(ptr, refSuffix), + Line: line, + Target: ParseRefTarget(string(value)), + } + a.total++ + a.byLine[line] = site + if site.Target.Local { + a.byTarget[site.Target.Pointer] = append(a.byTarget[site.Target.Pointer], site) + } +} + +func (a *indexAccum) finish() (*SpecIndex, *RefIndex) { + for target := range a.byTarget { + sites := a.byTarget[target] + sort.Slice(sites, func(i, j int) bool { return sites[i].Line < sites[j].Line }) + } + + return NewSpecIndex(a.line2ptr, a.ptr2line), + &RefIndex{byTarget: a.byTarget, byLine: a.byLine, total: a.total} +} diff --git a/cmd/genspec-tui/internal/ux/index/refindex_test.go b/cmd/genspec-tui/internal/ux/index/refindex_test.go new file mode 100644 index 00000000..b8bf00b2 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/index/refindex_test.go @@ -0,0 +1,189 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package index + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// refSpecJSON references /definitions/User from three places — a property, an +// array's items, and a response schema — plus one external ref that must be +// recorded but not resolvable, and a ref to a path key needing RFC 6901 +// escaping. Indented exactly as json.MarshalIndent renders. +const refSpecJSON = `{ + "definitions": { + "Team": { + "properties": { + "lead": { + "$ref": "#/definitions/User" + }, + "members": { + "items": { + "$ref": "#/definitions/User" + }, + "type": "array" + }, + "logo": { + "$ref": "https://example.com/schemas/logo.json#/Logo" + } + } + }, + "User": { + "properties": { + "name": { + "type": "string" + } + } + } + }, + "paths": { + "/pets": { + "get": { + "responses": { + "200": { + "schema": { + "$ref": "#/definitions/User" + } + } + } + } + } + } +}` + +// 0-based rendered lines of the four $ref values above. +const ( + refLineLead = 5 + refLineMembers = 9 + refLineLogo = 14 + refLineResponse = 32 + refLineUserDecl = 18 +) + +func TestRefIndex_FindsEveryLocalSite(t *testing.T) { + _, refs := BuildJSONIndex([]byte(refSpecJSON)) + + sites := refs.RefsToPointer("/definitions/User") + require.Len(t, sites, 3, "a property, an items schema and a response schema reference User") + + // Ordered by rendered line, which is the order F3 will step through them. + assert.Equal(t, []int{refLineLead, refLineMembers, refLineResponse}, + []int{sites[0].Line, sites[1].Line, sites[2].Line}) + + // Each site names the node HOLDING the $ref, not the $ref member itself — + // that is the node the user is navigating to. + assert.Equal(t, "/definitions/Team/properties/lead", sites[0].Pointer) + assert.Equal(t, "/definitions/Team/properties/members/items", sites[1].Pointer) + assert.Equal(t, "/paths/~1pets/get/responses/200/schema", sites[2].Pointer, + "the path key keeps its RFC 6901 escaping") +} + +func TestRefIndex_ExternalRefsAreRecordedNotResolved(t *testing.T) { + _, refs := BuildJSONIndex([]byte(refSpecJSON)) + + assert.Equal(t, 4, refs.Len(), "the external ref is counted") + assert.Empty(t, refs.RefsToPointer("/Logo"), + "an external ref must not be matched as if it were local") + + site, ok := refs.RefAt(refLineLogo) + require.True(t, ok, "the external ref is still locatable by line") + assert.False(t, site.Target.Local) + assert.Empty(t, site.Target.Pointer) + assert.Equal(t, "https://example.com/schemas/logo.json#/Logo", site.Target.Raw, + "the raw value is preserved verbatim, so the UI can say where it points") +} + +func TestRefIndex_RefAt(t *testing.T) { + _, refs := BuildJSONIndex([]byte(refSpecJSON)) + + site, ok := refs.RefAt(refLineLead) + require.True(t, ok) + assert.Equal(t, "/definitions/User", site.Target.Pointer) + assert.True(t, site.Target.Local) + + _, ok = refs.RefAt(0) // the opening brace: no $ref there + assert.False(t, ok) +} + +// The whole point of the index is the join: a site's target must be a pointer +// the SpecIndex can actually resolve to a line. +func TestRefIndex_TargetsResolveInTheSpecIndex(t *testing.T) { + spec, refs := BuildJSONIndex([]byte(refSpecJSON)) + + for _, site := range refs.RefsToPointer("/definitions/User") { + line, ok := spec.LineForPointer(site.Target.Pointer) + require.True(t, ok, "target %q must exist in the spec index", site.Target.Pointer) + assert.Equal(t, refLineUserDecl, line, "/definitions/User is declared once") + } +} + +// YAML renders the same document at different lines; the pointers must match +// the JSON side exactly, so navigation survives a format toggle. +func TestRefIndex_YAMLMatchesJSONPointers(t *testing.T) { + const refSpecYAML = `definitions: + Team: + properties: + lead: + $ref: '#/definitions/User' + members: + items: + $ref: '#/definitions/User' + type: array + User: + properties: + name: + type: string +` + _, refs := BuildYAMLIndex([]byte(refSpecYAML)) + + sites := refs.RefsToPointer("/definitions/User") + require.Len(t, sites, 2) + assert.Equal(t, "/definitions/Team/properties/lead", sites[0].Pointer) + assert.Equal(t, "/definitions/Team/properties/members/items", sites[1].Pointer) + assert.Equal(t, []int{4, 7}, []int{sites[0].Line, sites[1].Line}, + "same nodes, YAML line numbers") +} + +func TestParseRefTarget(t *testing.T) { + for _, c := range []struct { + name string + raw string + local bool + pointer string + }{ + {"local definition", "#/definitions/User", true, "/definitions/User"}, + {"local with escaped path key", "#/paths/~1pets/get", true, "/paths/~1pets/get"}, + {"local percent-encoded", "#/definitions/A%20B", true, "/definitions/A B"}, + {"hierarchical name", "#/definitions/pkg/Name", true, "/definitions/pkg/Name"}, + {"whole document", "#", true, ""}, + {"external file", "other.json", false, ""}, + {"external with fragment", "other.json#/definitions/User", false, ""}, + {"url", "https://example.com/s.json#/X", false, ""}, + {"plain-name fragment", "#Foo", false, ""}, + // A malformed escape must not drop the ref on the floor. + {"bad percent escape", "#/definitions/A%zz", true, "/definitions/A%zz"}, + } { + t.Run(c.name, func(t *testing.T) { + got := ParseRefTarget(c.raw) + assert.Equal(t, c.raw, got.Raw) + assert.Equal(t, c.local, got.Local) + assert.Equal(t, c.pointer, got.Pointer) + }) + } +} + +func TestRefIndex_NilAndEmpty(t *testing.T) { + var nilIdx *RefIndex + assert.Empty(t, nilIdx.RefsToPointer("/definitions/User")) + assert.Zero(t, nilIdx.Len()) + _, ok := nilIdx.RefAt(0) + assert.False(t, ok) + + _, refs := BuildJSONIndex([]byte(`{"definitions":{}}`)) + assert.Zero(t, refs.Len()) + assert.Empty(t, refs.RefsToPointer("/definitions/User")) +} diff --git a/cmd/genspec-tui/internal/ux/index/specindex.go b/cmd/genspec-tui/internal/ux/index/specindex.go index 1f511514..a8e46ef9 100644 --- a/cmd/genspec-tui/internal/ux/index/specindex.go +++ b/cmd/genspec-tui/internal/ux/index/specindex.go @@ -31,61 +31,37 @@ func NewSpecIndex(line2ptr map[int]string, ptr2line map[string]int) *SpecIndex { return &SpecIndex{line2ptr: line2ptr, ptr2line: ptr2line, lines: lines} } -// BuildJSONIndex builds a SpecIndex from indented JSON bytes using the -// jsontext.Decoder token stream: after each token, StackPointer() names the -// current node, and InputOffset() locates it. The first occurrence of a pointer -// is the member's declaration line; later repeats (its value, its close) are -// ignored. Best-effort — on a decode error it returns what it has so far (the -// rendered bytes are always valid JSON, so this is just defensive). +// BuildJSONIndex builds the per-render indexes from indented JSON bytes: the +// line↔pointer SpecIndex and the $ref-site RefIndex, in ONE lexer pass. // -// When using standard lib, this requires GOEXPERIMENT=jsonv2. For now, we are -// still relying on github.com/go-json-experiment/json. -// We are actively developing an alternative json lexer (part go-openapi/core/json) -// to avoid these complicated choices in the near future. -func BuildJSONIndex(b []byte) *SpecIndex { - line2ptr := make(map[int]string) - ptr2line := make(map[string]int) +// The lexer reports, per token, its JSON pointer (RFC 6901 escaping handled for +// us) and its 1-based source line. The first token to report a pointer is the +// line that member is declared on; later repeats (its value, its closing +// delimiter) are the same node seen again. Reads the bytes the pane renders, so +// the ordered keys of spec.Swagger's MarshalJSON are preserved. +func BuildJSONIndex(b []byte) (*SpecIndex, *RefIndex) { + acc := newIndexAccum() lex := lexer.NewVerbatimWithBytes(b, lexer.WithJSONPointer(true)) - for range lex.Tokens() { - line := lex.Line() - 1 - p := lex.JSONPointer().String() // current jsonpath of the token - if p == "" { - continue - } - if _, seen := ptr2line[p]; seen { - continue - } - ptr2line[p] = line - line2ptr[line] = p + for tok := range lex.Tokens() { + acc.add(lex.JSONPointer().String(), lex.Line()-1, tok.IsKey(), tok.Value()) } - return NewSpecIndex(line2ptr, ptr2line) + return acc.finish() } -// BuildYAMLIndex builds a SpecIndex from YAML bytes by walking the yaml.Node -// tree, which carries a 1-based .Line per node. Pointers use the same RFC 6901 -// escaping as the JSON side, so an index built from either render is -// interchangeable. Best-effort — a parse error yields an empty index. -func BuildYAMLIndex(b []byte) *SpecIndex { - line2ptr := make(map[int]string) - ptr2line := make(map[string]int) +// BuildYAMLIndex is BuildJSONIndex over the YAML render. The YAML lexer emits +// the same logical token stream with the same RFC 6901 pointer escaping, so the +// indexes built from either render are interchangeable — only the lines differ. +func BuildYAMLIndex(b []byte) (*SpecIndex, *RefIndex) { + acc := newIndexAccum() lex := yamllexer.NewWithBytes(b, yamllexer.WithJSONPointer(true)) - for range lex.Tokens() { - line := lex.Line() - 1 - p := lex.JSONPointer().String() // current jsonpath of the token - if p == "" { - continue - } - if _, seen := ptr2line[p]; seen { - continue - } - ptr2line[p] = line - line2ptr[line] = p + for tok := range lex.Tokens() { + acc.add(lex.JSONPointer().String(), lex.Line()-1, tok.IsKey(), tok.Value()) } - return NewSpecIndex(line2ptr, ptr2line) + return acc.finish() } // PointerAt returns the JSON pointer of the node at line, or the nearest member diff --git a/cmd/genspec-tui/internal/ux/index/specindex_test.go b/cmd/genspec-tui/internal/ux/index/specindex_test.go index e78b55c6..433067dc 100644 --- a/cmd/genspec-tui/internal/ux/index/specindex_test.go +++ b/cmd/genspec-tui/internal/ux/index/specindex_test.go @@ -31,7 +31,7 @@ const specJSON = `{ }` func TestBuildJSONIndex(t *testing.T) { - idx := BuildJSONIndex([]byte(specJSON)) + idx, _ := BuildJSONIndex([]byte(specJSON)) // pointer → 0-based line (count lines in specJSON above). want := map[string]int{ @@ -86,7 +86,7 @@ paths: ` func TestBuildYAMLIndex(t *testing.T) { - idx := BuildYAMLIndex([]byte(specYAML)) + idx, _ := BuildYAMLIndex([]byte(specYAML)) want := map[string]int{ "/definitions": 0, @@ -122,7 +122,7 @@ func TestSpecIndexEmptyAndNil(t *testing.T) { t.Error("nil index Len should be 0") } - empty := BuildJSONIndex([]byte(`{}`)) + empty, _ := BuildJSONIndex([]byte(`{}`)) if _, ok := empty.PointerAt(0); ok { t.Error("empty object should index no pointers") } diff --git a/cmd/genspec-tui/internal/ux/model.go b/cmd/genspec-tui/internal/ux/model.go index 14813357..f80142c4 100644 --- a/cmd/genspec-tui/internal/ux/model.go +++ b/cmd/genspec-tui/internal/ux/model.go @@ -135,6 +135,7 @@ type Model struct { specJSON string specYAML string specIndex *index.SpecIndex // rendered-line ↔ JSON-pointer map for the active format + refIndex *index.RefIndex // $ref sites in the active render (find-references / go-to-definition) srcIndex *index.SourceIndex // JSON-pointer ↔ Go source position (cross-ref linker) diags []grammar.Diagnostic scanErr error // hard error from the last codescan.Run, shown in the diag pane @@ -759,9 +760,9 @@ func (m *Model) refreshSpec() { return } if yamlFmt { - m.specIndex = index.BuildYAMLIndex([]byte(body)) + m.specIndex, m.refIndex = index.BuildYAMLIndex([]byte(body)) } else { - m.specIndex = index.BuildJSONIndex([]byte(body)) + m.specIndex, m.refIndex = index.BuildJSONIndex([]byte(body)) } m.spec.SetContent(body) } From 6661b7317b9f3e4dcc7b3c04b884bbd281d5e827 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Thu, 30 Jul 2026 16:07:19 +0200 Subject: [PATCH 08/13] feat(tui): find-references and go-to-definition (S11) Completes Phase D's navigation layer on top of S10's site index. F3 / shift+F3 step through the places the node under the spec cursor is referenced, wrapping; a backward step into a fresh cycle enters at the last site. A persistent REFS badge reports "ref 2/3 of -> ". A cycle continues only while the viewport is still where the last jump left it. Scroll away and the next F3 re-anchors on wherever you now are. Without that rule, pressing F3 repeatedly would chase the definition of whatever it last landed on instead of walking one definition's uses. The cursor need not sit on the definition line either: RefsNear walks up to the nearest referenced ancestor, the same segment-trim idiom as SourceIndex.PositionFor. The cycle is dropped whenever the render it was computed against is replaced (rescan, format toggle) and on esc, follow, search and options. Enter follows the $ref under the cursor to its definition. Local "#/..." refs only - an external target is named rather than guessed at, and the viewport holds. shift+F3 is terminal-dependent, and that is a bubbletea v1 limitation rather than a choice: tea.Key carries no Shift modifier, and the xterm family maps shift+F1..F12 onto F13..F24, so shift+F3 arrives as KeyF15. Hence key.ShiftF3 = "f15", with "shift+f3" also accepted for terminals (or a future bubbletea) that report the modifier directly. A terminal emitting nothing distinguishable simply has no prev key; forward cycling still wraps. The three keys dispatch through a new handleRefNav, mirroring handleDiagNav: gated on the spec pane, returning handled=false elsewhere so Enter still opens a file in the tree. That is not cosmetic - inlining the cases pushed handleKey to cyclomatic complexity 32 and tripped gocyclo. Tests caught a real bug: on a failed re-start the previous cycle's status lingered, so the badge kept reading "ref 1/3 of /definitions/User" while the user was looking elsewhere. cycleRefs now drops the old cycle before attempting a new one. Mutation-verified: forcing the cycle to always continue, and dropping RefsNear's ancestor walk, each fail a test. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- cmd/genspec-tui/internal/ux/index/refindex.go | 26 ++ cmd/genspec-tui/internal/ux/key/bindings.go | 15 + cmd/genspec-tui/internal/ux/model.go | 151 ++++++++- .../internal/ux/model_refs_test.go | 320 ++++++++++++++++++ 4 files changed, 510 insertions(+), 2 deletions(-) create mode 100644 cmd/genspec-tui/internal/ux/model_refs_test.go diff --git a/cmd/genspec-tui/internal/ux/index/refindex.go b/cmd/genspec-tui/internal/ux/index/refindex.go index 708b8968..1f831348 100644 --- a/cmd/genspec-tui/internal/ux/index/refindex.go +++ b/cmd/genspec-tui/internal/ux/index/refindex.go @@ -88,6 +88,32 @@ func (x *RefIndex) RefsToPointer(ptr string) []RefSite { return x.byTarget[ptr] } +// RefsNear returns the sites referencing ptr — or, when nothing references ptr +// itself, the sites referencing its nearest referenced ANCESTOR, along with the +// pointer that actually matched. +// +// The segment-trim walk mirrors SourceIndex.PositionFor, and for the same +// reason: the user's cursor is rarely on the definition line itself. Asking for +// the references of `/definitions/User/properties/name` should find the uses of +// `User`, not report nothing. +func (x *RefIndex) RefsNear(ptr string) (string, []RefSite) { + if x == nil { + return "", nil + } + for ptr != "" { + if sites := x.byTarget[ptr]; len(sites) > 0 { + return ptr, sites + } + i := strings.LastIndexByte(ptr, '/') + if i < 0 { + break + } + ptr = ptr[:i] + } + + return "", nil +} + // RefAt returns the $ref rendered on the given 0-based line, if any. Backs // go-to-definition: the user puts the cursor on a $ref and follows it. func (x *RefIndex) RefAt(line int) (RefSite, bool) { diff --git a/cmd/genspec-tui/internal/ux/key/bindings.go b/cmd/genspec-tui/internal/ux/key/bindings.go index 666ffe22..80dc066d 100644 --- a/cmd/genspec-tui/internal/ux/key/bindings.go +++ b/cmd/genspec-tui/internal/ux/key/bindings.go @@ -39,6 +39,21 @@ const ( Space Binding = " " Esc Binding = "esc" Enter Binding = "enter" + + // F3 steps to the next reference of the definition under the spec cursor. + F3 Binding = "f3" + + // ShiftF3 steps to the PREVIOUS reference. + // + // bubbletea v1's Key carries no Shift modifier, and the xterm family maps + // shift+F1..F12 onto F13..F24 — so shift+F3 reaches us as F15. This is + // terminal-dependent: a terminal that emits nothing distinguishable for + // shift+F3 simply has no prev key. + ShiftF3 Binding = "f15" + + // ShiftF3Named is the literal spelling, accepted so a terminal (or a future + // bubbletea) that reports the modifier directly also works. + ShiftF3Named Binding = "shift+f3" ) // MsgBinding normalizes a key message to a Binding. diff --git a/cmd/genspec-tui/internal/ux/model.go b/cmd/genspec-tui/internal/ux/model.go index f80142c4..089b9683 100644 --- a/cmd/genspec-tui/internal/ux/model.go +++ b/cmd/genspec-tui/internal/ux/model.go @@ -154,6 +154,14 @@ type Model struct { follow followMode followTarget string // human-readable resolved target, for the nav status badge + // Find-references cycle state (F3 / shift+F3). Valid only for the CURRENT + // render, so refreshSpec resets it. + refAnchor string // the definition pointer whose uses are being cycled + refSites []index.RefSite // its reference sites, ordered by rendered line + refCursor int // which site we are parked on + refExpectTop int // spec TopLine our last jump produced; -1 when idle + refStatus string // persistent status line while a cycle is active + tree panels.Tree fileView panels.FileView spec panels.Spec @@ -181,6 +189,8 @@ func New(workdir string, packages []string, scanModels bool) *Model { fileView: panels.NewFileView(), spec: panels.NewSpec(), diag: panels.NewDiagnostics(), + + refExpectTop: -1, } // Options-popup rows bind to the scan-config booleans (pointers into // m.cfg stay valid — m is heap-allocated). @@ -322,6 +332,10 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } + if cmd, handled := m.handleRefNav(msg); handled { + return m, cmd + } + if mdl, cmd, handled := m.handleSearchControl(msg); handled { return mdl, cmd } @@ -345,6 +359,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.startScan() case key.O: m.exitFollow() + m.resetRefCycle() m.optionsOpen = true m.optDirty = false return m, nil @@ -378,6 +393,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.exitFollow() return m, nil } + m.resetRefCycle() m.spec.ClearSearch() m.spec.ClearHighlight() return m, nil @@ -408,6 +424,27 @@ func (m *Model) handleSearchControl(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { return m, nil, false } +// handleRefNav handles the Phase-D navigation keys, which belong to the spec +// pane: F3 / shift+F3 cycle the references of the node under the cursor, Enter +// follows the $ref under it. Returns handled=false for every other pane, so +// their own bindings (notably Enter opening a file in the tree) still apply. +func (m *Model) handleRefNav(msg tea.KeyMsg) (tea.Cmd, bool) { + if m.focused != paneSpec { + return nil, false + } + + switch key.MsgBinding(msg) { + case key.F3: + return m.cycleRefs(+1), true + case key.ShiftF3, key.ShiftF3Named: + return m.cycleRefs(-1), true + case key.Enter: + return m.gotoDefinition(), true + } + + return nil, false +} + // handleDiagNav handles diagnostics-pane selection and follow. Returns // handled=false for keys it doesn't own, so global bindings still apply. func (m *Model) handleDiagNav(msg tea.KeyMsg) (tea.Cmd, bool) { @@ -618,6 +655,7 @@ func (m *Model) handleOptionsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // enterSearch opens the search input over the status line, focusing the spec. func (m *Model) enterSearch() (tea.Model, tea.Cmd) { m.exitFollow() + m.resetRefCycle() m.searching = true m.focused = paneSpec m.searchInput.SetValue("") @@ -754,8 +792,11 @@ func (m *Model) refreshSpec() { if yamlFmt { body = m.specYAML } + // Both indexes and the find-references cycle are per-render: a rescan or a + // format toggle invalidates every line number they hold. + m.resetRefCycle() if body == "" { - m.specIndex = nil + m.specIndex, m.refIndex = nil, nil m.spec.SetContent("(no spec generated yet)") return } @@ -789,6 +830,102 @@ func (m *Model) setSpecFormat(format string) { } } +// cycleRefs steps through the places the definition under the spec cursor is +// referenced (design §6.4 multi-candidate case): dir +1 for the next site, -1 +// for the previous, wrapping. +// +// A cycle continues only while the viewport is still where the last jump left +// it. The moment the user scrolls away, the node under the cursor has changed +// and the next F3 starts a fresh cycle from wherever they now are — which is +// what makes "F3 repeatedly" walk one definition's uses rather than chasing the +// definition of whatever it last landed on. +func (m *Model) cycleRefs(dir int) tea.Cmd { + if continuing := m.refAnchor != "" && m.spec.TopLine() == m.refExpectTop; continuing { + m.refCursor = (m.refCursor + dir + len(m.refSites)) % len(m.refSites) + } else { + // Drop the old cycle FIRST: if the new node has no references we must + // not leave "ref 1/3 of /definitions/User" on screen while the user is + // looking at something else entirely. + m.resetRefCycle() + if !m.startRefCycle(dir) { + return clearNoticeAfter(noticeTTL) + } + } + + site := m.refSites[m.refCursor] + m.spec.HighlightLine(site.Line) + m.refExpectTop = m.spec.TopLine() + m.refStatus = fmt.Sprintf("ref %d/%d of %s → %s", + m.refCursor+1, len(m.refSites), m.refAnchor, site.Pointer) + + return nil +} + +// startRefCycle begins a new cycle anchored on the node under the spec cursor, +// entering at the first site for a forward step and the last for a backward +// one. Reports false (with a notice explaining why) when there is nothing to +// cycle. +func (m *Model) startRefCycle(dir int) bool { + ptr, ok := m.specIndex.PointerAt(m.spec.TopLine()) + if !ok { + m.notice = noNodeDesc + + return false + } + anchor, sites := m.refIndex.RefsNear(ptr) + if len(sites) == 0 { + m.notice = "nothing references " + ptr + + return false + } + + m.refAnchor, m.refSites = anchor, sites + m.refCursor = 0 + if dir < 0 { + m.refCursor = len(sites) - 1 + } + + return true +} + +// resetRefCycle drops the find-references state. Called whenever the render it +// was computed against is replaced (rescan, format toggle) or the user moves on. +func (m *Model) resetRefCycle() { + m.refAnchor, m.refSites, m.refCursor = "", nil, 0 + m.refExpectTop = -1 + m.refStatus = "" +} + +// gotoDefinition follows the $ref under the spec cursor to the node it points +// at — the inverse of cycleRefs. Only local (`#/…`) refs are followable: the TUI +// renders one spec and is not a $ref resolver, so an external target is +// reported honestly rather than guessed at. +func (m *Model) gotoDefinition() tea.Cmd { + site, ok := m.refIndex.RefAt(m.spec.TopLine()) + if !ok { + m.notice = "no $ref on this line" + + return clearNoticeAfter(noticeTTL) + } + if !site.Target.Local { + m.notice = "external ref, not in this spec: " + site.Target.Raw + + return clearNoticeAfter(noticeTTL) + } + line, ok := m.specIndex.LineForPointer(site.Target.Pointer) + if !ok { + m.notice = site.Target.Pointer + notRenderedSuffix + + return clearNoticeAfter(noticeTTL) + } + + m.resetRefCycle() + m.spec.HighlightLine(line) + m.notice = "→ " + site.Target.Pointer + + return clearNoticeAfter(noticeTTL) +} + // locateInSpec jumps the spec pane to the first node produced by the given // source file (position-backed, via the SourceIndex), highlighting it and // focusing the spec. The exact replacement for the retired name-matching linker. @@ -816,6 +953,7 @@ func (m *Model) toggleFollow(mode followMode) { m.exitFollow() return } + m.resetRefCycle() // follow drives the viewport; the cycle's lines go stale m.follow = mode m.syncFollowIfActive() } @@ -1120,6 +1258,10 @@ func (m *Model) statusLine() string { if m.notice != "" { return theme.Status().Render(m.notice) } + if m.refStatus != "" { + return theme.Accent().Render(" REFS ") + + theme.Status().Render(" "+m.refStatus+" · F3/shift+F3: next/prev · enter: go to definition · esc: clear") + } if m.focused == paneTree && m.leftMode == modeView { if m.fileView.Editing() { return theme.Status().Render( @@ -1135,7 +1277,12 @@ func (m *Model) statusLine() string { } if m.focused == paneSpec && m.specIndex.Len() > 0 { if ptr, ok := m.specIndex.PointerAt(m.spec.TopLine()); ok { - return theme.Status().Render("node " + ptr + " · f: follow mode · /: search · tab: focus · c: copy") + hint := "f: follow · F3: find refs · enter: go to definition · /: search · tab: focus · c: copy" + if _, isRef := m.refIndex.RefAt(m.spec.TopLine()); !isRef { + // Nothing to follow from here; don't advertise it. + hint = "f: follow · F3: find refs · /: search · tab: focus · c: copy" + } + return theme.Status().Render("node " + ptr + " · " + hint) } } return theme.Status().Render( diff --git a/cmd/genspec-tui/internal/ux/model_refs_test.go b/cmd/genspec-tui/internal/ux/model_refs_test.go new file mode 100644 index 00000000..9f83f410 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_refs_test.go @@ -0,0 +1,320 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "testing" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// D2 — find-references cycling and go-to-definition, through the model. +// +// The spec pane has no line cursor, so "the node under the cursor" means the +// node at the TOP of the viewport — the same convention `f` follow and the +// status line already use. Tests park the viewport with ScrollTo accordingly. + +// refModelSpec references /definitions/User from three places and carries one +// external ref. Line numbers below are load-bearing. +const refModelSpec = `{ + "definitions": { + "Team": { + "properties": { + "lead": { + "$ref": "#/definitions/User" + }, + "logo": { + "$ref": "https://example.com/logo.json#/Logo" + }, + "members": { + "items": { + "$ref": "#/definitions/User" + }, + "type": "array" + } + } + }, + "User": { + "properties": { + "name": { + "type": "string" + } + } + } + }, + "paths": { + "/pets": { + "get": { + "responses": { + "200": { + "schema": { + "$ref": "#/definitions/User" + } + } + } + } + } + } +}` + +const ( + rmLineLead = 5 + rmLineLogo = 8 + rmLineItemsRef = 12 + rmLineUserDecl = 18 + rmLineUserName = 20 + rmLineRespRef = 32 + rmViewportH = 7 // panel height 10 minus border + title + rmSpecTotalLine = 40 +) + +func newRefModel(t *testing.T) *Model { + t.Helper() + m := &Model{ + spec: panels.NewSpec(), + fileView: panels.NewFileView(), + searchInput: textinput.New(), + } + m.spec.SetSize(60, 10) + m.fileView.SetSize(60, 10) + m.specJSON = refModelSpec + m.focused = paneSpec + m.refreshSpec() + + return m +} + +// wantRefTop mirrors HighlightLine's centring for this fixture's geometry. +func wantRefTop(line int) int { + return clampInt(line-rmViewportH/2, 0, max(rmSpecTotalLine-rmViewportH, 0)) +} + +func TestRefs_FixtureLines(t *testing.T) { + m := newRefModel(t) + + // Guards the hand-counted constants the rest of the file relies on. + for ptr, want := range map[string]int{ + "/definitions/User": rmLineUserDecl, + "/definitions/User/properties/name": rmLineUserName, + "/definitions/Team/properties/lead": rmLineLead - 1, + "/paths/~1pets/get/responses/200/schema": rmLineRespRef - 1, + } { + got, ok := m.specIndex.LineForPointer(ptr) + require.True(t, ok, "pointer %q", ptr) + assert.Equal(t, want, got, "rendered line of %s", ptr) + } + require.Equal(t, 4, m.refIndex.Len(), "three local refs plus the external one") +} + +func TestRefs_CycleForward(t *testing.T) { + m := newRefModel(t) + m.spec.ScrollTo(rmLineUserDecl) // park on the definition + + require.Nil(t, m.cycleRefs(+1)) + assert.Equal(t, wantRefTop(rmLineLead), m.spec.TopLine(), "first use") + assert.Contains(t, m.refStatus, "ref 1/3") + assert.Contains(t, m.refStatus, "/definitions/Team/properties/lead") + + require.Nil(t, m.cycleRefs(+1)) + assert.Equal(t, wantRefTop(rmLineItemsRef), m.spec.TopLine(), "second use") + assert.Contains(t, m.refStatus, "ref 2/3") + + require.Nil(t, m.cycleRefs(+1)) + assert.Equal(t, wantRefTop(rmLineRespRef), m.spec.TopLine(), "third use") + assert.Contains(t, m.refStatus, "ref 3/3") + + // Wraps back to the first. + require.Nil(t, m.cycleRefs(+1)) + assert.Equal(t, wantRefTop(rmLineLead), m.spec.TopLine(), "wrapped") + assert.Contains(t, m.refStatus, "ref 1/3") +} + +func TestRefs_CycleBackwardEntersAtTheLastSite(t *testing.T) { + m := newRefModel(t) + m.spec.ScrollTo(rmLineUserDecl) + + require.Nil(t, m.cycleRefs(-1)) + assert.Equal(t, wantRefTop(rmLineRespRef), m.spec.TopLine(), + "a backward step into a fresh cycle enters at the last site") + assert.Contains(t, m.refStatus, "ref 3/3") + + require.Nil(t, m.cycleRefs(-1)) + assert.Contains(t, m.refStatus, "ref 2/3") +} + +// The cursor need not be on the definition line itself: a node inside the +// definition resolves up to it, the way the rest of the tool resolves pointers. +func TestRefs_CycleFromInsideTheDefinition(t *testing.T) { + m := newRefModel(t) + m.spec.ScrollTo(rmLineUserName) // /definitions/User/properties/name + + require.Nil(t, m.cycleRefs(+1)) + assert.Contains(t, m.refStatus, "ref 1/3 of /definitions/User", + "an inner node cycles the enclosing definition's uses") +} + +// Scrolling away ends the cycle: the next F3 asks about wherever the user now +// is, rather than continuing to walk the previous definition's uses. +func TestRefs_ScrollingAwayStartsAFreshCycle(t *testing.T) { + m := newRefModel(t) + m.spec.ScrollTo(rmLineUserDecl) + require.Nil(t, m.cycleRefs(+1)) + require.Contains(t, m.refStatus, "ref 1/3") + + // The user scrolls to a node nothing references. + m.spec.ScrollTo(rmLineLogo) + require.NotNil(t, m.cycleRefs(+1), "a failed start returns the notice-clearing cmd") + assert.Contains(t, m.notice, "nothing references") + assert.Empty(t, m.refStatus, + "the previous cycle's status must not linger while the user is elsewhere") + assert.Empty(t, m.refAnchor) +} + +// Line 0 is the opening brace, which carries no pointer at all — a different +// miss from "this node has no references", and it must say so. +func TestRefs_CycleOnAnUnindexedLine(t *testing.T) { + m := newRefModel(t) + m.spec.ScrollTo(0) + + require.NotNil(t, m.cycleRefs(+1)) + assert.Equal(t, noNodeDesc, m.notice) + assert.Empty(t, m.refStatus) +} + +func TestRefs_NothingReferencesTheNode(t *testing.T) { + m := newRefModel(t) + m.spec.ScrollTo(rmLineLogo) // the external-ref property: nothing points here + + require.NotNil(t, m.cycleRefs(+1)) + assert.Contains(t, m.notice, "nothing references") + assert.Empty(t, m.refStatus, "no cycle was started") +} + +// The Phase-D keys belong to the spec pane. From anywhere else they must fall +// through untouched — Enter in particular still has to open a file in the tree. +func TestRefs_KeysAreSpecPaneOnly(t *testing.T) { + for _, p := range []pane{paneTree, paneDiag} { + m := newRefModel(t) + m.spec.ScrollTo(rmLineUserDecl) + m.focused = p + + for _, msg := range []tea.KeyMsg{ + {Type: tea.KeyF3}, {Type: tea.KeyF15}, {Type: tea.KeyEnter}, + } { + _, _ = m.handleKey(msg) + } + + assert.Empty(t, m.refStatus, "pane %d", p) + assert.Empty(t, m.notice, "pane %d", p) + } +} + +func TestRefs_KeyBindings(t *testing.T) { + // shift+F3 reaches bubbletea v1 as F15 (no Shift modifier on Key; xterm + // maps shift+F1..F12 onto F13..F24). Both spellings must step backward. + for _, c := range []struct { + name string + msg tea.KeyMsg + want string + }{ + {"f3", tea.KeyMsg{Type: tea.KeyF3}, "ref 1/3"}, + {"f15 (shift+f3 on xterm)", tea.KeyMsg{Type: tea.KeyF15}, "ref 3/3"}, + } { + t.Run(c.name, func(t *testing.T) { + m := newRefModel(t) + m.spec.ScrollTo(rmLineUserDecl) + + _, _ = m.handleKey(c.msg) + + assert.Contains(t, m.refStatus, c.want) + }) + } +} + +func TestRefs_GotoDefinition(t *testing.T) { + m := newRefModel(t) + m.spec.ScrollTo(rmLineLead) // a local $ref + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + + assert.Equal(t, wantRefTop(rmLineUserDecl), m.spec.TopLine(), + "enter followed the $ref to its definition") + assert.Equal(t, "→ /definitions/User", m.notice) +} + +func TestRefs_GotoDefinitionEdges(t *testing.T) { + t.Run("external ref is reported, not guessed at", func(t *testing.T) { + m := newRefModel(t) + m.spec.ScrollTo(rmLineLogo) + before := m.spec.TopLine() + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + + assert.Contains(t, m.notice, "external ref") + assert.Contains(t, m.notice, "logo.json") + assert.Equal(t, before, m.spec.TopLine(), "the viewport held") + }) + + t.Run("no $ref on this line", func(t *testing.T) { + m := newRefModel(t) + m.spec.ScrollTo(rmLineUserDecl) + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + + assert.Equal(t, "no $ref on this line", m.notice) + }) +} + +// The cycle holds rendered line numbers, so anything that replaces the render +// must drop it rather than let F3 jump to a line that no longer means anything. +func TestRefs_CycleResets(t *testing.T) { + active := func(t *testing.T) *Model { + t.Helper() + m := newRefModel(t) + m.specYAML = "definitions:\n Team:\n properties:\n lead:\n $ref: '#/definitions/User'\n User: {}\n" + m.spec.ScrollTo(rmLineUserDecl) + require.Nil(t, m.cycleRefs(+1)) + require.NotEmpty(t, m.refStatus) + return m + } + + t.Run("format toggle", func(t *testing.T) { + m := active(t) + m.setSpecFormat("YAML") + assert.Empty(t, m.refStatus) + assert.Empty(t, m.refAnchor) + }) + + t.Run("rescan", func(t *testing.T) { + m := active(t) + _, _ = m.Update(scanResultMsg{json: refModelSpec}) + assert.Empty(t, m.refStatus) + assert.Empty(t, m.refAnchor) + }) + + t.Run("esc", func(t *testing.T) { + m := active(t) + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEsc}) + assert.Empty(t, m.refStatus) + assert.Empty(t, m.refAnchor) + }) + + t.Run("entering follow mode", func(t *testing.T) { + m := active(t) + m.toggleFollow(followSpec) + assert.Empty(t, m.refStatus, "follow drives the viewport; the cycle's lines go stale") + }) + + t.Run("opening search", func(t *testing.T) { + m := active(t) + _, _, handled := m.handleSearchControl(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + require.True(t, handled) + assert.Empty(t, m.refStatus) + }) +} From 28317d53a0e3a4a295858cde465602471add0f73 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Thu, 30 Jul 2026 16:41:24 +0200 Subject: [PATCH 09/13] test(tui): end-to-end tests over a real scan, and a module README (S12) Every other test in this package feeds the model hand-written JSON. That proves the indexes and the navigation agree with each other, but not that either agrees with what codescan actually emits. These scan the petstore fixture for real and drive the finished model over the result. Doing so immediately found a bug in S11. cycleRefs CENTRES its target, so after an F3 the line jumped to is no longer the top of the viewport - and gotoDefinition was reading the top of the viewport. Pressing Enter on a reference to jump back to its definition answered "no $ref on this line", which is the headline Phase-D workflow. The S11 unit test missed it by scrolling straight to the ref line instead of arriving there via F3. The fix is specCursorLine: the line the last jump landed on, valid while the viewport still sits where that jump left it, falling back to the viewport top otherwise. Scrolling invalidates it implicitly, so there is no bookkeeping on the scroll path. It generalises and replaces S11's refExpectTop, and both startRefCycle and gotoDefinition read it. driveSpecToSource deliberately keeps reading TopLine, because it is driven BY scrolling and reading a mark it sets itself would freeze follow mode. The cursor carries an explicit "set" flag rather than a -1 sentinel, so the zero value of Model means "no cursor" - the sentinel form silently gave every hand-built test fixture a bogus cursor on line 0. The e2e tests cover: every recorded site really is a $ref pointing where the index claims, ordered by line, with an addressable holder; response refs index alongside definition refs; the F3-then-Enter round trip; one lap visiting every site exactly once before wrapping; JSON and YAML finding the same holder set; ref targets resolving through provenance to a .go file; and spec->source follow opening the file that really declares the type. The scan is cached per package behind a sync.Once. One packages.Load per test took this package from ~1s to 36s under -race; it is back to 6.5s. The library's scantest helpers cache for the same reason. Also adds cmd/genspec-tui/README.md, which the module never had: install and flags (checked against -h), layout, a full keymap per pane, how cross-ref navigation works, the internal package map, and an explicit "Honest limits" section - anchors-only resolution, stale-while-dirty, site-index-not-resolver, no line cursor in the spec pane, and the terminal-dependent shift+F3. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- cmd/genspec-tui/README.md | 203 +++++++++++++++ cmd/genspec-tui/internal/ux/model.go | 59 ++++- cmd/genspec-tui/internal/ux/model_e2e_test.go | 238 ++++++++++++++++++ .../internal/ux/model_refs_test.go | 35 +++ 4 files changed, 523 insertions(+), 12 deletions(-) create mode 100644 cmd/genspec-tui/README.md create mode 100644 cmd/genspec-tui/internal/ux/model_e2e_test.go diff --git a/cmd/genspec-tui/README.md b/cmd/genspec-tui/README.md new file mode 100644 index 00000000..2bf647f1 --- /dev/null +++ b/cmd/genspec-tui/README.md @@ -0,0 +1,203 @@ + + +# genspec-tui + +An interactive terminal front-end for [codescan][codescan]: browse a Go source +tree on the left, watch the Swagger spec it produces on the right, and see the +scanner's diagnostics underneath — all regenerated on every save. + +Its reason to exist is the loop: change an annotation, hit save, see the spec +change. Beyond that it links the two sides together, so you can ask "which Go +code produced this node?" and "what did this field turn into?" and get an +answer by position rather than by guessing at names. + +Audience: codescan/go-swagger maintainers and contributors. + +## Install and run + +`genspec-tui` is a **separate Go module** inside the codescan repo, so +bubbletea and its dependency tree never reach the lean library. + +```sh +go install github.com/go-openapi/codescan/cmd/genspec-tui@latest + +# scan the module in the current directory +genspec-tui + +# or point it somewhere, and narrow the scope +genspec-tui -workdir ../my-api -packages ./internal/models/...,./internal/api/... +``` + +From a checkout, the repo's `go.work` wires the module to the local library: + +```sh +go run ./cmd/genspec-tui -workdir ./fixtures -packages ./goparsing/petstore/... +``` + +| Flag | Default | Meaning | +|------|---------|---------| +| `-workdir` | `.` | module directory the scan runs in (codescan `WorkDir`) | +| `-packages` | `./...` | comma-separated package patterns, relative to `-workdir` | +| `-scan-models` | `true` | also emit definitions for `swagger:model` types | + +Scanner options that are booleans can be toggled live with `o` — the spec +re-renders on close, which makes the popup the fastest way to see what a flag +such as `EmitRefSiblings` actually changes. + +## Layout + +``` +┌───────────────────────┬──────────────────────────────────────┐ +│ source tree │ spec · JSON │ +│ or the file viewer │ the generated document │ +├───────────────────────┴──────────────────────────────────────┤ +│ diagnostics │ +├──────────────────────────────────────────────────────────────┤ +│ status / help │ +└──────────────────────────────────────────────────────────────┘ +``` + +The left pane shows either the **source tree** or, once you open a file, the +**file viewer**. The viewer is read-only and navigable by default; `i` turns it +into an editor and `Esc` steps back out. Saving writes to disk, the watcher +notices, and the spec re-renders. + +Clicking a pane focuses it, and the mouse wheel scrolls whichever pane is under +the pointer — `Tab` is never required. + +## Keys + +### Anywhere + +| Key | Action | +|-----|--------| +| `Tab` / `shift+Tab` | cycle focus forward / backward | +| click | focus the pane under the pointer | +| wheel | scroll the pane under the pointer | +| `c` | copy the focused pane's raw content to the clipboard | +| `r` | rescan now | +| `o` | scanner options popup (`space` toggles, `Esc`/`o` applies and rescans) | +| `ctrl+q` / `ctrl+c` | quit | + +### Spec pane + +| Key | Action | +|-----|--------| +| `ctrl+j` / `ctrl+y` | render as JSON / YAML — keeps you on the same **node**, not the same line | +| `/` | search; `n` / `N` step through matches | +| `f` | toggle follow mode (spec drives, the source pane mirrors) | +| `F3` / `shift+F3` | next / previous **reference** to the node under the cursor | +| `Enter` | follow the `$ref` under the cursor to its definition | +| `Esc` | clear search, highlight and the reference cycle | + +### Source tree + +| Key | Action | +|-----|--------| +| `↑` `↓` / `j` `k` | move the selection | +| `Enter` | open a file (or expand/collapse a directory) | +| `g` | locate the selected file's first node in the spec | + +### File viewer (read-only) + +| Key | Action | +|-----|--------| +| `↑` `↓` / `j` `k` | move the navigation line | +| `f` | toggle follow mode (source drives, the spec mirrors) | +| `i` / `Enter` | start editing | +| `Esc` | back to the tree | + +### File editor + +| Key | Action | +|-----|--------| +| `ctrl+f` | jump from the cursor's line to the spec node it produced | +| `ctrl+s` | save (triggers a rescan) | +| `Esc` | back to the read-only viewer | + +`ctrl+f` rather than `f` because the editor owns plain `f` for typing. + +### Diagnostics pane + +| Key | Action | +|-----|--------| +| `↑` `↓` / `j` `k` | select a diagnostic | +| `f` | toggle follow mode (the selection drives, the source pane mirrors) | + +## Cross-reference navigation + +Two indexes, rebuilt on every render, meet at a JSON pointer: + +- the **spec index** maps each rendered line to the pointer of the node on it; +- the **source index** maps pointers to Go source positions, from codescan's + `OnProvenance` callback. + +### Follow mode (`f`) + +`f` turns on a persistent link between two panes. The pane you pressed it in is +the **driver** and keeps focus; the other **mirrors** it on every cursor move, +centring and highlighting the linked line. The two roles are styled differently +so it is always clear which pane leads. A `SPEC ▸ SOURCE` badge names the +direction and the resolved target. + +Follow works in three directions: spec → source, source → spec, and +diagnostic → source. `Esc`, a second `f`, changing focus, or starting to edit +all leave it. + +### References (`F3`, `Enter`) + +`F3` steps through the places the node under the cursor is referenced, wrapping; +`shift+F3` goes back. `Enter` follows a `$ref` to its definition. + +A cycle stays anchored to one definition while you keep pressing `F3`. Scroll +away and the next `F3` re-anchors on wherever you now are. + +## Honest limits + +These are known and deliberate; the TUI says so rather than guessing. + +- **Not every node has source.** codescan anchors *code-detail* nodes — type + declarations, fields, values, route and meta blocks — and finer nodes resolve + to their nearest anchored ancestor. A node with no anchored ancestor at all + was not produced from code (an `InputSpec` overlay node, for instance); the + follower holds position and says so instead of jumping somewhere plausible. +- **Positions are as of the last scan.** With unsaved edits in the buffer, every + anchor below the edit has shifted, so follow shows a `STALE` badge. Saving + triggers a rescan and clears it. +- **`$ref` resolution is a site index, not a resolver.** References are found by + scanning the rendered document. Local `#/…` refs are followable; a ref into + another file or a URL is reported as external rather than chased. Ref-to-ref + chains and `$ref` nested in `allOf` are not unwound. +- **The spec pane has no line cursor.** "The node under the cursor" means the + node at the top of the viewport, except right after a jump, when it means the + line you landed on. +- **`shift+F3` is terminal-dependent.** bubbletea v1's key type carries no Shift + modifier, and the xterm family reports shift+F3 as F15. Terminals that send + something else have no previous-reference key; `F3` still wraps around. + +## Development + +```sh +go test ./... # from cmd/genspec-tui +golangci-lint run --new-from-rev master +``` + +The package layout under `internal/ux`: + +| Package | Contents | +|---------|----------| +| `ux` | the root bubbletea `Model`: key dispatch, layout, scan wiring, cross-ref navigation | +| `ux/panels` | the four panes — `Tree`, `FileView`, `Spec`, `Diagnostics` | +| `ux/index` | `SpecIndex` (line ↔ pointer), `RefIndex` (`$ref` sites), `SourceIndex` (pointer ↔ source position) | +| `ux/key` | `tea.KeyMsg` → a small named-binding enum | +| `ux/theme` | the shared lipgloss styles | +| `ux/gadgets` | clipboard support | + +The scanner writes nothing to stdout or stderr: diagnostics arrive through +codescan's `OnDiagnostic` callback, and `main` discards the standard logger, so +nothing paints over the alt-screen. + +[codescan]: https://github.com/go-openapi/codescan diff --git a/cmd/genspec-tui/internal/ux/model.go b/cmd/genspec-tui/internal/ux/model.go index 089b9683..e89c350e 100644 --- a/cmd/genspec-tui/internal/ux/model.go +++ b/cmd/genspec-tui/internal/ux/model.go @@ -154,13 +154,25 @@ type Model struct { follow followMode followTarget string // human-readable resolved target, for the nav status badge + // specCursor is the spec line the last JUMP put the user on, together with + // the viewport offset that jump produced. The spec pane has no real line + // cursor (only a scroll offset), and a jump CENTRES its target — so after + // one, "the node under the cursor" is emphatically not the node at the top + // of the viewport. Pairing the line with the offset that produced it lets + // specCursorLine hand back the jump target while the view is untouched, and + // fall back to the viewport top the moment the user scrolls away. + // specCursorSet distinguishes "no jump in effect" from a jump to line 0, + // so the ZERO VALUE of Model means no cursor — no initializer to forget. + specCursor int + specCursorTop int + specCursorSet bool + // Find-references cycle state (F3 / shift+F3). Valid only for the CURRENT // render, so refreshSpec resets it. - refAnchor string // the definition pointer whose uses are being cycled - refSites []index.RefSite // its reference sites, ordered by rendered line - refCursor int // which site we are parked on - refExpectTop int // spec TopLine our last jump produced; -1 when idle - refStatus string // persistent status line while a cycle is active + refAnchor string // the definition pointer whose uses are being cycled + refSites []index.RefSite // its reference sites, ordered by rendered line + refCursor int // which site we are parked on + refStatus string // persistent status line while a cycle is active tree panels.Tree fileView panels.FileView @@ -189,8 +201,6 @@ func New(workdir string, packages []string, scanModels bool) *Model { fileView: panels.NewFileView(), spec: panels.NewSpec(), diag: panels.NewDiagnostics(), - - refExpectTop: -1, } // Options-popup rows bind to the scan-config booleans (pointers into // m.cfg stay valid — m is heap-allocated). @@ -830,6 +840,30 @@ func (m *Model) setSpecFormat(format string) { } } +// specCursorLine is the rendered line the user means in the spec pane: the +// target of the last jump while the viewport still sits where that jump left +// it, otherwise the top of the viewport (the pane's only other notion of +// "here"). Scrolling invalidates the jump target implicitly — no bookkeeping +// on the scroll path. +func (m *Model) specCursorLine() int { + if m.specCursorSet && m.spec.TopLine() == m.specCursorTop { + return m.specCursor + } + + return m.spec.TopLine() +} + +// markSpecCursor records line as the jump target, together with the viewport +// offset the jump produced. Call it AFTER scrolling. +func (m *Model) markSpecCursor(line int) { + m.specCursor, m.specCursorTop, m.specCursorSet = line, m.spec.TopLine(), true +} + +// clearSpecCursor forgets the jump target, so the viewport top is "here" again. +func (m *Model) clearSpecCursor() { + m.specCursor, m.specCursorTop, m.specCursorSet = 0, 0, false +} + // cycleRefs steps through the places the definition under the spec cursor is // referenced (design §6.4 multi-candidate case): dir +1 for the next site, -1 // for the previous, wrapping. @@ -840,7 +874,7 @@ func (m *Model) setSpecFormat(format string) { // what makes "F3 repeatedly" walk one definition's uses rather than chasing the // definition of whatever it last landed on. func (m *Model) cycleRefs(dir int) tea.Cmd { - if continuing := m.refAnchor != "" && m.spec.TopLine() == m.refExpectTop; continuing { + if continuing := m.refAnchor != "" && m.specCursorSet && m.spec.TopLine() == m.specCursorTop; continuing { m.refCursor = (m.refCursor + dir + len(m.refSites)) % len(m.refSites) } else { // Drop the old cycle FIRST: if the new node has no references we must @@ -854,7 +888,7 @@ func (m *Model) cycleRefs(dir int) tea.Cmd { site := m.refSites[m.refCursor] m.spec.HighlightLine(site.Line) - m.refExpectTop = m.spec.TopLine() + m.markSpecCursor(site.Line) m.refStatus = fmt.Sprintf("ref %d/%d of %s → %s", m.refCursor+1, len(m.refSites), m.refAnchor, site.Pointer) @@ -866,7 +900,7 @@ func (m *Model) cycleRefs(dir int) tea.Cmd { // one. Reports false (with a notice explaining why) when there is nothing to // cycle. func (m *Model) startRefCycle(dir int) bool { - ptr, ok := m.specIndex.PointerAt(m.spec.TopLine()) + ptr, ok := m.specIndex.PointerAt(m.specCursorLine()) if !ok { m.notice = noNodeDesc @@ -892,8 +926,8 @@ func (m *Model) startRefCycle(dir int) bool { // was computed against is replaced (rescan, format toggle) or the user moves on. func (m *Model) resetRefCycle() { m.refAnchor, m.refSites, m.refCursor = "", nil, 0 - m.refExpectTop = -1 m.refStatus = "" + m.clearSpecCursor() } // gotoDefinition follows the $ref under the spec cursor to the node it points @@ -901,7 +935,7 @@ func (m *Model) resetRefCycle() { // renders one spec and is not a $ref resolver, so an external target is // reported honestly rather than guessed at. func (m *Model) gotoDefinition() tea.Cmd { - site, ok := m.refIndex.RefAt(m.spec.TopLine()) + site, ok := m.refIndex.RefAt(m.specCursorLine()) if !ok { m.notice = "no $ref on this line" @@ -921,6 +955,7 @@ func (m *Model) gotoDefinition() tea.Cmd { m.resetRefCycle() m.spec.HighlightLine(line) + m.markSpecCursor(line) m.notice = "→ " + site.Target.Pointer return clearNoticeAfter(noticeTTL) diff --git a/cmd/genspec-tui/internal/ux/model_e2e_test.go b/cmd/genspec-tui/internal/ux/model_e2e_test.go new file mode 100644 index 00000000..588cff2f --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_e2e_test.go @@ -0,0 +1,238 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// D3 — the whole chain against a REAL scan. +// +// Every other test in this package feeds the model hand-written JSON. That +// proves the indexes and the navigation agree with each other, but not that +// either agrees with what codescan actually emits: whether $refs arrive bare or +// allOf-wrapped, whether definition names survive as written, whether the +// provenance pointers line up with the rendered document. This scans the +// petstore fixture and drives the finished model over the result. + +// fixturesDir resolves the repo-level fixtures/ directory from this file's own +// location, so the test runs from any working directory (CI runs it from +// cmd/genspec-tui, not the repo root). Deliberately local rather than borrowing +// scantest.FixturesDir — the TUI module should not grow a dependency on the +// library's test helpers for one path join. +func fixturesDir(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok, "cannot resolve the caller's file path") + + // thisFile is /cmd/genspec-tui/internal/ux/model_e2e_test.go + return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "..", "fixtures")) +} + +// petstoreScan caches the scan across the whole package. A real scan means a +// real packages.Load, which costs seconds under -race; running one per test +// took this package from ~1s to ~36s. The result is immutable, and each test +// still gets its own Model built from it. Mirrors the caching the library's own +// scantest helpers do for the same reason. +var ( + petstoreOnce sync.Once //nolint:gochecknoglobals // test-only scan cache + petstoreRes scanResultMsg //nolint:gochecknoglobals // test-only scan cache +) + +// scanPetstore hands the cached scan to a fresh Model through the same message +// the bubbletea loop delivers. +func scanPetstore(t *testing.T) *Model { + t.Helper() + + petstoreOnce.Do(func() { + petstoreRes = doScan(codescan.Options{ + WorkDir: fixturesDir(t), + Packages: []string{"./goparsing/petstore/..."}, + ScanModels: true, + }) + }) + res := petstoreRes + require.NoError(t, res.err, "the petstore fixture must scan cleanly") + require.NotEmpty(t, res.json) + + m := &Model{ + spec: panels.NewSpec(), + fileView: panels.NewFileView(), + searchInput: textinput.New(), + } + m.spec.SetSize(100, 30) + m.fileView.SetSize(100, 30) + m.focused = paneSpec + _, _ = m.Update(res) + + return m +} + +// specLines is the rendered document the indexes were built from. +func specLines(m *Model) []string { return strings.Split(m.specJSON, "\n") } + +func TestE2E_RefIndexMatchesTheRenderedSpec(t *testing.T) { + m := scanPetstore(t) + lines := specLines(m) + + // The petstore references /definitions/pet from several operations. The + // exact count is fixture-dependent, so assert the property that matters. + sites := m.refIndex.RefsToPointer("/definitions/pet") + require.GreaterOrEqual(t, len(sites), 2, "pet must be referenced from at least two places") + + var last int + for i, site := range sites { + require.Less(t, site.Line, len(lines), "site line is inside the document") + + // Every recorded site must really be a $ref pointing where we claim. + text := lines[site.Line] + assert.Contains(t, text, `"$ref"`, "line %d", site.Line) + assert.Contains(t, text, "#/definitions/pet", "line %d", site.Line) + + if i > 0 { + assert.Greater(t, site.Line, last, "sites are ordered by rendered line") + } + last = site.Line + + // And the node holding it must be addressable in the spec index. + _, ok := m.specIndex.LineForPointer(site.Pointer) + assert.True(t, ok, "holder %q is a real node", site.Pointer) + } +} + +// codescan emits $refs to responses as well as definitions; both must index. +func TestE2E_ResponseRefsIndex(t *testing.T) { + m := scanPetstore(t) + + sites := m.refIndex.RefsToPointer("/responses/genericError") + require.GreaterOrEqual(t, len(sites), 2, "the shared error response is reused across operations") + + for _, site := range sites { + assert.True(t, site.Target.Local) + assert.Equal(t, "/responses/genericError", site.Target.Pointer) + } +} + +// The round trip: park on a definition, F3 to one of its uses, Enter to come +// back. If either index disagreed with the render, this would land elsewhere. +func TestE2E_CycleThenGoToDefinitionRoundTrips(t *testing.T) { + m := scanPetstore(t) + + defLine, ok := m.specIndex.LineForPointer("/definitions/pet") + require.True(t, ok) + m.spec.ScrollTo(defLine) + + // F3 → the first use. + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) + require.Contains(t, m.refStatus, "of /definitions/pet") + firstUse := m.spec.TopLine() + + // F3 again → a different use. + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) + assert.NotEqual(t, firstUse, m.spec.TopLine(), "the cycle advanced to another site") + require.Contains(t, m.refStatus, "ref 2/") + + // Enter on that $ref → back to the definition. + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + assert.Equal(t, "→ /definitions/pet", m.notice) + assert.Contains(t, specLines(m)[defLine], `"pet"`, + "the line we came back to really is the pet definition") +} + +func TestE2E_CycleVisitsEverySiteExactlyOnce(t *testing.T) { + m := scanPetstore(t) + + defLine, ok := m.specIndex.LineForPointer("/definitions/pet") + require.True(t, ok) + m.spec.ScrollTo(defLine) + + want := m.refIndex.RefsToPointer("/definitions/pet") + require.NotEmpty(t, want) + + seen := make(map[int]int, len(want)) + for range want { + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) + seen[m.refSites[m.refCursor].Line]++ + } + + assert.Len(t, seen, len(want), "one full lap visits every site") + for line, n := range seen { + assert.Equal(t, 1, n, "site at line %d visited once", line) + } + + // One more step wraps back to the start. + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) + assert.Contains(t, m.refStatus, "ref 1/") +} + +// Both renders of the same scan must find the same reference sites — only the +// line numbers differ. This is what makes ctrl+j/ctrl+y safe mid-investigation. +func TestE2E_YAMLFindsTheSameSites(t *testing.T) { + m := scanPetstore(t) + require.NotEmpty(t, m.specYAML, "the scan produced a YAML render") + + jsonHolders := holderSet(m, "/definitions/pet") + require.NotEmpty(t, jsonHolders) + + m.setSpecFormat("YAML") + require.Equal(t, "YAML", m.spec.Format()) + + assert.Equal(t, jsonHolders, holderSet(m, "/definitions/pet"), + "the same nodes reference pet in either render") +} + +func holderSet(m *Model, target string) map[string]bool { + out := make(map[string]bool) + for _, site := range m.refIndex.RefsToPointer(target) { + out[site.Pointer] = true + } + + return out +} + +// The two halves of the linker must agree on a real scan: a definition the ref +// index points at should also be a node the provenance index can take to source. +func TestE2E_RefTargetsHaveSource(t *testing.T) { + m := scanPetstore(t) + require.Positive(t, m.srcIndex.Len(), "the scan emitted provenance") + + for _, target := range []string{"/definitions/pet", "/definitions/order"} { + require.NotEmpty(t, m.refIndex.RefsToPointer(target), "%s is referenced", target) + + pos, ok := m.srcIndex.PositionFor(target) + require.True(t, ok, "%s resolves to source", target) + assert.True(t, strings.HasSuffix(pos.Filename, ".go"), "%s → %s", target, pos.Filename) + assert.Positive(t, pos.Line) + } +} + +// Spec→source follow, end to end: park on a definition, turn on follow, and the +// source pane must open the Go file that actually declares it. +func TestE2E_FollowOpensTheDeclaringFile(t *testing.T) { + m := scanPetstore(t) + + defLine, ok := m.specIndex.LineForPointer("/definitions/pet") + require.True(t, ok) + m.spec.ScrollTo(defLine) + + m.toggleFollow(followSpec) + + require.Equal(t, followSpec, m.follow) + assert.True(t, strings.HasSuffix(m.currentFile, ".go"), "opened %q", m.currentFile) + assert.Contains(t, m.followTarget, "/definitions/pet") + assert.Contains(t, m.fileView.Content(), "type Pet struct", + "the follower landed in the file that declares the type") +} diff --git a/cmd/genspec-tui/internal/ux/model_refs_test.go b/cmd/genspec-tui/internal/ux/model_refs_test.go index 9f83f410..16d29619 100644 --- a/cmd/genspec-tui/internal/ux/model_refs_test.go +++ b/cmd/genspec-tui/internal/ux/model_refs_test.go @@ -248,6 +248,41 @@ func TestRefs_GotoDefinition(t *testing.T) { assert.Equal(t, "→ /definitions/User", m.notice) } +// Regression: a jump CENTRES its target, so after F3 the line we landed on is +// not the top of the viewport. Enter must still act on where the jump put the +// user — otherwise the headline workflow (find a use, then go back to the +// definition) reports "no $ref on this line". +func TestRefs_CycleThenGotoDefinition(t *testing.T) { + m := newRefModel(t) + m.spec.ScrollTo(rmLineUserDecl) + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) + require.Contains(t, m.refStatus, "ref 1/3") + require.NotEqual(t, rmLineLead, m.spec.TopLine(), + "precondition: the jump centred, so TopLine is NOT the target line") + require.Equal(t, rmLineLead, m.specCursorLine(), "but the cursor is") + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + + assert.Equal(t, "→ /definitions/User", m.notice) + assert.Equal(t, rmLineUserDecl, m.specCursorLine(), + "and the definition we landed on becomes the new cursor, so Enter can chain") +} + +// Scrolling invalidates the jump target implicitly — no bookkeeping on the +// scroll path, which the pane could not hook anyway. +func TestRefs_ScrollingDropsTheJumpCursor(t *testing.T) { + m := newRefModel(t) + m.spec.ScrollTo(rmLineUserDecl) + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) + require.Equal(t, rmLineLead, m.specCursorLine()) + + m.spec.ScrollTo(rmLineUserName) + + assert.Equal(t, rmLineUserName, m.specCursorLine(), + "once the user scrolls, the viewport top is 'here' again") +} + func TestRefs_GotoDefinitionEdges(t *testing.T) { t.Run("external ref is reported, not guessed at", func(t *testing.T) { m := newRefModel(t) From 22d958d21c54f3b05dc1434944369425769c1da9 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Thu, 30 Jul 2026 16:50:31 +0200 Subject: [PATCH 10/13] feat(tui): link gutter marking navigable lines (S13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design 6.5's discoverability item: now that all three indexes exist, mark which lines actually lead somewhere instead of making the user probe for it. Two markers rather than one, because two link kinds now exist and they answer different questions. "•" means the node has a source position of its own, so following it lands exactly there - "where did this come from?". "→" marks a followable $ref, which Enter takes to its definition - "where does this go?". The $ref line wins the column where they would collide, though in practice they are disjoint: a $ref member is never itself anchored. Only EXACT anchors are marked, which departs from the plan's wording. Nearly every line resolves to something through nearest-ancestor resolution, so marking anything that resolves would dot the whole document and say nothing. A dot means "following this lands exactly here", which is the distinction worth drawing. External $refs are not marked either: Enter cannot follow them, and a marker would promise a jump that fails. The gutter costs nothing when there is nothing to say - a nil or empty map renders no column at all, so the layout is unchanged before the first scan. The spec side is driven from the source index (AnchoredPointers -> LineForPointer) rather than by walking the spec, which needed no new SpecIndex iteration surface. The marker is prefixed after search and xref highlighting so the styles wrap the text the user searched for rather than the marker column; match counting reads the raw line and is unaffected. Tests cover both panels (markers land, unmarked lines pad to keep alignment, no gutter costs no width, coexistence with search and xref styling) and the model (anchors vs refs, external refs unmarked, nil when nothing links, exact-anchors-only with a sanity check that the unmarked node really does still resolve, rebuild on format toggle, anchor set following the open file). Against a real petstore scan, every marked line must genuinely be navigable and the gutter must mark strictly fewer lines than the document has. Mutation-verified: marking every resolvable node fails three tests. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- cmd/genspec-tui/README.md | 17 ++ cmd/genspec-tui/internal/ux/index/refindex.go | 17 ++ .../internal/ux/index/sourceindex.go | 33 ++++ cmd/genspec-tui/internal/ux/model.go | 43 +++++ .../internal/ux/model_gutter_test.go | 169 ++++++++++++++++++ .../internal/ux/panels/fileview.go | 30 +++- .../internal/ux/panels/gutter_test.go | 101 +++++++++++ cmd/genspec-tui/internal/ux/panels/spec.go | 53 +++++- cmd/genspec-tui/internal/ux/theme/theme.go | 6 + 9 files changed, 461 insertions(+), 8 deletions(-) create mode 100644 cmd/genspec-tui/internal/ux/model_gutter_test.go create mode 100644 cmd/genspec-tui/internal/ux/panels/gutter_test.go diff --git a/cmd/genspec-tui/README.md b/cmd/genspec-tui/README.md index 2bf647f1..933af6cb 100644 --- a/cmd/genspec-tui/README.md +++ b/cmd/genspec-tui/README.md @@ -155,6 +155,23 @@ all leave it. A cycle stays anchored to one definition while you keep pressing `F3`. Scroll away and the next `F3` re-anchors on wherever you now are. +### The gutter + +Both panes mark which lines actually lead somewhere, so you can see what is +navigable without probing for it: + +| Marker | In the spec pane | In the source viewer | +|--------|------------------|----------------------| +| `•` | this node has a source position of its **own**, so following it lands exactly there | this line produced a spec node | +| `→` | a followable `$ref` — `Enter` goes to its definition | — | + +Only *exact* anchors are marked. Nearly every line resolves to **something** +through its nearest anchored ancestor, so marking those would dot the whole +document and tell you nothing. External `$ref`s are not marked either, because +`Enter` cannot follow them. + +The gutter column only appears when there is something to mark. + ## Honest limits These are known and deliberate; the TUI says so rather than guessing. diff --git a/cmd/genspec-tui/internal/ux/index/refindex.go b/cmd/genspec-tui/internal/ux/index/refindex.go index 1f831348..e68e4e32 100644 --- a/cmd/genspec-tui/internal/ux/index/refindex.go +++ b/cmd/genspec-tui/internal/ux/index/refindex.go @@ -125,6 +125,23 @@ func (x *RefIndex) RefAt(line int) (RefSite, bool) { return site, ok } +// LocalRefLines returns the 0-based rendered lines holding a FOLLOWABLE $ref, +// i.e. one pointing inside this document. Backs the spec pane's gutter: an +// external ref is not marked, because Enter cannot take you there. +func (x *RefIndex) LocalRefLines() []int { + if x == nil { + return nil + } + out := make([]int, 0, len(x.byLine)) + for line, site := range x.byLine { + if site.Target.Local { + out = append(out, line) + } + } + + return out +} + // Len reports how many $ref sites the index holds (0 for a nil index), // including non-local ones. func (x *RefIndex) Len() int { diff --git a/cmd/genspec-tui/internal/ux/index/sourceindex.go b/cmd/genspec-tui/internal/ux/index/sourceindex.go index 0925d66c..b71e506b 100644 --- a/cmd/genspec-tui/internal/ux/index/sourceindex.go +++ b/cmd/genspec-tui/internal/ux/index/sourceindex.go @@ -94,6 +94,39 @@ func (x *SourceIndex) FirstAnchor(file string) (string, bool) { return anchors[0].ptr, true // byFile is sorted by line } +// AnchoredPointers returns every pointer that has an anchor of its OWN (not one +// inherited from an ancestor). Backs the spec pane's gutter: the caller maps +// each to its rendered line, marking the nodes whose source position is exact. +func (x *SourceIndex) AnchoredPointers() []string { + if x == nil { + return nil + } + out := make([]string, 0, len(x.fwd)) + for ptr := range x.fwd { + out = append(out, ptr) + } + + return out +} + +// AnchorLines returns the 1-based source lines in file that carry an anchor, +// i.e. the lines that produced a spec node. Backs the source viewer's gutter. +func (x *SourceIndex) AnchorLines(file string) map[int]bool { + if x == nil { + return nil + } + anchors := x.byFile[file] + if len(anchors) == 0 { + return nil + } + out := make(map[int]bool, len(anchors)) + for _, a := range anchors { + out[a.line] = true + } + + return out +} + // PointerAt returns the pointer of the nearest anchor at or above (file, line) — // the spec node enclosing that source line. line is 1-based (token.Position.Line). // The bool is false when the file holds no anchors or line precedes the first. diff --git a/cmd/genspec-tui/internal/ux/model.go b/cmd/genspec-tui/internal/ux/model.go index e89c350e..47d1ac7e 100644 --- a/cmd/genspec-tui/internal/ux/model.go +++ b/cmd/genspec-tui/internal/ux/model.go @@ -591,6 +591,7 @@ func (m *Model) loadFileQuietly(path string) { } else { m.fileView.SetFile(relTo(m.cfg.WorkDir, path), string(content)) } + m.fileView.SetAnchors(m.srcIndex.AnchorLines(path)) m.leftMode = modeView } @@ -808,6 +809,7 @@ func (m *Model) refreshSpec() { if body == "" { m.specIndex, m.refIndex = nil, nil m.spec.SetContent("(no spec generated yet)") + m.rebuildGutters() return } if yamlFmt { @@ -816,6 +818,7 @@ func (m *Model) refreshSpec() { m.specIndex, m.refIndex = index.BuildJSONIndex([]byte(body)) } m.spec.SetContent(body) + m.rebuildGutters() } // setSpecFormat switches the spec render between JSON and YAML, preserving the @@ -961,6 +964,46 @@ func (m *Model) gotoDefinition() tea.Cmd { return clearNoticeAfter(noticeTTL) } +// rebuildGutters recomputes the link markers for both panes (design §6.5): the +// discoverability layer that says which lines actually lead somewhere, now that +// both indexes exist. +// +// The spec side is driven from the SOURCE index rather than by walking the spec: +// only pointers with an anchor of their OWN get a dot. Marking everything that +// merely resolves through an ancestor would dot nearly every line — true, since +// nearest-ancestor resolution almost always finds something, and useless for the +// same reason. A dot therefore means "following this lands exactly here". +func (m *Model) rebuildGutters() { + m.spec.SetGutter(m.specGutter()) + m.fileView.SetAnchors(m.srcIndex.AnchorLines(m.currentFile)) +} + +// specGutter maps rendered lines to their marker: an anchored node, or a +// followable $ref. Returns nil when there is nothing to mark, which keeps the +// gutter column off entirely. +func (m *Model) specGutter() map[int]rune { + if m.specIndex == nil { + return nil + } + + g := make(map[int]rune) + for _, ptr := range m.srcIndex.AnchoredPointers() { + if line, ok := m.specIndex.LineForPointer(ptr); ok { + g[line] = panels.GutterAnchor + } + } + // A $ref line is navigable via Enter even though the $ref member itself is + // never anchored, so it wins the column where the two would collide. + for _, line := range m.refIndex.LocalRefLines() { + g[line] = panels.GutterRef + } + if len(g) == 0 { + return nil + } + + return g +} + // locateInSpec jumps the spec pane to the first node produced by the given // source file (position-backed, via the SourceIndex), highlighting it and // focusing the spec. The exact replacement for the retired name-matching linker. diff --git a/cmd/genspec-tui/internal/ux/model_gutter_test.go b/cmd/genspec-tui/internal/ux/model_gutter_test.go new file mode 100644 index 00000000..af8ecf7e --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_gutter_test.go @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "go/token" + "os" + "path/filepath" + "testing" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/index" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/codescan/internal/scanner" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// S13 — the link gutter (design §6.5): which lines actually lead somewhere. + +func TestGutter_MarksAnchorsAndRefs(t *testing.T) { + m := newRefModel(t) + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User", Pos: token.Position{Filename: "user.go", Line: 3}}, + {Pointer: "/definitions/Team", Pos: token.Position{Filename: "team.go", Line: 3}}, + }) + m.rebuildGutters() + + g := m.specGutter() + require.NotNil(t, g) + + // Anchored definitions carry the anchor marker at their declaration line. + assert.Equal(t, panels.GutterAnchor, g[rmLineUserDecl], "/definitions/User is anchored") + + // Local $refs carry the ref marker. + assert.Equal(t, panels.GutterRef, g[rmLineLead], "the lead property's $ref is followable") + assert.Equal(t, panels.GutterRef, g[rmLineItemsRef]) + assert.Equal(t, panels.GutterRef, g[rmLineRespRef]) +} + +// An external $ref is not followable, so marking it would promise a jump that +// Enter cannot make. +func TestGutter_ExternalRefsAreNotMarked(t *testing.T) { + m := newRefModel(t) + m.srcIndex = index.BuildSourceIndex(nil) + m.rebuildGutters() + + _, marked := m.specGutter()[rmLineLogo] + assert.False(t, marked, "the external ref line carries no marker") +} + +// Nothing to say means no gutter at all, so the pane keeps its full width. +func TestGutter_NilWhenNothingLinks(t *testing.T) { + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.spec.SetSize(60, 10) + m.specJSON = `{"swagger":"2.0"}` + m.refreshSpec() + + assert.Nil(t, m.specGutter(), "no provenance and no refs → no gutter") +} + +// Only nodes with an anchor of their OWN are marked. Marking everything that +// merely resolves through an ancestor would dot nearly every line. +func TestGutter_OnlyExactAnchors(t *testing.T) { + m := newRefModel(t) + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User", Pos: token.Position{Filename: "user.go", Line: 3}}, + }) + m.rebuildGutters() + + g := m.specGutter() + require.Equal(t, panels.GutterAnchor, g[rmLineUserDecl]) + + // The name property resolves to User by nearest-ancestor, but has no anchor + // of its own, so it stays unmarked. + _, marked := g[rmLineUserName] + assert.False(t, marked, + "a node that only resolves through an ancestor is not marked") + + // Sanity: it really does resolve, which is what makes the distinction real. + _, ok := m.srcIndex.PositionFor("/definitions/User/properties/name") + require.True(t, ok) +} + +// The gutter holds rendered line numbers, so a format toggle must rebuild it. +func TestGutter_RebuiltOnFormatToggle(t *testing.T) { + m := newRefModel(t) + m.specYAML = "definitions:\n Team:\n properties:\n lead:\n $ref: '#/definitions/User'\n User: {}\n" + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User", Pos: token.Position{Filename: "user.go", Line: 3}}, + }) + m.rebuildGutters() + jsonGutter := m.specGutter() + require.Equal(t, panels.GutterAnchor, jsonGutter[rmLineUserDecl]) + + m.setSpecFormat("YAML") + + yamlGutter := m.specGutter() + require.NotNil(t, yamlGutter) + assert.NotEqual(t, jsonGutter, yamlGutter, "the YAML render puts the nodes on other lines") + + userLine, ok := m.specIndex.LineForPointer("/definitions/User") + require.True(t, ok) + assert.Equal(t, panels.GutterAnchor, yamlGutter[userLine], "marked at its YAML line") +} + +func TestGutter_SourceAnchorsFollowTheOpenFile(t *testing.T) { + dir := t.TempDir() + userGo := filepath.Join(dir, "user.go") + teamGo := filepath.Join(dir, "team.go") + require.NoError(t, os.WriteFile(userGo, []byte("package p\n\ntype User struct{}\n"), 0o600)) + require.NoError(t, os.WriteFile(teamGo, []byte("package p\n\ntype Team struct{}\n"), 0o600)) + + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.cfg.WorkDir = dir + m.spec.SetSize(60, 10) + m.fileView.SetSize(60, 10) + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User", Pos: token.Position{Filename: userGo, Line: 3}}, + {Pointer: "/definitions/Team", Pos: token.Position{Filename: teamGo, Line: 3}}, + }) + + m.loadFileQuietly(userGo) + assert.Equal(t, map[int]bool{3: true}, m.srcIndex.AnchorLines(userGo)) + + // Opening another file must swap the anchor set, not keep the old one. + m.loadFileQuietly(teamGo) + assert.Equal(t, map[int]bool{3: true}, m.srcIndex.AnchorLines(teamGo)) + assert.Nil(t, m.srcIndex.AnchorLines(filepath.Join(dir, "nope.go")), + "a file that produced nothing has no anchors") +} + +// Against a real scan: the gutter must mark real nodes, and every marked line +// must genuinely be navigable. +func TestE2E_GutterMarksNavigableLines(t *testing.T) { + m := scanPetstore(t) + + g := m.specGutter() + require.NotEmpty(t, g, "a real scan produces both anchors and refs") + + lines := specLines(m) + var anchors, refs int + for line, marker := range g { + require.Less(t, line, len(lines), "marker is inside the document") + + switch marker { + case panels.GutterAnchor: + anchors++ + ptr, ok := m.specIndex.PointerAt(line) + require.True(t, ok, "line %d has a pointer", line) + _, hasSrc := m.srcIndex.PositionFor(ptr) + assert.True(t, hasSrc, "anchored line %d (%s) leads to source", line, ptr) + case panels.GutterRef: + refs++ + site, ok := m.refIndex.RefAt(line) + require.True(t, ok, "line %d holds a $ref", line) + assert.True(t, site.Target.Local, "marked refs are followable") + _, ok = m.specIndex.LineForPointer(site.Target.Pointer) + assert.True(t, ok, "line %d resolves to a rendered node", line) + default: + t.Fatalf("unexpected marker %q on line %d", marker, line) + } + } + + assert.Positive(t, anchors, "the petstore has anchored definitions") + assert.Positive(t, refs, "and followable refs") + assert.Less(t, len(g), len(lines), + "the gutter is a hint, not a mark on every line — otherwise it says nothing") +} diff --git a/cmd/genspec-tui/internal/ux/panels/fileview.go b/cmd/genspec-tui/internal/ux/panels/fileview.go index f9ac2de1..758df279 100644 --- a/cmd/genspec-tui/internal/ux/panels/fileview.go +++ b/cmd/genspec-tui/internal/ux/panels/fileview.go @@ -28,8 +28,15 @@ type FileView struct { editing bool navLine int // 0-based highlighted line in read-only mode offset int // 0-based top visible line in read-only mode + + anchors map[int]bool // 1-based source lines that produced a spec node } +// SetAnchors installs the source lines that produced a spec node, for the +// viewer's link gutter (design §6.5). Keyed 1-based, matching token.Position. +// A nil map renders no gutter. +func (p *FileView) SetAnchors(lines map[int]bool) { p.anchors = lines } + // NewFileView returns an empty viewer. func NewFileView() FileView { ta := textarea.New() @@ -179,14 +186,20 @@ func (p *FileView) viewerBody(focused, navActive bool) string { lines := strings.Split(p.ta.Value(), "\n") total := len(lines) numW := len(strconv.Itoa(total)) - textW := max(inner-(numW+1), 0) + + // The link gutter only claims width when there is something to mark. + gutW := 0 + if len(p.anchors) > 0 { + gutW = gutterWidth + } + textW := max(inner-(numW+1)-gutW, 0) style := navStyle(focused) var b strings.Builder end := min(p.offset+visible, total) for i := p.offset; i < end; i++ { - row := fmt.Sprintf("%*d ", numW, i+1) + fit(lines[i], textW) + row := p.gutterFor(i+1, gutW) + fmt.Sprintf("%*d ", numW, i+1) + fit(lines[i], textW) if i == p.navLine && navActive { row = style.Render(row) } @@ -198,6 +211,19 @@ func (p *FileView) viewerBody(focused, navActive bool) string { return b.String() } +// gutterFor renders the link marker for a 1-based source line, or blanks of the +// same width to keep the line numbers aligned. Empty when the gutter is off. +func (p *FileView) gutterFor(srcLine, width int) string { + if width == 0 { + return "" + } + if !p.anchors[srcLine] { + return strings.Repeat(" ", width) + } + + return theme.Gutter().Render(string(GutterAnchor)) + " " +} + // navStyle is the whole-line style for the highlighted nav line: the strong bar // when this pane drives (the driver keeps focus in follow mode, design §6.1), a // muted tint when it is only mirroring another pane's cursor (§6.5). diff --git a/cmd/genspec-tui/internal/ux/panels/gutter_test.go b/cmd/genspec-tui/internal/ux/panels/gutter_test.go new file mode 100644 index 00000000..53fed7eb --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/gutter_test.go @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "strings" + "testing" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// gutterMark is the marker as it appears in rendered output. +func gutterMark(r rune) string { return theme.Gutter().Render(string(r)) } + +func TestSpec_GutterMarksOnlyTheGivenLines(t *testing.T) { + sp := NewSpec() + sp.SetSize(40, 12) + sp.SetContent("aaa\nbbb\nccc\nddd") + + sp.SetGutter(map[int]rune{1: GutterAnchor, 2: GutterRef}) + + view := sp.vp.View() + require.Contains(t, view, gutterMark(GutterAnchor)+" bbb") + require.Contains(t, view, gutterMark(GutterRef)+" ccc") + assert.Contains(t, view, strings.Repeat(" ", gutterWidth)+"aaa", + "unmarked lines are padded so the text stays aligned") +} + +// No gutter installed means no gutter column: the pane costs no width before a +// scan has produced anything to mark. +func TestSpec_NoGutterCostsNoWidth(t *testing.T) { + sp := NewSpec() + sp.SetSize(40, 12) + sp.SetContent("aaa\nbbb") + + // The viewport pads each line to its width, so compare prefixes: the text + // must start in column 0, not two columns in. + firstLine := func() string { return strings.SplitN(sp.vp.View(), "\n", 2)[0] } + + assert.True(t, strings.HasPrefix(firstLine(), "aaa"), + "content starts in column 0 when nothing is marked, got %q", firstLine()) + + sp.SetGutter(nil) + assert.True(t, strings.HasPrefix(firstLine(), "aaa"), + "an explicitly nil gutter costs no width either, got %q", firstLine()) + + // ...whereas installing one shifts the text right by the gutter width. + sp.SetGutter(map[int]rune{1: GutterAnchor}) + assert.True(t, strings.HasPrefix(firstLine(), strings.Repeat(" ", gutterWidth)+"aaa"), + "got %q", firstLine()) +} + +// The gutter is prefixed after highlighting, so both survive together and the +// styles still apply to the text rather than to the marker column. +func TestSpec_GutterCoexistsWithSearchAndXref(t *testing.T) { + sp := NewSpec() + sp.SetSize(40, 12) + sp.SetContent("aaa\nbbb\nccc") + sp.SetGutter(map[int]rune{0: GutterAnchor, 1: GutterAnchor}) + + n := sp.Search("bbb") + require.Equal(t, 1, n, "the gutter must not disturb match counting") + + view := sp.vp.View() + assert.Contains(t, view, gutterMark(GutterAnchor), "markers survive a search render") + assert.Contains(t, view, theme.Match().Render("bbb"), "the match is still highlighted") + + sp.MarkLine(2) + view = sp.View(true) + assert.Contains(t, view, gutterMark(GutterAnchor)) + assert.Contains(t, view, theme.Selected().Render("ccc"), + "the xref style wraps the text, not the gutter") +} + +func TestFileView_GutterMarksAnchoredLines(t *testing.T) { + fv := NewFileView() + fv.SetSize(40, 10) + fv.SetFile("x.go", "line1\nline2\nline3\nline4") + fv.SetAnchors(map[int]bool{2: true}) // 1-based, matching token.Position + + view := fv.View(false, false) + + assert.Contains(t, view, gutterMark(GutterAnchor)+" 2 line2", + "the anchored source line is marked") + assert.Contains(t, view, strings.Repeat(" ", gutterWidth)+"1 line1", + "other lines are padded, keeping the line numbers aligned") +} + +func TestFileView_NoAnchorsNoGutter(t *testing.T) { + fv := NewFileView() + fv.SetSize(40, 10) + fv.SetFile("x.go", "line1\nline2") + + view := fv.View(false, false) + + assert.NotContains(t, view, gutterMark(GutterAnchor)) + assert.Contains(t, view, "1 line1", "the line numbers keep their original column") +} diff --git a/cmd/genspec-tui/internal/ux/panels/spec.go b/cmd/genspec-tui/internal/ux/panels/spec.go index 5faa3758..01c2c9a5 100644 --- a/cmd/genspec-tui/internal/ux/panels/spec.go +++ b/cmd/genspec-tui/internal/ux/panels/spec.go @@ -28,8 +28,23 @@ type Spec struct { xrefLine int // content line highlighted by cross-ref follow, -1 for none focused bool // last focus state seen by View; picks the xref line's style + + gutter map[int]rune // content line → link marker; nil renders no gutter at all } +// Gutter markers (design §6.5): which lines actually lead somewhere. +const ( + // GutterAnchor marks a node with a source position of its OWN, so following + // it lands exactly there rather than on an ancestor. + GutterAnchor = '•' + + // GutterRef marks a followable $ref — Enter goes to its definition. + GutterRef = '→' + + // gutterWidth is the marker plus its separating space. + gutterWidth = 2 +) + // NewSpec returns a Spec defaulting to JSON with placeholder content. func NewSpec() Spec { const placeholder = "(no spec generated yet)" @@ -181,12 +196,23 @@ func (p *Spec) xrefStyle() lipgloss.Style { return theme.Follower() } +// SetGutter installs the link markers, keyed by content line. A nil or empty +// map renders no gutter at all, so the pane costs no width until there is +// something to say (before the first scan, or with provenance switched off). +func (p *Spec) SetGutter(g map[int]rune) { + p.gutter = g + p.render() +} + // render rebuilds the viewport content from the raw text, applying the active -// search highlight (per-substring) and the cross-ref highlight (whole line). -// The cross-ref line takes the whole-line style; search matches are still -// counted on it so n/N stays consistent. +// search highlight (per-substring), the cross-ref highlight (whole line) and +// the link gutter. The cross-ref line takes the whole-line style; search matches +// are still counted on it so n/N stays consistent. +// +// The gutter is prefixed AFTER highlighting, so the styles apply to the text the +// user searched for, not to the marker column. func (p *Spec) render() { - if p.query == "" && p.xrefLine == -1 { + if p.query == "" && p.xrefLine == -1 && len(p.gutter) == 0 { p.matches = nil p.vp.SetContent(p.content) return @@ -205,14 +231,29 @@ func (p *Spec) render() { } switch { case i == p.xrefLine: - lines[i] = p.xrefStyle().Render(ln) + ln = p.xrefStyle().Render(ln) case isMatch: - lines[i] = highlightAll(ln, p.query) + ln = highlightAll(ln, p.query) } + lines[i] = p.gutterFor(i) + ln } p.vp.SetContent(strings.Join(lines, "\n")) } +// gutterFor renders line i's marker column, or blanks of the same width so the +// text stays aligned. Empty string when no gutter is installed. +func (p *Spec) gutterFor(i int) string { + if len(p.gutter) == 0 { + return "" + } + marker, ok := p.gutter[i] + if !ok { + return strings.Repeat(" ", gutterWidth) + } + + return theme.Gutter().Render(string(marker)) + " " +} + func (p *Spec) scrollToMatch() { if len(p.matches) == 0 { return diff --git a/cmd/genspec-tui/internal/ux/theme/theme.go b/cmd/genspec-tui/internal/ux/theme/theme.go index 90c36cdd..611574ba 100644 --- a/cmd/genspec-tui/internal/ux/theme/theme.go +++ b/cmd/genspec-tui/internal/ux/theme/theme.go @@ -100,6 +100,12 @@ func Follower() lipgloss.Style { Background(colorFollower) } +// Gutter styles the link markers in the spec pane's and source viewer's gutter. +// Dim on purpose: they are a hint about what is navigable, not content. +func Gutter() lipgloss.Style { + return lipgloss.NewStyle().Foreground(colorHint) +} + // Stale styles the follow-mode badge shown while the source buffer has unsaved // edits, i.e. while cross-ref positions are older than what is on screen. func Stale() lipgloss.Style { From 261b781eda2e7db2602c455be2fdb57b9d731088 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Thu, 30 Jul 2026 17:13:22 +0200 Subject: [PATCH 11/13] fix(tui): let the read-only viewer pass global keys through handleViewerKey ended in a bare return, so it consumed every key it did not explicitly own. While a file was open in the left pane that silently disabled "/", o, r, g and the JSON/YAML toggle - which is precisely when you want to rescan or flip formats, since you are reading the source that produced the spec. It now mirrors handleDiagNav: it returns handled=false for anything outside the keys it genuinely owns (nav line, follow, edit, back-to-tree), and the global bindings take over. Tab, c and ctrl+q were duplicated verbatim in both handlers; the global copies now serve the viewer too. TestJoin_ViewerSwallowsGlobalKeys pinned the old behaviour so that changing it would be deliberate. Replaced by TestJoin_ViewerPassesGlobalKeysThrough, which checks both halves: the globals now reach the viewer, and the keys the viewer owns still belong to it. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- cmd/genspec-tui/README.md | 3 + cmd/genspec-tui/internal/ux/model.go | 35 +++++----- .../internal/ux/model_join_test.go | 64 ++++++++++++++++--- 3 files changed, 74 insertions(+), 28 deletions(-) diff --git a/cmd/genspec-tui/README.md b/cmd/genspec-tui/README.md index 933af6cb..8eaa2a7f 100644 --- a/cmd/genspec-tui/README.md +++ b/cmd/genspec-tui/README.md @@ -110,6 +110,9 @@ the pointer — `Tab` is never required. | `i` / `Enter` | start editing | | `Esc` | back to the tree | +The viewer shadows only these keys; every other binding (`/`, `o`, `r`, `g`, +`ctrl+j` / `ctrl+y`, `Tab`, `c`) still works while a file is open. + ### File editor | Key | Action | diff --git a/cmd/genspec-tui/internal/ux/model.go b/cmd/genspec-tui/internal/ux/model.go index 47d1ac7e..238c8a1f 100644 --- a/cmd/genspec-tui/internal/ux/model.go +++ b/cmd/genspec-tui/internal/ux/model.go @@ -331,7 +331,9 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.fileView.Editing() { return m.handleEditKey(msg) } - return m.handleViewerKey(msg) + if cmd, handled := m.handleViewerKey(msg); handled { + return m, cmd + } } // Diagnostics pane: select a diagnostic and follow it to source. Unhandled @@ -507,40 +509,35 @@ func (m *Model) driveDiagToSource() string { // handleViewerKey drives the read-only file viewer: move the highlighted nav // line, follow it to the spec node it produced (`f`), enter the editor (`i`/ // Enter), or leave back to the tree (Esc). -func (m *Model) handleViewerKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *Model) handleViewerKey(msg tea.KeyMsg) (tea.Cmd, bool) { switch key.MsgBinding(msg) { case key.Up, key.K: m.fileView.NavUp() - return m, nil + return nil, true case key.Down, key.J: m.fileView.NavDown() - return m, nil + return nil, true case key.F: // Toggle source-driven follow mode: as the nav line moves, the spec // mirrors the node it produced (source→spec). m.toggleFollow(followSource) - return m, nil + return nil, true case key.I, key.Enter: - return m, m.fileView.StartEdit() + return m.fileView.StartEdit(), true case key.Esc: if m.follow != followOff { m.exitFollow() - return m, nil + return nil, true } m.leftMode = modeBrowse - return m, nil - case key.Tab: - m.focused = (m.focused + 1) % paneCount - return m, m.syncEditFocus() - case key.ShiftTab: - m.focused = (m.focused + paneCount - 1) % paneCount - return m, m.syncEditFocus() - case key.C: - return m, m.copyFocused() - case key.CtrlQ, key.CtrlC: - return m, tea.Quit + return nil, true } - return m, nil + + // Everything else falls through to the global bindings. The viewer shadows + // only the keys it genuinely owns: swallowing the rest used to disable `/`, + // `o`, `r`, `g` and the format toggle for as long as a file was open, which + // is exactly when you most want to rescan or flip JSON↔YAML. + return nil, false } // handleEditKey routes keys to the file editor while it is focused. A few app diff --git a/cmd/genspec-tui/internal/ux/model_join_test.go b/cmd/genspec-tui/internal/ux/model_join_test.go index 37162ca2..69c543ad 100644 --- a/cmd/genspec-tui/internal/ux/model_join_test.go +++ b/cmd/genspec-tui/internal/ux/model_join_test.go @@ -444,18 +444,64 @@ func TestJoin_FollowExits(t *testing.T) { }) } -// The read-only viewer swallows every key it does not own, so the global -// bindings are unreachable while a file is open. Pinning the CURRENT behaviour -// (not endorsing it) — see the UX-polish note in the build plan. -func TestJoin_ViewerSwallowsGlobalKeys(t *testing.T) { - for _, k := range []rune{'/', 'o', 'r', 'g'} { +// The read-only viewer shadows only the keys it owns; everything else reaches +// the global bindings. Reading source is exactly when you want to rescan or +// flip JSON↔YAML, and those used to be dead for as long as a file was open. +func TestJoin_ViewerPassesGlobalKeysThrough(t *testing.T) { + viewing := func(t *testing.T) joinFixture { + t.Helper() f := newJoinFixture(t) f.m.loadFileQuietly(f.userGo) f.m.focused, f.m.leftMode = paneTree, modeView + return f + } - _, _ = f.m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{k}}) + t.Run("slash opens search", func(t *testing.T) { + f := viewing(t) + _, _ = f.m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + assert.True(t, f.m.searching) + }) - assert.False(t, f.m.optionsOpen, "%q reached the options popup", k) - assert.False(t, f.m.searching, "%q reached search", k) - } + t.Run("o opens the options popup", func(t *testing.T) { + f := viewing(t) + _, _ = f.m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) + assert.True(t, f.m.optionsOpen) + }) + + t.Run("format toggle works while reading source", func(t *testing.T) { + f := viewing(t) + f.m.specYAML = "definitions: {}\n" + _, _ = f.m.handleKey(tea.KeyMsg{Type: tea.KeyCtrlY}) + assert.Equal(t, "YAML", f.m.spec.Format()) + }) + + t.Run("r triggers a rescan", func(t *testing.T) { + f := viewing(t) + _, cmd := f.m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + assert.NotNil(t, cmd, "a scan command was issued") + assert.True(t, f.m.scanning) + }) + + // ...but the keys the viewer owns still belong to it. + t.Run("j still moves the nav line", func(t *testing.T) { + f := viewing(t) + before := f.m.fileView.CurrentLine() + _, _ = f.m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + assert.Equal(t, before+1, f.m.fileView.CurrentLine()) + assert.False(t, f.m.searching) + }) + + t.Run("esc returns to the tree", func(t *testing.T) { + f := viewing(t) + _, _ = f.m.handleKey(tea.KeyMsg{Type: tea.KeyEsc}) + assert.Equal(t, modeBrowse, f.m.leftMode) + }) + + // Tab / c / ctrl+q were duplicated in both handlers; the global copies now + // serve the viewer too, and must behave identically. + t.Run("tab still changes focus", func(t *testing.T) { + f := viewing(t) + _, _ = f.m.handleKey(tea.KeyMsg{Type: tea.KeyTab}) + assert.Equal(t, paneSpec, f.m.focused) + }) } From 9a745045e8406593d6616ccea734ddab5b5b81fc Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Thu, 30 Jul 2026 18:03:25 +0200 Subject: [PATCH 12/13] feat(tui): real line cursor in the spec pane, preserved across re-renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last two backlog items, which turned out to be one problem. B-spec-line-cursor. The spec pane had only a scroll offset, so "the node under the cursor" meant "the node at the top of the viewport" - workable for follow mode, awkward for go-to-definition, where you had to scroll a $ref to the very top to press Enter on it. S12 had already had to paper over the consequences with specCursorLine, a pseudo-cursor that remembered the last jump target for as long as the viewport had not moved. The pane now carries a real cursor, and that pseudo-cursor is gone. ↑↓/jk move it, PgUp/PgDn move it a page, Home/End jump to the ends, and the wheel moves it too - the view follows the cursor rather than leaving it behind off-screen where F3 or Enter would act on a node nobody can see. Rendering reuses S8's roles unchanged: the cursor takes the strong bar when the pane is focused (it drives) and the muted tint when it is not (it mirrors). This simplified more than it added. xrefLine and the cursor were two names for one idea, so MarkLine, HighlightLine and ClearHighlight collapse into SetCursor (incremental, minimal scroll) and JumpTo (centred). Search now parks the cursor on the match, so "/" then F3 or Enter composes. B-rescan-anchor. Every re-render renumbers lines, so a rescan that gains a definition above where you are reading used to slide you onto a different node. refreshSpec now captures the pointer under the cursor before rebuilding and restores it afterwards, which also subsumes the format-toggle handling added in S7. When the node itself is gone - you deleted the type - the walk falls back to its nearest surviving ancestor rather than landing somewhere arbitrary. The restore scrolls MINIMALLY rather than centring. This is the hot path: every save fires it, and after a typical rescan the node has moved a line or two and is still on screen, so yanking the viewport each time would be worse than the drift it fixes. An explicit format switch does recentre, that being a deliberate change of view rather than an incremental one. Extracting routePaneKey keeps handleKey under the gocyclo threshold, and gives each pane first refusal on a key in one place. Mutation-verified: disabling the restore fails six tests. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- cmd/genspec-tui/README.md | 17 +- cmd/genspec-tui/internal/ux/key/bindings.go | 4 + cmd/genspec-tui/internal/ux/model.go | 217 ++++++++++-------- cmd/genspec-tui/internal/ux/model_e2e_test.go | 6 +- .../internal/ux/model_edges_test.go | 22 +- .../internal/ux/model_join_test.go | 53 ++--- .../internal/ux/model_refs_test.go | 138 +++++++---- .../internal/ux/model_rescan_test.go | 214 +++++++++++++++++ .../internal/ux/panels/gutter_test.go | 48 ++-- .../internal/ux/panels/navvisuals_test.go | 12 +- cmd/genspec-tui/internal/ux/panels/spec.go | 134 ++++++----- .../internal/ux/panels/spec_test.go | 61 +++-- 12 files changed, 633 insertions(+), 293 deletions(-) create mode 100644 cmd/genspec-tui/internal/ux/model_rescan_test.go diff --git a/cmd/genspec-tui/README.md b/cmd/genspec-tui/README.md index 8eaa2a7f..9ea9bd0e 100644 --- a/cmd/genspec-tui/README.md +++ b/cmd/genspec-tui/README.md @@ -68,6 +68,11 @@ notices, and the spec re-renders. Clicking a pane focuses it, and the mouse wheel scrolls whichever pane is under the pointer — `Tab` is never required. +A rescan keeps you where you were: the cursor is restored to the same **node**, +not the same line number, so a definition appearing above what you are reading +does not slide you somewhere else. If that node is gone — you deleted the type — +the cursor falls back to its nearest surviving ancestor. + ## Keys ### Anywhere @@ -86,12 +91,19 @@ the pointer — `Tab` is never required. | Key | Action | |-----|--------| +| `↑` `↓` / `j` `k` | move the cursor | +| `PgUp` / `PgDn` | move it a page (the view never leaves the cursor behind) | +| `Home` / `End` | first / last line | | `ctrl+j` / `ctrl+y` | render as JSON / YAML — keeps you on the same **node**, not the same line | | `/` | search; `n` / `N` step through matches | | `f` | toggle follow mode (spec drives, the source pane mirrors) | | `F3` / `shift+F3` | next / previous **reference** to the node under the cursor | | `Enter` | follow the `$ref` under the cursor to its definition | -| `Esc` | clear search, highlight and the reference cycle | +| `Esc` | clear the search and the reference cycle | + +The spec pane has a line cursor, and everything above acts on **the node under +it**. Searching parks the cursor on the match, so `/` then `F3` or `Enter` +composes. ### Source tree @@ -191,9 +203,6 @@ These are known and deliberate; the TUI says so rather than guessing. scanning the rendered document. Local `#/…` refs are followable; a ref into another file or a URL is reported as external rather than chased. Ref-to-ref chains and `$ref` nested in `allOf` are not unwound. -- **The spec pane has no line cursor.** "The node under the cursor" means the - node at the top of the viewport, except right after a jump, when it means the - line you landed on. - **`shift+F3` is terminal-dependent.** bubbletea v1's key type carries no Shift modifier, and the xterm family reports shift+F3 as F15. Terminals that send something else have no previous-reference key; `F3` still wraps around. diff --git a/cmd/genspec-tui/internal/ux/key/bindings.go b/cmd/genspec-tui/internal/ux/key/bindings.go index 80dc066d..fd9634cd 100644 --- a/cmd/genspec-tui/internal/ux/key/bindings.go +++ b/cmd/genspec-tui/internal/ux/key/bindings.go @@ -36,6 +36,10 @@ const ( G Binding = "g" F Binding = "f" I Binding = "i" + PgUp Binding = "pgup" + PgDown Binding = "pgdown" + Home Binding = "home" + End Binding = "end" Space Binding = " " Esc Binding = "esc" Enter Binding = "enter" diff --git a/cmd/genspec-tui/internal/ux/model.go b/cmd/genspec-tui/internal/ux/model.go index 238c8a1f..e595cce5 100644 --- a/cmd/genspec-tui/internal/ux/model.go +++ b/cmd/genspec-tui/internal/ux/model.go @@ -154,19 +154,6 @@ type Model struct { follow followMode followTarget string // human-readable resolved target, for the nav status badge - // specCursor is the spec line the last JUMP put the user on, together with - // the viewport offset that jump produced. The spec pane has no real line - // cursor (only a scroll offset), and a jump CENTRES its target — so after - // one, "the node under the cursor" is emphatically not the node at the top - // of the viewport. Pairing the line with the offset that produced it lets - // specCursorLine hand back the jump target while the view is untouched, and - // fall back to the viewport top the moment the user scrolls away. - // specCursorSet distinguishes "no jump in effect" from a jump to line 0, - // so the ZERO VALUE of Model means no cursor — no initializer to forget. - specCursor int - specCursorTop int - specCursorSet bool - // Find-references cycle state (F3 / shift+F3). Valid only for the CURRENT // render, so refreshSpec resets it. refAnchor string // the definition pointer whose uses are being cycled @@ -325,26 +312,14 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.searching { return m.handleSearchKey(msg) } - // The open file pane: the read-only viewer navigates; the editor captures - // input (except a few app keys). - if m.leftMode == modeView && m.focused == paneTree { - if m.fileView.Editing() { - return m.handleEditKey(msg) - } - if cmd, handled := m.handleViewerKey(msg); handled { - return m, cmd - } - } - - // Diagnostics pane: select a diagnostic and follow it to source. Unhandled - // keys fall through to the global bindings below. - if m.focused == paneDiag { - if cmd, handled := m.handleDiagNav(msg); handled { - return m, cmd - } + // The editor captures everything except a handful of app keys. + if m.leftMode == modeView && m.focused == paneTree && m.fileView.Editing() { + return m.handleEditKey(msg) } - if cmd, handled := m.handleRefNav(msg); handled { + // Then the focused pane gets first refusal; whatever it declines falls + // through to the global bindings below. + if cmd, handled := m.routePaneKey(msg); handled { return m, cmd } @@ -407,7 +382,6 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.resetRefCycle() m.spec.ClearSearch() - m.spec.ClearHighlight() return m, nil } @@ -436,6 +410,55 @@ func (m *Model) handleSearchControl(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { return m, nil, false } +// routePaneKey offers a key to the focused pane's own handler before the +// global bindings see it. Each handler reports handled=false for keys it does +// not own, so a pane shadows only what it genuinely needs — the alternative, +// swallowing everything, is what used to make `/`, `o` and `r` dead while a +// file was open. +func (m *Model) routePaneKey(msg tea.KeyMsg) (tea.Cmd, bool) { + if m.leftMode == modeView && m.focused == paneTree { + return m.handleViewerKey(msg) + } + if m.focused == paneDiag { + return m.handleDiagNav(msg) + } + if cmd, handled := m.handleSpecNav(msg); handled { + return cmd, true + } + + return m.handleRefNav(msg) +} + +// handleSpecNav moves the spec pane's line cursor. The pane is navigable in its +// own right now, so scrolling and "where the user is" are the same thing — +// paging moves the cursor with the view rather than leaving it behind off +// screen, where F3 or Enter would act on a node nobody can see. +func (m *Model) handleSpecNav(msg tea.KeyMsg) (tea.Cmd, bool) { + if m.focused != paneSpec { + return nil, false + } + + page := max(m.topH-3, 1) // the viewport's visible height + switch key.MsgBinding(msg) { + case key.Up, key.K: + m.spec.MoveCursor(-1) + case key.Down, key.J: + m.spec.MoveCursor(+1) + case key.PgUp: + m.spec.MoveCursor(-page) + case key.PgDown: + m.spec.MoveCursor(+page) + case key.Home: + m.spec.SetCursor(0) + case key.End: + m.spec.SetCursor(m.spec.LastLine()) + default: + return nil, false + } + + return nil, true +} + // handleRefNav handles the Phase-D navigation keys, which belong to the spec // pane: F3 / shift+F3 cycle the references of the node under the cursor, Enter // follows the $ref under it. Returns handled=false for every other pane, so @@ -736,7 +759,8 @@ func (m *Model) scrollPane(p pane, msg tea.MouseMsg, delta int) tea.Cmd { m.tree.ScrollBy(delta) return nil case paneSpec: - return m.spec.Update(msg) + m.spec.MoveCursor(delta) // the cursor leads; the view follows it + return nil case paneDiag: return m.diag.Update(msg) } @@ -800,6 +824,17 @@ func (m *Model) refreshSpec() { if yamlFmt { body = m.specYAML } + + // Remember the NODE under the cursor before anything is rebuilt. Every + // re-render renumbers lines — a rescan that gains a definition above you + // shifts everything below it — so carrying the line number across would + // silently move the user to a different node. This is the hot path: every + // save fires it, and live-reload is the tool's whole point. + anchor, anchored := "", false + if m.specIndex != nil { + anchor, anchored = m.specIndex.PointerAt(m.spec.CursorLine()) + } + // Both indexes and the find-references cycle are per-render: a rescan or a // format toggle invalidates every line number they hold. m.resetRefCycle() @@ -815,71 +850,70 @@ func (m *Model) refreshSpec() { m.specIndex, m.refIndex = index.BuildJSONIndex([]byte(body)) } m.spec.SetContent(body) + if anchored { + m.restoreCursorTo(anchor) + } m.rebuildGutters() } -// setSpecFormat switches the spec render between JSON and YAML, preserving the -// NODE at the top of the viewport rather than the raw line number. The two -// renders index the same pointers at different lines, so carrying the scroll -// offset across silently lands the user on an unrelated node (design §6.4). -// A no-op when the format is already active. +// restoreCursorTo puts the cursor back on ptr in the freshly built index. +// +// When the node itself is gone — you deleted the type that produced it — the +// walk falls back to its nearest surviving ancestor, so you land in the right +// neighbourhood rather than somewhere arbitrary. When nothing on its path +// survives, the clamped line SetContent already chose stands; there is nothing +// more honest to say. +// +// It scrolls MINIMALLY rather than centring: after a rescan the node has usually +// moved by a line or two and is still on screen, and yanking the viewport on +// every save would be worse than the drift it fixes. An explicit format switch +// recentres afterwards (setSpecFormat), that being a deliberate change of view. +func (m *Model) restoreCursorTo(ptr string) { + for ptr != "" { + if line, ok := m.specIndex.LineForPointer(ptr); ok { + m.spec.SetCursor(line) + return + } + i := strings.LastIndexByte(ptr, '/') + if i < 0 { + return + } + ptr = ptr[:i] + } +} + +// setSpecFormat switches the spec render between JSON and YAML. refreshSpec +// keeps the cursor on the same NODE across the re-render; this additionally +// recentres it, because switching format is a deliberate change of view and the +// node has usually moved far enough that a minimal scroll would leave it pinned +// against an edge. A no-op when the format is already active. func (m *Model) setSpecFormat(format string) { if m.spec.Format() == format { return } - anchor, anchored := m.specIndex.PointerAt(m.spec.TopLine()) m.spec.SetFormat(format) - m.refreshSpec() // rebuilds m.specIndex for the new render - - if !anchored { - return - } - if line, ok := m.specIndex.LineForPointer(anchor); ok { - m.spec.ScrollTo(line) - } -} - -// specCursorLine is the rendered line the user means in the spec pane: the -// target of the last jump while the viewport still sits where that jump left -// it, otherwise the top of the viewport (the pane's only other notion of -// "here"). Scrolling invalidates the jump target implicitly — no bookkeeping -// on the scroll path. -func (m *Model) specCursorLine() int { - if m.specCursorSet && m.spec.TopLine() == m.specCursorTop { - return m.specCursor - } - - return m.spec.TopLine() -} - -// markSpecCursor records line as the jump target, together with the viewport -// offset the jump produced. Call it AFTER scrolling. -func (m *Model) markSpecCursor(line int) { - m.specCursor, m.specCursorTop, m.specCursorSet = line, m.spec.TopLine(), true -} - -// clearSpecCursor forgets the jump target, so the viewport top is "here" again. -func (m *Model) clearSpecCursor() { - m.specCursor, m.specCursorTop, m.specCursorSet = 0, 0, false + m.refreshSpec() + m.spec.JumpTo(m.spec.CursorLine()) } -// cycleRefs steps through the places the definition under the spec cursor is +// cycleRefs steps through the places the node under the spec cursor is // referenced (design §6.4 multi-candidate case): dir +1 for the next site, -1 // for the previous, wrapping. // -// A cycle continues only while the viewport is still where the last jump left -// it. The moment the user scrolls away, the node under the cursor has changed -// and the next F3 starts a fresh cycle from wherever they now are — which is -// what makes "F3 repeatedly" walk one definition's uses rather than chasing the -// definition of whatever it last landed on. +// A cycle continues only while the cursor is still parked on the site the last +// step put it on. Move it and the next F3 re-anchors on the node you are now +// on — which is what makes "F3 repeatedly" walk one definition's uses rather +// than chasing the definition of whatever it last landed on. func (m *Model) cycleRefs(dir int) tea.Cmd { - if continuing := m.refAnchor != "" && m.specCursorSet && m.spec.TopLine() == m.specCursorTop; continuing { + onCurrentSite := m.refAnchor != "" && m.refCursor < len(m.refSites) && + m.spec.CursorLine() == m.refSites[m.refCursor].Line + if onCurrentSite { m.refCursor = (m.refCursor + dir + len(m.refSites)) % len(m.refSites) } else { // Drop the old cycle FIRST: if the new node has no references we must // not leave "ref 1/3 of /definitions/User" on screen while the user is - // looking at something else entirely. + // looking at something else. m.resetRefCycle() if !m.startRefCycle(dir) { return clearNoticeAfter(noticeTTL) @@ -887,8 +921,7 @@ func (m *Model) cycleRefs(dir int) tea.Cmd { } site := m.refSites[m.refCursor] - m.spec.HighlightLine(site.Line) - m.markSpecCursor(site.Line) + m.spec.JumpTo(site.Line) m.refStatus = fmt.Sprintf("ref %d/%d of %s → %s", m.refCursor+1, len(m.refSites), m.refAnchor, site.Pointer) @@ -900,7 +933,7 @@ func (m *Model) cycleRefs(dir int) tea.Cmd { // one. Reports false (with a notice explaining why) when there is nothing to // cycle. func (m *Model) startRefCycle(dir int) bool { - ptr, ok := m.specIndex.PointerAt(m.specCursorLine()) + ptr, ok := m.specIndex.PointerAt(m.spec.CursorLine()) if !ok { m.notice = noNodeDesc @@ -927,7 +960,6 @@ func (m *Model) startRefCycle(dir int) bool { func (m *Model) resetRefCycle() { m.refAnchor, m.refSites, m.refCursor = "", nil, 0 m.refStatus = "" - m.clearSpecCursor() } // gotoDefinition follows the $ref under the spec cursor to the node it points @@ -935,7 +967,7 @@ func (m *Model) resetRefCycle() { // renders one spec and is not a $ref resolver, so an external target is // reported honestly rather than guessed at. func (m *Model) gotoDefinition() tea.Cmd { - site, ok := m.refIndex.RefAt(m.specCursorLine()) + site, ok := m.refIndex.RefAt(m.spec.CursorLine()) if !ok { m.notice = "no $ref on this line" @@ -954,8 +986,7 @@ func (m *Model) gotoDefinition() tea.Cmd { } m.resetRefCycle() - m.spec.HighlightLine(line) - m.markSpecCursor(line) + m.spec.JumpTo(line) m.notice = "→ " + site.Target.Pointer return clearNoticeAfter(noticeTTL) @@ -1015,7 +1046,7 @@ func (m *Model) locateInSpec(path string) tea.Cmd { m.notice = "node not in the current spec view: " + ptr return clearNoticeAfter(noticeTTL) } - m.spec.HighlightLine(specLine) + m.spec.JumpTo(specLine) m.focused = paneSpec m.notice = "→ " + ptr return clearNoticeAfter(noticeTTL) @@ -1041,7 +1072,6 @@ func (m *Model) exitFollow() { } m.follow = followOff m.followTarget = "" - m.spec.ClearHighlight() } // syncFollowIfActive re-mirrors the follower pane from the driver's current @@ -1077,13 +1107,10 @@ func (m *Model) syncFollowIfActive() { // the viewport, WITHOUT moving focus or the spec scroll (the user drives it). // Returns a human-readable target for the status badge. func (m *Model) driveSpecToSource() string { - ptr, ok := m.specIndex.PointerAt(m.spec.TopLine()) + ptr, ok := m.specIndex.PointerAt(m.spec.CursorLine()) if !ok { return noNodeDesc } - if specLine, found := m.specIndex.LineForPointer(ptr); found { - m.spec.MarkLine(specLine) // mark the mapped node, no scroll - } pos, ok := m.srcIndex.PositionFor(ptr) if !ok { // Hold the follower where it is rather than jumping somewhere wrong @@ -1122,7 +1149,7 @@ func (m *Model) linkSourceToSpec() (string, bool) { if !ok { return ptr + notRenderedSuffix, false } - m.spec.HighlightLine(specLine) // follower scrolls + highlights + m.spec.JumpTo(specLine) // the follower centres on the produced node return ptr, true } @@ -1351,9 +1378,9 @@ func (m *Model) statusLine() string { m.diagCursor+1, len(m.diags))) } if m.focused == paneSpec && m.specIndex.Len() > 0 { - if ptr, ok := m.specIndex.PointerAt(m.spec.TopLine()); ok { + if ptr, ok := m.specIndex.PointerAt(m.spec.CursorLine()); ok { hint := "f: follow · F3: find refs · enter: go to definition · /: search · tab: focus · c: copy" - if _, isRef := m.refIndex.RefAt(m.spec.TopLine()); !isRef { + if _, isRef := m.refIndex.RefAt(m.spec.CursorLine()); !isRef { // Nothing to follow from here; don't advertise it. hint = "f: follow · F3: find refs · /: search · tab: focus · c: copy" } diff --git a/cmd/genspec-tui/internal/ux/model_e2e_test.go b/cmd/genspec-tui/internal/ux/model_e2e_test.go index 588cff2f..94feccdb 100644 --- a/cmd/genspec-tui/internal/ux/model_e2e_test.go +++ b/cmd/genspec-tui/internal/ux/model_e2e_test.go @@ -133,7 +133,7 @@ func TestE2E_CycleThenGoToDefinitionRoundTrips(t *testing.T) { defLine, ok := m.specIndex.LineForPointer("/definitions/pet") require.True(t, ok) - m.spec.ScrollTo(defLine) + m.spec.SetCursor(defLine) // F3 → the first use. _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) @@ -157,7 +157,7 @@ func TestE2E_CycleVisitsEverySiteExactlyOnce(t *testing.T) { defLine, ok := m.specIndex.LineForPointer("/definitions/pet") require.True(t, ok) - m.spec.ScrollTo(defLine) + m.spec.SetCursor(defLine) want := m.refIndex.RefsToPointer("/definitions/pet") require.NotEmpty(t, want) @@ -226,7 +226,7 @@ func TestE2E_FollowOpensTheDeclaringFile(t *testing.T) { defLine, ok := m.specIndex.LineForPointer("/definitions/pet") require.True(t, ok) - m.spec.ScrollTo(defLine) + m.spec.SetCursor(defLine) m.toggleFollow(followSpec) diff --git a/cmd/genspec-tui/internal/ux/model_edges_test.go b/cmd/genspec-tui/internal/ux/model_edges_test.go index ff99f4aa..2f966c63 100644 --- a/cmd/genspec-tui/internal/ux/model_edges_test.go +++ b/cmd/genspec-tui/internal/ux/model_edges_test.go @@ -92,8 +92,8 @@ func TestSpecFormatToggle_PreservesPointerNotLine(t *testing.T) { jsonLine, ok := m.specIndex.LineForPointer(emailPtr) require.True(t, ok, "the email property must be indexed in the JSON render") require.Equal(t, emailJSONLine, jsonLine) - m.spec.ScrollTo(jsonLine) - require.Equal(t, jsonLine, m.spec.TopLine()) + m.spec.SetCursor(jsonLine) + require.Equal(t, jsonLine, m.spec.CursorLine()) m.setSpecFormat("YAML") @@ -102,25 +102,25 @@ func TestSpecFormatToggle_PreservesPointerNotLine(t *testing.T) { require.True(t, ok, "the same pointer must be indexed in the YAML render") require.Equal(t, emailYAMLLine, yamlLine, "the two renders put the node on different lines") - assert.Equal(t, yamlLine, m.spec.TopLine(), + assert.Equal(t, yamlLine, m.spec.CursorLine(), "the toggle must land on the same NODE, not the same line number") // Round-tripping back restores the JSON line for the same node. m.setSpecFormat("JSON") - assert.Equal(t, jsonLine, m.spec.TopLine()) + assert.Equal(t, jsonLine, m.spec.CursorLine()) } func TestSpecFormatToggle_SameFormatIsNoop(t *testing.T) { m := toggleFixture(t) - m.spec.ScrollTo(emailJSONLine) + m.spec.SetCursor(emailJSONLine) m.setSpecFormat("JSON") - assert.Equal(t, emailJSONLine, m.spec.TopLine(), - "re-selecting the active format must not move the viewport") + assert.Equal(t, emailJSONLine, m.spec.CursorLine(), + "re-selecting the active format must not move the cursor") } -func TestSpecFormatToggle_UnindexedTopLine(t *testing.T) { +func TestSpecFormatToggle_UnindexedCursorLine(t *testing.T) { m := toggleFixture(t) m.specIndex = nil // no index: nothing to preserve, but nothing may panic either @@ -133,13 +133,13 @@ func TestSpecFormatToggle_UnindexedTopLine(t *testing.T) { // pointer-preserving path (the bug was in the key handler, not the helper). func TestSpecFormatToggle_ViaKey(t *testing.T) { m := toggleFixture(t) - m.spec.ScrollTo(emailJSONLine) + m.spec.SetCursor(emailJSONLine) _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyCtrlY}) assert.Equal(t, "YAML", m.spec.Format()) - assert.Equal(t, emailYAMLLine, m.spec.TopLine(), - "ctrl+y must preserve the node under the viewport top") + assert.Equal(t, emailYAMLLine, m.spec.CursorLine(), + "ctrl+y must preserve the node under the cursor") } func TestLinkSourceToSpec_NamesEachMiss(t *testing.T) { diff --git a/cmd/genspec-tui/internal/ux/model_join_test.go b/cmd/genspec-tui/internal/ux/model_join_test.go index 69c543ad..5440a4ed 100644 --- a/cmd/genspec-tui/internal/ux/model_join_test.go +++ b/cmd/genspec-tui/internal/ux/model_join_test.go @@ -8,7 +8,6 @@ import ( "os" "path/filepath" "strconv" - "strings" "testing" "github.com/charmbracelet/bubbles/textinput" @@ -61,17 +60,13 @@ const joinSpecJSON = `{ // 0-based rendered lines of the nodes these tests navigate to. const ( - joinLineAddress = 2 - joinLineCity = 4 - joinLineUser = 9 - joinLineEmail = 11 - joinLineEmailType = 12 // NOT anchored — resolves up to the email property - joinLineManager = 14 - joinLineManagerRef = 15 // NOT anchored — resolves up to the manager property - joinSpecTotalLines = 21 - joinViewportHeight = 7 // panel height 10 minus border+title - joinSpecMaxYOffset = joinSpecTotalLines - joinViewportHeight - joinFollowerContext = joinViewportHeight / 2 // HighlightLine centres (S8) + joinLineAddress = 2 + joinLineCity = 4 + joinLineUser = 9 + joinLineEmail = 11 + joinLineEmailType = 12 // NOT anchored — resolves up to the email property + joinLineManager = 14 + joinLineManagerRef = 15 // NOT anchored — resolves up to the manager property ) // The synthesized source. Anchors point at the 1-based lines noted alongside. @@ -148,22 +143,10 @@ func newJoinFixture(t *testing.T) joinFixture { } } -// wantFollowerTop is where the spec viewport ends up once it has centred on -// line (S8: a jump centres, clamped at both edges). -func wantFollowerTop(line int) int { - return wantFollowerTopIn(line, joinSpecTotalLines) -} - -// wantFollowerTopIn is the same for a render of a different height — the bottom -// clamp depends on the total line count, which changes when the spec does. -func wantFollowerTopIn(line, totalLines int) int { - return clampInt(line-joinFollowerContext, 0, max(totalLines-joinViewportHeight, 0)) -} - -// driveSpec parks the spec viewport so that `line` is the top-of-viewport node -// (what driveSpecToSource reads) and re-mirrors the follower. +// driveSpec moves the spec cursor to `line` (what driveSpecToSource reads) and +// re-mirrors the follower. func (f joinFixture) driveSpec(line int) { - f.m.spec.ScrollTo(line) + f.m.spec.SetCursor(line) f.m.syncFollowIfActive() } @@ -194,7 +177,7 @@ func TestJoin_SpecIndexMatchesFixture(t *testing.T) { func TestJoin_SpecToSource_LandsOnTheAnchoredLine(t *testing.T) { f := newJoinFixture(t) f.m.focused = paneSpec - f.m.spec.ScrollTo(joinLineEmail) + f.m.spec.SetCursor(joinLineEmail) f.m.toggleFollow(followSpec) @@ -225,7 +208,7 @@ func TestJoin_SpecToSource_NearestAncestor(t *testing.T) { func TestJoin_SpecToSource_SwitchesFileWhenTheTargetMoves(t *testing.T) { f := newJoinFixture(t) f.m.focused = paneSpec - f.m.spec.ScrollTo(joinLineEmail) + f.m.spec.SetCursor(joinLineEmail) f.m.toggleFollow(followSpec) require.Equal(t, f.userGo, f.m.currentFile) @@ -269,7 +252,7 @@ func TestJoin_SourceToSpec_LandsOnTheProducedNode(t *testing.T) { require.Equal(t, followSource, f.m.follow) assert.Equal(t, "/definitions/User/properties/email", f.m.followTarget) - assert.Equal(t, wantFollowerTop(joinLineEmail), f.m.spec.TopLine(), + assert.Equal(t, joinLineEmail, f.m.spec.CursorLine(), "the spec follower centred on the produced node") } @@ -284,7 +267,7 @@ func TestJoin_SourceToSpec_NearestEnclosing(t *testing.T) { assert.Equal(t, "/definitions/User/properties/manager", f.m.followTarget, "an unanchored line resolves to the nearest enclosing anchor") - assert.Equal(t, wantFollowerTop(joinLineManager), f.m.spec.TopLine()) + assert.Equal(t, joinLineManager, f.m.spec.CursorLine()) } func TestJoin_SourceToSpec_TracksTheDriver(t *testing.T) { @@ -305,7 +288,7 @@ func TestJoin_SourceToSpec_TracksTheDriver(t *testing.T) { } { f.driveSource(step.srcLine) assert.Equal(t, step.wantPtr, f.m.followTarget, "source line %d", step.srcLine) - assert.Equal(t, wantFollowerTop(step.wantSpec), f.m.spec.TopLine(), "source line %d", step.srcLine) + assert.Equal(t, step.wantSpec, f.m.spec.CursorLine(), "source line %d", step.srcLine) } } @@ -333,7 +316,7 @@ func TestJoin_FollowSurvivesRescan(t *testing.T) { f.m.focused, f.m.leftMode = paneTree, modeView f.m.fileView.GotoLine(joinSrcEmail - 1) f.m.toggleFollow(followSource) - require.Equal(t, wantFollowerTop(joinLineEmail), f.m.spec.TopLine()) + require.Equal(t, joinLineEmail, f.m.spec.CursorLine()) // The same spec with a definition inserted ABOVE User, so every User node // shifts down. A stale line number would now point at the wrong node. @@ -378,7 +361,7 @@ func TestJoin_FollowSurvivesRescan(t *testing.T) { require.NotEqual(t, joinLineEmail, newLine, "precondition: the node moved in the new render") assert.Equal(t, "/definitions/User/properties/email", f.m.followTarget) - assert.Equal(t, wantFollowerTopIn(newLine, strings.Count(grown, "\n")+1), f.m.spec.TopLine(), + assert.Equal(t, newLine, f.m.spec.CursorLine(), "follow re-resolved against the rebuilt index") } @@ -402,7 +385,7 @@ func TestJoin_FollowExits(t *testing.T) { t.Helper() f := newJoinFixture(t) f.m.focused, f.m.leftMode = paneSpec, modeBrowse - f.m.spec.ScrollTo(joinLineEmail) + f.m.spec.SetCursor(joinLineEmail) f.m.toggleFollow(followSpec) require.Equal(t, followSpec, f.m.follow) return f diff --git a/cmd/genspec-tui/internal/ux/model_refs_test.go b/cmd/genspec-tui/internal/ux/model_refs_test.go index 16d29619..0d6f6979 100644 --- a/cmd/genspec-tui/internal/ux/model_refs_test.go +++ b/cmd/genspec-tui/internal/ux/model_refs_test.go @@ -16,9 +16,8 @@ import ( // D2 — find-references cycling and go-to-definition, through the model. // -// The spec pane has no line cursor, so "the node under the cursor" means the -// node at the TOP of the viewport — the same convention `f` follow and the -// status line already use. Tests park the viewport with ScrollTo accordingly. +// The spec pane carries a real line cursor, so "the node under the cursor" means +// exactly that. Tests park it with SetCursor and assert on CursorLine. // refModelSpec references /definitions/User from three places and carries one // external ref. Line numbers below are load-bearing. @@ -64,14 +63,12 @@ const refModelSpec = `{ }` const ( - rmLineLead = 5 - rmLineLogo = 8 - rmLineItemsRef = 12 - rmLineUserDecl = 18 - rmLineUserName = 20 - rmLineRespRef = 32 - rmViewportH = 7 // panel height 10 minus border + title - rmSpecTotalLine = 40 + rmLineLead = 5 + rmLineLogo = 8 + rmLineItemsRef = 12 + rmLineUserDecl = 18 + rmLineUserName = 20 + rmLineRespRef = 32 ) func newRefModel(t *testing.T) *Model { @@ -90,11 +87,6 @@ func newRefModel(t *testing.T) *Model { return m } -// wantRefTop mirrors HighlightLine's centring for this fixture's geometry. -func wantRefTop(line int) int { - return clampInt(line-rmViewportH/2, 0, max(rmSpecTotalLine-rmViewportH, 0)) -} - func TestRefs_FixtureLines(t *testing.T) { m := newRefModel(t) @@ -114,33 +106,33 @@ func TestRefs_FixtureLines(t *testing.T) { func TestRefs_CycleForward(t *testing.T) { m := newRefModel(t) - m.spec.ScrollTo(rmLineUserDecl) // park on the definition + m.spec.SetCursor(rmLineUserDecl) // park on the definition require.Nil(t, m.cycleRefs(+1)) - assert.Equal(t, wantRefTop(rmLineLead), m.spec.TopLine(), "first use") + assert.Equal(t, rmLineLead, m.spec.CursorLine(), "first use") assert.Contains(t, m.refStatus, "ref 1/3") assert.Contains(t, m.refStatus, "/definitions/Team/properties/lead") require.Nil(t, m.cycleRefs(+1)) - assert.Equal(t, wantRefTop(rmLineItemsRef), m.spec.TopLine(), "second use") + assert.Equal(t, rmLineItemsRef, m.spec.CursorLine(), "second use") assert.Contains(t, m.refStatus, "ref 2/3") require.Nil(t, m.cycleRefs(+1)) - assert.Equal(t, wantRefTop(rmLineRespRef), m.spec.TopLine(), "third use") + assert.Equal(t, rmLineRespRef, m.spec.CursorLine(), "third use") assert.Contains(t, m.refStatus, "ref 3/3") // Wraps back to the first. require.Nil(t, m.cycleRefs(+1)) - assert.Equal(t, wantRefTop(rmLineLead), m.spec.TopLine(), "wrapped") + assert.Equal(t, rmLineLead, m.spec.CursorLine(), "wrapped") assert.Contains(t, m.refStatus, "ref 1/3") } func TestRefs_CycleBackwardEntersAtTheLastSite(t *testing.T) { m := newRefModel(t) - m.spec.ScrollTo(rmLineUserDecl) + m.spec.SetCursor(rmLineUserDecl) require.Nil(t, m.cycleRefs(-1)) - assert.Equal(t, wantRefTop(rmLineRespRef), m.spec.TopLine(), + assert.Equal(t, rmLineRespRef, m.spec.CursorLine(), "a backward step into a fresh cycle enters at the last site") assert.Contains(t, m.refStatus, "ref 3/3") @@ -152,7 +144,7 @@ func TestRefs_CycleBackwardEntersAtTheLastSite(t *testing.T) { // definition resolves up to it, the way the rest of the tool resolves pointers. func TestRefs_CycleFromInsideTheDefinition(t *testing.T) { m := newRefModel(t) - m.spec.ScrollTo(rmLineUserName) // /definitions/User/properties/name + m.spec.SetCursor(rmLineUserName) // /definitions/User/properties/name require.Nil(t, m.cycleRefs(+1)) assert.Contains(t, m.refStatus, "ref 1/3 of /definitions/User", @@ -163,12 +155,12 @@ func TestRefs_CycleFromInsideTheDefinition(t *testing.T) { // is, rather than continuing to walk the previous definition's uses. func TestRefs_ScrollingAwayStartsAFreshCycle(t *testing.T) { m := newRefModel(t) - m.spec.ScrollTo(rmLineUserDecl) + m.spec.SetCursor(rmLineUserDecl) require.Nil(t, m.cycleRefs(+1)) require.Contains(t, m.refStatus, "ref 1/3") // The user scrolls to a node nothing references. - m.spec.ScrollTo(rmLineLogo) + m.spec.SetCursor(rmLineLogo) require.NotNil(t, m.cycleRefs(+1), "a failed start returns the notice-clearing cmd") assert.Contains(t, m.notice, "nothing references") assert.Empty(t, m.refStatus, @@ -180,7 +172,7 @@ func TestRefs_ScrollingAwayStartsAFreshCycle(t *testing.T) { // miss from "this node has no references", and it must say so. func TestRefs_CycleOnAnUnindexedLine(t *testing.T) { m := newRefModel(t) - m.spec.ScrollTo(0) + m.spec.SetCursor(0) require.NotNil(t, m.cycleRefs(+1)) assert.Equal(t, noNodeDesc, m.notice) @@ -189,7 +181,7 @@ func TestRefs_CycleOnAnUnindexedLine(t *testing.T) { func TestRefs_NothingReferencesTheNode(t *testing.T) { m := newRefModel(t) - m.spec.ScrollTo(rmLineLogo) // the external-ref property: nothing points here + m.spec.SetCursor(rmLineLogo) // the external-ref property: nothing points here require.NotNil(t, m.cycleRefs(+1)) assert.Contains(t, m.notice, "nothing references") @@ -201,7 +193,7 @@ func TestRefs_NothingReferencesTheNode(t *testing.T) { func TestRefs_KeysAreSpecPaneOnly(t *testing.T) { for _, p := range []pane{paneTree, paneDiag} { m := newRefModel(t) - m.spec.ScrollTo(rmLineUserDecl) + m.spec.SetCursor(rmLineUserDecl) m.focused = p for _, msg := range []tea.KeyMsg{ @@ -228,7 +220,7 @@ func TestRefs_KeyBindings(t *testing.T) { } { t.Run(c.name, func(t *testing.T) { m := newRefModel(t) - m.spec.ScrollTo(rmLineUserDecl) + m.spec.SetCursor(rmLineUserDecl) _, _ = m.handleKey(c.msg) @@ -239,11 +231,11 @@ func TestRefs_KeyBindings(t *testing.T) { func TestRefs_GotoDefinition(t *testing.T) { m := newRefModel(t) - m.spec.ScrollTo(rmLineLead) // a local $ref + m.spec.SetCursor(rmLineLead) // a local $ref _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) - assert.Equal(t, wantRefTop(rmLineUserDecl), m.spec.TopLine(), + assert.Equal(t, rmLineUserDecl, m.spec.CursorLine(), "enter followed the $ref to its definition") assert.Equal(t, "→ /definitions/User", m.notice) } @@ -254,39 +246,93 @@ func TestRefs_GotoDefinition(t *testing.T) { // definition) reports "no $ref on this line". func TestRefs_CycleThenGotoDefinition(t *testing.T) { m := newRefModel(t) - m.spec.ScrollTo(rmLineUserDecl) + m.spec.SetCursor(rmLineUserDecl) _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) require.Contains(t, m.refStatus, "ref 1/3") require.NotEqual(t, rmLineLead, m.spec.TopLine(), "precondition: the jump centred, so TopLine is NOT the target line") - require.Equal(t, rmLineLead, m.specCursorLine(), "but the cursor is") + require.Equal(t, rmLineLead, m.spec.CursorLine(), "but the cursor is") _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) assert.Equal(t, "→ /definitions/User", m.notice) - assert.Equal(t, rmLineUserDecl, m.specCursorLine(), + assert.Equal(t, rmLineUserDecl, m.spec.CursorLine(), "and the definition we landed on becomes the new cursor, so Enter can chain") } -// Scrolling invalidates the jump target implicitly — no bookkeeping on the -// scroll path, which the pane could not hook anyway. -func TestRefs_ScrollingDropsTheJumpCursor(t *testing.T) { +// Moving the cursor off the site the cycle parked it on ends the cycle: the +// next F3 asks about the node the user is now on. +func TestRefs_MovingTheCursorEndsTheCycle(t *testing.T) { m := newRefModel(t) - m.spec.ScrollTo(rmLineUserDecl) + m.spec.SetCursor(rmLineUserDecl) + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) + require.Equal(t, rmLineLead, m.spec.CursorLine()) + require.Contains(t, m.refStatus, "ref 1/3") + + // One line down and we are off the site, so the cycle cannot continue. + // (That line is inside Team, which nothing references — hence no new cycle.) + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyDown}) + require.Equal(t, rmLineLead+1, m.spec.CursorLine()) + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) + assert.NotContains(t, m.refStatus, "ref 2/3", "the cycle did not continue") + assert.Contains(t, m.notice, "nothing references") + + // Park inside User again and F3 re-anchors there, from the first site. + m.spec.SetCursor(rmLineUserName) _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) - require.Equal(t, rmLineLead, m.specCursorLine()) + assert.Contains(t, m.refStatus, "ref 1/3 of /definitions/User", + "a fresh cycle, re-anchored on the node now under the cursor") +} + +// The spec pane is navigable in its own right: the cursor keys move the cursor, +// and paging moves it with the view rather than leaving it behind off screen. +func TestSpecNav_CursorKeys(t *testing.T) { + m := newRefModel(t) + m.topH = 10 // gives handleSpecNav a page size + m.spec.SetCursor(rmLineUserDecl) + + for _, c := range []struct { + name string + msg tea.KeyMsg + want int + }{ + {"down", tea.KeyMsg{Type: tea.KeyDown}, rmLineUserDecl + 1}, + {"j", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}, rmLineUserDecl + 2}, + {"up", tea.KeyMsg{Type: tea.KeyUp}, rmLineUserDecl + 1}, + {"k", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}, rmLineUserDecl}, + {"home", tea.KeyMsg{Type: tea.KeyHome}, 0}, + {"end", tea.KeyMsg{Type: tea.KeyEnd}, m.spec.LastLine()}, + } { + t.Run(c.name, func(t *testing.T) { + _, _ = m.handleKey(c.msg) + assert.Equal(t, c.want, m.spec.CursorLine()) + }) + } + + // Paging moves the cursor too, so F3/Enter never act on an off-screen node. + m.spec.SetCursor(0) + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyPgDown}) + assert.Positive(t, m.spec.CursorLine(), "page down carried the cursor with it") +} + +// The nav keys belong to the spec pane only — j/k must still drive the tree and +// the diagnostics list. +func TestSpecNav_OnlyFromTheSpecPane(t *testing.T) { + m := newRefModel(t) + m.spec.SetCursor(rmLineUserDecl) + m.focused = paneDiag - m.spec.ScrollTo(rmLineUserName) + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyDown}) - assert.Equal(t, rmLineUserName, m.specCursorLine(), - "once the user scrolls, the viewport top is 'here' again") + assert.Equal(t, rmLineUserDecl, m.spec.CursorLine(), "the spec cursor did not move") } func TestRefs_GotoDefinitionEdges(t *testing.T) { t.Run("external ref is reported, not guessed at", func(t *testing.T) { m := newRefModel(t) - m.spec.ScrollTo(rmLineLogo) + m.spec.SetCursor(rmLineLogo) before := m.spec.TopLine() _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) @@ -298,7 +344,7 @@ func TestRefs_GotoDefinitionEdges(t *testing.T) { t.Run("no $ref on this line", func(t *testing.T) { m := newRefModel(t) - m.spec.ScrollTo(rmLineUserDecl) + m.spec.SetCursor(rmLineUserDecl) _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) @@ -313,7 +359,7 @@ func TestRefs_CycleResets(t *testing.T) { t.Helper() m := newRefModel(t) m.specYAML = "definitions:\n Team:\n properties:\n lead:\n $ref: '#/definitions/User'\n User: {}\n" - m.spec.ScrollTo(rmLineUserDecl) + m.spec.SetCursor(rmLineUserDecl) require.Nil(t, m.cycleRefs(+1)) require.NotEmpty(t, m.refStatus) return m diff --git a/cmd/genspec-tui/internal/ux/model_rescan_test.go b/cmd/genspec-tui/internal/ux/model_rescan_test.go new file mode 100644 index 00000000..fbd41e28 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_rescan_test.go @@ -0,0 +1,214 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "testing" + + "github.com/charmbracelet/bubbles/textinput" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// B-rescan-anchor — a re-render must keep the user on the same NODE. +// +// This is the hot path: every save triggers a rescan, and live-reload is the +// tool's reason to exist. Carrying the raw line number across would slide the +// user to a different node whenever the spec gained or lost lines above them. + +// rescanBase is the starting render. +const rescanBase = `{ + "definitions": { + "Address": { + "properties": { + "city": { + "type": "string" + } + } + }, + "User": { + "properties": { + "name": { + "type": "string" + } + } + } + } +}` + +// rescanGrown is the same spec with a definition inserted ABOVE both, so every +// node below shifts down. +const rescanGrown = `{ + "definitions": { + "AAA": { + "properties": { + "zzz": { + "type": "string" + } + } + }, + "Address": { + "properties": { + "city": { + "type": "string" + } + } + }, + "User": { + "properties": { + "name": { + "type": "string" + } + } + } + } +}` + +// rescanShrunk drops User entirely — the type was deleted. +const rescanShrunk = `{ + "definitions": { + "Address": { + "properties": { + "city": { + "type": "string" + } + } + } + } +}` + +func newRescanModel(t *testing.T) *Model { + t.Helper() + m := &Model{ + spec: panels.NewSpec(), + fileView: panels.NewFileView(), + searchInput: textinput.New(), + } + // Tall enough that a node shifted by a few lines is still on screen — + // otherwise "did it avoid scrolling?" cannot be asked. + m.spec.SetSize(60, 20) + m.fileView.SetSize(60, 20) + m.focused = paneSpec + m.specJSON = rescanBase + m.refreshSpec() + + return m +} + +// parkOn puts the cursor on a pointer and returns the line it was on. +func parkOn(t *testing.T, m *Model, ptr string) int { + t.Helper() + line, ok := m.specIndex.LineForPointer(ptr) + require.True(t, ok, "pointer %q must be in the render", ptr) + m.spec.SetCursor(line) + + return line +} + +func TestRescan_KeepsTheCursorOnTheSameNode(t *testing.T) { + m := newRescanModel(t) + const ptr = "/definitions/User" + before := parkOn(t, m, ptr) + + // A rescan whose spec gained a definition above the one being read. + m.specJSON = rescanGrown + m.refreshSpec() + + after, ok := m.specIndex.LineForPointer(ptr) + require.True(t, ok) + require.NotEqual(t, before, after, "precondition: the node moved in the new render") + + assert.Equal(t, after, m.spec.CursorLine(), + "the cursor followed the node, not the line number") +} + +func TestRescan_ViaScanResultMessage(t *testing.T) { + m := newRescanModel(t) + const ptr = "/definitions/User/properties/name" + parkOn(t, m, ptr) + + // The real path a scan arrives by. + _, _ = m.Update(scanResultMsg{json: rescanGrown}) + + after, ok := m.specIndex.LineForPointer(ptr) + require.True(t, ok) + assert.Equal(t, after, m.spec.CursorLine()) +} + +// When the node is gone, land in its neighbourhood rather than somewhere +// arbitrary: the walk falls back to the nearest surviving ancestor. +func TestRescan_DeletedNodeFallsBackToItsAncestor(t *testing.T) { + m := newRescanModel(t) + parkOn(t, m, "/definitions/User/properties/name") + + m.specJSON = rescanShrunk + m.refreshSpec() + + _, gone := m.specIndex.LineForPointer("/definitions/User") + require.False(t, gone, "precondition: User was deleted") + + definitionsLine, ok := m.specIndex.LineForPointer("/definitions") + require.True(t, ok) + assert.Equal(t, definitionsLine, m.spec.CursorLine(), + "fell back to the nearest ancestor that survived") +} + +// An unchanged rescan — the common case, since most saves do not move anything +// — must not move the cursor at all. +func TestRescan_IdenticalSpecDoesNotMoveTheCursor(t *testing.T) { + m := newRescanModel(t) + before := parkOn(t, m, "/definitions/User") + topBefore := m.spec.TopLine() + + m.refreshSpec() + + assert.Equal(t, before, m.spec.CursorLine()) + assert.Equal(t, topBefore, m.spec.TopLine(), + "and the viewport did not jump either") +} + +// The restore scrolls minimally rather than centring: on the hot path, yanking +// the viewport on every save would be worse than the drift it fixes. +func TestRescan_DoesNotYankTheViewport(t *testing.T) { + m := newRescanModel(t) + parkOn(t, m, "/definitions/User") + topBefore := m.spec.TopLine() + + m.specJSON = rescanGrown + m.refreshSpec() + + // The node moved a few lines but is still on screen, so the view should be + // steady — not recentred on the cursor. + assert.Equal(t, topBefore, m.spec.TopLine(), + "the node was still visible, so nothing needed to scroll") +} + +// ...whereas an explicit format switch is a deliberate change of view, and does +// recentre. +func TestRescan_FormatSwitchRecentres(t *testing.T) { + m := newRescanModel(t) + m.specYAML = "definitions:\n Address:\n properties:\n city:\n type: string\n User:\n properties:\n name:\n type: string\n" + parkOn(t, m, "/definitions/User") + + m.setSpecFormat("YAML") + + line, ok := m.specIndex.LineForPointer("/definitions/User") + require.True(t, ok) + assert.Equal(t, line, m.spec.CursorLine(), "same node") + assert.Equal(t, max(line-(20-3)/2, 0), m.spec.TopLine(), "centred in the viewport") +} + +// The first scan has no previous index to anchor against, and must not panic or +// jump anywhere. +func TestRescan_FirstScanStartsAtTheTop(t *testing.T) { + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.spec.SetSize(60, 20) + + m.specJSON = rescanBase + m.refreshSpec() + + assert.Equal(t, 0, m.spec.CursorLine()) +} diff --git a/cmd/genspec-tui/internal/ux/panels/gutter_test.go b/cmd/genspec-tui/internal/ux/panels/gutter_test.go index 53fed7eb..ac97e3d2 100644 --- a/cmd/genspec-tui/internal/ux/panels/gutter_test.go +++ b/cmd/genspec-tui/internal/ux/panels/gutter_test.go @@ -21,11 +21,12 @@ func TestSpec_GutterMarksOnlyTheGivenLines(t *testing.T) { sp.SetContent("aaa\nbbb\nccc\nddd") sp.SetGutter(map[int]rune{1: GutterAnchor, 2: GutterRef}) + sp.SetCursor(0) // keep the cursor off the lines under test view := sp.vp.View() require.Contains(t, view, gutterMark(GutterAnchor)+" bbb") require.Contains(t, view, gutterMark(GutterRef)+" ccc") - assert.Contains(t, view, strings.Repeat(" ", gutterWidth)+"aaa", + assert.Contains(t, view, strings.Repeat(" ", gutterWidth)+"ddd", "unmarked lines are padded so the text stays aligned") } @@ -37,42 +38,43 @@ func TestSpec_NoGutterCostsNoWidth(t *testing.T) { sp.SetContent("aaa\nbbb") // The viewport pads each line to its width, so compare prefixes: the text - // must start in column 0, not two columns in. - firstLine := func() string { return strings.SplitN(sp.vp.View(), "\n", 2)[0] } + // must start in column 0, not two columns in. Line 1 is used throughout so + // the always-rendered cursor (line 0) does not wrap the line under test. + sp.SetCursor(0) + secondLine := func() string { return strings.Split(sp.vp.View(), "\n")[1] } - assert.True(t, strings.HasPrefix(firstLine(), "aaa"), - "content starts in column 0 when nothing is marked, got %q", firstLine()) + assert.True(t, strings.HasPrefix(secondLine(), "bbb"), + "content starts in column 0 when nothing is marked, got %q", secondLine()) sp.SetGutter(nil) - assert.True(t, strings.HasPrefix(firstLine(), "aaa"), - "an explicitly nil gutter costs no width either, got %q", firstLine()) + assert.True(t, strings.HasPrefix(secondLine(), "bbb"), + "an explicitly nil gutter costs no width either, got %q", secondLine()) // ...whereas installing one shifts the text right by the gutter width. - sp.SetGutter(map[int]rune{1: GutterAnchor}) - assert.True(t, strings.HasPrefix(firstLine(), strings.Repeat(" ", gutterWidth)+"aaa"), - "got %q", firstLine()) + sp.SetGutter(map[int]rune{0: GutterAnchor}) + assert.True(t, strings.HasPrefix(secondLine(), strings.Repeat(" ", gutterWidth)+"bbb"), + "got %q", secondLine()) } // The gutter is prefixed after highlighting, so both survive together and the // styles still apply to the text rather than to the marker column. -func TestSpec_GutterCoexistsWithSearchAndXref(t *testing.T) { +func TestSpec_GutterCoexistsWithSearchAndCursor(t *testing.T) { sp := NewSpec() sp.SetSize(40, 12) - sp.SetContent("aaa\nbbb\nccc") - sp.SetGutter(map[int]rune{0: GutterAnchor, 1: GutterAnchor}) + sp.SetContent("aaa\nbbb\nbcd") + sp.SetGutter(map[int]rune{0: GutterAnchor, 2: GutterAnchor}) - n := sp.Search("bbb") - require.Equal(t, 1, n, "the gutter must not disturb match counting") + n := sp.Search("b") + require.Equal(t, 2, n, "the gutter must not disturb match counting") + require.Equal(t, 1, sp.CursorLine(), "the search parked the cursor on the first match") - view := sp.vp.View() + view := sp.View(true) assert.Contains(t, view, gutterMark(GutterAnchor), "markers survive a search render") - assert.Contains(t, view, theme.Match().Render("bbb"), "the match is still highlighted") - - sp.MarkLine(2) - view = sp.View(true) - assert.Contains(t, view, gutterMark(GutterAnchor)) - assert.Contains(t, view, theme.Selected().Render("ccc"), - "the xref style wraps the text, not the gutter") + assert.Contains(t, view, theme.Match().Render("b"), + "a match the cursor is NOT on keeps its substring highlight") + assert.Contains(t, view, theme.Selected().Render("bbb"), + "the cursor line takes the whole-line bar, and the style wraps the text "+ + "rather than the gutter") } func TestFileView_GutterMarksAnchoredLines(t *testing.T) { diff --git a/cmd/genspec-tui/internal/ux/panels/navvisuals_test.go b/cmd/genspec-tui/internal/ux/panels/navvisuals_test.go index 30d1fb9f..163836fd 100644 --- a/cmd/genspec-tui/internal/ux/panels/navvisuals_test.go +++ b/cmd/genspec-tui/internal/ux/panels/navvisuals_test.go @@ -38,15 +38,15 @@ func TestSpec_XrefStyleFollowsFocus(t *testing.T) { sp := NewSpec() sp.SetSize(40, 12) sp.SetContent("aaa\nbbb\nccc\nddd") - sp.MarkLine(1) + sp.SetCursor(1) // The driver pane keeps focus in follow mode, so focused == drives. _ = sp.View(true) - assert.Equal(t, theme.Selected().GetBackground(), sp.xrefStyle().GetBackground(), + assert.Equal(t, theme.Selected().GetBackground(), sp.cursorStyle().GetBackground(), "a focused spec pane paints its xref line as the driver") _ = sp.View(false) - assert.Equal(t, theme.Follower().GetBackground(), sp.xrefStyle().GetBackground(), + assert.Equal(t, theme.Follower().GetBackground(), sp.cursorStyle().GetBackground(), "an unfocused spec pane is mirroring, so its xref line is a follower") } @@ -57,7 +57,7 @@ func TestSpec_FocusChangeRepaints(t *testing.T) { sp := NewSpec() sp.SetSize(40, 12) sp.SetContent("aaa\nbbb\nccc\nddd") - sp.MarkLine(1) + sp.SetCursor(1) driverView := sp.View(true) followerView := sp.View(false) @@ -110,10 +110,10 @@ func TestSpec_HighlightLineCenters(t *testing.T) { sp.SetSize(40, 13) // viewport height = 10 sp.SetContent(numberedContent(60)) - sp.HighlightLine(30) + sp.JumpTo(30) assert.Equal(t, 25, sp.TopLine(), "target - height/2") - sp.HighlightLine(2) + sp.JumpTo(2) assert.Equal(t, 0, sp.TopLine(), "clamped at the top rather than scrolling negative") } diff --git a/cmd/genspec-tui/internal/ux/panels/spec.go b/cmd/genspec-tui/internal/ux/panels/spec.go index 01c2c9a5..57508c73 100644 --- a/cmd/genspec-tui/internal/ux/panels/spec.go +++ b/cmd/genspec-tui/internal/ux/panels/spec.go @@ -26,8 +26,8 @@ type Spec struct { matches []int // indices of content lines containing the query matchIdx int - xrefLine int // content line highlighted by cross-ref follow, -1 for none - focused bool // last focus state seen by View; picks the xref line's style + cursor int // 0-based content line the user is on + focused bool // last focus state seen by View; picks the cursor's style gutter map[int]rune // content line → link marker; nil renders no gutter at all } @@ -50,7 +50,7 @@ func NewSpec() Spec { const placeholder = "(no spec generated yet)" vp := viewport.New(0, 0) vp.SetContent(placeholder) - return Spec{vp: vp, format: "JSON", content: placeholder, xrefLine: -1} + return Spec{vp: vp, format: "JSON", content: placeholder} } // SetSize fits the panel to outer dimensions w×h (border + title reserved). @@ -66,24 +66,29 @@ func (p *Spec) SetFormat(f string) { p.format = f } // Format returns the active render format label. func (p *Spec) Format() string { return p.format } -// SetContent replaces the raw spec text, re-applying any active search. A new -// spec invalidates any cross-ref highlight (the line may have moved). +// SetContent replaces the raw spec text, re-applying any active search. The +// cursor is CLAMPED, not reset: a rescan usually re-renders nearly the same +// document, and dropping the user back to line 0 on every save would make the +// live-reload loop unusable. Restoring it to the same NODE is the caller's job +// (see Model.refreshSpec). func (p *Spec) SetContent(s string) { p.content = s - p.xrefLine = -1 + p.cursor = clampSpec(p.cursor, 0, max(p.lineCount()-1, 0)) p.render() + p.revealCursor() } // Content returns the raw (unhighlighted) panel text, for clipboard copy. func (p *Spec) Content() string { return p.content } -// Search sets the query, highlights matching lines, scrolls to the first -// match, and returns the match count. A search supersedes any cross-ref -// highlight. +// Search sets the query, highlights matching lines, moves the cursor to the +// first match, and returns the match count. Putting the CURSOR on the match +// (rather than merely scrolling to it) means every cursor-driven action — +// follow, find-references, go-to-definition — acts on what you just searched +// for. func (p *Spec) Search(query string) int { p.query = query p.matchIdx = 0 - p.xrefLine = -1 p.render() if len(p.matches) > 0 { p.scrollToMatch() @@ -116,53 +121,78 @@ func (p *Spec) MatchInfo() (cur, total int) { return p.matchIdx + 1, len(p.matches) } -// TopLine returns the 0-based index of the top visible content line, for -// mapping the current scroll position to a spec node via the SpecIndex. +// scrollContext is how many lines of context to keep above a scrolled-to match. +const scrollContext = 2 + +// CursorLine returns the 0-based content line the user is on. This is what +// every "the node under the cursor" question resolves against. +func (p *Spec) CursorLine() int { return p.cursor } + +// TopLine returns the 0-based index of the top visible content line. func (p *Spec) TopLine() int { return p.vp.YOffset } -// scrollContext is how many lines of context to keep above a scrolled-to target. -const scrollContext = 2 +// LastLine is the index of the final content line. +func (p *Spec) LastLine() int { return max(p.lineCount()-1, 0) } -// MarkLine marks the 0-based content line as the cross-ref target (same style -// the source pane uses for its nav line) WITHOUT scrolling. Used when the spec -// is the driver pane (the user controls its scroll) and only the node mapping -// should be shown. -func (p *Spec) MarkLine(line int) { - if p.xrefLine == line { - return // already marked; avoid a re-render on every driver scroll - } - p.xrefLine = line - p.render() +// SetCursor parks the cursor on the 0-based line, scrolling only as far as +// needed to keep it visible. The incremental primitive. +func (p *Spec) SetCursor(line int) { + p.moveCursorTo(line) + p.revealCursor() } -// HighlightLine marks the line and scrolls it to the VERTICAL CENTRE of the -// viewport, clamped at the edges (design §6.1). Every cross-ref landing comes -// through here — follow-mode mirroring, `g` locate, the ctrl+f jump. +// MoveCursor steps the cursor by delta, scrolling minimally. Used by the nav +// keys and the wheel, where a lurching viewport would be miserable. +func (p *Spec) MoveCursor(delta int) { p.SetCursor(p.cursor + delta) } + +// JumpTo parks the cursor on the line and scrolls it to the VERTICAL CENTRE, +// clamped at the edges (design §6.1). The JUMP primitive: every cross-ref +// landing comes through here — follow-mode mirroring, `g` locate, the ctrl+f +// jump, F3 cycling, go-to-definition. // // Centring rather than the top-biased scroll search uses: in follow mode the // target moves continuously, so a top bias pins it against whichever edge it // entered from and makes it jitter, instead of letting it sit still while its // surroundings slide past. For a one-shot jump it simply shows context on both // sides of the destination. -func (p *Spec) HighlightLine(line int) { - p.vp.SetYOffset(max(line-p.vp.Height/2, 0)) - p.MarkLine(line) +func (p *Spec) JumpTo(line int) { + p.moveCursorTo(line) + p.vp.SetYOffset(max(p.cursor-p.vp.Height/2, 0)) } -// ScrollTo puts the 0-based content line at the TOP of the viewport without -// marking it. Used by the format toggle to restore the node the user was -// looking at, whose line number differs between the JSON and YAML renders. -func (p *Spec) ScrollTo(line int) { p.vp.SetYOffset(line) } - -// ClearHighlight drops the cross-ref highlight, if any. -func (p *Spec) ClearHighlight() { - if p.xrefLine == -1 { +// moveCursorTo clamps and sets the cursor, re-rendering only when it actually +// moved (the cursor style is baked into the viewport content). +func (p *Spec) moveCursorTo(line int) { + line = clampSpec(line, 0, max(p.lineCount()-1, 0)) + if line == p.cursor { return } - p.xrefLine = -1 + p.cursor = line p.render() } +// revealCursor scrolls the minimum distance that brings the cursor into view. +func (p *Spec) revealCursor() { + switch { + case p.cursor < p.vp.YOffset: + p.vp.SetYOffset(p.cursor) + case p.cursor >= p.vp.YOffset+p.vp.Height: + p.vp.SetYOffset(p.cursor - p.vp.Height + 1) + } +} + +func (p *Spec) lineCount() int { return strings.Count(p.content, "\n") + 1 } + +func clampSpec(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + // Update forwards a message to the underlying viewport (scrolling). func (p *Spec) Update(msg tea.Msg) tea.Cmd { var cmd tea.Cmd @@ -179,17 +209,15 @@ func (p *Spec) Update(msg tea.Msg) tea.Cmd { func (p *Spec) View(focused bool) string { if p.focused != focused { p.focused = focused - if p.xrefLine >= 0 { - p.render() - } + p.render() } title := theme.Title(focused).Render("spec · " + p.format) return theme.Panel(p.w, p.h, focused).Render(title + "\n" + p.vp.View()) } -// xrefStyle is the whole-line style for the cross-ref line: the strong bar when -// this pane drives, a muted tint when it is mirroring another pane. -func (p *Spec) xrefStyle() lipgloss.Style { +// cursorStyle is the whole-line style for the cursor: the strong bar when this +// pane drives, a muted tint when it is mirroring another pane. +func (p *Spec) cursorStyle() lipgloss.Style { if p.focused { return theme.Selected() } @@ -212,12 +240,6 @@ func (p *Spec) SetGutter(g map[int]rune) { // The gutter is prefixed AFTER highlighting, so the styles apply to the text the // user searched for, not to the marker column. func (p *Spec) render() { - if p.query == "" && p.xrefLine == -1 && len(p.gutter) == 0 { - p.matches = nil - p.vp.SetContent(p.content) - return - } - needle := "" if p.query != "" { needle = strings.ToLower(p.query) @@ -230,8 +252,8 @@ func (p *Spec) render() { p.matches = append(p.matches, i) } switch { - case i == p.xrefLine: - ln = p.xrefStyle().Render(ln) + case i == p.cursor: + ln = p.cursorStyle().Render(ln) case isMatch: ln = highlightAll(ln, p.query) } @@ -258,8 +280,10 @@ func (p *Spec) scrollToMatch() { if len(p.matches) == 0 { return } - // keep a little context above the match - p.vp.SetYOffset(max(p.matches[p.matchIdx]-2, 0)) + p.moveCursorTo(p.matches[p.matchIdx]) + // keep a little context above the match, rather than centring: when + // stepping matches you want to see the ones that follow. + p.vp.SetYOffset(max(p.cursor-scrollContext, 0)) } // highlightAll wraps every case-insensitive occurrence of query in line with diff --git a/cmd/genspec-tui/internal/ux/panels/spec_test.go b/cmd/genspec-tui/internal/ux/panels/spec_test.go index 929ecf12..6fd99d0d 100644 --- a/cmd/genspec-tui/internal/ux/panels/spec_test.go +++ b/cmd/genspec-tui/internal/ux/panels/spec_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" ) func newLoadedSpec() Spec { @@ -16,33 +17,63 @@ func newLoadedSpec() Spec { return sp } -func TestSpec_HighlightLine(t *testing.T) { +func TestSpec_CursorStartsAtTheTop(t *testing.T) { + sp := newLoadedSpec() + assert.Equal(t, 0, sp.CursorLine(), "a fresh spec puts the cursor on the first line") +} + +func TestSpec_JumpToMovesTheCursor(t *testing.T) { + sp := newLoadedSpec() + + sp.JumpTo(2) + + assert.Equal(t, 2, sp.CursorLine()) + assert.Contains(t, sp.Content(), "\"b\": 2", + "raw content is unchanged — the cursor is view-only") +} + +func TestSpec_CursorClamps(t *testing.T) { + sp := newLoadedSpec() // 5 lines + + sp.SetCursor(99) + assert.Equal(t, sp.LastLine(), sp.CursorLine(), "clamped at the last line") + + sp.SetCursor(-5) + assert.Equal(t, 0, sp.CursorLine(), "clamped at the first") + + sp.MoveCursor(+2) + assert.Equal(t, 2, sp.CursorLine()) + sp.MoveCursor(-99) + assert.Equal(t, 0, sp.CursorLine()) +} + +// Searching parks the cursor ON the match, so that follow, find-references and +// go-to-definition all act on what was just searched for. +func TestSpec_SearchMovesTheCursorToTheMatch(t *testing.T) { sp := newLoadedSpec() - assert.Equal(t, -1, sp.xrefLine, "no highlight on a fresh spec") - sp.HighlightLine(2) - assert.Equal(t, 2, sp.xrefLine, "cross-ref highlight set") - assert.Contains(t, sp.Content(), "\"b\": 2", "raw content is unchanged (highlight is view-only)") + require.Equal(t, 1, sp.Search("b")) - sp.ClearHighlight() - assert.Equal(t, -1, sp.xrefLine, "highlight cleared") + assert.Equal(t, 2, sp.CursorLine(), `the line holding "b": 2`) } -func TestSpec_HighlightInvalidatedBySearchAndContent(t *testing.T) { +// New content CLAMPS the cursor rather than resetting it: a rescan re-renders +// nearly the same document, and dropping to line 0 on every save would make the +// live-reload loop unusable. Restoring the same NODE is the caller's job. +func TestSpec_SetContentClampsRatherThanResets(t *testing.T) { sp := newLoadedSpec() + sp.JumpTo(3) - sp.HighlightLine(1) - sp.Search("b") - assert.Equal(t, -1, sp.xrefLine, "a search supersedes the cross-ref highlight") + sp.SetContent("{\n \"x\": 9,\n \"y\": 8\n}") + assert.Equal(t, 3, sp.CursorLine(), "still in range, so kept") - sp.HighlightLine(3) - sp.SetContent("{\n \"x\": 9\n}") - assert.Equal(t, -1, sp.xrefLine, "new content invalidates the highlight") + sp.SetContent("{\n}") + assert.Equal(t, 1, sp.CursorLine(), "clamped into the shorter document") } func TestSpec_RenderPreservesContent(t *testing.T) { sp := newLoadedSpec() - sp.HighlightLine(2) // forces the styled render path + sp.JumpTo(2) // forces the styled render path // The viewport render must still show every source line. view := sp.vp.View() for _, want := range []string{"\"a\": 1", "\"b\": 2", "\"c\": 3"} { From f4d9bb4f707ddd4da25e6180227bd868af8b4a6d Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Thu, 30 Jul 2026 19:34:34 +0200 Subject: [PATCH 13/13] chore: addd go.work for monorepo Signed-off-by: Frederic BIDON --- go.work | 1 + 1 file changed, 1 insertion(+) diff --git a/go.work b/go.work index 2b3afe54..999734d3 100644 --- a/go.work +++ b/go.work @@ -10,4 +10,5 @@ use ( . ./cmd/genspec-tui ./docs/examples + ./fixtures )