mirror of https://git.jolheiser.com/ugit.git
tui
parent
86aa09929f
commit
d87caf61fd
|
@ -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 {
|
||||
|
|
|
@ -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
19
go.mod
|
@ -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
44
go.sum
|
@ -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=
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
|
@ -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"),
|
||||
),
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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())
|
||||
}
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue