jolheiser 2025-06-10 09:28:18 -05:00
parent 86aa09929f
commit d87caf61fd
No known key found for this signature in database
12 changed files with 883 additions and 36 deletions

View File

@ -18,6 +18,7 @@ type cliArgs struct {
Profile profileArgs
Log logArgs
ShowPrivate bool
TUI bool
}
type sshArgs struct {
@ -114,6 +115,7 @@ func parseArgs(args []string) (c cliArgs, e error) {
fs.StringVar(&c.Meta.Description, "meta.description", c.Meta.Description, "App description")
fs.StringVar(&c.Profile.Username, "profile.username", c.Profile.Username, "Username for index page")
fs.StringVar(&c.Profile.Email, "profile.email", c.Profile.Email, "Email for index page")
fs.BoolVar(&c.TUI, "tui", c.TUI, "Run the TUI interface directly")
fs.Func("profile.links", "Link(s) for index page", func(s string) error {
parts := strings.SplitN(s, ",", 2)
if len(parts) != 2 {

View File

@ -20,6 +20,7 @@ import (
"go.jolheiser.com/ugit/internal/git"
"go.jolheiser.com/ugit/internal/http"
"go.jolheiser.com/ugit/internal/ssh"
"go.jolheiser.com/ugit/internal/tui"
)
func main() {
@ -40,6 +41,14 @@ func main() {
panic(err)
}
// Run TUI mode if requested
if args.TUI {
if err := tui.Run(args.RepoDir); err != nil {
panic(err)
}
return
}
slog.SetLogLoggerLevel(args.Log.Level)
middleware.DefaultLogger = httplog.RequestLogger(httplog.NewLogger("ugit", httplog.Options{
JSON: args.Log.JSON,

19
go.mod
View File

@ -7,6 +7,9 @@ toolchain go1.23.3
require (
github.com/alecthomas/assert/v2 v2.11.0
github.com/alecthomas/chroma/v2 v2.15.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.5
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/ssh v0.0.0-20241211182756-4fe22b0f1b7c
github.com/charmbracelet/wish v1.4.4
github.com/dustin/go-humanize v1.0.1
@ -28,14 +31,16 @@ require (
github.com/ProtonMail/go-crypto v1.1.4 // indirect
github.com/alecthomas/repr v0.4.0 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbletea v1.2.4 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/keygen v0.5.1 // indirect
github.com/charmbracelet/lipgloss v1.0.0 // indirect
github.com/charmbracelet/log v0.4.0 // indirect
github.com/charmbracelet/x/ansi v0.6.0 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/conpty v0.1.0 // indirect
github.com/charmbracelet/x/errors v0.0.0-20250107110353-48b574af22a5 // indirect
github.com/charmbracelet/x/input v0.2.0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/charmbracelet/x/termios v0.1.0 // indirect
github.com/cloudflare/circl v1.5.0 // indirect
@ -58,18 +63,20 @@ require (
github.com/mmcloughlin/avo v0.6.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.3-0.20240509142007-81b8f94111d5 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pjbgf/sha1cd v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.0 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.1-0.20250107080300-1c14dcadc3ab // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.29.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect

44
go.sum
View File

@ -17,26 +17,40 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE=
github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc=
github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/keygen v0.5.1 h1:zBkkYPtmKDVTw+cwUyY6ZwGDhRxXkEp0Oxs9sqMLqxI=
github.com/charmbracelet/keygen v0.5.1/go.mod h1:zznJVmK/GWB6dAtjluqn2qsttiCBhA5MZSiwb80fcHw=
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
github.com/charmbracelet/ssh v0.0.0-20241211182756-4fe22b0f1b7c h1:treQxMBdI2PaD4eOYfFux8stfCkUxhuUxaqGcxKqVpI=
github.com/charmbracelet/ssh v0.0.0-20241211182756-4fe22b0f1b7c/go.mod h1:CY1xbl2z+ZeBmNWItKZyxx0zgDgnhmR57+DTsHOobJ4=
github.com/charmbracelet/wish v1.4.4 h1:wtfoAMkf8Db9zi+9Lme2f7XKMxL6BqfgDWbqcTUHLaU=
github.com/charmbracelet/wish v1.4.4/go.mod h1:XB8v51UxIFMRlUod9lLaAgOsj/wpe+qW9HjsoYIiNMo=
github.com/charmbracelet/x/ansi v0.6.0 h1:qOznutrb93gx9oMiGf7caF7bqqubh6YIM0SWKyA08pA=
github.com/charmbracelet/x/ansi v0.6.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20250107110353-48b574af22a5 h1:Hx72S6S4jAfrrWE3pv9IbudVdUV4htBgkOX800o17Bk=
github.com/charmbracelet/x/errors v0.0.0-20250107110353-48b574af22a5/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/input v0.2.0 h1:1Sv+y/flcqUfUH2PXNIDKDIdT2G8smOnGOgawqhwy8A=
github.com/charmbracelet/x/input v0.2.0/go.mod h1:KUSFIS6uQymtnr5lHVSOK9j8RvwTD4YHnWnzJUYnd/M=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k=
@ -96,6 +110,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@ -110,8 +126,8 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.15.3-0.20240509142007-81b8f94111d5 h1:NiONcKK0EV5gUZcnCiPMORaZA0eBDc+Fgepl9xl4lZ8=
github.com/muesli/termenv v0.15.3-0.20240509142007-81b8f94111d5/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc=
@ -128,6 +144,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
@ -141,6 +159,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
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=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
@ -159,8 +179,8 @@ golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -169,8 +189,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.1-0.20250107080300-1c14dcadc3ab h1:BMkEEWYOjkvOX7+YKOGbp6jCyQ5pR2j0Ah47p1Vdsx4=
golang.org/x/sys v0.29.1-0.20250107080300-1c14dcadc3ab/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=

View File

@ -57,6 +57,9 @@ func (t *TagSet) UnmarshalJSON(b []byte) error {
if err := json.Unmarshal(b, &s); err != nil {
return err
}
if *t == nil {
*t = make(TagSet)
}
for _, ss := range s {
t.Add(ss)
}

View File

@ -0,0 +1,61 @@
package git
import (
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
)
// ListRepos returns all directory entries in the given directory
func ListRepos(dir string) ([]fs.DirEntry, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return []fs.DirEntry{}, nil
}
return nil, err
}
return entries, nil
}
// DeleteRepo deletes a git repository from the filesystem
func DeleteRepo(repoPath string) error {
return os.RemoveAll(repoPath)
}
// RenameRepo renames a git repository
func RenameRepo(repoDir, oldName, newName string) error {
if !filepath.IsAbs(repoDir) {
return errors.New("repository directory must be an absolute path")
}
if !filepath.IsAbs(oldName) && !filepath.IsAbs(newName) {
oldPath := filepath.Join(repoDir, oldName)
if !strings.HasSuffix(oldPath, ".git") {
oldPath += ".git"
}
newPath := filepath.Join(repoDir, newName)
if !strings.HasSuffix(newPath, ".git") {
newPath += ".git"
}
return os.Rename(oldPath, newPath)
}
return errors.New("repository names should not be absolute paths")
}
// RepoPathExists checks if a path exists
func RepoPathExists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if errors.Is(err, fs.ErrNotExist) {
return false, nil
}
return false, err
}

View File

@ -12,6 +12,7 @@ import (
"text/tabwriter"
"go.jolheiser.com/ugit/internal/git"
"go.jolheiser.com/ugit/internal/tui"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
@ -99,8 +100,12 @@ func Middleware(repoDir string, cloneURL string, port int, gh Hooks) wish.Middle
}
}
// Repo list
// No args, start TUI
if len(cmd) == 0 {
if err := tui.Start(s, repoDir); err != nil {
slog.Error("failed to start TUI", "error", err)
// Fall back to simple list on TUI error
des, err := os.ReadDir(repoDir)
if err != nil && err != fs.ErrNotExist {
slog.Error("invalid repository", "error", err)
@ -122,6 +127,8 @@ func Middleware(repoDir string, cloneURL string, port int, gh Hooks) wish.Middle
}
tw.Flush()
}
return
}
sh(s)
}
}

View File

@ -0,0 +1,230 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"go.jolheiser.com/ugit/internal/git"
)
type repoForm struct {
inputs []textinput.Model
isPrivate bool
focusIndex int
width int
height int
done bool
save bool
selectedRepo *git.Repo
}
// newRepoForm creates a new repository editing form
func newRepoForm() repoForm {
var inputs []textinput.Model
nameInput := textinput.New()
nameInput.Placeholder = "Repository name"
nameInput.Focus()
nameInput.Width = 50
inputs = append(inputs, nameInput)
descInput := textinput.New()
descInput.Placeholder = "Repository description"
descInput.Width = 50
inputs = append(inputs, descInput)
tagsInput := textinput.New()
tagsInput.Placeholder = "Tags (comma separated)"
tagsInput.Width = 50
inputs = append(inputs, tagsInput)
return repoForm{
inputs: inputs,
focusIndex: 0,
}
}
// setValues sets the form values from the selected repo
func (f *repoForm) setValues(repo *git.Repo) {
f.inputs[0].SetValue(repo.Name())
f.inputs[1].SetValue(repo.Meta.Description)
f.inputs[2].SetValue(strings.Join(repo.Meta.Tags.Slice(), ", "))
f.isPrivate = repo.Meta.Private
f.inputs[0].Focus()
f.focusIndex = 0
}
// setSize sets the form dimensions
func (f *repoForm) setSize(width, height int) {
f.width = width
f.height = height
for i := range f.inputs {
f.inputs[i].Width = width - 10
}
}
// isPrivateToggleFocused returns true if the private toggle is focused
func (f *repoForm) isPrivateToggleFocused() bool {
return f.focusIndex == len(f.inputs)
}
// isSaveButtonFocused returns true if the save button is focused
func (f *repoForm) isSaveButtonFocused() bool {
return f.focusIndex == len(f.inputs)+1
}
// isCancelButtonFocused returns true if the cancel button is focused
func (f *repoForm) isCancelButtonFocused() bool {
return f.focusIndex == len(f.inputs)+2
}
// Update handles form updates
func (f repoForm) Update(msg tea.Msg) (repoForm, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "tab", "shift+tab", "up", "down":
if msg.String() == "up" || msg.String() == "shift+tab" {
f.focusIndex--
if f.focusIndex < 0 {
f.focusIndex = len(f.inputs) + 3 - 1
}
} else {
f.focusIndex++
if f.focusIndex >= len(f.inputs)+3 {
f.focusIndex = 0
}
}
for i := range f.inputs {
if i == f.focusIndex {
cmds = append(cmds, f.inputs[i].Focus())
} else {
f.inputs[i].Blur()
}
}
case "enter":
if f.isSaveButtonFocused() {
f.done = true
f.save = true
return f, nil
}
if f.isCancelButtonFocused() {
f.done = true
f.save = false
return f, nil
}
case "esc":
f.done = true
f.save = false
return f, nil
case " ":
if f.isPrivateToggleFocused() {
f.isPrivate = !f.isPrivate
}
if f.isSaveButtonFocused() {
f.done = true
f.save = true
return f, nil
}
if f.isCancelButtonFocused() {
f.done = true
f.save = false
return f, nil
}
}
}
for i := range f.inputs {
if i == f.focusIndex {
var cmd tea.Cmd
f.inputs[i], cmd = f.inputs[i].Update(msg)
cmds = append(cmds, cmd)
}
}
return f, tea.Batch(cmds...)
}
// View renders the form
func (f repoForm) View() string {
var b strings.Builder
formStyle := lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("170")).
Padding(1, 2)
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("170")).
MarginBottom(1)
b.WriteString(titleStyle.Render("Edit Repository"))
b.WriteString("\n\n")
b.WriteString("Repository Name:\n")
b.WriteString(f.inputs[0].View())
b.WriteString("\n\n")
b.WriteString("Description:\n")
b.WriteString(f.inputs[1].View())
b.WriteString("\n\n")
b.WriteString("Tags (comma separated):\n")
b.WriteString(f.inputs[2].View())
b.WriteString("\n\n")
toggleStyle := lipgloss.NewStyle()
if f.isPrivateToggleFocused() {
toggleStyle = toggleStyle.Foreground(lipgloss.Color("170")).Bold(true)
}
visibility := "Public 🔓"
if f.isPrivate {
visibility = "Private 🔒"
}
b.WriteString(toggleStyle.Render(fmt.Sprintf("[%s] %s", visibility, "Toggle with Space")))
b.WriteString("\n\n")
buttonStyle := lipgloss.NewStyle().
Padding(0, 3).
MarginRight(1)
focusedButtonStyle := buttonStyle.Copy().
Foreground(lipgloss.Color("0")).
Background(lipgloss.Color("170")).
Bold(true)
saveButton := buttonStyle.Render("[ Save ]")
cancelButton := buttonStyle.Render("[ Cancel ]")
if f.isSaveButtonFocused() {
saveButton = focusedButtonStyle.Render("[ Save ]")
}
if f.isCancelButtonFocused() {
cancelButton = focusedButtonStyle.Render("[ Cancel ]")
}
b.WriteString(saveButton + cancelButton)
b.WriteString("\n\n")
b.WriteString("\nTab: Next • Shift+Tab: Previous • Enter: Select • Esc: Cancel")
return formStyle.Width(f.width - 4).Render(b.String())
}

View File

@ -0,0 +1,65 @@
package tui
import (
"github.com/charmbracelet/bubbles/key"
)
// keyMap defines the keybindings for the TUI
type keyMap struct {
Up key.Binding
Down key.Binding
Edit key.Binding
Delete key.Binding
Help key.Binding
Quit key.Binding
Confirm key.Binding
Cancel key.Binding
}
// ShortHelp returns keybindings to be shown in the mini help view.
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Edit, k.Delete, k.Quit}
}
// FullHelp returns keybindings for the expanded help view.
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.Edit},
{k.Delete, k.Help, k.Quit},
}
}
var keys = keyMap{
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
Edit: key.NewBinding(
key.WithKeys("e"),
key.WithHelp("e", "edit"),
),
Delete: key.NewBinding(
key.WithKeys("d"),
key.WithHelp("d", "delete"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "help"),
),
Quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "quit"),
),
Confirm: key.NewBinding(
key.WithKeys("y"),
key.WithHelp("y", "confirm"),
),
Cancel: key.NewBinding(
key.WithKeys("n", "esc"),
key.WithHelp("n", "cancel"),
),
}

View File

@ -0,0 +1,50 @@
package tui
import (
"fmt"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Run runs the TUI standalone, useful for development or local usage
func Run(repoDir string) error {
model := Model{
repoDir: repoDir,
help: help.New(),
keys: keys,
activeView: ViewList,
repoForm: newRepoForm(),
}
repos, err := loadRepos(repoDir)
if err != nil {
return fmt.Errorf("failed to load repos: %w", err)
}
model.repos = repos
items := make([]list.Item, len(repos))
for i, repo := range repos {
items[i] = repoItem{repo: repo}
}
delegate := list.NewDefaultDelegate()
delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Foreground(lipgloss.Color("170"))
delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(lipgloss.Color("244"))
repoList := list.New(items, delegate, 0, 0)
repoList.Title = "Git Repositories"
repoList.SetShowStatusBar(true)
repoList.SetFilteringEnabled(true)
repoList.Styles.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Padding(0, 0, 0, 2)
repoList.StatusMessageLifetime = 3
model.repoList = repoList
p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())
_, err = p.Run()
return err
}

View File

@ -0,0 +1,72 @@
package tui
import (
"strings"
"go.jolheiser.com/ugit/internal/git"
)
// repoItem represents a repository item in the list
type repoItem struct {
repo *git.Repo
}
// Title returns the title for the list item
func (r repoItem) Title() string {
return r.repo.Name()
}
// Description returns the description for the list item
func (r repoItem) Description() string {
var builder strings.Builder
if r.repo.Meta.Private {
builder.WriteString("🔒")
} else {
builder.WriteString("🔓")
}
builder.WriteString(" • ")
if r.repo.Meta.Description != "" {
builder.WriteString(r.repo.Meta.Description)
} else {
builder.WriteString("No description")
}
builder.WriteString(" • ")
builder.WriteString("[")
if len(r.repo.Meta.Tags) > 0 {
builder.WriteString(strings.Join(r.repo.Meta.Tags.Slice(), ", "))
}
builder.WriteString("]")
builder.WriteString(" • ")
lastCommit, err := r.repo.LastCommit()
if err == nil {
builder.WriteString(lastCommit.Short())
} else {
builder.WriteString("deadbeef")
}
return builder.String()
}
// FilterValue returns the value to use for filtering
func (r repoItem) FilterValue() string {
var builder strings.Builder
builder.WriteString(r.repo.Name())
builder.WriteString(" ")
builder.WriteString(r.repo.Meta.Description)
if len(r.repo.Meta.Tags) > 0 {
for _, tag := range r.repo.Meta.Tags.Slice() {
builder.WriteString(" ")
builder.WriteString(tag)
}
}
return strings.ToLower(builder.String())
}

321
internal/tui/tui.go 100644
View File

@ -0,0 +1,321 @@
package tui
import (
"fmt"
"log/slog"
"path/filepath"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/ssh"
"go.jolheiser.com/ugit/internal/git"
)
// Model is the main TUI model
type Model struct {
repoList list.Model
repos []*git.Repo
repoDir string
width int
height int
help help.Model
keys keyMap
activeView View
repoForm repoForm
session ssh.Session
}
// View represents the current active view in the TUI
type View int
const (
ViewList View = iota
ViewForm
ViewConfirmDelete
)
// New creates a new TUI model
func New(s ssh.Session, repoDir string) (*Model, error) {
repos, err := loadRepos(repoDir)
if err != nil {
return nil, fmt.Errorf("failed to load repos: %w", err)
}
items := make([]list.Item, len(repos))
for i, repo := range repos {
items[i] = repoItem{repo: repo}
}
delegate := list.NewDefaultDelegate()
delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Foreground(lipgloss.Color("170"))
delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(lipgloss.Color("244"))
repoList := list.New(items, delegate, 0, 0)
repoList.Title = "Git Repositories"
repoList.SetShowStatusBar(true)
repoList.SetFilteringEnabled(true)
repoList.Styles.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Padding(0, 0, 0, 2)
repoList.StatusMessageLifetime = 3
repoList.FilterInput.Placeholder = "Type to filter repositories..."
repoList.FilterInput.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("170"))
repoList.FilterInput.TextStyle = lipgloss.NewStyle()
help := help.New()
repoForm := newRepoForm()
return &Model{
repoList: repoList,
repos: repos,
repoDir: repoDir,
help: help,
keys: keys,
activeView: ViewList,
repoForm: repoForm,
session: s,
}, nil
}
// loadRepos loads all git repositories from the given directory
func loadRepos(repoDir string) ([]*git.Repo, error) {
entries, err := git.ListRepos(repoDir)
if err != nil {
return nil, err
}
repos := make([]*git.Repo, 0, len(entries))
for _, entry := range entries {
if !strings.HasSuffix(entry.Name(), ".git") {
continue
}
repo, err := git.NewRepo(repoDir, entry.Name())
if err != nil {
slog.Error("error loading repo", "name", entry.Name(), "error", err)
continue
}
repos = append(repos, repo)
}
return repos, nil
}
// Init initializes the model
func (m Model) Init() tea.Cmd {
return nil
}
// Update handles all the messages and updates the model accordingly
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.help.ShowAll = !m.help.ShowAll
}
switch m.activeView {
case ViewList:
var cmd tea.Cmd
m.repoList, cmd = m.repoList.Update(msg)
cmds = append(cmds, cmd)
if m.repoList.FilterState() == list.Filtering {
break
}
switch {
case key.Matches(msg, m.keys.Edit):
if len(m.repos) == 0 {
m.repoList.NewStatusMessage("No repositories to edit")
break
}
selectedItem := m.repoList.SelectedItem().(repoItem)
m.repoForm.selectedRepo = selectedItem.repo
m.repoForm.setValues(selectedItem.repo)
m.activeView = ViewForm
return m, textinput.Blink
case key.Matches(msg, m.keys.Delete):
if len(m.repos) == 0 {
m.repoList.NewStatusMessage("No repositories to delete")
break
}
m.activeView = ViewConfirmDelete
}
case ViewForm:
var cmd tea.Cmd
m.repoForm, cmd = m.repoForm.Update(msg)
cmds = append(cmds, cmd)
if m.repoForm.done {
if m.repoForm.save {
selectedRepo := m.repoForm.selectedRepo
repoDir := filepath.Dir(selectedRepo.Path())
oldName := selectedRepo.Name()
newName := m.repoForm.inputs[0].Value()
var renamed bool
if oldName != newName {
if err := git.RenameRepo(repoDir, oldName, newName); err != nil {
m.repoList.NewStatusMessage(fmt.Sprintf("Error renaming repo: %s", err))
} else {
m.repoList.NewStatusMessage(fmt.Sprintf("Repository renamed from %s to %s", oldName, newName))
renamed = true
}
}
if renamed {
if newRepo, err := git.NewRepo(repoDir, newName+".git"); err == nil {
selectedRepo = newRepo
} else {
m.repoList.NewStatusMessage(fmt.Sprintf("Error loading renamed repo: %s", err))
}
}
selectedRepo.Meta.Description = m.repoForm.inputs[1].Value()
selectedRepo.Meta.Private = m.repoForm.isPrivate
tags := make(git.TagSet)
for _, tag := range strings.Split(m.repoForm.inputs[2].Value(), ",") {
tag = strings.TrimSpace(tag)
if tag != "" {
tags.Add(tag)
}
}
selectedRepo.Meta.Tags = tags
if err := selectedRepo.SaveMeta(); err != nil {
m.repoList.NewStatusMessage(fmt.Sprintf("Error saving repo metadata: %s", err))
} else if !renamed {
m.repoList.NewStatusMessage("Repository updated successfully")
}
}
m.repoForm.done = false
m.repoForm.save = false
m.activeView = ViewList
if repos, err := loadRepos(m.repoDir); err == nil {
m.repos = repos
items := make([]list.Item, len(repos))
for i, repo := range repos {
items[i] = repoItem{repo: repo}
}
m.repoList.SetItems(items)
}
}
case ViewConfirmDelete:
switch {
case key.Matches(msg, m.keys.Confirm):
selectedItem := m.repoList.SelectedItem().(repoItem)
repo := selectedItem.repo
if err := git.DeleteRepo(repo.Path()); err != nil {
m.repoList.NewStatusMessage(fmt.Sprintf("Error deleting repo: %s", err))
} else {
m.repoList.NewStatusMessage(fmt.Sprintf("Repository %s deleted", repo.Name()))
if repos, err := loadRepos(m.repoDir); err == nil {
m.repos = repos
items := make([]list.Item, len(repos))
for i, repo := range repos {
items[i] = repoItem{repo: repo}
}
m.repoList.SetItems(items)
}
}
m.activeView = ViewList
case key.Matches(msg, m.keys.Cancel):
m.activeView = ViewList
}
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
headerHeight := 3
footerHeight := 2
m.repoList.SetSize(msg.Width, msg.Height-headerHeight-footerHeight)
m.repoForm.setSize(msg.Width, msg.Height)
m.help.Width = msg.Width
}
return m, tea.Batch(cmds...)
}
// View renders the current UI
func (m Model) View() string {
switch m.activeView {
case ViewList:
return fmt.Sprintf("%s\n%s", m.repoList.View(), m.help.View(m.keys))
case ViewForm:
return m.repoForm.View()
case ViewConfirmDelete:
selectedItem := m.repoList.SelectedItem().(repoItem)
repo := selectedItem.repo
confirmStyle := lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("170")).
Padding(1, 2).
Width(m.width - 4).
Align(lipgloss.Center)
confirmText := fmt.Sprintf(
"Are you sure you want to delete repository '%s'?\n\nThis action cannot be undone!\n\nPress y to confirm or n to cancel.",
repo.Name(),
)
return confirmStyle.Render(confirmText)
}
return ""
}
// Start runs the TUI
func Start(s ssh.Session, repoDir string) error {
model, err := New(s, repoDir)
if err != nil {
return err
}
// Get terminal dimensions from SSH session if available
pty, _, isPty := s.Pty()
if isPty && pty.Window.Width > 0 && pty.Window.Height > 0 {
// Initialize with correct size
model.width = pty.Window.Width
model.height = pty.Window.Height
headerHeight := 3
footerHeight := 2
model.repoList.SetSize(pty.Window.Width, pty.Window.Height-headerHeight-footerHeight)
model.repoForm.setSize(pty.Window.Width, pty.Window.Height)
model.help.Width = pty.Window.Width
}
p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion(), tea.WithInput(s), tea.WithOutput(s))
_, err = p.Run()
return err
}