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