Mirror of https://git.jolheiser.com/ugit

Compare changes

Choose any two refs to compare.

+2
cmd/ugitd/args.go
··· 18 Profile profileArgs 19 Log logArgs 20 ShowPrivate bool 21 } 22 23 type sshArgs struct { ··· 114 fs.StringVar(&c.Meta.Description, "meta.description", c.Meta.Description, "App description") 115 fs.StringVar(&c.Profile.Username, "profile.username", c.Profile.Username, "Username for index page") 116 fs.StringVar(&c.Profile.Email, "profile.email", c.Profile.Email, "Email for index page") 117 fs.Func("profile.links", "Link(s) for index page", func(s string) error { 118 parts := strings.SplitN(s, ",", 2) 119 if len(parts) != 2 {
··· 18 Profile profileArgs 19 Log logArgs 20 ShowPrivate bool 21 + TUI bool 22 } 23 24 type sshArgs struct { ··· 115 fs.StringVar(&c.Meta.Description, "meta.description", c.Meta.Description, "App description") 116 fs.StringVar(&c.Profile.Username, "profile.username", c.Profile.Username, "Username for index page") 117 fs.StringVar(&c.Profile.Email, "profile.email", c.Profile.Email, "Email for index page") 118 + fs.BoolVar(&c.TUI, "tui", c.TUI, "Run the TUI interface directly") 119 fs.Func("profile.links", "Link(s) for index page", func(s string) error { 120 parts := strings.SplitN(s, ",", 2) 121 if len(parts) != 2 {
+9
cmd/ugitd/main.go
··· 20 "go.jolheiser.com/ugit/internal/git" 21 "go.jolheiser.com/ugit/internal/http" 22 "go.jolheiser.com/ugit/internal/ssh" 23 ) 24 25 func main() { ··· 39 if err != nil { 40 panic(err) 41 } 42 43 slog.SetLogLoggerLevel(args.Log.Level) 44 middleware.DefaultLogger = httplog.RequestLogger(httplog.NewLogger("ugit", httplog.Options{
··· 20 "go.jolheiser.com/ugit/internal/git" 21 "go.jolheiser.com/ugit/internal/http" 22 "go.jolheiser.com/ugit/internal/ssh" 23 + "go.jolheiser.com/ugit/internal/tui" 24 ) 25 26 func main() { ··· 40 if err != nil { 41 panic(err) 42 } 43 + 44 + // Run TUI mode if requested 45 + if args.TUI { 46 + if err := tui.Run(args.RepoDir); err != nil { 47 + panic(err) 48 + } 49 + return 50 + } 51 52 slog.SetLogLoggerLevel(args.Log.Level) 53 middleware.DefaultLogger = httplog.RequestLogger(httplog.NewLogger("ugit", httplog.Options{
+13 -6
go.mod
··· 7 require ( 8 github.com/alecthomas/assert/v2 v2.11.0 9 github.com/alecthomas/chroma/v2 v2.15.0 10 github.com/charmbracelet/ssh v0.0.0-20241211182756-4fe22b0f1b7c 11 github.com/charmbracelet/wish v1.4.4 12 github.com/dustin/go-humanize v1.0.1 ··· 28 github.com/ProtonMail/go-crypto v1.1.4 // indirect 29 github.com/alecthomas/repr v0.4.0 // indirect 30 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 31 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 32 - github.com/charmbracelet/bubbletea v1.2.4 // indirect 33 github.com/charmbracelet/keygen v0.5.1 // indirect 34 - github.com/charmbracelet/lipgloss v1.0.0 // indirect 35 github.com/charmbracelet/log v0.4.0 // indirect 36 - github.com/charmbracelet/x/ansi v0.6.0 // indirect 37 github.com/charmbracelet/x/conpty v0.1.0 // indirect 38 github.com/charmbracelet/x/errors v0.0.0-20250107110353-48b574af22a5 // indirect 39 github.com/charmbracelet/x/term v0.2.1 // indirect 40 github.com/charmbracelet/x/termios v0.1.0 // indirect 41 github.com/cloudflare/circl v1.5.0 // indirect ··· 58 github.com/mmcloughlin/avo v0.6.0 // indirect 59 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 60 github.com/muesli/cancelreader v0.2.2 // indirect 61 - github.com/muesli/termenv v0.15.3-0.20240509142007-81b8f94111d5 // indirect 62 github.com/pjbgf/sha1cd v0.3.1 // indirect 63 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 64 github.com/rivo/uniseg v0.4.7 // indirect 65 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 66 github.com/skeema/knownhosts v1.3.0 // indirect 67 github.com/xanzy/ssh-agent v0.3.3 // indirect 68 golang.org/x/crypto v0.32.0 // indirect 69 golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect 70 golang.org/x/mod v0.22.0 // indirect 71 - golang.org/x/sync v0.10.0 // indirect 72 - golang.org/x/sys v0.29.1-0.20250107080300-1c14dcadc3ab // indirect 73 golang.org/x/text v0.21.0 // indirect 74 golang.org/x/tools v0.29.0 // indirect 75 gopkg.in/warnings.v0 v0.1.2 // indirect
··· 7 require ( 8 github.com/alecthomas/assert/v2 v2.11.0 9 github.com/alecthomas/chroma/v2 v2.15.0 10 + github.com/charmbracelet/bubbles v0.21.0 11 + github.com/charmbracelet/bubbletea v1.3.5 12 + github.com/charmbracelet/lipgloss v1.1.0 13 github.com/charmbracelet/ssh v0.0.0-20241211182756-4fe22b0f1b7c 14 github.com/charmbracelet/wish v1.4.4 15 github.com/dustin/go-humanize v1.0.1 ··· 31 github.com/ProtonMail/go-crypto v1.1.4 // indirect 32 github.com/alecthomas/repr v0.4.0 // indirect 33 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 34 + github.com/atotto/clipboard v0.1.4 // indirect 35 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 36 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 37 github.com/charmbracelet/keygen v0.5.1 // indirect 38 github.com/charmbracelet/log v0.4.0 // indirect 39 + github.com/charmbracelet/x/ansi v0.8.0 // indirect 40 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 41 github.com/charmbracelet/x/conpty v0.1.0 // indirect 42 github.com/charmbracelet/x/errors v0.0.0-20250107110353-48b574af22a5 // indirect 43 + github.com/charmbracelet/x/input v0.2.0 // indirect 44 github.com/charmbracelet/x/term v0.2.1 // indirect 45 github.com/charmbracelet/x/termios v0.1.0 // indirect 46 github.com/cloudflare/circl v1.5.0 // indirect ··· 63 github.com/mmcloughlin/avo v0.6.0 // indirect 64 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 65 github.com/muesli/cancelreader v0.2.2 // indirect 66 + github.com/muesli/termenv v0.16.0 // indirect 67 github.com/pjbgf/sha1cd v0.3.1 // indirect 68 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 69 github.com/rivo/uniseg v0.4.7 // indirect 70 + github.com/sahilm/fuzzy v0.1.1 // indirect 71 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 72 github.com/skeema/knownhosts v1.3.0 // indirect 73 github.com/xanzy/ssh-agent v0.3.3 // indirect 74 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 75 golang.org/x/crypto v0.32.0 // indirect 76 golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect 77 golang.org/x/mod v0.22.0 // indirect 78 + golang.org/x/sync v0.13.0 // indirect 79 + golang.org/x/sys v0.32.0 // indirect 80 golang.org/x/text v0.21.0 // indirect 81 golang.org/x/tools v0.29.0 // indirect 82 gopkg.in/warnings.v0 v0.1.2 // indirect
+32 -12
go.sum
··· 17 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 18 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 19 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 20 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 21 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 22 - github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= 23 - github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= 24 github.com/charmbracelet/keygen v0.5.1 h1:zBkkYPtmKDVTw+cwUyY6ZwGDhRxXkEp0Oxs9sqMLqxI= 25 github.com/charmbracelet/keygen v0.5.1/go.mod h1:zznJVmK/GWB6dAtjluqn2qsttiCBhA5MZSiwb80fcHw= 26 - github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= 27 - github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= 28 github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= 29 github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= 30 github.com/charmbracelet/ssh v0.0.0-20241211182756-4fe22b0f1b7c h1:treQxMBdI2PaD4eOYfFux8stfCkUxhuUxaqGcxKqVpI= 31 github.com/charmbracelet/ssh v0.0.0-20241211182756-4fe22b0f1b7c/go.mod h1:CY1xbl2z+ZeBmNWItKZyxx0zgDgnhmR57+DTsHOobJ4= 32 github.com/charmbracelet/wish v1.4.4 h1:wtfoAMkf8Db9zi+9Lme2f7XKMxL6BqfgDWbqcTUHLaU= 33 github.com/charmbracelet/wish v1.4.4/go.mod h1:XB8v51UxIFMRlUod9lLaAgOsj/wpe+qW9HjsoYIiNMo= 34 - github.com/charmbracelet/x/ansi v0.6.0 h1:qOznutrb93gx9oMiGf7caF7bqqubh6YIM0SWKyA08pA= 35 - github.com/charmbracelet/x/ansi v0.6.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= 36 github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= 37 github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= 38 github.com/charmbracelet/x/errors v0.0.0-20250107110353-48b574af22a5 h1:Hx72S6S4jAfrrWE3pv9IbudVdUV4htBgkOX800o17Bk= 39 github.com/charmbracelet/x/errors v0.0.0-20250107110353-48b574af22a5/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 40 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 41 github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 42 github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k= ··· 96 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 97 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 98 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 99 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 100 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 101 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= ··· 110 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 111 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 112 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 113 - github.com/muesli/termenv v0.15.3-0.20240509142007-81b8f94111d5 h1:NiONcKK0EV5gUZcnCiPMORaZA0eBDc+Fgepl9xl4lZ8= 114 - github.com/muesli/termenv v0.15.3-0.20240509142007-81b8f94111d5/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= 115 github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 116 github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 117 github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc= ··· 128 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 129 github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 130 github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 131 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= 132 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 133 github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= ··· 141 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 142 github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 143 github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 144 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 145 github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 146 github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= ··· 159 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 160 golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 161 golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 162 - golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 163 - golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 164 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 165 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 166 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 169 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 170 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 171 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 172 - golang.org/x/sys v0.29.1-0.20250107080300-1c14dcadc3ab h1:BMkEEWYOjkvOX7+YKOGbp6jCyQ5pR2j0Ah47p1Vdsx4= 173 - golang.org/x/sys v0.29.1-0.20250107080300-1c14dcadc3ab/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 174 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 175 golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= 176 golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
··· 17 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 18 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 19 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 20 + github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 21 + github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 22 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 23 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 24 + github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 25 + github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 26 + github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 27 + github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 28 + github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= 29 + github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= 30 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 31 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 32 github.com/charmbracelet/keygen v0.5.1 h1:zBkkYPtmKDVTw+cwUyY6ZwGDhRxXkEp0Oxs9sqMLqxI= 33 github.com/charmbracelet/keygen v0.5.1/go.mod h1:zznJVmK/GWB6dAtjluqn2qsttiCBhA5MZSiwb80fcHw= 34 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 35 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 36 github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= 37 github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= 38 github.com/charmbracelet/ssh v0.0.0-20241211182756-4fe22b0f1b7c h1:treQxMBdI2PaD4eOYfFux8stfCkUxhuUxaqGcxKqVpI= 39 github.com/charmbracelet/ssh v0.0.0-20241211182756-4fe22b0f1b7c/go.mod h1:CY1xbl2z+ZeBmNWItKZyxx0zgDgnhmR57+DTsHOobJ4= 40 github.com/charmbracelet/wish v1.4.4 h1:wtfoAMkf8Db9zi+9Lme2f7XKMxL6BqfgDWbqcTUHLaU= 41 github.com/charmbracelet/wish v1.4.4/go.mod h1:XB8v51UxIFMRlUod9lLaAgOsj/wpe+qW9HjsoYIiNMo= 42 + github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 43 + github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 44 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 45 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 46 github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= 47 github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= 48 github.com/charmbracelet/x/errors v0.0.0-20250107110353-48b574af22a5 h1:Hx72S6S4jAfrrWE3pv9IbudVdUV4htBgkOX800o17Bk= 49 github.com/charmbracelet/x/errors v0.0.0-20250107110353-48b574af22a5/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 50 + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 51 + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 52 + github.com/charmbracelet/x/input v0.2.0 h1:1Sv+y/flcqUfUH2PXNIDKDIdT2G8smOnGOgawqhwy8A= 53 + github.com/charmbracelet/x/input v0.2.0/go.mod h1:KUSFIS6uQymtnr5lHVSOK9j8RvwTD4YHnWnzJUYnd/M= 54 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 55 github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 56 github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k= ··· 110 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 111 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 112 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 113 + github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 114 + github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 115 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 116 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 117 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= ··· 126 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 127 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 128 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 129 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 130 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 131 github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 132 github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 133 github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc= ··· 144 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 145 github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 146 github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 147 + github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 148 + github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 149 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= 150 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 151 github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= ··· 159 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 160 github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 161 github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 162 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 163 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 164 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 165 github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 166 github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= ··· 179 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 180 golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 181 golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 182 + golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 183 + golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 184 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 185 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 186 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 189 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 190 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 191 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 192 + golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 193 + golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 194 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 195 golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= 196 golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
+3
internal/git/meta.go
··· 57 if err := json.Unmarshal(b, &s); err != nil { 58 return err 59 } 60 for _, ss := range s { 61 t.Add(ss) 62 }
··· 57 if err := json.Unmarshal(b, &s); err != nil { 58 return err 59 } 60 + if *t == nil { 61 + *t = make(TagSet) 62 + } 63 for _, ss := range s { 64 t.Add(ss) 65 }
+61
internal/git/repo_utils.go
···
··· 1 + package git 2 + 3 + import ( 4 + "errors" 5 + "io/fs" 6 + "os" 7 + "path/filepath" 8 + "strings" 9 + ) 10 + 11 + // ListRepos returns all directory entries in the given directory 12 + func ListRepos(dir string) ([]fs.DirEntry, error) { 13 + entries, err := os.ReadDir(dir) 14 + if err != nil { 15 + if errors.Is(err, fs.ErrNotExist) { 16 + return []fs.DirEntry{}, nil 17 + } 18 + return nil, err 19 + } 20 + return entries, nil 21 + } 22 + 23 + // DeleteRepo deletes a git repository from the filesystem 24 + func DeleteRepo(repoPath string) error { 25 + return os.RemoveAll(repoPath) 26 + } 27 + 28 + // RenameRepo renames a git repository 29 + func RenameRepo(repoDir, oldName, newName string) error { 30 + if !filepath.IsAbs(repoDir) { 31 + return errors.New("repository directory must be an absolute path") 32 + } 33 + 34 + if !filepath.IsAbs(oldName) && !filepath.IsAbs(newName) { 35 + oldPath := filepath.Join(repoDir, oldName) 36 + if !strings.HasSuffix(oldPath, ".git") { 37 + oldPath += ".git" 38 + } 39 + 40 + newPath := filepath.Join(repoDir, newName) 41 + if !strings.HasSuffix(newPath, ".git") { 42 + newPath += ".git" 43 + } 44 + 45 + return os.Rename(oldPath, newPath) 46 + } 47 + 48 + return errors.New("repository names should not be absolute paths") 49 + } 50 + 51 + // RepoPathExists checks if a path exists 52 + func RepoPathExists(path string) (bool, error) { 53 + _, err := os.Stat(path) 54 + if err == nil { 55 + return true, nil 56 + } 57 + if errors.Is(err, fs.ErrNotExist) { 58 + return false, nil 59 + } 60 + return false, err 61 + }
+25 -18
internal/ssh/wish.go
··· 12 "text/tabwriter" 13 14 "go.jolheiser.com/ugit/internal/git" 15 16 "github.com/charmbracelet/ssh" 17 "github.com/charmbracelet/wish" ··· 99 } 100 } 101 102 - // Repo list 103 if len(cmd) == 0 { 104 - des, err := os.ReadDir(repoDir) 105 - if err != nil && err != fs.ErrNotExist { 106 - slog.Error("invalid repository", "error", err) 107 - } 108 - tw := tabwriter.NewWriter(s, 0, 0, 1, ' ', 0) 109 - for _, de := range des { 110 - if filepath.Ext(de.Name()) != ".git" { 111 - continue 112 } 113 - repo, err := git.NewRepo(repoDir, de.Name()) 114 - visibility := "โ“" 115 - if err == nil { 116 - visibility = "๐Ÿ”“" 117 - if repo.Meta.Private { 118 - visibility = "๐Ÿ”’" 119 } 120 } 121 - fmt.Fprintf(tw, "%[1]s\t%[3]s\t%[2]s/%[1]s.git\n", strings.TrimSuffix(de.Name(), ".git"), cloneURL, visibility) 122 } 123 - tw.Flush() 124 } 125 sh(s) 126 } ··· 174 pktLine := fmt.Sprintf("%04x%s\n", len(msg)+5, msg) 175 _, _ = wish.WriteString(s, pktLine) 176 s.Exit(1) // nolint: errcheck 177 - }
··· 12 "text/tabwriter" 13 14 "go.jolheiser.com/ugit/internal/git" 15 + "go.jolheiser.com/ugit/internal/tui" 16 17 "github.com/charmbracelet/ssh" 18 "github.com/charmbracelet/wish" ··· 100 } 101 } 102 103 + // No args, start TUI 104 if len(cmd) == 0 { 105 + if err := tui.Start(s, repoDir); err != nil { 106 + slog.Error("failed to start TUI", "error", err) 107 + 108 + // Fall back to simple list on TUI error 109 + des, err := os.ReadDir(repoDir) 110 + if err != nil && err != fs.ErrNotExist { 111 + slog.Error("invalid repository", "error", err) 112 } 113 + tw := tabwriter.NewWriter(s, 0, 0, 1, ' ', 0) 114 + for _, de := range des { 115 + if filepath.Ext(de.Name()) != ".git" { 116 + continue 117 + } 118 + repo, err := git.NewRepo(repoDir, de.Name()) 119 + visibility := "โ“" 120 + if err == nil { 121 + visibility = "๐Ÿ”“" 122 + if repo.Meta.Private { 123 + visibility = "๐Ÿ”’" 124 + } 125 } 126 + fmt.Fprintf(tw, "%[1]s\t%[3]s\t%[2]s/%[1]s.git\n", strings.TrimSuffix(de.Name(), ".git"), cloneURL, visibility) 127 } 128 + tw.Flush() 129 } 130 + return 131 } 132 sh(s) 133 } ··· 181 pktLine := fmt.Sprintf("%04x%s\n", len(msg)+5, msg) 182 _, _ = wish.WriteString(s, pktLine) 183 s.Exit(1) // nolint: errcheck 184 + }
+230
internal/tui/form.go
···
··· 1 + package tui 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/charmbracelet/bubbles/textinput" 8 + tea "github.com/charmbracelet/bubbletea" 9 + "github.com/charmbracelet/lipgloss" 10 + "go.jolheiser.com/ugit/internal/git" 11 + ) 12 + 13 + type repoForm struct { 14 + inputs []textinput.Model 15 + isPrivate bool 16 + focusIndex int 17 + width int 18 + height int 19 + done bool 20 + save bool 21 + selectedRepo *git.Repo 22 + } 23 + 24 + // newRepoForm creates a new repository editing form 25 + func newRepoForm() repoForm { 26 + var inputs []textinput.Model 27 + 28 + nameInput := textinput.New() 29 + nameInput.Placeholder = "Repository name" 30 + nameInput.Focus() 31 + nameInput.Width = 50 32 + inputs = append(inputs, nameInput) 33 + 34 + descInput := textinput.New() 35 + descInput.Placeholder = "Repository description" 36 + descInput.Width = 50 37 + inputs = append(inputs, descInput) 38 + 39 + tagsInput := textinput.New() 40 + tagsInput.Placeholder = "Tags (comma separated)" 41 + tagsInput.Width = 50 42 + inputs = append(inputs, tagsInput) 43 + 44 + return repoForm{ 45 + inputs: inputs, 46 + focusIndex: 0, 47 + } 48 + } 49 + 50 + // setValues sets the form values from the selected repo 51 + func (f *repoForm) setValues(repo *git.Repo) { 52 + f.inputs[0].SetValue(repo.Name()) 53 + f.inputs[1].SetValue(repo.Meta.Description) 54 + f.inputs[2].SetValue(strings.Join(repo.Meta.Tags.Slice(), ", ")) 55 + f.isPrivate = repo.Meta.Private 56 + 57 + f.inputs[0].Focus() 58 + f.focusIndex = 0 59 + } 60 + 61 + // setSize sets the form dimensions 62 + func (f *repoForm) setSize(width, height int) { 63 + f.width = width 64 + f.height = height 65 + 66 + for i := range f.inputs { 67 + f.inputs[i].Width = width - 10 68 + } 69 + } 70 + 71 + // isPrivateToggleFocused returns true if the private toggle is focused 72 + func (f *repoForm) isPrivateToggleFocused() bool { 73 + return f.focusIndex == len(f.inputs) 74 + } 75 + 76 + // isSaveButtonFocused returns true if the save button is focused 77 + func (f *repoForm) isSaveButtonFocused() bool { 78 + return f.focusIndex == len(f.inputs)+1 79 + } 80 + 81 + // isCancelButtonFocused returns true if the cancel button is focused 82 + func (f *repoForm) isCancelButtonFocused() bool { 83 + return f.focusIndex == len(f.inputs)+2 84 + } 85 + 86 + // Update handles form updates 87 + func (f repoForm) Update(msg tea.Msg) (repoForm, tea.Cmd) { 88 + var cmds []tea.Cmd 89 + 90 + switch msg := msg.(type) { 91 + case tea.KeyMsg: 92 + switch msg.String() { 93 + case "tab", "shift+tab", "up", "down": 94 + if msg.String() == "up" || msg.String() == "shift+tab" { 95 + f.focusIndex-- 96 + if f.focusIndex < 0 { 97 + f.focusIndex = len(f.inputs) + 3 - 1 98 + } 99 + } else { 100 + f.focusIndex++ 101 + if f.focusIndex >= len(f.inputs)+3 { 102 + f.focusIndex = 0 103 + } 104 + } 105 + 106 + for i := range f.inputs { 107 + if i == f.focusIndex { 108 + cmds = append(cmds, f.inputs[i].Focus()) 109 + } else { 110 + f.inputs[i].Blur() 111 + } 112 + } 113 + 114 + case "enter": 115 + if f.isSaveButtonFocused() { 116 + f.done = true 117 + f.save = true 118 + return f, nil 119 + } 120 + 121 + if f.isCancelButtonFocused() { 122 + f.done = true 123 + f.save = false 124 + return f, nil 125 + } 126 + 127 + case "esc": 128 + f.done = true 129 + f.save = false 130 + return f, nil 131 + 132 + case " ": 133 + if f.isPrivateToggleFocused() { 134 + f.isPrivate = !f.isPrivate 135 + } 136 + 137 + if f.isSaveButtonFocused() { 138 + f.done = true 139 + f.save = true 140 + return f, nil 141 + } 142 + 143 + if f.isCancelButtonFocused() { 144 + f.done = true 145 + f.save = false 146 + return f, nil 147 + } 148 + } 149 + } 150 + 151 + for i := range f.inputs { 152 + if i == f.focusIndex { 153 + var cmd tea.Cmd 154 + f.inputs[i], cmd = f.inputs[i].Update(msg) 155 + cmds = append(cmds, cmd) 156 + } 157 + } 158 + 159 + return f, tea.Batch(cmds...) 160 + } 161 + 162 + // View renders the form 163 + func (f repoForm) View() string { 164 + var b strings.Builder 165 + 166 + formStyle := lipgloss.NewStyle(). 167 + BorderStyle(lipgloss.RoundedBorder()). 168 + BorderForeground(lipgloss.Color("170")). 169 + Padding(1, 2) 170 + 171 + titleStyle := lipgloss.NewStyle(). 172 + Bold(true). 173 + Foreground(lipgloss.Color("170")). 174 + MarginBottom(1) 175 + 176 + b.WriteString(titleStyle.Render("Edit Repository")) 177 + b.WriteString("\n\n") 178 + 179 + b.WriteString("Repository Name:\n") 180 + b.WriteString(f.inputs[0].View()) 181 + b.WriteString("\n\n") 182 + 183 + b.WriteString("Description:\n") 184 + b.WriteString(f.inputs[1].View()) 185 + b.WriteString("\n\n") 186 + 187 + b.WriteString("Tags (comma separated):\n") 188 + b.WriteString(f.inputs[2].View()) 189 + b.WriteString("\n\n") 190 + 191 + toggleStyle := lipgloss.NewStyle() 192 + if f.isPrivateToggleFocused() { 193 + toggleStyle = toggleStyle.Foreground(lipgloss.Color("170")).Bold(true) 194 + } 195 + 196 + visibility := "Public ๐Ÿ”“" 197 + if f.isPrivate { 198 + visibility = "Private ๐Ÿ”’" 199 + } 200 + 201 + b.WriteString(toggleStyle.Render(fmt.Sprintf("[%s] %s", visibility, "Toggle with Space"))) 202 + b.WriteString("\n\n") 203 + 204 + buttonStyle := lipgloss.NewStyle(). 205 + Padding(0, 3). 206 + MarginRight(1) 207 + 208 + focusedButtonStyle := buttonStyle.Copy(). 209 + Foreground(lipgloss.Color("0")). 210 + Background(lipgloss.Color("170")). 211 + Bold(true) 212 + 213 + saveButton := buttonStyle.Render("[ Save ]") 214 + cancelButton := buttonStyle.Render("[ Cancel ]") 215 + 216 + if f.isSaveButtonFocused() { 217 + saveButton = focusedButtonStyle.Render("[ Save ]") 218 + } 219 + 220 + if f.isCancelButtonFocused() { 221 + cancelButton = focusedButtonStyle.Render("[ Cancel ]") 222 + } 223 + 224 + b.WriteString(saveButton + cancelButton) 225 + b.WriteString("\n\n") 226 + 227 + b.WriteString("\nTab: Next โ€ข Shift+Tab: Previous โ€ข Enter: Select โ€ข Esc: Cancel") 228 + 229 + return formStyle.Width(f.width - 4).Render(b.String()) 230 + }
+65
internal/tui/keymap.go
···
··· 1 + package tui 2 + 3 + import ( 4 + "github.com/charmbracelet/bubbles/key" 5 + ) 6 + 7 + // keyMap defines the keybindings for the TUI 8 + type keyMap struct { 9 + Up key.Binding 10 + Down key.Binding 11 + Edit key.Binding 12 + Delete key.Binding 13 + Help key.Binding 14 + Quit key.Binding 15 + Confirm key.Binding 16 + Cancel key.Binding 17 + } 18 + 19 + // ShortHelp returns keybindings to be shown in the mini help view. 20 + func (k keyMap) ShortHelp() []key.Binding { 21 + return []key.Binding{k.Help, k.Edit, k.Delete, k.Quit} 22 + } 23 + 24 + // FullHelp returns keybindings for the expanded help view. 25 + func (k keyMap) FullHelp() [][]key.Binding { 26 + return [][]key.Binding{ 27 + {k.Up, k.Down, k.Edit}, 28 + {k.Delete, k.Help, k.Quit}, 29 + } 30 + } 31 + 32 + var keys = keyMap{ 33 + Up: key.NewBinding( 34 + key.WithKeys("up", "k"), 35 + key.WithHelp("โ†‘/k", "up"), 36 + ), 37 + Down: key.NewBinding( 38 + key.WithKeys("down", "j"), 39 + key.WithHelp("โ†“/j", "down"), 40 + ), 41 + Edit: key.NewBinding( 42 + key.WithKeys("e"), 43 + key.WithHelp("e", "edit"), 44 + ), 45 + Delete: key.NewBinding( 46 + key.WithKeys("d"), 47 + key.WithHelp("d", "delete"), 48 + ), 49 + Help: key.NewBinding( 50 + key.WithKeys("?"), 51 + key.WithHelp("?", "help"), 52 + ), 53 + Quit: key.NewBinding( 54 + key.WithKeys("q", "ctrl+c"), 55 + key.WithHelp("q", "quit"), 56 + ), 57 + Confirm: key.NewBinding( 58 + key.WithKeys("y"), 59 + key.WithHelp("y", "confirm"), 60 + ), 61 + Cancel: key.NewBinding( 62 + key.WithKeys("n", "esc"), 63 + key.WithHelp("n", "cancel"), 64 + ), 65 + }
+50
internal/tui/main.go
···
··· 1 + package tui 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/charmbracelet/bubbles/help" 7 + "github.com/charmbracelet/bubbles/list" 8 + tea "github.com/charmbracelet/bubbletea" 9 + "github.com/charmbracelet/lipgloss" 10 + ) 11 + 12 + // Run runs the TUI standalone, useful for development or local usage 13 + func Run(repoDir string) error { 14 + model := Model{ 15 + repoDir: repoDir, 16 + help: help.New(), 17 + keys: keys, 18 + activeView: ViewList, 19 + repoForm: newRepoForm(), 20 + } 21 + 22 + repos, err := loadRepos(repoDir) 23 + if err != nil { 24 + return fmt.Errorf("failed to load repos: %w", err) 25 + } 26 + model.repos = repos 27 + 28 + items := make([]list.Item, len(repos)) 29 + for i, repo := range repos { 30 + items[i] = repoItem{repo: repo} 31 + } 32 + 33 + delegate := list.NewDefaultDelegate() 34 + delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Foreground(lipgloss.Color("170")) 35 + delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(lipgloss.Color("244")) 36 + 37 + repoList := list.New(items, delegate, 0, 0) 38 + repoList.Title = "Git Repositories" 39 + repoList.SetShowStatusBar(true) 40 + repoList.SetFilteringEnabled(true) 41 + repoList.Styles.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Padding(0, 0, 0, 2) 42 + repoList.StatusMessageLifetime = 3 43 + 44 + model.repoList = repoList 45 + 46 + p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()) 47 + 48 + _, err = p.Run() 49 + return err 50 + }
+72
internal/tui/repo_item.go
···
··· 1 + package tui 2 + 3 + import ( 4 + "strings" 5 + 6 + "go.jolheiser.com/ugit/internal/git" 7 + ) 8 + 9 + // repoItem represents a repository item in the list 10 + type repoItem struct { 11 + repo *git.Repo 12 + } 13 + 14 + // Title returns the title for the list item 15 + func (r repoItem) Title() string { 16 + return r.repo.Name() 17 + } 18 + 19 + // Description returns the description for the list item 20 + func (r repoItem) Description() string { 21 + var builder strings.Builder 22 + 23 + if r.repo.Meta.Private { 24 + builder.WriteString("๐Ÿ”’") 25 + } else { 26 + builder.WriteString("๐Ÿ”“") 27 + } 28 + 29 + builder.WriteString(" โ€ข ") 30 + 31 + if r.repo.Meta.Description != "" { 32 + builder.WriteString(r.repo.Meta.Description) 33 + } else { 34 + builder.WriteString("No description") 35 + } 36 + 37 + builder.WriteString(" โ€ข ") 38 + 39 + builder.WriteString("[") 40 + if len(r.repo.Meta.Tags) > 0 { 41 + builder.WriteString(strings.Join(r.repo.Meta.Tags.Slice(), ", ")) 42 + } 43 + builder.WriteString("]") 44 + 45 + builder.WriteString(" โ€ข ") 46 + 47 + lastCommit, err := r.repo.LastCommit() 48 + if err == nil { 49 + builder.WriteString(lastCommit.Short()) 50 + } else { 51 + builder.WriteString("deadbeef") 52 + } 53 + 54 + return builder.String() 55 + } 56 + 57 + // FilterValue returns the value to use for filtering 58 + func (r repoItem) FilterValue() string { 59 + var builder strings.Builder 60 + builder.WriteString(r.repo.Name()) 61 + builder.WriteString(" ") 62 + builder.WriteString(r.repo.Meta.Description) 63 + 64 + if len(r.repo.Meta.Tags) > 0 { 65 + for _, tag := range r.repo.Meta.Tags.Slice() { 66 + builder.WriteString(" ") 67 + builder.WriteString(tag) 68 + } 69 + } 70 + 71 + return strings.ToLower(builder.String()) 72 + }
+321
internal/tui/tui.go
···
··· 1 + package tui 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "path/filepath" 7 + "strings" 8 + 9 + "github.com/charmbracelet/bubbles/help" 10 + "github.com/charmbracelet/bubbles/key" 11 + "github.com/charmbracelet/bubbles/list" 12 + "github.com/charmbracelet/bubbles/textinput" 13 + tea "github.com/charmbracelet/bubbletea" 14 + "github.com/charmbracelet/lipgloss" 15 + "github.com/charmbracelet/ssh" 16 + "go.jolheiser.com/ugit/internal/git" 17 + ) 18 + 19 + // Model is the main TUI model 20 + type Model struct { 21 + repoList list.Model 22 + repos []*git.Repo 23 + repoDir string 24 + width int 25 + height int 26 + help help.Model 27 + keys keyMap 28 + activeView View 29 + repoForm repoForm 30 + session ssh.Session 31 + } 32 + 33 + // View represents the current active view in the TUI 34 + type View int 35 + 36 + const ( 37 + ViewList View = iota 38 + ViewForm 39 + ViewConfirmDelete 40 + ) 41 + 42 + // New creates a new TUI model 43 + func New(s ssh.Session, repoDir string) (*Model, error) { 44 + repos, err := loadRepos(repoDir) 45 + if err != nil { 46 + return nil, fmt.Errorf("failed to load repos: %w", err) 47 + } 48 + 49 + items := make([]list.Item, len(repos)) 50 + for i, repo := range repos { 51 + items[i] = repoItem{repo: repo} 52 + } 53 + 54 + delegate := list.NewDefaultDelegate() 55 + delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Foreground(lipgloss.Color("170")) 56 + delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(lipgloss.Color("244")) 57 + 58 + repoList := list.New(items, delegate, 0, 0) 59 + repoList.Title = "Git Repositories" 60 + repoList.SetShowStatusBar(true) 61 + repoList.SetFilteringEnabled(true) 62 + repoList.Styles.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Padding(0, 0, 0, 2) 63 + repoList.StatusMessageLifetime = 3 64 + 65 + repoList.FilterInput.Placeholder = "Type to filter repositories..." 66 + repoList.FilterInput.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("170")) 67 + repoList.FilterInput.TextStyle = lipgloss.NewStyle() 68 + 69 + help := help.New() 70 + 71 + repoForm := newRepoForm() 72 + 73 + return &Model{ 74 + repoList: repoList, 75 + repos: repos, 76 + repoDir: repoDir, 77 + help: help, 78 + keys: keys, 79 + activeView: ViewList, 80 + repoForm: repoForm, 81 + session: s, 82 + }, nil 83 + } 84 + 85 + // loadRepos loads all git repositories from the given directory 86 + func loadRepos(repoDir string) ([]*git.Repo, error) { 87 + entries, err := git.ListRepos(repoDir) 88 + if err != nil { 89 + return nil, err 90 + } 91 + 92 + repos := make([]*git.Repo, 0, len(entries)) 93 + for _, entry := range entries { 94 + if !strings.HasSuffix(entry.Name(), ".git") { 95 + continue 96 + } 97 + repo, err := git.NewRepo(repoDir, entry.Name()) 98 + if err != nil { 99 + slog.Error("error loading repo", "name", entry.Name(), "error", err) 100 + continue 101 + } 102 + repos = append(repos, repo) 103 + } 104 + 105 + return repos, nil 106 + } 107 + 108 + // Init initializes the model 109 + func (m Model) Init() tea.Cmd { 110 + return nil 111 + } 112 + 113 + // Update handles all the messages and updates the model accordingly 114 + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 115 + var cmds []tea.Cmd 116 + 117 + switch msg := msg.(type) { 118 + case tea.KeyMsg: 119 + switch { 120 + case key.Matches(msg, m.keys.Quit): 121 + return m, tea.Quit 122 + case key.Matches(msg, m.keys.Help): 123 + m.help.ShowAll = !m.help.ShowAll 124 + } 125 + 126 + switch m.activeView { 127 + case ViewList: 128 + var cmd tea.Cmd 129 + m.repoList, cmd = m.repoList.Update(msg) 130 + cmds = append(cmds, cmd) 131 + 132 + if m.repoList.FilterState() == list.Filtering { 133 + break 134 + } 135 + 136 + switch { 137 + case key.Matches(msg, m.keys.Edit): 138 + if len(m.repos) == 0 { 139 + m.repoList.NewStatusMessage("No repositories to edit") 140 + break 141 + } 142 + 143 + selectedItem := m.repoList.SelectedItem().(repoItem) 144 + m.repoForm.selectedRepo = selectedItem.repo 145 + 146 + m.repoForm.setValues(selectedItem.repo) 147 + m.activeView = ViewForm 148 + return m, textinput.Blink 149 + 150 + case key.Matches(msg, m.keys.Delete): 151 + if len(m.repos) == 0 { 152 + m.repoList.NewStatusMessage("No repositories to delete") 153 + break 154 + } 155 + 156 + m.activeView = ViewConfirmDelete 157 + } 158 + 159 + case ViewForm: 160 + var cmd tea.Cmd 161 + m.repoForm, cmd = m.repoForm.Update(msg) 162 + cmds = append(cmds, cmd) 163 + 164 + if m.repoForm.done { 165 + if m.repoForm.save { 166 + selectedRepo := m.repoForm.selectedRepo 167 + repoDir := filepath.Dir(selectedRepo.Path()) 168 + oldName := selectedRepo.Name() 169 + newName := m.repoForm.inputs[0].Value() 170 + 171 + var renamed bool 172 + if oldName != newName { 173 + if err := git.RenameRepo(repoDir, oldName, newName); err != nil { 174 + m.repoList.NewStatusMessage(fmt.Sprintf("Error renaming repo: %s", err)) 175 + } else { 176 + m.repoList.NewStatusMessage(fmt.Sprintf("Repository renamed from %s to %s", oldName, newName)) 177 + renamed = true 178 + } 179 + } 180 + 181 + if renamed { 182 + if newRepo, err := git.NewRepo(repoDir, newName+".git"); err == nil { 183 + selectedRepo = newRepo 184 + } else { 185 + m.repoList.NewStatusMessage(fmt.Sprintf("Error loading renamed repo: %s", err)) 186 + } 187 + } 188 + 189 + selectedRepo.Meta.Description = m.repoForm.inputs[1].Value() 190 + selectedRepo.Meta.Private = m.repoForm.isPrivate 191 + 192 + tags := make(git.TagSet) 193 + for _, tag := range strings.Split(m.repoForm.inputs[2].Value(), ",") { 194 + tag = strings.TrimSpace(tag) 195 + if tag != "" { 196 + tags.Add(tag) 197 + } 198 + } 199 + selectedRepo.Meta.Tags = tags 200 + 201 + if err := selectedRepo.SaveMeta(); err != nil { 202 + m.repoList.NewStatusMessage(fmt.Sprintf("Error saving repo metadata: %s", err)) 203 + } else if !renamed { 204 + m.repoList.NewStatusMessage("Repository updated successfully") 205 + } 206 + } 207 + 208 + m.repoForm.done = false 209 + m.repoForm.save = false 210 + m.activeView = ViewList 211 + 212 + if repos, err := loadRepos(m.repoDir); err == nil { 213 + m.repos = repos 214 + items := make([]list.Item, len(repos)) 215 + for i, repo := range repos { 216 + items[i] = repoItem{repo: repo} 217 + } 218 + m.repoList.SetItems(items) 219 + } 220 + } 221 + 222 + case ViewConfirmDelete: 223 + switch { 224 + case key.Matches(msg, m.keys.Confirm): 225 + selectedItem := m.repoList.SelectedItem().(repoItem) 226 + repo := selectedItem.repo 227 + 228 + if err := git.DeleteRepo(repo.Path()); err != nil { 229 + m.repoList.NewStatusMessage(fmt.Sprintf("Error deleting repo: %s", err)) 230 + } else { 231 + m.repoList.NewStatusMessage(fmt.Sprintf("Repository %s deleted", repo.Name())) 232 + 233 + if repos, err := loadRepos(m.repoDir); err == nil { 234 + m.repos = repos 235 + items := make([]list.Item, len(repos)) 236 + for i, repo := range repos { 237 + items[i] = repoItem{repo: repo} 238 + } 239 + m.repoList.SetItems(items) 240 + } 241 + } 242 + m.activeView = ViewList 243 + 244 + case key.Matches(msg, m.keys.Cancel): 245 + m.activeView = ViewList 246 + } 247 + } 248 + 249 + case tea.WindowSizeMsg: 250 + m.width = msg.Width 251 + m.height = msg.Height 252 + 253 + headerHeight := 3 254 + footerHeight := 2 255 + 256 + m.repoList.SetSize(msg.Width, msg.Height-headerHeight-footerHeight) 257 + m.repoForm.setSize(msg.Width, msg.Height) 258 + 259 + m.help.Width = msg.Width 260 + } 261 + 262 + return m, tea.Batch(cmds...) 263 + } 264 + 265 + // View renders the current UI 266 + func (m Model) View() string { 267 + switch m.activeView { 268 + case ViewList: 269 + return fmt.Sprintf("%s\n%s", m.repoList.View(), m.help.View(m.keys)) 270 + 271 + case ViewForm: 272 + return m.repoForm.View() 273 + 274 + case ViewConfirmDelete: 275 + selectedItem := m.repoList.SelectedItem().(repoItem) 276 + repo := selectedItem.repo 277 + 278 + confirmStyle := lipgloss.NewStyle(). 279 + BorderStyle(lipgloss.RoundedBorder()). 280 + BorderForeground(lipgloss.Color("170")). 281 + Padding(1, 2). 282 + Width(m.width - 4). 283 + Align(lipgloss.Center) 284 + 285 + confirmText := fmt.Sprintf( 286 + "Are you sure you want to delete repository '%s'?\n\nThis action cannot be undone!\n\nPress y to confirm or n to cancel.", 287 + repo.Name(), 288 + ) 289 + 290 + return confirmStyle.Render(confirmText) 291 + } 292 + 293 + return "" 294 + } 295 + 296 + // Start runs the TUI 297 + func Start(s ssh.Session, repoDir string) error { 298 + model, err := New(s, repoDir) 299 + if err != nil { 300 + return err 301 + } 302 + 303 + // Get terminal dimensions from SSH session if available 304 + pty, _, isPty := s.Pty() 305 + if isPty && pty.Window.Width > 0 && pty.Window.Height > 0 { 306 + // Initialize with correct size 307 + model.width = pty.Window.Width 308 + model.height = pty.Window.Height 309 + 310 + headerHeight := 3 311 + footerHeight := 2 312 + model.repoList.SetSize(pty.Window.Width, pty.Window.Height-headerHeight-footerHeight) 313 + model.repoForm.setSize(pty.Window.Width, pty.Window.Height) 314 + model.help.Width = pty.Window.Width 315 + } 316 + 317 + p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion(), tea.WithInput(s), tea.WithOutput(s)) 318 + 319 + _, err = p.Run() 320 + return err 321 + }