From d87caf61fd67b5618db44968e560732f66890390 Mon Sep 17 00:00:00 2001 From: jolheiser Date: Tue, 10 Jun 2025 09:28:18 -0500 Subject: [PATCH] tui --- cmd/ugitd/args.go | 2 + cmd/ugitd/main.go | 9 ++ go.mod | 19 ++- go.sum | 44 +++-- internal/git/meta.go | 3 + internal/git/repo_utils.go | 61 +++++++ internal/ssh/wish.go | 43 ++--- internal/tui/form.go | 230 ++++++++++++++++++++++++++ internal/tui/keymap.go | 65 ++++++++ internal/tui/main.go | 50 ++++++ internal/tui/repo_item.go | 72 +++++++++ internal/tui/tui.go | 321 +++++++++++++++++++++++++++++++++++++ 12 files changed, 883 insertions(+), 36 deletions(-) create mode 100644 internal/git/repo_utils.go create mode 100644 internal/tui/form.go create mode 100644 internal/tui/keymap.go create mode 100644 internal/tui/main.go create mode 100644 internal/tui/repo_item.go create mode 100644 internal/tui/tui.go diff --git a/cmd/ugitd/args.go b/cmd/ugitd/args.go index 5dd586a..df6dd7e 100644 --- a/cmd/ugitd/args.go +++ b/cmd/ugitd/args.go @@ -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 { diff --git a/cmd/ugitd/main.go b/cmd/ugitd/main.go index afb48cd..497c743 100644 --- a/cmd/ugitd/main.go +++ b/cmd/ugitd/main.go @@ -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() { @@ -39,6 +40,14 @@ func main() { if err != nil { 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{ diff --git a/go.mod b/go.mod index 1f0c033..72dc41d 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 6c331a1..4fab0c2 100644 --- a/go.sum +++ b/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= diff --git a/internal/git/meta.go b/internal/git/meta.go index 0a13833..d84ab83 100644 --- a/internal/git/meta.go +++ b/internal/git/meta.go @@ -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) } diff --git a/internal/git/repo_utils.go b/internal/git/repo_utils.go new file mode 100644 index 0000000..003098d --- /dev/null +++ b/internal/git/repo_utils.go @@ -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 +} \ No newline at end of file diff --git a/internal/ssh/wish.go b/internal/ssh/wish.go index bdf504b..2c6c7f6 100644 --- a/internal/ssh/wish.go +++ b/internal/ssh/wish.go @@ -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,28 +100,34 @@ func Middleware(repoDir string, cloneURL string, port int, gh Hooks) wish.Middle } } - // Repo list + // No args, start TUI if len(cmd) == 0 { - des, err := os.ReadDir(repoDir) - if err != nil && err != fs.ErrNotExist { - slog.Error("invalid repository", "error", err) - } - tw := tabwriter.NewWriter(s, 0, 0, 1, ' ', 0) - for _, de := range des { - if filepath.Ext(de.Name()) != ".git" { - continue + 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) } - repo, err := git.NewRepo(repoDir, de.Name()) - visibility := "❓" - if err == nil { - visibility = "🔓" - if repo.Meta.Private { - visibility = "🔒" + tw := tabwriter.NewWriter(s, 0, 0, 1, ' ', 0) + for _, de := range des { + if filepath.Ext(de.Name()) != ".git" { + continue } + repo, err := git.NewRepo(repoDir, de.Name()) + visibility := "❓" + if err == nil { + visibility = "🔓" + if repo.Meta.Private { + visibility = "🔒" + } + } + fmt.Fprintf(tw, "%[1]s\t%[3]s\t%[2]s/%[1]s.git\n", strings.TrimSuffix(de.Name(), ".git"), cloneURL, visibility) } - fmt.Fprintf(tw, "%[1]s\t%[3]s\t%[2]s/%[1]s.git\n", strings.TrimSuffix(de.Name(), ".git"), cloneURL, visibility) + tw.Flush() } - tw.Flush() + return } sh(s) } @@ -174,4 +181,4 @@ func Fatal(s ssh.Session, v ...interface{}) { pktLine := fmt.Sprintf("%04x%s\n", len(msg)+5, msg) _, _ = wish.WriteString(s, pktLine) s.Exit(1) // nolint: errcheck -} +} \ No newline at end of file diff --git a/internal/tui/form.go b/internal/tui/form.go new file mode 100644 index 0000000..db32f2e --- /dev/null +++ b/internal/tui/form.go @@ -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()) +} diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go new file mode 100644 index 0000000..4357d52 --- /dev/null +++ b/internal/tui/keymap.go @@ -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"), + ), +} \ No newline at end of file diff --git a/internal/tui/main.go b/internal/tui/main.go new file mode 100644 index 0000000..7209857 --- /dev/null +++ b/internal/tui/main.go @@ -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 +} diff --git a/internal/tui/repo_item.go b/internal/tui/repo_item.go new file mode 100644 index 0000000..672d8ab --- /dev/null +++ b/internal/tui/repo_item.go @@ -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()) +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..607b75a --- /dev/null +++ b/internal/tui/tui.go @@ -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 +}