From 75f9219e2c1bbbe36d1066eb7caa339f4cd97d0f Mon Sep 17 00:00:00 2001 From: Dolev Hadar Date: Mon, 20 Apr 2026 22:19:18 +0300 Subject: [PATCH 1/2] feat(diffviewer): use viewport by robinovitch61 WIP chore: upgrade package --- go.mod | 41 ++-- go.sum | 74 +++---- pkg/ui/common/scrollbar.go | 54 ++++-- pkg/ui/common/scrollbar_test.go | 220 +++++++++++++++++++++ pkg/ui/keys.go | 84 +++++--- pkg/ui/panes/diffviewer/diffviewer.go | 266 +++++++++++++++++++++----- pkg/ui/tui.go | 129 ++++++++----- pkg/ui/tui_test.go | 30 +-- 8 files changed, 692 insertions(+), 206 deletions(-) create mode 100644 pkg/ui/common/scrollbar_test.go diff --git a/go.mod b/go.mod index 62708d9..cfccdd2 100644 --- a/go.mod +++ b/go.mod @@ -1,28 +1,29 @@ module github.com/dlvhdr/diffnav -go 1.25.8 +go 1.26 require ( - charm.land/bubbles/v2 v2.0.0-rc.1 - charm.land/bubbletea/v2 v2.0.2 + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.7 charm.land/fang/v2 v2.0.1 - charm.land/lipgloss/v2 v2.0.1 + charm.land/lipgloss/v2 v2.0.4 charm.land/log/v2 v2.0.0 github.com/atotto/clipboard v0.1.4 github.com/bluekeyes/go-gitdiff v0.8.1 - github.com/charmbracelet/colorprofile v0.4.2 - github.com/charmbracelet/x/ansi v0.11.6 - github.com/lrstanley/bubblezone/v2 v2.0.0-alpha.3 - github.com/lrstanley/go-nf v0.0.0-20260301073247-b319fa3edf0e - github.com/mattn/go-runewidth v0.0.20 + github.com/charmbracelet/colorprofile v0.4.3 + github.com/charmbracelet/x/ansi v0.11.7 + github.com/lrstanley/bubblezone/v2 v2.0.0 + github.com/lrstanley/go-nf v0.0.0-20260418212552-215ab243b591 + github.com/mattn/go-runewidth v0.0.24 github.com/muesli/reflow v0.3.0 + github.com/robinovitch61/viewport v0.14.1-0.20260718213438-0e7e3ee636f2 github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect - github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20260615092313-b57e5e6d29bb // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect @@ -30,21 +31,21 @@ require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/mango v0.1.0 // indirect - github.com/muesli/mango-cobra v1.2.0 // indirect - github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/mango v0.2.0 // indirect + github.com/muesli/mango-cobra v1.3.0 // indirect + github.com/muesli/mango-pflag v0.2.0 // indirect github.com/muesli/roff v0.1.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // 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 ) -replace charm.land/bubbles/v2 v2.0.0-rc.1 => github.com/dlvhdr/bubbles/v2 v2.0.0-rc-dlvhdr-2 +replace charm.land/bubbles/v2 v2.1.0 => github.com/dlvhdr/bubbles/v2 v2.1.0-rc-dlvhdr replace charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251114164805-d267651963ad => charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 diff --git a/go.sum b/go.sum index 01df673..1987bdb 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ -charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= -charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= +charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY= charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII= -charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= -charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= +charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik= charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s= charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -12,14 +12,14 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/bluekeyes/go-gitdiff v0.8.1 h1:lL1GofKMywO17c0lgQmJYcKek5+s8X6tXVNOLxy4smI= github.com/bluekeyes/go-gitdiff v0.8.1/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE= -github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= -github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= -github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= -github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= -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/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= +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/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk= +github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= +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/exp/charmtone v0.0.0-20260615092313-b57e5e6d29bb h1:hoqNT54vrpXamSaQe5GxupakGgvvqFmVgmLJjotpHco= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20260615092313-b57e5e6d29bb/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -35,31 +35,31 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlvhdr/bubbles/v2 v2.0.0-rc-dlvhdr-2 h1:v+RlksFammxNyMhCHPXURvBv0DCee93sSn8zi/g+4kY= -github.com/dlvhdr/bubbles/v2 v2.0.0-rc-dlvhdr-2/go.mod h1:7AcMcBkRUKAZ4Z0D5MwWbxKxVhVIqxLlNh+4rJJjGF4= +github.com/dlvhdr/bubbles/v2 v2.1.0-rc-dlvhdr h1:y07CsjN8cK5q6lJMmyOvp5bPLHfpiI2GOK0P3E+/SCo= +github.com/dlvhdr/bubbles/v2 v2.1.0-rc-dlvhdr/go.mod h1:+RA3Q41MJE80yU1HGaR2fyNsACPnOz80IzYFql/Yj4M= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/lrstanley/bubblezone/v2 v2.0.0-alpha.3 h1:hFH0W7GQO1tCu9p0ljSxxr0PLWjrp/9NgHXEMWoCL70= -github.com/lrstanley/bubblezone/v2 v2.0.0-alpha.3/go.mod h1:O2jUHrhH1gDH/VhsqNIv35PN8+7zyAQqZ16rQPpCJxU= -github.com/lrstanley/go-nf v0.0.0-20260301073247-b319fa3edf0e h1:xBf4TxIi9coll8yHG71reJQOXK4MNvGXhY1HcxCHlOw= -github.com/lrstanley/go-nf v0.0.0-20260301073247-b319fa3edf0e/go.mod h1:MW7vmvMkwop4wD5DgTS5dzykKwN/r+3OWcxfzAANOrw= -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/lrstanley/bubblezone/v2 v2.0.0 h1:pMb9fHKs0slJF6OrzQ2hEgWusqyl9VU/S0UZ5hyh7ZA= +github.com/lrstanley/bubblezone/v2 v2.0.0/go.mod h1:yV/QTjcm4Zu5cqvGvdHi7xVUfnB36w/SafOuDp57dgY= +github.com/lrstanley/go-nf v0.0.0-20260418212552-215ab243b591 h1:WTkxFAeEMXU0lVPiUmUUoZJWCgPquyaxQGUf9e8dBh8= +github.com/lrstanley/go-nf v0.0.0-20260418212552-215ab243b591/go.mod h1:MW7vmvMkwop4wD5DgTS5dzykKwN/r+3OWcxfzAANOrw= +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-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= -github.com/mattn/go-runewidth v0.0.20/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/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= -github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= -github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= -github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= -github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= -github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ= +github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec= +github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E= +github.com/muesli/mango-pflag v0.2.0 h1:QViokgKDZQCzKhYe1zH8D+UlPJzBSGoP9yx0hBG0t5k= +github.com/muesli/mango-pflag v0.2.0/go.mod h1:X9LT1p/pbGA1wjvEbtwnixujKErkP0jVmrxwrw3fL0Y= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= @@ -70,6 +70,8 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robinovitch61/viewport v0.14.1-0.20260718213438-0e7e3ee636f2 h1:w5kS+VBMRZSW7xzhPw6yj3cpSoIvxLc9fLvXe2OzBMo= +github.com/robinovitch61/viewport v0.14.1-0.20260718213438-0e7e3ee636f2/go.mod h1:1kKpze6sbzH3QBHNFdMzqfJ8++LcigA01ieq6kIf4ds= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= @@ -81,14 +83,14 @@ github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf 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/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= -golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= +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.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= 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= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/pkg/ui/common/scrollbar.go b/pkg/ui/common/scrollbar.go index 50faa85..d58a49c 100644 --- a/pkg/ui/common/scrollbar.go +++ b/pkg/ui/common/scrollbar.go @@ -1,43 +1,67 @@ package common import ( + "math" "strings" "charm.land/lipgloss/v2" ) +type ScrollbarStyles struct { + Thumb lipgloss.Style + Track lipgloss.Style +} + +type Scrollbar struct { + Styles ScrollbarStyles +} + // RenderScrollbar renders a vertical scrollbar track with a thumb indicator. -// It takes the viewport height, total number of content lines, and the current -// scroll offset (YOffset). Returns an empty string if all content fits. -func RenderScrollbar(viewHeight, totalLines, yOffset int) string { - if totalLines <= viewHeight { +// It takes the viewport height, total number of items, and the current +// scroll item idx. Returns an empty string if all content fits. +func (m Scrollbar) View(trackHeight, totalItems, firstItemIdx int, lastItemIdx int) string { + if totalItems <= trackHeight { return "" } - trackHeight := viewHeight - thumbSize := max(1, trackHeight*viewHeight/totalLines) + scroll := float64(1) / float64(totalItems-trackHeight) + thumbSize := max(1, int(math.Floor(float64(trackHeight)*scroll))) + if thumbSize > 1 && thumbSize+2 > trackHeight { + thumbSize -= 1 + } - scrollableLines := totalLines - viewHeight + scrollableLines := totalItems - trackHeight thumbPos := 0 if scrollableLines > 0 { - thumbPos = yOffset * (trackHeight - thumbSize) / scrollableLines - if yOffset > 0 && thumbPos == 0 { + thumbPos = int(math.Min( + float64(trackHeight-thumbSize), + math.Floor( + float64(firstItemIdx)*(float64(trackHeight-thumbSize))/float64(scrollableLines), + ), + )) + if firstItemIdx == 0 { + thumbPos = 0 + } else if firstItemIdx > 0 && thumbPos == 0 { + // put thumb almost at the top-most position thumbPos = 1 + } else if lastItemIdx < totalItems-1 && thumbPos+thumbSize == trackHeight { + // put thumb almost at the bottom-most position + thumbPos = thumbPos - 1 + } else if firstItemIdx+trackHeight > totalItems-1 && thumbPos+thumbSize < trackHeight { + // put thumb at the bottom-most position + thumbPos = trackHeight - thumbSize } } - track := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - thumb := lipgloss.NewStyle().Foreground(lipgloss.Blue) - var sb strings.Builder - for i := 0; i < trackHeight; i++ { + for i := range trackHeight { if i > 0 { sb.WriteByte('\n') } if i >= thumbPos && i < thumbPos+thumbSize { - sb.WriteString(thumb.Render("┃")) + sb.WriteString(m.Styles.Thumb.Render("┃")) } else { - sb.WriteString(track.Render("│")) + sb.WriteString(m.Styles.Track.Render("│")) } } return sb.String() diff --git a/pkg/ui/common/scrollbar_test.go b/pkg/ui/common/scrollbar_test.go new file mode 100644 index 0000000..2318755 --- /dev/null +++ b/pkg/ui/common/scrollbar_test.go @@ -0,0 +1,220 @@ +package common + +import ( + "strings" + "testing" +) + +func Test_Scrollbar(t *testing.T) { + tests := []struct { + name string + want string + trackHeight int + totalItems int + firstItemIdx int + lastItemIdx int + }{ + { + name: "No scrollbar as total lines <= height", + want: "", + trackHeight: 3, + totalItems: 3, + firstItemIdx: 0, + lastItemIdx: 2, + }, + { + name: "View height is close to the total lines", + want: ` + ┃ + ┃ + │ + `, + trackHeight: 3, + totalItems: 4, + firstItemIdx: 0, + lastItemIdx: 2, + }, + { + name: "y offset is at the bottom", + want: ` + │ + ┃ + ┃ + `, + trackHeight: 3, + totalItems: 4, + firstItemIdx: 4, + lastItemIdx: 3, + }, + { + name: "y offset is at the bottom with large total lines", + want: ` + │ + │ + │ + │ + ┃ + `, + trackHeight: 5, + totalItems: 1000, + firstItemIdx: 995, + lastItemIdx: 999, + }, + { + name: "y offset is almost at the top with large total lines", + want: ` + │ + ┃ + │ + │ + │ + `, + trackHeight: 5, + totalItems: 1000, + firstItemIdx: 1, + lastItemIdx: 5, + }, + { + name: "y offset is at the middle and track allows bigger thumb size", + want: ` + │ + │ + │ + ┃ + ┃ + │ + │ + │ + │ + `, + trackHeight: 9, + totalItems: 13, + firstItemIdx: 2, + lastItemIdx: 11, + }, + { + name: "y offset is almost at the bottom with room to scroll", + want: ` + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + ┃ + │ + `, + trackHeight: 40, + totalItems: 238, + firstItemIdx: 197, + lastItemIdx: 236, + }, + { + name: "y offset is at the bottom with large scrollbar without room to scroll", + want: ` + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + ┃ + `, + trackHeight: 40, + totalItems: 238, + firstItemIdx: 198, + lastItemIdx: 237, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sb := Scrollbar{} + want := "" + lines := strings.Split(tt.want, "\n") + for i, line := range lines { + if i == 0 || i == len(lines)-1 { + continue + } + want = want + strings.TrimSpace(line) + if len(lines) < 2 || i < len(lines)-2 { + want = want + "\n" + } + + } + got := sb.View(tt.trackHeight, tt.totalItems, tt.firstItemIdx, tt.lastItemIdx) + if want != got { + t.Fatalf( + "expected scrollbar to be\n%s\n, but got \n%s\n", + want, + got, + ) + } + }) + } +} diff --git a/pkg/ui/keys.go b/pkg/ui/keys.go index b2e4b79..bad9e15 100644 --- a/pkg/ui/keys.go +++ b/pkg/ui/keys.go @@ -3,31 +3,36 @@ package ui import "charm.land/bubbles/v2/key" type KeyMap struct { - ExpandNode key.Binding - CollapseNode key.Binding - ToggleNode key.Binding - Up key.Binding - Down key.Binding - Bottom key.Binding - Top key.Binding - NextFile key.Binding - PrevFile key.Binding - CtrlD key.Binding - CtrlU key.Binding - CtrlE key.Binding - CtrlY key.Binding - ScrollLeft key.Binding - ScrollRight key.Binding - ToggleFileTree key.Binding - Search key.Binding - Quit key.Binding - Copy key.Binding - SwitchPanel key.Binding - OpenInEditor key.Binding - ToggleDiffView key.Binding - ToggleIconStyle key.Binding - ToggleHelp key.Binding - ToggleMessage key.Binding + ExpandNode key.Binding + CollapseNode key.Binding + ToggleNode key.Binding + Up key.Binding + Down key.Binding + Bottom key.Binding + Top key.Binding + NextFile key.Binding + PrevFile key.Binding + CtrlD key.Binding + CtrlU key.Binding + CtrlE key.Binding + CtrlY key.Binding + ScrollLeft key.Binding + ScrollRight key.Binding + ToggleFileTree key.Binding + SearchFiles key.Binding + SearchDiffExact key.Binding + SearchDiffInsensitive key.Binding + SearchDiffRegex key.Binding + SearchDiffFuzzy key.Binding + Quit key.Binding + Copy key.Binding + SwitchPanel key.Binding + OpenInEditor key.Binding + ToggleDiffView key.Binding + ToggleIconStyle key.Binding + ToggleHelp key.Binding + ToggleMessage key.Binding + ToggleSelection key.Binding } var keys = &KeyMap{ @@ -95,10 +100,26 @@ var keys = &KeyMap{ key.WithKeys("e"), key.WithHelp("e", "toggle file tree"), ), - Search: key.NewBinding( + SearchFiles: key.NewBinding( key.WithKeys("t"), key.WithHelp("t", "search files"), ), + SearchDiffExact: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "search diff (exact)"), + ), + SearchDiffInsensitive: key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "search diff (case insensitive)"), + ), + SearchDiffRegex: key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r", "search diff (regex)"), + ), + SearchDiffFuzzy: key.NewBinding( + key.WithKeys("ctrl+f"), + key.WithHelp("ctrl+f", "search diff (fuzzy)"), + ), Quit: key.NewBinding( key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit"), @@ -131,6 +152,10 @@ var keys = &KeyMap{ key.WithKeys("m"), key.WithHelp("m", "commit info"), ), + ToggleSelection: key.NewBinding( + key.WithKeys("v"), + key.WithHelp("v", "toggle selection"), + ), } func KeyGroups() [][]key.Binding { @@ -148,11 +173,16 @@ func KeyGroups() [][]key.Binding { keys.ScrollRight, }, { keys.ToggleFileTree, - keys.Search, + keys.SearchFiles, + keys.SearchDiffExact, + keys.SearchDiffInsensitive, + keys.SearchDiffRegex, + keys.SearchDiffFuzzy, keys.Copy, keys.OpenInEditor, keys.ToggleDiffView, keys.ToggleIconStyle, + keys.ToggleSelection, }, { keys.ToggleMessage, keys.ToggleHelp, diff --git a/pkg/ui/panes/diffviewer/diffviewer.go b/pkg/ui/panes/diffviewer/diffviewer.go index f51ad54..c01d5d4 100644 --- a/pkg/ui/panes/diffviewer/diffviewer.go +++ b/pkg/ui/panes/diffviewer/diffviewer.go @@ -6,15 +6,19 @@ import ( "os/exec" "strings" - "charm.land/bubbles/v2/viewport" + "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "charm.land/log/v2" "github.com/bluekeyes/go-gitdiff/gitdiff" "github.com/dlvhdr/diffnav/pkg/filenode" "github.com/dlvhdr/diffnav/pkg/icons" "github.com/dlvhdr/diffnav/pkg/ui/common" "github.com/dlvhdr/diffnav/pkg/utils" + "github.com/robinovitch61/viewport/filterableviewport" + "github.com/robinovitch61/viewport/viewport" + "github.com/robinovitch61/viewport/viewport/item" ) const dirHeaderHeight = 3 @@ -24,7 +28,7 @@ type cachedNode struct { files []*gitdiff.File additions int64 deletions int64 - diff string + diff []diffLine } type nodeCache map[string]*cachedNode @@ -36,14 +40,58 @@ func cacheKey(path string, sideBySide bool) string { return path } +type diffLine struct { + item item.Item +} + +func (o diffLine) GetItem() item.Item { + return o.item +} + +var ViewportKeyMap = viewport.KeyMap{ + HalfPageDown: key.NewBinding( + key.WithKeys("ctrl+d"), + key.WithHelp("ctrl+d", "scroll half page down"), + ), + HalfPageUp: key.NewBinding( + key.WithKeys("ctrl+u"), + key.WithHelp("ctrl+u", "scroll half page up"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "prev file"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "next file"), + ), + Bottom: key.NewBinding( + key.WithKeys("G"), + key.WithHelp("G", "bottom"), + ), + Top: key.NewBinding( + key.WithKeys("g"), + key.WithHelp("g", "top"), + ), + Left: key.NewBinding( + key.WithKeys("left"), + key.WithHelp("←", "scroll left"), + ), + Right: key.NewBinding( + key.WithKeys("right"), + key.WithHelp("→", "scroll right"), + ), +} + type Model struct { common.Common - vp viewport.Model + fvp *filterableviewport.Model[diffLine] file *cachedNode dir *cachedNode cache nodeCache sideBySide bool preamble string + sb common.Scrollbar } // SetPreamble stores the preamble text (e.g. commit metadata from git show). @@ -52,8 +100,100 @@ func (m *Model) SetPreamble(preamble string) { } func New(sideBySide bool) Model { + sb := common.Scrollbar{ + Styles: common.ScrollbarStyles{ + Thumb: lipgloss.NewStyle().Foreground(lipgloss.Blue), + Track: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), + }, + } + vp := viewport.New( + 0, + 0, + viewport.WithKeyMap[diffLine](ViewportKeyMap), + viewport.WithStyles[diffLine]( + viewport.Styles{ + SelectionPrefix: lipgloss.NewStyle().Foreground(lipgloss.Blue).Render("▐"), + SelectedItemStyle: lipgloss.NewStyle(). + Background(common.Colors[common.Selected]). + Foreground(lipgloss.BrightWhite), + FooterStyle: lipgloss.NewStyle(). + BorderForeground(lipgloss.BrightBlack). + Background(lipgloss.BrightBlack). + Border(lipgloss.Border{Left: "", Right: ""}, false, true, false, true). + Foreground(lipgloss.White). + Italic(true), + }, + ), + ) + + filterableViewportKeyMap := filterableviewport.DefaultKeyMap() + filterableViewportKeyMap.CancelFilterKey = key.NewBinding( + key.WithKeys("esc", "ctrl+c"), + key.WithHelp("esc/ctrl+c", "cancel filter"), + ) + + filterableViewportStyles := filterableviewport.DefaultStyles() + filterableViewportStyles.Filter.Focused.TextInput.Text = lipgloss.NewStyle(). + Foreground(lipgloss.BrightWhite) + filterableViewportStyles.Filter.Unfocused.TextInput.Text = lipgloss.NewStyle(). + Foreground(lipgloss.White) + filterableViewportStyles.Filter.Empty = lipgloss.NewStyle().Foreground(lipgloss.White) + filterableViewportStyles.MatchesCount.Matches = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF9E65")) + filterableViewportStyles.Match.Focused = lipgloss.NewStyle(). + Background(lipgloss.Color("#FF9E65")). + Foreground(lipgloss.Color("#1E202F")) + filterableViewportStyles.Match.Unfocused = lipgloss.NewStyle(). + Background(lipgloss.Color("#3D59A1")). + Foreground(lipgloss.White) + return Model{ - vp: viewport.Model{}, + sb: sb, + fvp: filterableviewport.New( + vp, + filterableviewport.WithKeyMap[diffLine](filterableViewportKeyMap), + filterableviewport.WithStyles[diffLine](filterableViewportStyles), + filterableviewport.WithPrefixText[diffLine]( + lipgloss.NewStyle().Bold(true).Render("Filter:"), + ), + filterableviewport.WithFilterModes[diffLine]( + []filterableviewport.FilterMode{ + filterableviewport.ExactFilterMode( + key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "exact fitler mode"), + ), + ), filterableviewport.RegexFilterMode( + key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r", "regex fitler mode"), + ), + ), + filterableviewport.FuzzyFilterMode( + key.NewBinding( + key.WithKeys("ctrl+f"), + key.WithHelp("ctrl+f", "fuzzy fitler mode"), + ), + ), + filterableviewport.CaseInsensitiveFilterMode(key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "case insensitive filter"), + )), + }, + ), + filterableviewport.WithPlaceholderText[diffLine]("type to search…"), + filterableviewport.WithItemDescriptor[diffLine]("lines"), + filterableviewport.WithEmptyText[diffLine]( + "Search… "+lipgloss.NewStyle(). + Faint(true). + Render("(/ exact ⋅ ⌃+s insensitive ⋅ ⌃+r regex ⋅ ⌃+f fuzzy)"), + ), + filterableviewport.WithFilterLinePosition[diffLine](filterableviewport.FilterLineTop), + filterableviewport.WithMatchingItemsOnly[diffLine](false), + filterableviewport.WithCanToggleMatchingItemsOnly[diffLine](true), + filterableviewport.WithVerticalPad[diffLine](8), + filterableviewport.WithHorizontalPad[diffLine](8), + ), sideBySide: sideBySide, cache: map[string]*cachedNode{}, } @@ -64,38 +204,46 @@ func (m Model) Init() tea.Cmd { } func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + log.Debug("diffviewer", "msg", fmt.Sprintf("%T", msg)) cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case diffContentMsg: if _, ok := m.cache[msg.cacheKey]; ok { - m.cache[msg.cacheKey].diff = msg.text + m.cache[msg.cacheKey].diff = msg.lines } - m.vp.SetContent(msg.text) + m.fvp.SetObjects(msg.lines) } - vp, vpCmd := m.vp.Update(msg) - cmds = append(cmds, vpCmd) - m.vp = vp + m.fvp.SetHeader(strings.Split(m.headerView(), "\n")) + fvp, fvpCmd := m.fvp.Update(msg) + cmds = append(cmds, fvpCmd) + m.fvp = fvp return m, tea.Batch(cmds...) } -const scrollbarWidth = 3 // 1 space + 1 scrollbar character + 1 padding +const scrollbarWidth = 2 // 1 scrollbar character + 1 padding func (m Model) View() string { - vpView := m.vp.View() - scrollbar := common.RenderScrollbar(m.vp.Height(), m.vp.TotalLineCount(), m.vp.YOffset()) + vpView := m.fvp.View() + itemMetrics := m.fvp.GetItemMetrics() + scrollbar := m.sb.View( + m.fvp.GetHeight(), + itemMetrics.TotalItems, + itemMetrics.FirstVisibleItemIdx, + itemMetrics.LastVisibleItemIdx, + ) if scrollbar != "" { - vpView = lipgloss.JoinHorizontal(lipgloss.Top, vpView, " ", scrollbar) + vpView = lipgloss.JoinHorizontal(lipgloss.Top, vpView, scrollbar) } - return lipgloss.JoinVertical(lipgloss.Left, m.headerView(), vpView) + return vpView } func (m *Model) SetSize(width, height int) tea.Cmd { m.Width = width m.Height = height - m.vp.SetWidth(m.contentWidth()) - m.vp.SetHeight(m.Height - dirHeaderHeight) + m.fvp.SetWidth(m.contentWidth()) + m.fvp.SetHeight(m.Height) m.ClearCache() return m.diff() } @@ -107,9 +255,9 @@ func (m Model) contentWidth() int { func (m *Model) diff() tea.Cmd { if m.file != nil { key := cacheKey(m.file.path, m.sideBySide) - if cached, ok := m.cache[key]; ok && cached.diff != "" { + if cached, ok := m.cache[key]; ok && len(cached.diff) != 0 { m.file = cached - m.vp.SetContent(cached.diff) + m.fvp.SetObjects(cached.diff) return nil } node := &cachedNode{ @@ -123,9 +271,9 @@ func (m *Model) diff() tea.Cmd { return diffFile(node, m.contentWidth(), m.sideBySide) } else if m.dir != nil { key := cacheKey(m.dir.path, m.sideBySide) - if cached, ok := m.cache[key]; ok && cached.diff != "" { + if cached, ok := m.cache[key]; ok && len(cached.diff) != 0 { m.dir = cached - m.vp.SetContent(cached.diff) + m.fvp.SetObjects(cached.diff) return nil } node := &cachedNode{ @@ -159,13 +307,13 @@ func (m Model) headerView() string { fileIcon := icons.GetIcon(name, false) prefix := base.Render(fileIcon) + base.Render(" ") - name = utils.TruncateString(name, m.Width-lipgloss.Width(prefix)) + name = utils.TruncateString(name, m.contentWidth()-lipgloss.Width(prefix)) top := prefix + base.Bold(true).Render(name) bottom := filenode.ViewFileDiffStats(m.file.files[0], base) return base. - Width(m.Width). + Width(m.contentWidth()). Height(dirHeaderHeight - 1). BorderStyle(lipgloss.NormalBorder()). BorderBottom(true). @@ -176,12 +324,12 @@ func (m Model) headerView() string { func (m Model) dirHeaderView() string { base := lipgloss.NewStyle().Foreground(lipgloss.Blue) prefix := base.Render(" ") - name := utils.TruncateString(m.dir.path, m.Width-lipgloss.Width(prefix)) + name := utils.TruncateString(m.dir.path, m.contentWidth()-lipgloss.Width(prefix)) top := prefix + base.Bold(true).Render(name) bottom := filenode.ViewDiffStats(m.dir.additions, m.dir.deletions, base) return base. - Width(m.Width). + Width(m.contentWidth()). Height(dirHeaderHeight - 1). BorderStyle(lipgloss.NormalBorder()). BorderBottom(true). @@ -196,7 +344,7 @@ func (m Model) SetFilePatch(file *gitdiff.File) (Model, tea.Cmd) { key := cacheKey(fname, m.sideBySide) if cached, ok := m.cache[key]; ok { m.file = cached - m.vp.SetContent(cached.diff) + m.fvp.SetObjects(cached.diff) return m, nil } @@ -220,7 +368,7 @@ func (m Model) SetDirPatch(dirPath string, files []*gitdiff.File) (Model, tea.Cm key := cacheKey(dirPath, m.sideBySide) if cached, ok := m.cache[key]; ok { m.dir = cached - m.vp.SetContent(cached.diff) + m.fvp.SetObjects(cached.diff) return m, nil } @@ -244,10 +392,6 @@ func (m Model) SetDirPatch(dirPath string, files []*gitdiff.File) (Model, tea.Cm return m, diffDir(m.dir, m.contentWidth(), m.sideBySide, preamble) } -func (m *Model) GoToTop() { - m.vp.GotoTop() -} - // SetSideBySide updates the diff view mode and re-renders. func (m *Model) SetSideBySide(sideBySide bool) tea.Cmd { m.sideBySide = sideBySide @@ -256,32 +400,32 @@ func (m *Model) SetSideBySide(sideBySide bool) tea.Cmd { // ScrollUp scrolls the viewport up by the given number of lines. func (m *Model) ScrollUp(lines int) { - m.vp.ScrollUp(lines) + m.fvp.ScrollUp(lines) } // ScrollDown scrolls the viewport down by the given number of lines. func (m *Model) ScrollDown(lines int) { - m.vp.ScrollDown(lines) + m.fvp.ScrollDown(lines) } // ScrollBottom scrolls the viewport to the bottom. func (m *Model) ScrollBottom() { - m.vp.GotoBottom() + m.fvp.GoToBottom() } // ScrollTop scrolls the viewport to its top. func (m *Model) ScrollTop() { - m.vp.GotoTop() + m.fvp.GoToTop() } -// ScrollLeft scrolls the viewport one column toward column 0. -func (m *Model) ScrollLeft() { - m.vp.ScrollLeft(1) +// ScrollLeft moves the viewport to the left by the given number of columns. +func (m *Model) ScrollLeft(cols int) { + m.fvp.ScrollLeft(cols) } // ScrollRight scrolls the viewport one column away from column 0. -func (m *Model) ScrollRight() { - m.vp.ScrollRight(1) +func (m *Model) ScrollRight(cols int) { + m.fvp.ScrollRight(cols) } func diffFile(node *cachedNode, width int, sideBySide bool) tea.Cmd { @@ -316,7 +460,8 @@ func diffFile(node *cachedNode, width int, sideBySide bool) tea.Cmd { if err != nil { return common.ErrMsg{Err: err} } - return diffContentMsg{cacheKey: key, text: string(out)} + + return diffContentMsg{cacheKey: key, lines: stringToDiffLines(string(out))} } } @@ -363,7 +508,7 @@ func diffDir(dir *cachedNode, width int, sideBySide bool, preamble string) tea.C if preamble != "" { text = renderPreamble(preamble) + "\n" + text } - return diffContentMsg{cacheKey: key, text: text} + return diffContentMsg{cacheKey: key, lines: stringToDiffLines(text)} } } @@ -377,7 +522,7 @@ func renderPreamble(preamble string) string { yellow := lipgloss.NewStyle().Foreground(lipgloss.Yellow) var out []string - for _, line := range strings.Split(preamble, "\n") { + for line := range strings.SplitSeq(preamble, "\n") { switch { case strings.HasPrefix(line, "commit "): out = append( @@ -401,7 +546,7 @@ func renderPreamble(preamble string) string { type diffContentMsg struct { cacheKey string - text string + lines []diffLine } func (m *Model) ClearCache() { @@ -415,3 +560,38 @@ func (m *Model) RootDiffStats() (int64, int64) { return 0, 0 } + +func (m *Model) Searching() bool { + return m.fvp.FilterFocused() +} + +func (m *Model) SetFiltering() { + var mode filterableviewport.FilterModeName + if curr := m.fvp.GetActiveFilterMode(); curr != nil { + mode = curr.Name + } else { + mode = filterableviewport.FilterExact + } + m.fvp.SetFilter(m.fvp.GetFilterText(), mode) +} + +func (m *Model) Filtering() bool { + return m.fvp.GetActiveFilterMode() != nil +} + +func (m *Model) SelectionEnabled() bool { + return m.fvp.GetSelectionEnabled() +} + +func (m *Model) SetSelectionEnabled(val bool) { + m.fvp.SetSelectionEnabled(val) +} + +func stringToDiffLines(val string) []diffLine { + lines := strings.Split(val, "\n") + objects := make([]diffLine, len(lines)) + for i, line := range lines { + objects[i] = diffLine{item: item.NewItem(line)} + } + return objects +} diff --git a/pkg/ui/tui.go b/pkg/ui/tui.go index b3c10fc..6e71f36 100644 --- a/pkg/ui/tui.go +++ b/pkg/ui/tui.go @@ -73,10 +73,10 @@ type mainModel struct { height int isShowingFileTree bool activePanel Panel - search textinput.Model + filesSearch textinput.Model resultsVp viewport.Model resultsCursor int - searching bool + searchingFiles bool filtered []string config config.Config draggingSidebar bool @@ -120,18 +120,18 @@ func New(input string, cfg config.Config) mainModel { m.help = help.New() m.help.SetKeys(KeyGroups()) - m.search = textinput.New() - m.search.ShowSuggestions = true - m.search.KeyMap.AcceptSuggestion = key.NewBinding(key.WithKeys("tab")) - m.search.Prompt = " " - m.search.Placeholder = "Filter files 󰬛 " - m.search.SetStyles(textinput.Styles{ + m.filesSearch = textinput.New() + m.filesSearch.ShowSuggestions = true + m.filesSearch.KeyMap.AcceptSuggestion = key.NewBinding(key.WithKeys("tab")) + m.filesSearch.Prompt = " " + m.filesSearch.Placeholder = "Filter files 󰬛 " + m.filesSearch.SetStyles(textinput.Styles{ Focused: textinput.StyleState{ Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), }, }) - m.search.SetWidth(cfg.UI.FileTreeWidth - 2) + m.filesSearch.SetWidth(cfg.UI.FileTreeWidth - 2) m.resultsVp = viewport.Model{} @@ -175,6 +175,7 @@ func (m mainModel) fetchWatchDiff() tea.Msg { } func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + log.Debug("tui", "msg", fmt.Sprintf("%T", msg)) var cmd tea.Cmd var cmds []tea.Cmd @@ -183,13 +184,20 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleMouse(msg) } - if m.searching { + if m.searchingFiles { var sCmds []tea.Cmd m, sCmds = m.searchUpdate(msg) cmds = append(cmds, sCmds...) return m, tea.Batch(cmds...) } + if m.diffViewer.Searching() { + var sCmd tea.Cmd + m.diffViewer, sCmd = m.diffViewer.Update(msg) + cmds = append(cmds, sCmd) + return m, tea.Batch(cmds...) + } + switch msg := msg.(type) { case tea.KeyPressMsg: switch { @@ -197,6 +205,10 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.helpOpen = !m.helpOpen m.messageOpen = false return m, tea.Batch(cmds...) + case key.Matches(msg, keys.ToggleSelection): + m.diffViewer.SetSelectionEnabled(!m.diffViewer.SelectionEnabled()) + m.activePanel = DiffViewerPanel + return m, tea.Batch(cmds...) case key.Matches(msg, keys.ToggleMessage): if m.preamble != "" { m.messageOpen = !m.messageOpen @@ -228,10 +240,10 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) case key.Matches(msg, keys.Quit): return m, tea.Quit - case key.Matches(msg, keys.Search): - m.searching = true - m.search.SetWidth(m.searchWidth()) - m.search.SetValue("") + case key.Matches(msg, keys.SearchFiles): + m.searchingFiles = true + m.filesSearch.SetWidth(m.searchWidth()) + m.filesSearch.SetValue("") m.resultsCursor = 0 m.setSearchResults() @@ -239,8 +251,13 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.resultsVp.SetHeight(m.mainContentHeight() - searchHeight) m.resultsVp.SetContent(m.resultsView()) - dfCmd := m.diffViewer.SetSize(m.width-m.sidebarWidth(), m.mainContentHeight()) - cmds = append(cmds, dfCmd, m.search.Focus()) + cmds = append(cmds, m.filesSearch.Focus()) + + case key.Matches(msg, keys.SearchDiffExact, keys.SearchDiffInsensitive, keys.SearchDiffRegex, keys.SearchDiffFuzzy): + m.activePanel = DiffViewerPanel + m.diffViewer, cmd = m.diffViewer.Update(msg) + cmds = append(cmds, cmd) + case key.Matches(msg, keys.ToggleFileTree): m.isShowingFileTree = !m.isShowingFileTree sidebarWidth := m.sidebarWidth() @@ -257,7 +274,7 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.fileTree.SetSize(treeWidth, h-searchHeight) - m.search.SetWidth(m.searchWidth()) + m.filesSearch.SetWidth(m.searchWidth()) dfCmd := m.diffViewer.SetSize(m.width-sidebarWidth, h) cmds = append(cmds, dfCmd) case key.Matches(msg, keys.ToggleIconStyle): @@ -275,10 +292,18 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } case key.Matches(msg, keys.PrevFile): - m, cmd = m.moveToFile(-1) + if m.diffViewer.Filtering() { + m.diffViewer, cmd = m.diffViewer.Update(msg) + } else { + m, cmd = m.moveToFile(-1) + } cmds = append(cmds, cmd) case key.Matches(msg, keys.NextFile): - m, cmd = m.moveToFile(1) + if m.diffViewer.Filtering() { + m.diffViewer, cmd = m.diffViewer.Update(msg) + } else { + m, cmd = m.moveToFile(1) + } cmds = append(cmds, cmd) case key.Matches(msg, keys.Up): if m.activePanel == FileTreePanel { @@ -310,11 +335,11 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case key.Matches(msg, keys.ScrollLeft): if m.activePanel != FileTreePanel { - m.diffViewer.ScrollLeft() + m.diffViewer.ScrollLeft(1) } case key.Matches(msg, keys.ScrollRight): if m.activePanel != FileTreePanel { - m.diffViewer.ScrollRight() + m.diffViewer.ScrollRight(1) } case key.Matches(msg, keys.Copy): cmd = m.fileTree.CopyCurrNodePath() @@ -357,7 +382,7 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { tWidth, tHeight := m.sidebarWidth(), m.mainContentHeight()-searchHeight m.fileTree.SetSize(tWidth, tHeight) - m.search.SetWidth(m.searchWidth()) + m.filesSearch.SetWidth(m.searchWidth()) if m.messageOpen { m.updateMessageVp() } @@ -451,7 +476,7 @@ func (m *mainModel) cycleIconStyle() { func (m mainModel) searchUpdate(msg tea.Msg) (mainModel, []tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd - if m.search.Focused() { + if m.filesSearch.Focused() { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { @@ -463,8 +488,6 @@ func (m mainModel) searchUpdate(msg tea.Msg) (mainModel, []tea.Cmd) { return m, []tea.Cmd{tea.Quit} case "enter": m.stopSearch() - dfCmd := m.diffViewer.SetSize(m.width-m.sidebarWidth(), m.mainContentHeight()) - cmds = append(cmds, dfCmd) if selected, ok := m.selectedSearchResult(); ok { for _, f := range m.files { @@ -491,9 +514,9 @@ func (m mainModel) searchUpdate(msg tea.Msg) (mainModel, []tea.Cmd) { m.resultsCursor = 0 } } - s, sc := m.search.Update(msg) + s, sc := m.filesSearch.Update(msg) cmds = append(cmds, sc) - m.search = s + m.filesSearch = s m.setSearchResults() m.resultsVp.SetContent(m.resultsView()) } @@ -510,7 +533,7 @@ func (m mainModel) View() tea.View { // Determine colors based on active panel. leftColor := lipgloss.Color("8") rightColor := lipgloss.Color("8") - if m.activePanel == FileTreePanel && !m.searching { + if m.activePanel == FileTreePanel && !m.searchingFiles { leftColor = lipgloss.Color("4") } else if m.activePanel == DiffViewerPanel { rightColor = lipgloss.Color("4") @@ -543,11 +566,11 @@ func (m mainModel) View() tea.View { Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("8")). Width(m.sidebarWidth()). - Render(m.search.View()) + Render(m.filesSearch.View()) searchBox = zone.Mark(zoneSearchBox, searchBox) content := "" - if m.searching { + if m.searchingFiles { content = zone.Mark(zoneSearchResults, m.resultsVp.View()) } else { content = zone.Mark(zoneFileTree, m.fileTree.View()) @@ -836,6 +859,12 @@ func (m mainModel) footerView() string { sep, ) + if m.diffViewer.SelectionEnabled() { + selectingLabel := base.Foreground(lipgloss.Yellow).Render("selection enabled (WIP)") + parts = append(parts, sep, selectingLabel) + usedWidth += lipgloss.Width(sep) + lipgloss.Width(selectingLabel) + } + if m.watchEnabled { watchLabel := base.Foreground(lipgloss.Yellow).Render("watching: " + m.watchCmd) parts = append(parts, sep, watchLabel) @@ -966,7 +995,7 @@ func (m mainModel) resultsView() string { } func (m mainModel) sidebarWidth() int { - if m.searching { + if m.searchingFiles { return m.config.UI.SearchTreeWidth } @@ -996,10 +1025,10 @@ func (m *mainModel) searchWidth() int { } func (m *mainModel) stopSearch() { - m.searching = false - m.search.SetValue("") - m.search.Blur() - m.search.SetWidth(m.searchWidth()) + m.searchingFiles = false + m.filesSearch.SetValue("") + m.filesSearch.Blur() + m.filesSearch.SetWidth(m.searchWidth()) } func (m mainModel) openInEditor() tea.Cmd { @@ -1109,7 +1138,7 @@ func (m mainModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if msg.Button == tea.MouseLeft { // Keep coordinate check for resize border (hybrid approach). sidebarWidth := m.sidebarWidth() - if !m.searching && m.isShowingFileTree && + if !m.searchingFiles && m.isShowingFileTree && abs(msg.X-sidebarWidth) <= sidebarGrabThreshold { m.draggingSidebar = true return m, nil @@ -1125,10 +1154,10 @@ func (m mainModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if zone.Get(zoneSearchBox).InBounds(msg) { return m.handleSearchBoxClick() } - if m.searching && zone.Get(zoneSearchResults).InBounds(msg) { + if m.searchingFiles && zone.Get(zoneSearchResults).InBounds(msg) { return m.handleSearchResultClick(msg) } - if !m.searching && zone.Get(zoneFileTree).InBounds(msg) { + if !m.searchingFiles && zone.Get(zoneFileTree).InBounds(msg) { return m.handleFileTreeClick(msg) } if zone.Get(zoneHelp).InBounds(msg) { @@ -1192,12 +1221,12 @@ func (m mainModel) handleSearchResultClick(msg tea.MouseMsg) (tea.Model, tea.Cmd } func (m mainModel) handleSearchBoxClick() (tea.Model, tea.Cmd) { - if m.searching { + if m.searchingFiles { return m, nil } - m.searching = true - m.search.SetWidth(m.searchWidth()) - m.search.SetValue("") + m.searchingFiles = true + m.filesSearch.SetWidth(m.searchWidth()) + m.filesSearch.SetValue("") m.resultsCursor = 0 m.setSearchResults() @@ -1206,7 +1235,7 @@ func (m mainModel) handleSearchBoxClick() (tea.Model, tea.Cmd) { m.resultsVp.SetContent(m.resultsView()) dfCmd := m.diffViewer.SetSize(m.width-m.sidebarWidth(), m.mainContentHeight()) - return m, tea.Batch(dfCmd, m.search.Focus()) + return m, tea.Batch(dfCmd, m.filesSearch.Focus()) } func (m mainModel) handleFileTreeClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { @@ -1235,13 +1264,13 @@ func (m mainModel) handleScroll(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // Check if scrolling in sidebar (file tree or search results). if zone.Get(zoneFileTree).InBounds(msg) || zone.Get(zoneSearchResults).InBounds(msg) { if msg.Mouse().Button == tea.MouseWheelUp { - if m.searching { + if m.searchingFiles { m.resultsVp.ScrollUp(lines) } else { m.fileTree.ScrollUp(lines) } } else { - if m.searching { + if m.searchingFiles { m.resultsVp.ScrollDown(lines) } else { m.fileTree.ScrollDown(lines) @@ -1262,7 +1291,7 @@ func (m mainModel) handleScroll(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } func (m mainModel) handleSidebarDrag(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - if m.searching { + if m.searchingFiles { m.draggingSidebar = false return m, nil } @@ -1318,7 +1347,7 @@ func (m mainModel) moveToFile(movement int) (mainModel, tea.Cmd) { node := m.fileTree.GetCurrNode() m, cmd = m.setNodeDiff(node) - m.diffViewer.GoToTop() + m.diffViewer.ScrollTop() return m, cmd } @@ -1347,7 +1376,7 @@ func (m mainModel) moveCursor(move movement) (mainModel, tea.Cmd) { node := m.fileTree.GetCurrNode() m, cmd = m.setNodeDiff(node) - m.diffViewer.GoToTop() + m.diffViewer.ScrollTop() return m, cmd } @@ -1379,7 +1408,7 @@ func (m *mainModel) setSearchResults() { for _, f := range m.files { if strings.Contains( strings.ToLower(filenode.GetFileName(f)), - strings.ToLower(m.search.Value()), + strings.ToLower(m.filesSearch.Value()), ) { filtered = append(filtered, filenode.GetFileName(f)) } @@ -1406,5 +1435,5 @@ func (m mainModel) selectedSearchResult() (string, bool) { } func (m mainModel) isSidebarVisible() bool { - return m.isShowingFileTree || m.searching + return m.isShowingFileTree || m.searchingFiles } diff --git a/pkg/ui/tui_test.go b/pkg/ui/tui_test.go index 8cea969..9389623 100644 --- a/pkg/ui/tui_test.go +++ b/pkg/ui/tui_test.go @@ -16,14 +16,14 @@ func TestSearchUpdateEnterWithNoResultsDoesNotPanic(t *testing.T) { m := newTestMainModel(t) m.width = 100 m.height = 40 - m.searching = true - m.search.Focus() - m.search.SetValue("does-not-match") + m.searchingFiles = true + m.filesSearch.Focus() + m.filesSearch.SetValue("does-not-match") m.setSearchResults() updated, _ := m.searchUpdate(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) - if updated.searching { + if updated.searchingFiles { t.Fatal("expected search to stop after pressing enter") } if updated.resultsCursor != 0 { @@ -33,8 +33,8 @@ func TestSearchUpdateEnterWithNoResultsDoesNotPanic(t *testing.T) { func TestSearchUpdateKeepsCursorValidWhenResultsAreEmpty(t *testing.T) { m := newTestMainModel(t) - m.searching = true - m.search.Focus() + m.searchingFiles = true + m.filesSearch.Focus() m.filtered = nil m.resultsCursor = 0 @@ -47,7 +47,7 @@ func TestSearchUpdateKeepsCursorValidWhenResultsAreEmpty(t *testing.T) { } updated.resultsCursor = -3 - updated.search.SetValue("does-not-match") + updated.filesSearch.SetValue("does-not-match") updated.setSearchResults() if updated.resultsCursor != 0 { t.Fatalf("expected cursor to clamp to 0 for empty results, got %d", updated.resultsCursor) @@ -59,8 +59,8 @@ func TestSearchResultsRenderWhenFileTreeIsHidden(t *testing.T) { m.width = 100 m.height = 40 m.isShowingFileTree = false - m.searching = true - m.search.SetWidth(m.searchWidth()) + m.searchingFiles = true + m.filesSearch.SetWidth(m.searchWidth()) m.setSearchResults() m.resultsVp.SetWidth(m.config.UI.SearchTreeWidth) m.resultsVp.SetHeight(m.mainContentHeight() - searchHeight) @@ -85,8 +85,8 @@ func TestHiddenTreeSearchEnterThenToggleDoesNotPanic(t *testing.T) { if !m.isShowingFileTree { t.Fatal("expected file tree to be visible after toggling it back on") } - if m.search.Width() < 0 { - t.Fatalf("expected non-negative search width, got %d", m.search.Width()) + if m.filesSearch.Width() < 0 { + t.Fatalf("expected non-negative search width, got %d", m.filesSearch.Width()) } _ = m.View().Content } @@ -96,7 +96,7 @@ func TestHiddenTreeSearchClickNearLeftEdgeDoesNotShowFileTree(t *testing.T) { m.width = 100 m.height = 40 m.isShowingFileTree = false - m.searching = true + m.searchingFiles = true updated, _ := m.handleMouse(tea.MouseClickMsg(tea.Mouse{X: 1, Y: 1, Button: tea.MouseLeft})) @@ -114,7 +114,7 @@ func TestHiddenSidebarGrabStillShowsFileTreeWhenNotSearching(t *testing.T) { m.width = 100 m.height = 40 m.isShowingFileTree = false - m.searching = false + m.searchingFiles = false updated, _ := m.handleMouse(tea.MouseClickMsg(tea.Mouse{X: 1, Y: 1, Button: tea.MouseLeft})) @@ -132,7 +132,7 @@ func TestSearchSidebarBorderClickDoesNotStartDragging(t *testing.T) { m.width = 100 m.height = 40 m.isShowingFileTree = true - m.searching = true + m.searchingFiles = true m.fileTree.SetSize(m.config.UI.FileTreeWidth, m.mainContentHeight()-searchHeight) updated, _ := m.handleMouse(tea.MouseClickMsg(tea.Mouse{ @@ -155,7 +155,7 @@ func TestSearchSidebarDragMotionIsIgnored(t *testing.T) { m.width = 100 m.height = 40 m.isShowingFileTree = true - m.searching = true + m.searchingFiles = true m.draggingSidebar = true m.fileTree.SetSize(m.config.UI.FileTreeWidth, m.mainContentHeight()-searchHeight) From a7b76abe94916efb56529e2617b90ad66abdec72 Mon Sep 17 00:00:00 2001 From: Dolev Hadar Date: Fri, 24 Jul 2026 13:15:29 +0300 Subject: [PATCH 2/2] chore: lint --- pkg/ui/common/scrollbar_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/ui/common/scrollbar_test.go b/pkg/ui/common/scrollbar_test.go index 2318755..0874e98 100644 --- a/pkg/ui/common/scrollbar_test.go +++ b/pkg/ui/common/scrollbar_test.go @@ -205,7 +205,6 @@ func Test_Scrollbar(t *testing.T) { if len(lines) < 2 || i < len(lines)-2 { want = want + "\n" } - } got := sb.View(tt.trackHeight, tt.totalItems, tt.firstItemIdx, tt.lastItemIdx) if want != got {