Home

ugit @main - refs - log -
-
https://git.jolheiser.com/ugit.git
The code powering this h*ckin' site
tree log patch
tui: init
Signature
-----BEGIN SSH SIGNATURE----- U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgBTEvCQk6VqUAdN2RuH6bj1dNkY oOpbPWj+jw4ua1B1cAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 AAAAQKkOmXcQrCGu11kljmogxpB2laeo31VOMZpSMzN7iHaVHIDwEo+HGAA6wjcTnriXuf 8rfIhQ1hB31bD2eyfycwQ= -----END SSH SIGNATURE-----
jolheiser <git@jolheiser.com>
11 hours ago
6 changed files, 272 additions(+), 11 deletions(-)
go.modgo.mod.srigo.suminternal/ssh/ssh.gointernal/ssh/tui.gointernal/ssh/wish.go
M go.mod -> go.mod
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
diff --git a/go.mod b/go.mod
index 4d2ca0fe7f8fdedae6d8ed700ab03d058fdd2472..84dfa2cc0a6e139bfb97a2d4b1ee09d11b7fe71c 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
 	github.com/a-h/templ v0.3.1020
 	github.com/alecthomas/assert/v2 v2.11.0
 	github.com/alecthomas/chroma/v2 v2.27.0
+	github.com/charmbracelet/bubbletea v1.3.10
 	github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
 	github.com/charmbracelet/wish v1.4.7
 	github.com/dustin/go-humanize v1.0.1
@@ -33,7 +34,6 @@ 	github.com/andybalholm/brotli v1.1.0 // indirect
 	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
-	github.com/charmbracelet/bubbletea v1.3.10 // indirect
 	github.com/charmbracelet/colorprofile v0.4.3 // indirect
 	github.com/charmbracelet/keygen v0.5.4 // indirect
 	github.com/charmbracelet/lipgloss v1.1.0 // indirect
@@ -41,8 +41,10 @@ 	github.com/charmbracelet/log v1.0.0 // indirect
 	github.com/charmbracelet/x/ansi v0.11.7 // indirect
 	github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
 	github.com/charmbracelet/x/conpty v0.2.0 // indirect
+	github.com/charmbracelet/x/input v0.3.4 // indirect
 	github.com/charmbracelet/x/term v0.2.2 // indirect
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
+	github.com/charmbracelet/x/windows v0.2.0 // indirect
 	github.com/cli/browser v1.3.0 // indirect
 	github.com/clipperhouse/displaywidth v0.11.0 // indirect
 	github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
M go.mod.sri -> go.mod.sri
1
2
3
4
5
6
7
8
9
diff --git a/go.mod.sri b/go.mod.sri
index 0a4c6f04e07d90192484c28c81317d46870f2830..8619d03f24c6afdbff0414d086b64d31745dbb9d 100644
--- a/go.mod.sri
+++ b/go.mod.sri
@@ -1 +1 @@
-sha256-1r7CDfQqopP/0bSfqINrJcH3pwkwxW7G0DD4G1sAsLk=
\ No newline at end of file
+sha256-i5ALTkq66Idtb4Z7+fadV08G6xOqERBYjqMb7c+dRdI=
\ No newline at end of file
M go.sum -> go.sum
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
diff --git a/go.sum b/go.sum
index 0143b8596d5c9edebd42d55a222888808a3d6f61..172106093375fd38eacc1c357c532d46535a04ba 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,5 @@
-cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 h1:Zh+Ur3OsoWpvALHPLT45nOekHkgOt+IOfutBbPqM17I=
-cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8=
 cuelabs.dev/go/oci/ociregistry v0.0.0-20260601085548-328ff8e2c943 h1:XUtzi/yWlmuy8V6kkmVbbmirmUqcFe9Ce3gmEaHXf1Q=
 cuelabs.dev/go/oci/ociregistry v0.0.0-20260601085548-328ff8e2c943/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8=
-cuelang.org/go v0.16.1 h1:iPN1lHZd2J0hjcr8hfq9PnIGk7VfPkKFfxH4de+m9sE=
-cuelang.org/go v0.16.1/go.mod h1:/aW3967FeWC5Hc1cDrN4Z4ICVApdMi83wO5L3uF/1hM=
 cuelang.org/go v0.17.1 h1:liOkxZDqTHrzq0USJX+6bMYOZ5PSf+wzvQr15AHpDCQ=
 cuelang.org/go v0.17.1/go.mod h1:xlly/o1wSLvxOsi5vkQGieU0rLOt7TvUIizOFtnxHRU=
 dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
@@ -55,10 +51,14 @@ github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
 github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
 github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY=
 github.com/charmbracelet/x/conpty v0.2.0/go.mod h1:fexgUnVrZgw8scD49f6VSi0Ggj9GWYIrpedRthAwW/8=
+github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0=
+github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
 github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
 github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
+github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
 github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
 github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
 github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
@@ -111,8 +111,8 @@ github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
 github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
 github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
 github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
-github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
-github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
+github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
+github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -182,8 +182,6 @@ github.com/protocolbuffers/txtpbfmt v0.0.0-20260420112717-c39628bde8b5 h1:Mckui8l+Wqz2Ve7XQvsE8SbHNmDWu8NA7Xce5NFJ/kM=
 github.com/protocolbuffers/txtpbfmt v0.0.0-20260420112717-c39628bde8b5/go.mod h1:JSbkp0BviKovYYt9XunS95M3mLPibE9bGg+Y95DsEEY=
 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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
-github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
 github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
 github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
 github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
M internal/ssh/ssh.go -> internal/ssh/ssh.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go
index e094092d5ee3f6c8cf5225af8e9c71f87dba72f2..ece95a37179c22515ca67ad533f27cc6233537fc 100644
--- a/internal/ssh/ssh.go
+++ b/internal/ssh/ssh.go
@@ -6,6 +6,7 @@ 	"log"
 
 	"github.com/charmbracelet/ssh"
 	"github.com/charmbracelet/wish"
+	bm "github.com/charmbracelet/wish/bubbletea"
 	"github.com/charmbracelet/wish/logging"
 )
 
@@ -25,6 +26,7 @@ 		wish.WithAuthorizedKeys(settings.AuthorizedKeys),
 		wish.WithAddress(fmt.Sprintf(":%d", settings.Port)),
 		wish.WithHostKeyPath(settings.HostKey),
 		wish.WithMiddleware(
+			bm.Middleware(tuiHandler(settings.RepoDir)),
 			Middleware(settings.RepoDir, settings.CloneURL, settings.Port, hooks{}),
 			logging.MiddlewareWithLogger(DefaultLogger),
 		),
I internal/ssh/tui.go
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
diff --git a/internal/ssh/tui.go b/internal/ssh/tui.go
new file mode 100644
index 0000000000000000000000000000000000000000..9fd362241879860a45a7dcd6e7330a4414568c00
--- /dev/null
+++ b/internal/ssh/tui.go
@@ -0,0 +1,258 @@
+package ssh
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"slices"
+	"strings"
+	"unicode/utf8"
+
+	"go.jolheiser.com/ugit/internal/git"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/ssh"
+	bm "github.com/charmbracelet/wish/bubbletea"
+)
+
+func tuiHandler(repoDir string) bm.Handler {
+	return func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
+		if _, _, ok := s.Pty(); !ok || len(s.Command()) > 0 {
+			return nil, nil
+		}
+		m := &model{repoDir: repoDir}
+		m.reload()
+		return m, []tea.ProgramOption{tea.WithAltScreen()}
+	}
+}
+
+type mode int
+
+const (
+	modeList mode = iota
+	modeEdit
+	modeDelete
+)
+
+const (
+	fieldDesc = iota
+	fieldPrivate
+)
+
+type model struct {
+	repoDir string
+	repos   []*git.Repo
+	cursor  int
+	mode    mode
+	err     error
+
+	// edit buffers
+	desc    []rune
+	pos     int // cursor position within desc
+	private bool
+	field   int
+}
+
+func (m *model) reload() {
+	m.repos = loadRepos(m.repoDir)
+	if m.cursor >= len(m.repos) {
+		m.cursor = max(len(m.repos)-1, 0)
+	}
+}
+
+func loadRepos(repoDir string) []*git.Repo {
+	des, err := os.ReadDir(repoDir)
+	if err != nil {
+		return nil
+	}
+	var repos []*git.Repo
+	for _, de := range des {
+		if filepath.Ext(de.Name()) != ".git" {
+			continue
+		}
+		repo, err := git.NewRepo(repoDir, de.Name())
+		if err != nil {
+			continue
+		}
+		repos = append(repos, repo)
+	}
+	return repos
+}
+
+func (m *model) Init() tea.Cmd { return nil }
+
+func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	key, ok := msg.(tea.KeyMsg)
+	if !ok {
+		return m, nil
+	}
+	if key.String() == "ctrl+c" {
+		return m, tea.Quit
+	}
+	switch m.mode {
+	case modeList:
+		return m.updateList(key)
+	case modeEdit:
+		return m.updateEdit(key)
+	case modeDelete:
+		return m.updateDelete(key)
+	}
+	return m, nil
+}
+
+func (m *model) updateList(key tea.KeyMsg) (tea.Model, tea.Cmd) {
+	switch key.String() {
+	case "q":
+		return m, tea.Quit
+	case "up", "k":
+		if m.cursor > 0 {
+			m.cursor--
+		}
+	case "down", "j":
+		if m.cursor < len(m.repos)-1 {
+			m.cursor++
+		}
+	case "enter":
+		if len(m.repos) > 0 {
+			r := m.repos[m.cursor]
+			m.desc, m.private, m.field = []rune(r.Meta.Description), r.Meta.Private, fieldDesc
+			m.pos = len(m.desc)
+			m.err = nil
+			m.mode = modeEdit
+		}
+	case "d":
+		if len(m.repos) > 0 {
+			m.err = nil
+			m.mode = modeDelete
+		}
+	}
+	return m, nil
+}
+
+func (m *model) updateEdit(key tea.KeyMsg) (tea.Model, tea.Cmd) {
+	switch key.Type {
+	case tea.KeyEsc:
+		m.mode = modeList
+	case tea.KeyUp, tea.KeyDown, tea.KeyTab, tea.KeyShiftTab:
+		m.field = (m.field + 1) % 2
+	case tea.KeyEnter:
+		r := m.repos[m.cursor]
+		r.Meta.Description, r.Meta.Private = string(m.desc), m.private
+		m.err = r.SaveMeta()
+		m.mode = modeList
+	case tea.KeyLeft:
+		if m.field == fieldDesc && m.pos > 0 {
+			m.pos--
+		}
+	case tea.KeyRight:
+		if m.field == fieldDesc && m.pos < len(m.desc) {
+			m.pos++
+		}
+	case tea.KeyHome, tea.KeyCtrlA:
+		if m.field == fieldDesc {
+			m.pos = 0
+		}
+	case tea.KeyEnd, tea.KeyCtrlE:
+		if m.field == fieldDesc {
+			m.pos = len(m.desc)
+		}
+	case tea.KeyBackspace:
+		if m.field == fieldDesc && m.pos > 0 {
+			m.desc = slices.Delete(m.desc, m.pos-1, m.pos)
+			m.pos--
+		}
+	case tea.KeyDelete:
+		if m.field == fieldDesc && m.pos < len(m.desc) {
+			m.desc = slices.Delete(m.desc, m.pos, m.pos+1)
+		}
+	case tea.KeySpace:
+		if m.field == fieldPrivate {
+			m.private = !m.private
+		} else {
+			m.insert([]rune{' '})
+		}
+	case tea.KeyRunes:
+		if m.field == fieldDesc {
+			m.insert(key.Runes)
+		}
+	}
+	return m, nil
+}
+
+func (m *model) insert(rs []rune) {
+	m.desc = slices.Insert(m.desc, m.pos, rs...)
+	m.pos += len(rs)
+}
+
+func (m *model) updateDelete(key tea.KeyMsg) (tea.Model, tea.Cmd) {
+	if key.String() == "y" {
+		m.err = os.RemoveAll(m.repos[m.cursor].Path())
+		m.reload()
+	}
+	m.mode = modeList
+	return m, nil
+}
+
+func (m *model) View() string {
+	var b strings.Builder
+	b.WriteString("ugit\n\n")
+
+	if m.mode == modeEdit {
+		r := m.repos[m.cursor]
+		fmt.Fprintf(&b, "Editing %s\n\n", r.Name())
+		cur := [2]string{"  ", "  "}
+		cur[m.field] = "> "
+		desc := string(m.desc)
+		if m.field == fieldDesc {
+			cursor := " "
+			if m.pos < len(m.desc) {
+				cursor = string(m.desc[m.pos])
+			}
+			desc = string(m.desc[:m.pos]) + "\x1b[7m" + cursor + "\x1b[0m"
+			if m.pos < len(m.desc) {
+				desc += string(m.desc[m.pos+1:])
+			}
+		}
+		fmt.Fprintf(&b, "%sDescription: %s\n", cur[fieldDesc], desc)
+		fmt.Fprintf(&b, "%sPrivate:     %s\n", cur[fieldPrivate], checkbox(m.private))
+		b.WriteString("\n↑/↓: next field • ←/→: move • space: toggle • enter: save • esc: cancel\n")
+		return b.String()
+	}
+
+	if len(m.repos) == 0 {
+		b.WriteString("No repos.\n")
+	}
+	width := 0
+	for _, r := range m.repos {
+		width = max(width, utf8.RuneCountInString(r.Name()))
+	}
+	for i, r := range m.repos {
+		cursor := "  "
+		if i == m.cursor {
+			cursor = "> "
+		}
+		vis := "🔓"
+		if r.Meta.Private {
+			vis = "🔒"
+		}
+		fmt.Fprintf(&b, "%s%s %-*s  %s\n", cursor, vis, width, r.Name(), r.Meta.Description)
+	}
+
+	b.WriteString("\n")
+	if m.err != nil {
+		fmt.Fprintf(&b, "error: %v\n", m.err)
+	}
+	if m.mode == modeDelete {
+		fmt.Fprintf(&b, "Delete %s? (y/N)\n", m.repos[m.cursor].Name())
+	} else {
+		b.WriteString("↑/↓: move • enter: edit • d: delete • q: quit\n")
+	}
+	return b.String()
+}
+
+func checkbox(v bool) string {
+	if v {
+		return "[x]"
+	}
+	return "[ ]"
+}
M internal/ssh/wish.go -> internal/ssh/wish.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
diff --git a/internal/ssh/wish.go b/internal/ssh/wish.go
index 64f6be07be25325f7b183186cad75b9bfce16ca6..ec76abe30ada129fde479cb7580c12121ab622ab 100644
--- a/internal/ssh/wish.go
+++ b/internal/ssh/wish.go
@@ -109,7 +109,8 @@ 				}
 			}
 
 			// Repo list
-			if len(cmd) == 0 {
+			// Interactive sessions get the TUI instead
+			if _, _, isPty := s.Pty(); len(cmd) == 0 && !isPty {
 				des, err := os.ReadDir(repoDir)
 				if err != nil && err != fs.ErrNotExist {
 					slog.Error("invalid repository", "error", err)