02e1617b19312e900e2544c3b2fb5a9457ce3fa8
- Author
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Committer
- GitHub <noreply@github.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/cmd/soft/browse.go b/cmd/soft/browse.go
2new file mode 100644
3index 0000000000000000000000000000000000000000..324e7cb469be682d73a1c3501d251f9f66214009
4--- /dev/null
5+++ b/cmd/soft/browse.go
6@@ -0,0 +1,301 @@
7+package main
8+
9+import (
10+ "fmt"
11+ "path/filepath"
12+ "time"
13+
14+ "github.com/charmbracelet/bubbles/key"
15+ tea "github.com/charmbracelet/bubbletea"
16+ "github.com/charmbracelet/lipgloss"
17+ "github.com/charmbracelet/soft-serve/git"
18+ "github.com/charmbracelet/soft-serve/server/proto"
19+ "github.com/charmbracelet/soft-serve/server/ui/common"
20+ "github.com/charmbracelet/soft-serve/server/ui/components/footer"
21+ "github.com/charmbracelet/soft-serve/server/ui/pages/repo"
22+ "github.com/muesli/termenv"
23+ "github.com/spf13/cobra"
24+)
25+
26+var browseCmd = &cobra.Command{
27+ Use: "browse PATH",
28+ Short: "Browse a repository",
29+ Args: cobra.MaximumNArgs(1),
30+ RunE: func(cmd *cobra.Command, args []string) error {
31+ rp := "."
32+ if len(args) > 0 {
33+ rp = args[0]
34+ }
35+
36+ abs, err := filepath.Abs(rp)
37+ if err != nil {
38+ return err
39+ }
40+
41+ r, err := git.Open(abs)
42+ if err != nil {
43+ return fmt.Errorf("failed to open repository: %w", err)
44+ }
45+
46+ // Bubble Tea uses Termenv default output so we have to use the same
47+ // thing here.
48+ output := termenv.DefaultOutput()
49+ ctx := cmd.Context()
50+ c := common.NewCommon(ctx, output, 0, 0)
51+ comps := []common.TabComponent{
52+ repo.NewReadme(c),
53+ repo.NewFiles(c),
54+ repo.NewLog(c),
55+ }
56+ if !r.IsBare {
57+ comps = append(comps, repo.NewStash(c))
58+ }
59+ comps = append(comps, repo.NewRefs(c, git.RefsHeads), repo.NewRefs(c, git.RefsTags))
60+ m := &model{
61+ model: repo.New(c, comps...),
62+ repo: repository{r},
63+ common: c,
64+ }
65+
66+ m.footer = footer.New(c, m)
67+ p := tea.NewProgram(m,
68+ tea.WithAltScreen(),
69+ tea.WithMouseCellMotion(),
70+ )
71+
72+ _, err = p.Run()
73+ return err
74+ },
75+}
76+
77+func init() {
78+ // HACK: This is a hack to hide the clone url
79+ // TODO: Make this configurable
80+ common.CloneCmd = func(publicURL, name string) string { return "" }
81+ rootCmd.AddCommand(browseCmd)
82+}
83+
84+type state int
85+
86+const (
87+ startState state = iota
88+ errorState
89+)
90+
91+type model struct {
92+ model *repo.Repo
93+ footer *footer.Footer
94+ repo proto.Repository
95+ common common.Common
96+ state state
97+ showFooter bool
98+ error error
99+}
100+
101+var _ tea.Model = &model{}
102+
103+func (m *model) SetSize(w, h int) {
104+ m.common.SetSize(w, h)
105+ style := m.common.Styles.App.Copy()
106diff --git a/cmd/soft/migrate_config.go b/cmd/soft/migrate_config.go
107index 86bea23d39ad148efb7b5a0cea1f2d2663306fb4..5138e93c0e8195d7dc328d4dc830abdfa1afce63 100644
108--- a/cmd/soft/migrate_config.go
109+++ b/cmd/soft/migrate_config.go
110@@ -124,7 +124,7 @@ var migrateConfig = &cobra.Command{
111 }
112 }
113
114- readme, readmePath, err := git.LatestFile(r, "README*")
115+ readme, readmePath, err := git.LatestFile(r, nil, "README*")
116 hasReadme := err == nil
117
118 // Set server name
119diff --git a/cmd/soft/root.go b/cmd/soft/root.go
120index 31a1f78031a0e477841136cae5460c147bd38f87..e031a67a377a68c2f44a1277a74da17a5cc4d20c 100644
121--- a/cmd/soft/root.go
122+++ b/cmd/soft/root.go
123@@ -33,6 +33,9 @@ var (
124 Short: "A self-hostable Git server for the command line",
125 Long: "Soft Serve is a self-hostable Git server for the command line.",
126 SilenceUsage: true,
127+ RunE: func(cmd *cobra.Command, args []string) error {
128+ return browseCmd.RunE(cmd, args)
129+ },
130 }
131 )
132
133diff --git a/git/commit.go b/git/commit.go
134index 3dd0a8ff377914166b3c66ca3b2748b4ee979605..1e955ff31b405a98faa7804e96fe4b1d93214002 100644
135--- a/git/commit.go
136+++ b/git/commit.go
137@@ -4,27 +4,11 @@ import (
138 "github.com/gogs/git-module"
139 )
140
141-// ZeroHash is the zero hash.
142-var ZeroHash Hash = git.EmptyID
143-
144-// Hash represents a git hash.
145-type Hash string
146-
147-// String returns the string representation of a hash as a string.
148-func (h Hash) String() string {
149- return string(h)
150-}
151-
152-// SHA1 represents the hash as a SHA1.
153-func (h Hash) SHA1() *git.SHA1 {
154- return git.MustIDFromString(h.String())
155-}
156+// ZeroID is the zero hash.
157+const ZeroID = git.EmptyID
158
159 // Commit is a wrapper around git.Commit with helper methods.
160-type Commit struct {
161- *git.Commit
162- Hash Hash
163-}
164+type Commit = git.Commit
165
166 // Commits is a list of commits.
167 type Commits []*Commit
168diff --git a/git/patch.go b/git/patch.go
169index aaa6b9d78fb2692f530115e0038112bffa1dbdde..d166390cbef50227d022ed56ee144acaff0116c6 100644
170--- a/git/patch.go
171+++ b/git/patch.go
172@@ -115,14 +115,14 @@ func (f *DiffFileChange) Mode() git.EntryMode {
173
174 // Files returns the diff files.
175 func (f *DiffFile) Files() (from *DiffFileChange, to *DiffFileChange) {
176- if f.OldIndex != ZeroHash.String() {
177+ if f.OldIndex != ZeroID {
178 from = &DiffFileChange{
179 hash: f.OldIndex,
180 name: f.OldName(),
181 mode: f.OldMode(),
182 }
183 }
184- if f.Index != ZeroHash.String() {
185+ if f.Index != ZeroID {
186 to = &DiffFileChange{
187 hash: f.Index,
188 name: f.Name,
189@@ -298,14 +298,14 @@ func writeFilePatchHeader(sb *strings.Builder, filePatch *DiffFile) {
190 lines = append(lines,
191 fmt.Sprintf("diff --git %s %s", srcPrefix+to.Name(), dstPrefix+to.Name()),
192 fmt.Sprintf("new file mode %o", to.Mode()),
193- fmt.Sprintf("index %s..%s", ZeroHash, to.Hash()),
194+ fmt.Sprintf("index %s..%s", ZeroID, to.Hash()),
195 )
196 lines = appendPathLines(lines, "/dev/null", dstPrefix+to.Name(), isBinary)
197 case to == nil:
198 lines = append(lines,
199 fmt.Sprintf("diff --git %s %s", srcPrefix+from.Name(), dstPrefix+from.Name()),
200 fmt.Sprintf("deleted file mode %o", from.Mode()),
201- fmt.Sprintf("index %s..%s", from.Hash(), ZeroHash),
202+ fmt.Sprintf("index %s..%s", from.Hash(), ZeroID),
203 )
204 lines = appendPathLines(lines, srcPrefix+from.Name(), "/dev/null", isBinary)
205 }
206@@ -332,3 +332,24 @@ func (d *Diff) Patch() string {
207 }
208 return p.String()
209 }
210+
211+func toDiff(ddiff *git.Diff) *Diff {
212+ files := make([]*DiffFile, 0, len(ddiff.Files))
213+ for _, df := range ddiff.Files {
214+ sections := make([]*DiffSection, 0, len(df.Sections))
215+ for _, ds := range df.Sections {
216+ sections = append(sections, &DiffSection{
217+ DiffSection: ds,
218+ })
219+ }
220+ files = append(files, &DiffFile{
221+ DiffFile: df,
222+ Sections: sections,
223+ })
224+ }
225+ diff := &Diff{
226+ Diff: ddiff,
227+ Files: files,
228+ }
229+ return diff
230+}
231diff --git a/git/reference.go b/git/reference.go
232index ec01a53fdc8c70f52283107c33029ae5fa57577d..47aceb95bbbec2577195fbf58512d0dd6adaa2be 100644
233--- a/git/reference.go
234+++ b/git/reference.go
235@@ -18,23 +18,12 @@ const (
236 // Reference is a wrapper around git.Reference with helper methods.
237 type Reference struct {
238 *git.Reference
239- Hash Hash
240 path string // repo path
241 }
242
243 // ReferenceName is a Refspec wrapper.
244 type ReferenceName string
245
246-// NewReference creates a new reference.
247-func NewReference(rp, refspec string) *Reference {
248- return &Reference{
249- Reference: &git.Reference{
250- Refspec: refspec,
251- },
252- path: rp,
253- }
254-}
255-
256 // String returns the reference name i.e. refs/heads/master.
257 func (r ReferenceName) String() string {
258 return string(r)
259@@ -42,11 +31,7 @@ func (r ReferenceName) String() string {
260
261 // Short returns the short name of the reference i.e. master.
262 func (r ReferenceName) Short() string {
263- s := strings.Split(r.String(), "/")
264- if len(s) > 0 {
265- return s[len(s)-1]
266- }
267- return r.String()
268+ return git.RefShortName(string(r))
269 }
270
271 // Name returns the reference name i.e. refs/heads/master.
272@@ -63,14 +48,3 @@ func (r *Reference) IsBranch() bool {
273 func (r *Reference) IsTag() bool {
274 return strings.HasPrefix(r.Refspec, git.RefsTags)
275 }
276-
277-// TargetHash returns the hash of the reference target.
278-func (r *Reference) TargetHash() Hash {
279- if r.IsTag() {
280- id, err := git.ShowRefVerify(r.path, r.Refspec)
281- if err == nil {
282- return Hash(id)
283- }
284- }
285- return r.Hash
286-}
287diff --git a/git/repo.go b/git/repo.go
288index ef3d2759d63411b4d7f297ebb838eb959bd6b00d..b71abd1cffd4929c11bb755e59e286a0f7e08e96 100644
289--- a/git/repo.go
290+++ b/git/repo.go
291@@ -77,7 +77,6 @@ func (r *Repository) HEAD() (*Reference, error) {
292 ID: hash,
293 Refspec: rn,
294 },
295- Hash: Hash(hash),
296 path: r.Path,
297 }, nil
298 }
299@@ -92,7 +91,6 @@ func (r *Repository) References() ([]*Reference, error) {
300 for _, ref := range refs {
301 rrefs = append(rrefs, &Reference{
302 Reference: ref,
303- Hash: Hash(ref.ID),
304 path: r.Path,
305 })
306 }
307@@ -121,7 +119,7 @@ func (r *Repository) Tree(ref *Reference) (*Tree, error) {
308 }
309 ref = rref
310 }
311- return r.LsTree(ref.Hash.String())
312+ return r.LsTree(ref.ID)
313 }
314
315 // TreePath returns the tree for the given path.
316@@ -142,7 +140,7 @@ func (r *Repository) TreePath(ref *Reference, path string) (*Tree, error) {
317
318 // Diff returns the diff for the given commit.
319 func (r *Repository) Diff(commit *Commit) (*Diff, error) {
320- ddiff, err := r.Repository.Diff(commit.Hash.String(), DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars, git.DiffOptions{
321+ diff, err := r.Repository.Diff(commit.ID.String(), DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars, git.DiffOptions{
322 CommandOptions: git.CommandOptions{
323 Envs: []string{"GIT_CONFIG_GLOBAL=/dev/null"},
324 },
325@@ -150,24 +148,7 @@ func (r *Repository) Diff(commit *Commit) (*Diff, error) {
326 if err != nil {
327 return nil, err
328 }
329- files := make([]*DiffFile, 0, len(ddiff.Files))
330- for _, df := range ddiff.Files {
331- sections := make([]*DiffSection, 0, len(df.Sections))
332- for _, ds := range df.Sections {
333- sections = append(sections, &DiffSection{
334- DiffSection: ds,
335- })
336- }
337- files = append(files, &DiffFile{
338- DiffFile: df,
339- Sections: sections,
340- })
341- }
342- diff := &Diff{
343- Diff: ddiff,
344- Files: files,
345- }
346- return diff, nil
347+ return toDiff(diff), nil
348 }
349
350 // Patch returns the patch for the given reference.
351@@ -191,12 +172,7 @@ func (r *Repository) CommitsByPage(ref *Reference, page, size int) (Commits, err
352 return nil, err
353 }
354 commits := make(Commits, len(cs))
355- for i, c := range cs {
356- commits[i] = &Commit{
357- Commit: c,
358- Hash: Hash(c.ID.String()),
359- }
360- }
361+ copy(commits, cs)
362 return commits, nil
363 }
364
365diff --git a/git/stash.go b/git/stash.go
366new file mode 100644
367index 0000000000000000000000000000000000000000..0a669ddd395c33f59cd6b66cf30dd8e9e4169fd1
368--- /dev/null
369+++ b/git/stash.go
370@@ -0,0 +1,16 @@
371+package git
372+
373+import "github.com/gogs/git-module"
374+
375+// StashDiff returns the diff of the given stash index.
376+func (r *Repository) StashDiff(index int) (*Diff, error) {
377+ diff, err := r.Repository.StashDiff(index, DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars, git.DiffOptions{
378+ CommandOptions: git.CommandOptions{
379+ Envs: []string{"GIT_CONFIG_GLOBAL=/dev/null"},
380+ },
381+ })
382+ if err != nil {
383+ return nil, err
384+ }
385+ return toDiff(diff), nil
386+}
387diff --git a/git/tag.go b/git/tag.go
388new file mode 100644
389index 0000000000000000000000000000000000000000..f09d39a25a58c888d413734da5c53e5897254862
390--- /dev/null
391+++ b/git/tag.go
392@@ -0,0 +1,6 @@
393+package git
394+
395+import "github.com/gogs/git-module"
396+
397+// Tag is a git tag.
398+type Tag = git.Tag
399diff --git a/git/utils.go b/git/utils.go
400index 3710e172de7772b6c28c2a7e2f6806343c940028..b4ca50fc62d987eb823dc65cff0572a500f057d6 100644
401--- a/git/utils.go
402+++ b/git/utils.go
403@@ -8,14 +8,17 @@ import (
404 )
405
406 // LatestFile returns the contents of the first file at the specified path pattern in the repository and its file path.
407-func LatestFile(repo *Repository, pattern string) (string, string, error) {
408+func LatestFile(repo *Repository, ref *Reference, pattern string) (string, string, error) {
409 g := glob.MustCompile(pattern)
410 dir := filepath.Dir(pattern)
411- head, err := repo.HEAD()
412- if err != nil {
413- return "", "", err
414+ if ref == nil {
415+ head, err := repo.HEAD()
416+ if err != nil {
417+ return "", "", err
418+ }
419+ ref = head
420 }
421- t, err := repo.TreePath(head, dir)
422+ t, err := repo.TreePath(ref, dir)
423 if err != nil {
424 return "", "", err
425 }
426diff --git a/go.mod b/go.mod
427index dfd99002a3b4f54eba8325d8acbbd056862e5788..25682ab2cf20e14a46498bc18a094d8557d4eab4 100644
428--- a/go.mod
429+++ b/go.mod
430@@ -2,6 +2,8 @@ module github.com/charmbracelet/soft-serve
431
432 go 1.20
433
434+replace github.com/gogs/git-module => github.com/aymanbagabas/git-module v1.4.1-0.20231025145308-5e8facf7a213
435+
436 require (
437 github.com/alecthomas/chroma v0.10.0
438 github.com/charmbracelet/bubbles v0.16.1
439diff --git a/go.sum b/go.sum
440index 37e2c33b441280bf6fef695e93c9e8f8f1b68e9f..22b77cacd99bb1c500becd06bad4eb4031a1941b 100644
441--- a/go.sum
442+++ b/go.sum
443@@ -4,6 +4,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
444 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
445 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
446 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
447+github.com/aymanbagabas/git-module v1.4.1-0.20231025145308-5e8facf7a213 h1:/tUfPeV5T/tn2UjvQedq1incFa9B9WkFHTv0fdt5Ah0=
448+github.com/aymanbagabas/git-module v1.4.1-0.20231025145308-5e8facf7a213/go.mod h1:3OBxY2gWeblk83u6BlGMO1TYDEbV4bspATMP/S2Kfsk=
449 github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
450 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
451 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
452@@ -63,8 +65,6 @@ github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfC
453 github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
454 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
455 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
456-github.com/gogs/git-module v1.8.3 h1:4N9HOLzkmSfb5y4Go4f/gdt1/Z60/aQaAKr8lbsfFps=
457-github.com/gogs/git-module v1.8.3/go.mod h1:yAn6ZMwh8x0u3fMotXqMP7Ct1XNNOZWNdBSBx6IFGCY=
458 github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
459 github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
460 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
461@@ -209,7 +209,6 @@ golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfS
462 golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
463 golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
464 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
465-golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
466 golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
467 golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
468 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
469diff --git a/server/backend/utils.go b/server/backend/utils.go
470index 024ba8af228a6741d1e957580f90ae722c05584f..5e90873e5369615c3fb8eb62ef347995770bd9ae 100644
471--- a/server/backend/utils.go
472+++ b/server/backend/utils.go
473@@ -7,17 +7,17 @@ import (
474
475 // LatestFile returns the contents of the latest file at the specified path in
476 // the repository and its file path.
477-func LatestFile(r proto.Repository, pattern string) (string, string, error) {
478+func LatestFile(r proto.Repository, ref *git.Reference, pattern string) (string, string, error) {
479 repo, err := r.Open()
480 if err != nil {
481 return "", "", err
482 }
483- return git.LatestFile(repo, pattern)
484+ return git.LatestFile(repo, ref, pattern)
485 }
486
487 // Readme returns the repository's README.
488-func Readme(r proto.Repository) (readme string, path string, err error) {
489+func Readme(r proto.Repository, ref *git.Reference) (readme string, path string, err error) {
490 pattern := "[rR][eE][aA][dD][mM][eE]*"
491- readme, path, err = LatestFile(r, pattern)
492+ readme, path, err = LatestFile(r, ref, pattern)
493 return
494 }
495diff --git a/server/ssh/cmd/blob.go b/server/ssh/cmd/blob.go
496index 5065bd1c63177cd86cf0c0e9d4af6515eded0f1a..06034d94a88b5840de084a80350368fe712ffdf0 100644
497--- a/server/ssh/cmd/blob.go
498+++ b/server/ssh/cmd/blob.go
499@@ -2,29 +2,21 @@ package cmd
500
501 import (
502 "fmt"
503- "strings"
504
505- "github.com/alecthomas/chroma/lexers"
506- gansi "github.com/charmbracelet/glamour/ansi"
507- "github.com/charmbracelet/lipgloss"
508 "github.com/charmbracelet/soft-serve/git"
509 "github.com/charmbracelet/soft-serve/server/backend"
510 "github.com/charmbracelet/soft-serve/server/ui/common"
511- "github.com/muesli/termenv"
512+ "github.com/charmbracelet/soft-serve/server/ui/styles"
513 "github.com/spf13/cobra"
514 )
515
516-var (
517- lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
518- lineBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
519-)
520-
521 // blobCommand returns a command that prints the contents of a file.
522 func blobCommand() *cobra.Command {
523 var linenumber bool
524 var color bool
525 var raw bool
526
527+ styles := styles.DefaultStyles()
528 cmd := &cobra.Command{
529 Use: "blob REPOSITORY [REFERENCE] [PATH]",
530 Aliases: []string{"cat", "show"},
531@@ -60,7 +52,7 @@ func blobCommand() *cobra.Command {
532 if err != nil {
533 return err
534 }
535- ref = head.Hash.String()
536+ ref = head.ID
537 }
538
539 tree, err := r.LsTree(ref)
540@@ -92,14 +84,14 @@ func blobCommand() *cobra.Command {
541 }
542 } else {
543 if color {
544- c, err = withFormatting(fp, c)
545+ c, err = common.FormatHighlight(fp, c)
546 if err != nil {
547 return err
548 }
549 }
550
551 if linenumber {
552- c = withLineNumber(c, color)
553+ c, _ = common.FormatLineNumber(styles, c, color)
554 }
555
556 cmd.Println(c)
557@@ -114,50 +106,3 @@ func blobCommand() *cobra.Command {
558
559 return cmd
560 }
561-
562-func withLineNumber(s string, color bool) string {
563- lines := strings.Split(s, "\n")
564- // NB: len() is not a particularly safe way to count string width (because
565- // it's counting bytes instead of runes) but in this case it's okay
566- // because we're only dealing with digits, which are one byte each.
567- mll := len(fmt.Sprintf("%d", len(lines)))
568- for i, l := range lines {
569- digit := fmt.Sprintf("%*d", mll, i+1)
570- bar := "│"
571- if color {
572- digit = lineDigitStyle.Render(digit)
573- bar = lineBarStyle.Render(bar)
574- }
575- if i < len(lines)-1 || len(l) != 0 {
576- // If the final line was a newline we'll get an empty string for
577- // the final line, so drop the newline altogether.
578- lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
579- }
580- }
581- return strings.Join(lines, "\n")
582-}
583-
584-func withFormatting(p, c string) (string, error) {
585- zero := uint(0)
586- lang := ""
587- lexer := lexers.Match(p)
588- if lexer != nil && lexer.Config() != nil {
589- lang = lexer.Config().Name
590- }
591- formatter := &gansi.CodeBlockElement{
592- Code: c,
593- Language: lang,
594- }
595- r := strings.Builder{}
596- styles := common.StyleConfig()
597- styles.CodeBlock.Margin = &zero
598- rctx := gansi.NewRenderContext(gansi.Options{
599diff --git a/server/ssh/cmd/commit.go b/server/ssh/cmd/commit.go
600index f8ffaa1cf18aaaf3fb663de66aeabeb656c52028..75f02efbded39de4a8ed4ab157d1d7ba00e732ec 100644
601--- a/server/ssh/cmd/commit.go
602+++ b/server/ssh/cmd/commit.go
603@@ -40,16 +40,11 @@ func commitCommand() *cobra.Command {
604 return err
605 }
606
607- rawCommit, err := r.CommitByRevision(commitSHA)
608+ commit, err := r.CommitByRevision(commitSHA)
609 if err != nil {
610 return err
611 }
612
613- commit := &git.Commit{
614- Commit: rawCommit,
615- Hash: git.Hash(commitSHA),
616- }
617-
618 patch, err := r.Patch(commit)
619 if err != nil {
620 return err
621diff --git a/server/ssh/cmd/tree.go b/server/ssh/cmd/tree.go
622index ccb72a70f890a6bd5800bbb1379b831839ef3ee1..e380a348ea7f6d1ed41914343f60399693896187 100644
623--- a/server/ssh/cmd/tree.go
624+++ b/server/ssh/cmd/tree.go
625@@ -49,7 +49,7 @@ func treeCommand() *cobra.Command {
626 return err
627 }
628
629- ref = head.Hash.String()
630+ ref = head.ID
631 }
632
633 tree, err := r.LsTree(ref)
634diff --git a/server/ssh/session.go b/server/ssh/session.go
635index a5bd4d168d7e71f0d6d4038ce173108cc0967c8f..53496e3af78c886343d5edfb07efff5a75dac86d 100644
636--- a/server/ssh/session.go
637+++ b/server/ssh/session.go
638@@ -9,7 +9,6 @@ import (
639 "github.com/charmbracelet/soft-serve/server/backend"
640 "github.com/charmbracelet/soft-serve/server/config"
641 "github.com/charmbracelet/soft-serve/server/proto"
642- "github.com/charmbracelet/soft-serve/server/ui"
643 "github.com/charmbracelet/soft-serve/server/ui/common"
644 "github.com/charmbracelet/ssh"
645 "github.com/charmbracelet/wish"
646@@ -58,7 +57,7 @@ func SessionHandler(s ssh.Session) *tea.Program {
647 output := termenv.NewOutput(s, termenv.WithColorCache(true), termenv.WithEnvironment(envs))
648 c := common.NewCommon(ctx, output, pty.Window.Width, pty.Window.Height)
649 c.SetValue(common.ConfigKey, cfg)
650- m := ui.New(c, initialRepo)
651+ m := NewUI(c, initialRepo)
652 p := tea.NewProgram(m,
653 tea.WithInput(s),
654 tea.WithOutput(s),
655diff --git a/server/ui/ui.go b/server/ssh/ui.go
656rename from server/ui/ui.go
657rename to server/ssh/ui.go
658index 5e259004d7b91fada6151713c9c6c6adcb939a95..87c73ad711067dda2f06735bc88c3347628d00d6 100644
659--- a/server/ui/ui.go
660+++ b/server/ssh/ui.go
661@@ -1,4 +1,4 @@
662-package ui
663+package ssh
664
665 import (
666 "errors"
667@@ -7,6 +7,7 @@ import (
668 "github.com/charmbracelet/bubbles/list"
669 tea "github.com/charmbracelet/bubbletea"
670 "github.com/charmbracelet/lipgloss"
671+ "github.com/charmbracelet/soft-serve/git"
672 "github.com/charmbracelet/soft-serve/server/proto"
673 "github.com/charmbracelet/soft-serve/server/ui/common"
674 "github.com/charmbracelet/soft-serve/server/ui/components/footer"
675@@ -45,8 +46,8 @@ type UI struct {
676 error error
677 }
678
679-// New returns a new UI model.
680-func New(c common.Common, initialRepo string) *UI {
681+// NewUI returns a new UI model.
682+func NewUI(c common.Common, initialRepo string) *UI {
683 serverName := c.Config().Name
684 h := header.New(c, serverName)
685 ui := &UI{
686@@ -133,7 +134,13 @@ func (ui *UI) SetSize(width, height int) {
687 // Init implements tea.Model.
688 func (ui *UI) Init() tea.Cmd {
689 ui.pages[selectionPage] = selection.New(ui.common)
690- ui.pages[repoPage] = repo.New(ui.common)
691+ ui.pages[repoPage] = repo.New(ui.common,
692+ repo.NewReadme(ui.common),
693+ repo.NewFiles(ui.common),
694+ repo.NewLog(ui.common),
695+ repo.NewRefs(ui.common, git.RefsHeads),
696+ repo.NewRefs(ui.common, git.RefsTags),
697+ )
698 ui.SetSize(ui.common.Width, ui.common.Height)
699 cmds := make([]tea.Cmd, 0)
700 cmds = append(cmds,
701@@ -273,10 +280,10 @@ func (ui *UI) View() string {
702 view = "Unknown state :/ this is a bug!"
703 }
704 if ui.activePage == selectionPage {
705- view = lipgloss.JoinVertical(lipgloss.Left, ui.header.View(), view)
706+ view = lipgloss.JoinVertical(lipgloss.Top, ui.header.View(), view)
707 }
708 if ui.showFooter {
709- view = lipgloss.JoinVertical(lipgloss.Left, view, ui.footer.View())
710+ view = lipgloss.JoinVertical(lipgloss.Top, view, ui.footer.View())
711 }
712 return ui.common.Zone.Scan(
713 ui.common.Styles.App.Render(view),
714diff --git a/server/ui/common/component.go b/server/ui/common/component.go
715index ed8b9bf05cd39b52086a2a490b045231ac1fd96e..1f4d20df875384b23e21cc6977fa8e40b4296e6c 100644
716--- a/server/ui/common/component.go
717+++ b/server/ui/common/component.go
718@@ -11,3 +11,21 @@ type Component interface {
719 help.KeyMap
720 SetSize(width, height int)
721 }
722+
723+// TabComponenet represents a model that is mounted to a tab.
724+// TODO: find a better name
725+type TabComponent interface {
726+ Component
727+
728+ // StatusBarValue returns the status bar value component.
729+ StatusBarValue() string
730+
731+ // StatusBarInfo returns the status bar info component.
732+ StatusBarInfo() string
733+
734+ // SpinnerID returns the ID of the spinner.
735+ SpinnerID() int
736+
737+ // TabName returns the name of the tab.
738+ TabName() string
739+}
740diff --git a/server/ui/common/format.go b/server/ui/common/format.go
741new file mode 100644
742index 0000000000000000000000000000000000000000..90d00dc547d77d64a10d903fbd4fb86ed74ad95c
743--- /dev/null
744+++ b/server/ui/common/format.go
745@@ -0,0 +1,60 @@
746+package common
747+
748+import (
749+ "fmt"
750+ "strings"
751+
752+ "github.com/alecthomas/chroma/lexers"
753+ gansi "github.com/charmbracelet/glamour/ansi"
754+ "github.com/charmbracelet/soft-serve/server/ui/styles"
755+ "github.com/muesli/termenv"
756+)
757+
758+// FormatLineNumber adds line numbers to a string.
759+func FormatLineNumber(styles *styles.Styles, s string, color bool) (string, int) {
760+ lines := strings.Split(s, "\n")
761+ // NB: len() is not a particularly safe way to count string width (because
762+ // it's counting bytes instead of runes) but in this case it's okay
763+ // because we're only dealing with digits, which are one byte each.
764+ mll := len(fmt.Sprintf("%d", len(lines)))
765+ for i, l := range lines {
766+ digit := fmt.Sprintf("%*d", mll, i+1)
767+ bar := "│"
768+ if color {
769+ digit = styles.Code.LineDigit.Render(digit)
770+ bar = styles.Code.LineBar.Render(bar)
771+ }
772+ if i < len(lines)-1 || len(l) != 0 {
773+ // If the final line was a newline we'll get an empty string for
774+ // the final line, so drop the newline altogether.
775+ lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
776+ }
777+ }
778+ return strings.Join(lines, "\n"), mll
779+}
780+
781+// FormatHighlight adds syntax highlighting to a string.
782+func FormatHighlight(p, c string) (string, error) {
783+ zero := uint(0)
784+ lang := ""
785+ lexer := lexers.Match(p)
786+ if lexer != nil && lexer.Config() != nil {
787+ lang = lexer.Config().Name
788+ }
789+ formatter := &gansi.CodeBlockElement{
790+ Code: c,
791+ Language: lang,
792+ }
793+ r := strings.Builder{}
794+ styles := StyleConfig()
795+ styles.CodeBlock.Margin = &zero
796+ rctx := gansi.NewRenderContext(gansi.Options{
797+ Styles: styles,
798+ ColorProfile: termenv.TrueColor,
799+ })
800+ err := formatter.Render(&r, rctx)
801+ if err != nil {
802+ return "", err
803+ }
804+ return r.String(), nil
805+}
806diff --git a/server/ui/common/utils.go b/server/ui/common/utils.go
807index aac2db6da3bd5f496f0c77409382323d4a0080fe..49eaf00ce2ff31f51ca0508c005605eb413aa0a2 100644
808--- a/server/ui/common/utils.go
809+++ b/server/ui/common/utils.go
810@@ -35,6 +35,6 @@ func RepoURL(publicURL, name string) string {
811 }
812
813 // CloneCmd returns the URL of the repository.
814-func CloneCmd(publicURL, name string) string {
815+var CloneCmd = func(publicURL, name string) string {
816 return fmt.Sprintf("git clone %s", RepoURL(publicURL, name))
817 }
818diff --git a/server/ui/components/code/code.go b/server/ui/components/code/code.go
819index aff1a526506d0e3eb37a40cf7164f9797f833d78..5d6d4a91ea6ddb4c9fbd321bc0a4cdd91aa5854d 100644
820--- a/server/ui/components/code/code.go
821+++ b/server/ui/components/code/code.go
822@@ -1,7 +1,7 @@
823 package code
824
825 import (
826- "fmt"
827+ "math"
828 "strings"
829 "sync"
830
831@@ -16,40 +16,38 @@ import (
832 )
833
834 const (
835- tabWidth = 4
836-)
837-
838-var (
839- lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
840- lineBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
841+ defaultTabWidth = 4
842+ defaultSideNotePercent = 0.3
843 )
844
845 // Code is a code snippet.
846 type Code struct {
847 *vp.Viewport
848- common common.Common
849- content string
850- extension string
851- renderContext gansi.RenderContext
852- renderMutex sync.Mutex
853- styleConfig gansi.StyleConfig
854- showLineNumber bool
855-
856- NoContentStyle lipgloss.Style
857- LineDigitStyle lipgloss.Style
858- LineBarStyle lipgloss.Style
859+ common common.Common
860+ sidenote string
861+ content string
862+ extension string
863+ renderContext gansi.RenderContext
864+ renderMutex sync.Mutex
865+ styleConfig gansi.StyleConfig
866+
867+ SideNotePercent float64
868+ TabWidth int
869+ ShowLineNumber bool
870+ NoContentStyle lipgloss.Style
871+ UseGlamour bool
872 }
873
874 // New returns a new Code.
875 func New(c common.Common, content, extension string) *Code {
876 r := &Code{
877- common: c,
878- content: content,
879- extension: extension,
880- Viewport: vp.New(c),
881- NoContentStyle: c.Styles.NoContent.Copy(),
882- LineDigitStyle: lineDigitStyle,
883- LineBarStyle: lineBarStyle,
884+ common: c,
885+ content: content,
886+ extension: extension,
887+ TabWidth: defaultTabWidth,
888+ SideNotePercent: defaultSideNotePercent,
889+ Viewport: vp.New(c),
890+ NoContentStyle: c.Styles.NoContent.Copy().SetString("No Content."),
891 }
892 st := common.StyleConfig()
893 r.styleConfig = st
894@@ -61,11 +59,6 @@ func New(c common.Common, content, extension string) *Code {
895 return r
896 }
897
898-// SetShowLineNumber sets whether to show line numbers.
899-func (r *Code) SetShowLineNumber(show bool) {
900- r.showLineNumber = show
901-}
902-
903 // SetSize implements common.Component.
904 func (r *Code) SetSize(width, height int) {
905 r.common.SetSize(width, height)
906@@ -79,19 +72,62 @@ func (r *Code) SetContent(c, ext string) tea.Cmd {
907 return r.Init()
908 }
909
910+// SetSideNote sets the sidenote of the Code.
911+func (r *Code) SetSideNote(s string) tea.Cmd {
912+ r.sidenote = s
913+ return r.Init()
914+}
915+
916 // Init implements tea.Model.
917 func (r *Code) Init() tea.Cmd {
918 w := r.common.Width
919- c := r.content
920- if c == "" {
921+ content := r.content
922diff --git a/server/ui/components/selector/selector.go b/server/ui/components/selector/selector.go
923index aaf3191f3ba52e77035c2c165caa6fdefc30245a..22dcfefaf3ff4210063fbb93aaa5b99be31d3265 100644
924--- a/server/ui/components/selector/selector.go
925+++ b/server/ui/components/selector/selector.go
926@@ -1,6 +1,8 @@
927 package selector
928
929 import (
930+ "sync"
931+
932 "github.com/charmbracelet/bubbles/key"
933 "github.com/charmbracelet/bubbles/list"
934 tea "github.com/charmbracelet/bubbletea"
935@@ -9,10 +11,16 @@ import (
936
937 // Selector is a list of items that can be selected.
938 type Selector struct {
939- list.Model
940+ *list.Model
941 common common.Common
942 active int
943 filterState list.FilterState
944+
945+ // XXX: we use a mutex to support concurrent access to the model. This is
946+ // needed to implement pagination for the Log component. list.Model does
947+ // not support item pagination so we hack it ourselves on top of
948+ // list.Model.
949+ mtx sync.RWMutex
950 }
951
952 // IdentifiableItem is an item that can be identified by a string. Implements
953@@ -40,9 +48,9 @@ func New(common common.Common, items []IdentifiableItem, delegate ItemDelegate)
954 itms[i] = item
955 }
956 l := list.New(itms, delegate, common.Width, common.Height)
957- l.Styles.NoItems = common.Styles.NoItems
958+ l.Styles.NoItems = common.Styles.NoContent
959 s := &Selector{
960- Model: l,
961+ Model: &l,
962 common: common,
963 }
964 s.SetSize(common.Width, common.Height)
965@@ -51,66 +59,111 @@ func New(common common.Common, items []IdentifiableItem, delegate ItemDelegate)
966
967 // PerPage returns the number of items per page.
968 func (s *Selector) PerPage() int {
969+ s.mtx.RLock()
970+ defer s.mtx.RUnlock()
971 return s.Model.Paginator.PerPage
972 }
973
974 // SetPage sets the current page.
975 func (s *Selector) SetPage(page int) {
976+ s.mtx.Lock()
977+ defer s.mtx.Unlock()
978 s.Model.Paginator.Page = page
979 }
980
981 // Page returns the current page.
982 func (s *Selector) Page() int {
983+ s.mtx.RLock()
984+ defer s.mtx.RUnlock()
985 return s.Model.Paginator.Page
986 }
987
988 // TotalPages returns the total number of pages.
989 func (s *Selector) TotalPages() int {
990+ s.mtx.RLock()
991+ defer s.mtx.RUnlock()
992 return s.Model.Paginator.TotalPages
993 }
994
995+// SetTotalPages sets the total number of pages given the number of items.
996+func (s *Selector) SetTotalPages(items int) int {
997+ s.mtx.Lock()
998+ defer s.mtx.Unlock()
999+ return s.Model.Paginator.SetTotalPages(items)
1000+}
1001+
1002+// SelectedItem returns the currently selected item.
1003+func (s *Selector) SelectedItem() IdentifiableItem {
1004+ s.mtx.RLock()
1005+ defer s.mtx.RUnlock()
1006+ item := s.Model.SelectedItem()
1007+ i, ok := item.(IdentifiableItem)
1008+ if !ok {
1009+ return nil
1010+ }
1011+ return i
1012+}
1013+
1014 // Select selects the item at the given index.
1015 func (s *Selector) Select(index int) {
1016+ s.mtx.RLock()
1017+ defer s.mtx.RUnlock()
1018 s.Model.Select(index)
1019 }
1020
1021 // SetShowTitle sets the show title flag.
1022 func (s *Selector) SetShowTitle(show bool) {
1023+ s.mtx.Lock()
1024+ defer s.mtx.Unlock()
1025 s.Model.SetShowTitle(show)
1026diff --git a/server/ui/components/statusbar/statusbar.go b/server/ui/components/statusbar/statusbar.go
1027index e5f0f70a5d7f35bebfebdc23d13003570e02aca8..f60e07795b4dfd1d7b053484be6e4514ae0562eb 100644
1028--- a/server/ui/components/statusbar/statusbar.go
1029+++ b/server/ui/components/statusbar/statusbar.go
1030@@ -7,16 +7,8 @@ import (
1031 "github.com/muesli/reflow/truncate"
1032 )
1033
1034-// StatusBarMsg is a message sent to the status bar.
1035-type StatusBarMsg struct { //nolint:revive
1036- Key string
1037- Value string
1038- Info string
1039- Extra string
1040-}
1041-
1042-// StatusBar is a status bar model.
1043-type StatusBar struct {
1044+// Model is a status bar model.
1045+type Model struct {
1046 common common.Common
1047 key string
1048 value string
1049@@ -24,53 +16,52 @@ type StatusBar struct {
1050 extra string
1051 }
1052
1053-// Model is an interface that supports setting the status bar information.
1054-type Model interface {
1055- StatusBarValue() string
1056- StatusBarInfo() string
1057-}
1058-
1059 // New creates a new status bar component.
1060-func New(c common.Common) *StatusBar {
1061- s := &StatusBar{
1062+func New(c common.Common) *Model {
1063+ s := &Model{
1064 common: c,
1065 }
1066 return s
1067 }
1068
1069 // SetSize implements common.Component.
1070-func (s *StatusBar) SetSize(width, height int) {
1071+func (s *Model) SetSize(width, height int) {
1072 s.common.Width = width
1073 s.common.Height = height
1074 }
1075
1076+// SetStatus sets the status bar status.
1077+func (s *Model) SetStatus(key, value, info, extra string) {
1078+ if key != "" {
1079+ s.key = key
1080+ }
1081+ if value != "" {
1082+ s.value = value
1083+ }
1084+ if info != "" {
1085+ s.info = info
1086+ }
1087+ if extra != "" {
1088+ s.extra = extra
1089+ }
1090+}
1091+
1092 // Init implements tea.Model.
1093-func (s *StatusBar) Init() tea.Cmd {
1094+func (s *Model) Init() tea.Cmd {
1095 return nil
1096 }
1097
1098 // Update implements tea.Model.
1099-func (s *StatusBar) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1100+func (s *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1101 switch msg := msg.(type) {
1102- case StatusBarMsg:
1103- if msg.Key != "" {
1104- s.key = msg.Key
1105- }
1106- if msg.Value != "" {
1107- s.value = msg.Value
1108- }
1109- if msg.Info != "" {
1110- s.info = msg.Info
1111- }
1112- if msg.Extra != "" {
1113- s.extra = msg.Extra
1114- }
1115+ case tea.WindowSizeMsg:
1116+ s.SetSize(msg.Width, msg.Height)
1117 }
1118 return s, nil
1119 }
1120
1121 // View implements tea.Model.
1122-func (s *StatusBar) View() string {
1123+func (s *Model) View() string {
1124 st := s.common.Styles
1125 w := lipgloss.Width
1126 help := s.common.Zone.Mark(
1127diff --git a/server/ui/pages/repo/files.go b/server/ui/pages/repo/files.go
1128index a0a79352bf4fee2b564be5cb1d6b10d810c6489e..38521a7be511b43d6d10f50fdb64e4fdc3503ec9 100644
1129--- a/server/ui/pages/repo/files.go
1130+++ b/server/ui/pages/repo/files.go
1131@@ -3,23 +3,26 @@ package repo
1132 import (
1133 "errors"
1134 "fmt"
1135- "log"
1136 "path/filepath"
1137+ "strings"
1138
1139 "github.com/alecthomas/chroma/lexers"
1140 "github.com/charmbracelet/bubbles/key"
1141+ "github.com/charmbracelet/bubbles/spinner"
1142 tea "github.com/charmbracelet/bubbletea"
1143 "github.com/charmbracelet/soft-serve/git"
1144 "github.com/charmbracelet/soft-serve/server/proto"
1145 "github.com/charmbracelet/soft-serve/server/ui/common"
1146 "github.com/charmbracelet/soft-serve/server/ui/components/code"
1147 "github.com/charmbracelet/soft-serve/server/ui/components/selector"
1148+ gitm "github.com/gogs/git-module"
1149 )
1150
1151 type filesView int
1152
1153 const (
1154- filesViewFiles filesView = iota
1155+ filesViewLoading filesView = iota
1156+ filesViewFiles
1157 filesViewContent
1158 )
1159
1160@@ -34,6 +37,14 @@ var (
1161 key.WithKeys("l"),
1162 key.WithHelp("l", "toggle line numbers"),
1163 )
1164+ blameView = key.NewBinding(
1165+ key.WithKeys("b"),
1166+ key.WithHelp("b", "toggle blame view"),
1167+ )
1168+ preview = key.NewBinding(
1169+ key.WithKeys("p"),
1170+ key.WithHelp("p", "toggle preview"),
1171+ )
1172 )
1173
1174 // FileItemsMsg is a message that contains a list of files.
1175@@ -45,6 +56,9 @@ type FileContentMsg struct {
1176 ext string
1177 }
1178
1179+// FileBlameMsg is a message that contains the blame of a file.
1180+type FileBlameMsg *gitm.Blame
1181+
1182 // Files is the model for the files view.
1183 type Files struct {
1184 common common.Common
1185@@ -56,8 +70,12 @@ type Files struct {
1186 path string
1187 currentItem *FileItem
1188 currentContent FileContentMsg
1189+ currentBlame FileBlameMsg
1190 lastSelected []int
1191 lineNumber bool
1192+ spinner spinner.Model
1193+ cursor int
1194+ blameView bool
1195 }
1196
1197 // NewFiles creates a new files model.
1198@@ -65,7 +83,7 @@ func NewFiles(common common.Common) *Files {
1199 f := &Files{
1200 common: common,
1201 code: code.New(common, "", ""),
1202- activeView: filesViewFiles,
1203+ activeView: filesViewLoading,
1204 lastSelected: make([]int, 0),
1205 lineNumber: true,
1206 }
1207@@ -80,10 +98,18 @@ func NewFiles(common common.Common) *Files {
1208 selector.KeyMap.NextPage = common.KeyMap.NextPage
1209 selector.KeyMap.PrevPage = common.KeyMap.PrevPage
1210 f.selector = selector
1211- f.code.SetShowLineNumber(f.lineNumber)
1212+ f.code.ShowLineNumber = f.lineNumber
1213+ s := spinner.New(spinner.WithSpinner(spinner.Dot),
1214+ spinner.WithStyle(common.Styles.Spinner))
1215+ f.spinner = s
1216 return f
1217 }
1218
1219+// TabName returns the tab name.
1220+func (f *Files) TabName() string {
1221+ return "Files"
1222+}
1223+
1224 // SetSize implements common.Component.
1225 func (f *Files) SetSize(width, height int) {
1226 f.common.SetSize(width, height)
1227@@ -96,30 +122,16 @@ func (f *Files) ShortHelp() []key.Binding {
1228 k := f.selector.KeyMap
1229 switch f.activeView {
1230 case filesViewFiles:
1231diff --git a/server/ui/pages/repo/filesitem.go b/server/ui/pages/repo/filesitem.go
1232index d9d4841c23a376e5ebd2cb376e014d80d192bad5..c4f358b50cee559a8c480b6084fecaac8dc185b7 100644
1233--- a/server/ui/pages/repo/filesitem.go
1234+++ b/server/ui/pages/repo/filesitem.go
1235@@ -60,9 +60,8 @@ func (cl FileItems) Less(i, j int) bool {
1236 return true
1237 } else if cl[j].entry.IsTree() {
1238 return false
1239- } else {
1240- return cl[i].Title() < cl[j].Title()
1241 }
1242+ return cl[i].Title() < cl[j].Title()
1243 }
1244
1245 // FileItemDelegate is the delegate for the file item list.
1246diff --git a/server/ui/pages/repo/log.go b/server/ui/pages/repo/log.go
1247index 60659e5a670e93489ec6965b7a5f7bc06faf4a7d..5ea5854316df744360f3c0ddd77fb6dd19f365ae 100644
1248--- a/server/ui/pages/repo/log.go
1249+++ b/server/ui/pages/repo/log.go
1250@@ -16,6 +16,7 @@ import (
1251 "github.com/charmbracelet/soft-serve/server/ui/components/footer"
1252 "github.com/charmbracelet/soft-serve/server/ui/components/selector"
1253 "github.com/charmbracelet/soft-serve/server/ui/components/viewport"
1254+ "github.com/charmbracelet/soft-serve/server/ui/styles"
1255 "github.com/muesli/reflow/wrap"
1256 "github.com/muesli/termenv"
1257 )
1258@@ -25,7 +26,8 @@ var waitBeforeLoading = time.Millisecond * 100
1259 type logView int
1260
1261 const (
1262- logViewCommits logView = iota
1263+ logViewLoading logView = iota
1264+ logViewCommits
1265 logViewDiff
1266 )
1267
1268@@ -55,7 +57,6 @@ type Log struct {
1269 selectedCommit *git.Commit
1270 currentDiff *git.Diff
1271 loadingTime time.Time
1272- loading bool
1273 spinner spinner.Model
1274 }
1275
1276@@ -83,6 +84,11 @@ func NewLog(common common.Common) *Log {
1277 return l
1278 }
1279
1280+// TabName returns the name of the tab.
1281+func (l *Log) TabName() string {
1282+ return "Commits"
1283+}
1284+
1285 // SetSize implements common.Component.
1286 func (l *Log) SetSize(width, height int) {
1287 l.common.SetSize(width, height)
1288@@ -163,15 +169,10 @@ func (l *Log) FullHelp() [][]key.Binding {
1289
1290 func (l *Log) startLoading() tea.Cmd {
1291 l.loadingTime = time.Now()
1292- l.loading = true
1293+ l.activeView = logViewLoading
1294 return l.spinner.Tick
1295 }
1296
1297-func (l *Log) stopLoading() tea.Cmd {
1298- l.loading = false
1299- return updateStatusBarCmd
1300-}
1301-
1302 // Init implements tea.Model.
1303 func (l *Log) Init() tea.Cmd {
1304 l.activeView = logViewCommits
1305@@ -179,9 +180,8 @@ func (l *Log) Init() tea.Cmd {
1306 l.count = 0
1307 l.activeCommit = nil
1308 l.selectedCommit = nil
1309- l.selector.Select(0)
1310 return tea.Batch(
1311- l.updateCommitsCmd,
1312+ l.countCommitsCmd,
1313 // start loading on init
1314 l.startLoading(),
1315 )
1316@@ -195,15 +195,17 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1317 l.repo = msg
1318 case RefMsg:
1319 l.ref = msg
1320+ l.selector.Select(0)
1321 cmds = append(cmds, l.Init())
1322 case LogCountMsg:
1323 l.count = int64(msg)
1324+ l.selector.SetTotalPages(int(msg))
1325+ l.selector.SetItems(make([]selector.IdentifiableItem, l.count))
1326+ cmds = append(cmds, l.updateCommitsCmd)
1327 case LogItemsMsg:
1328- cmds = append(cmds,
1329- l.selector.SetItems(msg),
1330- // stop loading after receiving items
1331- l.stopLoading(),
1332- )
1333+ // stop loading after receiving items
1334+ l.activeView = logViewCommits
1335+ cmds = append(cmds, l.selector.SetItems(msg))
1336 l.selector.SetPage(l.nextPage)
1337 l.SetSize(l.common.Width, l.common.Height)
1338 i := l.selector.SelectedItem()
1339@@ -217,10 +219,11 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1340 case tea.KeyMsg:
1341 switch {
1342 case key.Matches(kmsg, l.common.KeyMap.SelectItem):
1343- cmds = append(cmds, l.selector.SelectItem)
1344+ cmds = append(cmds, l.selector.SelectItemCmd)
1345 }
1346 }
1347- // This is a hack for loading commits on demand based on list.Pagination.
1348+ // XXX: This is a hack for loading commits on demand based on
1349+ // list.Pagination.
1350diff --git a/server/ui/pages/repo/readme.go b/server/ui/pages/repo/readme.go
1351index 6c6b610f7c85f290fc0f2657330ff55a1aa8880f..fe7207326c81967fddbebf2a39d6791bb4a88a18 100644
1352--- a/server/ui/pages/repo/readme.go
1353+++ b/server/ui/pages/repo/readme.go
1354@@ -5,6 +5,7 @@ import (
1355 "path/filepath"
1356
1357 "github.com/charmbracelet/bubbles/key"
1358+ "github.com/charmbracelet/bubbles/spinner"
1359 tea "github.com/charmbracelet/bubbletea"
1360 "github.com/charmbracelet/soft-serve/server/backend"
1361 "github.com/charmbracelet/soft-serve/server/proto"
1362@@ -14,7 +15,8 @@ import (
1363
1364 // ReadmeMsg is a message sent when the readme is loaded.
1365 type ReadmeMsg struct {
1366- Msg tea.Msg
1367+ Content string
1368+ Path string
1369 }
1370
1371 // Readme is the readme component page.
1372@@ -24,18 +26,30 @@ type Readme struct {
1373 ref RefMsg
1374 repo proto.Repository
1375 readmePath string
1376+ spinner spinner.Model
1377+ isLoading bool
1378 }
1379
1380 // NewReadme creates a new readme model.
1381 func NewReadme(common common.Common) *Readme {
1382 readme := code.New(common, "", "")
1383 readme.NoContentStyle = readme.NoContentStyle.Copy().SetString("No readme found.")
1384+ readme.UseGlamour = true
1385+ s := spinner.New(spinner.WithSpinner(spinner.Dot),
1386+ spinner.WithStyle(common.Styles.Spinner))
1387 return &Readme{
1388- code: readme,
1389- common: common,
1390+ code: readme,
1391+ common: common,
1392+ spinner: s,
1393+ isLoading: true,
1394 }
1395 }
1396
1397+// TabName returns the name of the tab.
1398+func (r *Readme) TabName() string {
1399+ return "Readme"
1400+}
1401+
1402 // SetSize implements common.Component.
1403 func (r *Readme) SetSize(width, height int) {
1404 r.common.SetSize(width, height)
1405@@ -72,7 +86,8 @@ func (r *Readme) FullHelp() [][]key.Binding {
1406
1407 // Init implements tea.Model.
1408 func (r *Readme) Init() tea.Cmd {
1409- return r.updateReadmeCmd
1410+ r.isLoading = true
1411+ return tea.Batch(r.spinner.Tick, r.updateReadmeCmd)
1412 }
1413
1414 // Update implements tea.Model.
1415@@ -84,9 +99,26 @@ func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1416 case RefMsg:
1417 r.ref = msg
1418 cmds = append(cmds, r.Init())
1419+ case tea.WindowSizeMsg:
1420+ r.SetSize(msg.Width, msg.Height)
1421 case EmptyRepoMsg:
1422- r.code.SetContent(defaultEmptyRepoMsg(r.common.Config(),
1423- r.repo.Name()), ".md")
1424+ cmds = append(cmds,
1425+ r.code.SetContent(defaultEmptyRepoMsg(r.common.Config(),
1426+ r.repo.Name()), ".md"),
1427+ )
1428+ case ReadmeMsg:
1429+ r.isLoading = false
1430+ r.readmePath = msg.Path
1431+ r.code.GotoTop()
1432+ cmds = append(cmds, r.code.SetContent(msg.Content, msg.Path))
1433+ case spinner.TickMsg:
1434+ if r.isLoading && r.spinner.ID() == msg.ID {
1435+ s, cmd := r.spinner.Update(msg)
1436+ r.spinner = s
1437+ if cmd != nil {
1438+ cmds = append(cmds, cmd)
1439+ }
1440+ }
1441 }
1442 c, cmd := r.code.Update(msg)
1443 r.code = c.(*code.Code)
1444@@ -98,34 +130,38 @@ func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1445
1446 // View implements tea.Model.
1447 func (r *Readme) View() string {
1448+ if r.isLoading {
1449+ return renderLoading(r.common, r.spinner)
1450+ }
1451 return r.code.View()
1452 }
1453
1454diff --git a/server/ui/pages/repo/refs.go b/server/ui/pages/repo/refs.go
1455index 2dac5b38192d5c08c03d4ccc77fcbd4800f6cb69..e89eee409ec10706a7697157f1624b3c56372c10 100644
1456--- a/server/ui/pages/repo/refs.go
1457+++ b/server/ui/pages/repo/refs.go
1458@@ -6,17 +6,16 @@ import (
1459 "strings"
1460
1461 "github.com/charmbracelet/bubbles/key"
1462+ "github.com/charmbracelet/bubbles/spinner"
1463 tea "github.com/charmbracelet/bubbletea"
1464 "github.com/charmbracelet/soft-serve/git"
1465- ggit "github.com/charmbracelet/soft-serve/git"
1466 "github.com/charmbracelet/soft-serve/server/proto"
1467 "github.com/charmbracelet/soft-serve/server/ui/common"
1468 "github.com/charmbracelet/soft-serve/server/ui/components/selector"
1469- "github.com/charmbracelet/soft-serve/server/ui/components/tabs"
1470 )
1471
1472 // RefMsg is a message that contains a git.Reference.
1473-type RefMsg *ggit.Reference
1474+type RefMsg *git.Reference
1475
1476 // RefItemsMsg is a message that contains a list of RefItem.
1477 type RefItemsMsg struct {
1478@@ -32,6 +31,8 @@ type Refs struct {
1479 ref *git.Reference
1480 activeRef *git.Reference
1481 refPrefix string
1482+ spinner spinner.Model
1483+ isLoading bool
1484 }
1485
1486 // NewRefs creates a new Refs component.
1487@@ -39,6 +40,7 @@ func NewRefs(common common.Common, refPrefix string) *Refs {
1488 r := &Refs{
1489 common: common,
1490 refPrefix: refPrefix,
1491+ isLoading: true,
1492 }
1493 s := selector.New(common, []selector.IdentifiableItem{}, RefItemDelegate{&common})
1494 s.SetShowFilter(false)
1495@@ -49,9 +51,22 @@ func NewRefs(common common.Common, refPrefix string) *Refs {
1496 s.SetFilteringEnabled(false)
1497 s.DisableQuitKeybindings()
1498 r.selector = s
1499+ sp := spinner.New(spinner.WithSpinner(spinner.Dot),
1500+ spinner.WithStyle(common.Styles.Spinner))
1501+ r.spinner = sp
1502 return r
1503 }
1504
1505+// TabName returns the name of the tab.
1506+func (r *Refs) TabName() string {
1507+ if r.refPrefix == git.RefsHeads {
1508+ return "Branches"
1509+ } else if r.refPrefix == git.RefsTags {
1510+ return "Tags"
1511+ }
1512+ return "Refs"
1513+}
1514+
1515 // SetSize implements common.Component.
1516 func (r *Refs) SetSize(width, height int) {
1517 r.common.SetSize(width, height)
1518@@ -94,7 +109,8 @@ func (r *Refs) FullHelp() [][]key.Binding {
1519
1520 // Init implements tea.Model.
1521 func (r *Refs) Init() tea.Cmd {
1522- return r.updateItemsCmd
1523+ r.isLoading = true
1524+ return tea.Batch(r.spinner.Tick, r.updateItemsCmd)
1525 }
1526
1527 // Update implements tea.Model.
1528@@ -107,6 +123,8 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1529 case RefMsg:
1530 r.ref = msg
1531 cmds = append(cmds, r.Init())
1532+ case tea.WindowSizeMsg:
1533+ r.SetSize(msg.Width, msg.Height)
1534 case RefItemsMsg:
1535 if r.refPrefix == msg.prefix {
1536 cmds = append(cmds, r.selector.SetItems(msg.items))
1537@@ -114,29 +132,37 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1538 if i != nil {
1539 r.activeRef = i.(RefItem).Reference
1540 }
1541+ r.isLoading = false
1542 }
1543 case selector.ActiveMsg:
1544 switch sel := msg.IdentifiableItem.(type) {
1545 case RefItem:
1546 r.activeRef = sel.Reference
1547 }
1548- cmds = append(cmds, updateStatusBarCmd)
1549 case selector.SelectMsg:
1550 switch i := msg.IdentifiableItem.(type) {
1551 case RefItem:
1552 cmds = append(cmds,
1553 switchRefCmd(i.Reference),
1554- tabs.SelectTabCmd(int(filesTab)),
1555+ switchTabCmd(&Files{}),
1556 )
1557 }
1558diff --git a/server/ui/pages/repo/refsitem.go b/server/ui/pages/repo/refsitem.go
1559index 0d552bf01c6a3fcbff257a56950634c86232cfea..4ebec866989cf15ae9e66a0e6cb7354ae45cd8ef 100644
1560--- a/server/ui/pages/repo/refsitem.go
1561+++ b/server/ui/pages/repo/refsitem.go
1562@@ -3,6 +3,8 @@ package repo
1563 import (
1564 "fmt"
1565 "io"
1566+ "strings"
1567+ "time"
1568
1569 "github.com/charmbracelet/bubbles/key"
1570 "github.com/charmbracelet/bubbles/list"
1571@@ -10,11 +12,15 @@ import (
1572 "github.com/charmbracelet/lipgloss"
1573 "github.com/charmbracelet/soft-serve/git"
1574 "github.com/charmbracelet/soft-serve/server/ui/common"
1575+ "github.com/dustin/go-humanize"
1576+ "github.com/muesli/reflow/truncate"
1577 )
1578
1579 // RefItem is a git reference item.
1580 type RefItem struct {
1581 *git.Reference
1582+ *git.Tag
1583+ *git.Commit
1584 }
1585
1586 // ID implements selector.IdentifiableItem.
1587@@ -51,7 +57,12 @@ func (cl RefItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
1588
1589 // Less implements sort.Interface.
1590 func (cl RefItems) Less(i, j int) bool {
1591- return cl[i].Short() < cl[j].Short()
1592+ if cl[i].Commit != nil && cl[j].Commit != nil {
1593+ return cl[i].Commit.Author.When.After(cl[j].Commit.Author.When)
1594+ } else if cl[i].Commit != nil && cl[j].Commit == nil {
1595+ return true
1596+ }
1597+ return false
1598 }
1599
1600 // RefItemDelegate is the delegate for the ref item.
1601@@ -83,46 +94,112 @@ func (d RefItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
1602
1603 // Render implements list.ItemDelegate.
1604 func (d RefItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
1605- s := d.common.Styles.Ref
1606 i, ok := listItem.(RefItem)
1607 if !ok {
1608 return
1609 }
1610
1611- var st lipgloss.Style
1612- var selector string
1613-
1614 isTag := i.Reference.IsTag()
1615 isActive := index == m.Index()
1616+ s := d.common.Styles.Ref
1617+ st := s.Normal
1618+ selector := " "
1619+ if isActive {
1620+ st = s.Active
1621+ selector = s.ItemSelector.String()
1622+ }
1623
1624+ horizontalFrameSize := st.Base.GetHorizontalFrameSize()
1625+ var itemSt lipgloss.Style
1626 if isTag && isActive {
1627- st = s.Active.ItemTag
1628+ itemSt = st.ItemTag
1629 } else if isTag {
1630- st = s.Normal.ItemTag
1631+ itemSt = st.ItemTag
1632 } else if isActive {
1633- st = s.Active.Item
1634+ itemSt = st.Item
1635 } else {
1636- st = s.Normal.Item
1637+ itemSt = st.Item
1638 }
1639
1640- if isActive {
1641- selector = s.ItemSelector.String()
1642- } else {
1643- selector = " "
1644+ var sha string
1645+ c := i.Commit
1646+ if c != nil {
1647+ sha = c.ID.String()[:7]
1648 }
1649
1650 ref := i.Short()
1651- ref = s.ItemBranch.Render(ref)
1652- refMaxWidth := m.Width() -
1653- s.ItemSelector.GetMarginLeft() -
1654- s.ItemSelector.GetWidth() -
1655- s.Normal.Item.GetMarginLeft()
1656- ref = common.TruncateString(ref, refMaxWidth)
1657- ref = st.Render(ref)
1658+
1659+ var desc string
1660+ if isTag {
1661+ if c != nil {
1662diff --git a/server/ui/pages/repo/repo.go b/server/ui/pages/repo/repo.go
1663index 3f1927ba8de10535f6712756c01566040eac5950..dde1f2fee6d5bc16aff4a75e409ee679ccffd3f6 100644
1664--- a/server/ui/pages/repo/repo.go
1665+++ b/server/ui/pages/repo/repo.go
1666@@ -2,6 +2,7 @@ package repo
1667
1668 import (
1669 "fmt"
1670+ "strings"
1671
1672 "github.com/charmbracelet/bubbles/help"
1673 "github.com/charmbracelet/bubbles/key"
1674@@ -12,6 +13,7 @@ import (
1675 "github.com/charmbracelet/soft-serve/server/proto"
1676 "github.com/charmbracelet/soft-serve/server/ui/common"
1677 "github.com/charmbracelet/soft-serve/server/ui/components/footer"
1678+ "github.com/charmbracelet/soft-serve/server/ui/components/selector"
1679 "github.com/charmbracelet/soft-serve/server/ui/components/statusbar"
1680 "github.com/charmbracelet/soft-serve/server/ui/components/tabs"
1681 )
1682@@ -23,41 +25,17 @@ const (
1683 readyState
1684 )
1685
1686-type tab int
1687-
1688-const (
1689- readmeTab tab = iota
1690- filesTab
1691- commitsTab
1692- branchesTab
1693- tagsTab
1694- lastTab
1695-)
1696-
1697-func (t tab) String() string {
1698- return []string{
1699- "Readme",
1700- "Files",
1701- "Commits",
1702- "Branches",
1703- "Tags",
1704- }[t]
1705-}
1706-
1707 // EmptyRepoMsg is a message to indicate that the repository is empty.
1708 type EmptyRepoMsg struct{}
1709
1710 // CopyURLMsg is a message to copy the URL of the current repository.
1711 type CopyURLMsg struct{}
1712
1713-// UpdateStatusBarMsg updates the status bar.
1714-type UpdateStatusBarMsg struct{}
1715-
1716 // RepoMsg is a message that contains a git.Repository.
1717 type RepoMsg proto.Repository // nolint:revive
1718
1719-// BackMsg is a message to go back to the previous view.
1720-type BackMsg struct{}
1721+// GoBackMsg is a message to go back to the previous view.
1722+type GoBackMsg struct{}
1723
1724 // CopyMsg is a message to indicate copied text.
1725 type CopyMsg struct {
1726@@ -65,63 +43,60 @@ type CopyMsg struct {
1727 Message string
1728 }
1729
1730+// SwitchTabMsg is a message to switch tabs.
1731+type SwitchTabMsg common.TabComponent
1732+
1733 // Repo is a view for a git repository.
1734 type Repo struct {
1735 common common.Common
1736 selectedRepo proto.Repository
1737- activeTab tab
1738+ activeTab int
1739 tabs *tabs.Tabs
1740- statusbar *statusbar.StatusBar
1741- panes []common.Component
1742+ statusbar *statusbar.Model
1743+ panes []common.TabComponent
1744 ref *git.Reference
1745 state state
1746 spinner spinner.Model
1747- panesReady [lastTab]bool
1748+ panesReady []bool
1749 }
1750
1751 // New returns a new Repo.
1752-func New(c common.Common) *Repo {
1753+func New(c common.Common, comps ...common.TabComponent) *Repo {
1754 sb := statusbar.New(c)
1755- ts := make([]string, lastTab)
1756- // Tabs must match the order of tab constants above.
1757- for i, t := range []tab{readmeTab, filesTab, commitsTab, branchesTab, tagsTab} {
1758- ts[i] = t.String()
1759+ ts := make([]string, 0)
1760+ for _, c := range comps {
1761+ ts = append(ts, c.TabName())
1762 }
1763 c.Logger = c.Logger.WithPrefix("ui.repo")
1764 tb := tabs.New(c, ts)
1765- readme := NewReadme(c)
1766diff --git a/server/ui/pages/repo/stash.go b/server/ui/pages/repo/stash.go
1767new file mode 100644
1768index 0000000000000000000000000000000000000000..0693743f39471d5419b1f4cd1732aa96e6556702
1769--- /dev/null
1770+++ b/server/ui/pages/repo/stash.go
1771@@ -0,0 +1,279 @@
1772+package repo
1773+
1774+import (
1775+ "fmt"
1776+
1777+ "github.com/charmbracelet/bubbles/key"
1778+ "github.com/charmbracelet/bubbles/spinner"
1779+ tea "github.com/charmbracelet/bubbletea"
1780+ "github.com/charmbracelet/lipgloss"
1781+ "github.com/charmbracelet/soft-serve/git"
1782+ "github.com/charmbracelet/soft-serve/server/proto"
1783+ "github.com/charmbracelet/soft-serve/server/ui/common"
1784+ "github.com/charmbracelet/soft-serve/server/ui/components/code"
1785+ "github.com/charmbracelet/soft-serve/server/ui/components/selector"
1786+ gitm "github.com/gogs/git-module"
1787+)
1788+
1789+type stashState int
1790+
1791+const (
1792+ stashStateLoading stashState = iota
1793+ stashStateList
1794+ stashStatePatch
1795+)
1796+
1797+// StashListMsg is a message sent when the stash list is loaded.
1798+type StashListMsg []*gitm.Stash
1799+
1800+// StashPatchMsg is a message sent when the stash patch is loaded.
1801+type StashPatchMsg struct{ *git.Diff }
1802+
1803+// Stash is the stash component page.
1804+type Stash struct {
1805+ common common.Common
1806+ code *code.Code
1807+ ref RefMsg
1808+ repo proto.Repository
1809+ spinner spinner.Model
1810+ list *selector.Selector
1811+ state stashState
1812+ currentPatch StashPatchMsg
1813+}
1814+
1815+// NewStash creates a new stash model.
1816+func NewStash(common common.Common) *Stash {
1817+ code := code.New(common, "", "")
1818+ s := spinner.New(spinner.WithSpinner(spinner.Dot),
1819+ spinner.WithStyle(common.Styles.Spinner))
1820+ selector := selector.New(common, []selector.IdentifiableItem{}, StashItemDelegate{&common})
1821+ selector.SetShowFilter(false)
1822+ selector.SetShowHelp(false)
1823+ selector.SetShowPagination(false)
1824+ selector.SetShowStatusBar(false)
1825+ selector.SetShowTitle(false)
1826+ selector.SetFilteringEnabled(false)
1827+ selector.DisableQuitKeybindings()
1828+ selector.KeyMap.NextPage = common.KeyMap.NextPage
1829+ selector.KeyMap.PrevPage = common.KeyMap.PrevPage
1830+ return &Stash{
1831+ code: code,
1832+ common: common,
1833+ spinner: s,
1834+ list: selector,
1835+ }
1836+}
1837+
1838+// TabName returns the name of the tab.
1839+func (s *Stash) TabName() string {
1840+ return "Stash"
1841+}
1842+
1843+// SetSize implements common.Component.
1844+func (s *Stash) SetSize(width, height int) {
1845+ s.common.SetSize(width, height)
1846+ s.code.SetSize(width, height)
1847+ s.list.SetSize(width, height)
1848+}
1849+
1850+// ShortHelp implements help.KeyMap.
1851+func (s *Stash) ShortHelp() []key.Binding {
1852+ return []key.Binding{
1853+ s.common.KeyMap.Select,
1854+ s.common.KeyMap.Back,
1855+ s.common.KeyMap.UpDown,
1856+ }
1857+}
1858+
1859+// FullHelp implements help.KeyMap.
1860+func (s *Stash) FullHelp() [][]key.Binding {
1861+ b := [][]key.Binding{
1862+ {
1863+ s.common.KeyMap.Select,
1864+ s.common.KeyMap.Back,
1865+ s.common.KeyMap.Copy,
1866+ },
1867+ {
1868+ s.code.KeyMap.Down,
1869+ s.code.KeyMap.Up,
1870+ s.common.KeyMap.GotoTop,
1871diff --git a/server/ui/pages/repo/stashitem.go b/server/ui/pages/repo/stashitem.go
1872new file mode 100644
1873index 0000000000000000000000000000000000000000..482a40ad352b57f9c548bbcb84ba828e755fa16a
1874--- /dev/null
1875+++ b/server/ui/pages/repo/stashitem.go
1876@@ -0,0 +1,106 @@
1877+package repo
1878+
1879+import (
1880+ "fmt"
1881+ "io"
1882+
1883+ "github.com/charmbracelet/bubbles/key"
1884+ "github.com/charmbracelet/bubbles/list"
1885+ tea "github.com/charmbracelet/bubbletea"
1886+ "github.com/charmbracelet/soft-serve/server/ui/common"
1887+ gitm "github.com/gogs/git-module"
1888+)
1889+
1890+// StashItem represents a stash item.
1891+type StashItem struct{ *gitm.Stash }
1892+
1893+// ID returns the ID of the stash item.
1894+func (i StashItem) ID() string {
1895+ return fmt.Sprintf("stash@{%d}", i.Index)
1896+}
1897+
1898+// Title returns the title of the stash item.
1899+func (i StashItem) Title() string {
1900+ return i.Message
1901+}
1902+
1903+// Description returns the description of the stash item.
1904+func (i StashItem) Description() string {
1905+ return ""
1906+}
1907+
1908+// FilterValue implements list.Item.
1909+func (i StashItem) FilterValue() string { return i.Title() }
1910+
1911+// StashItems is a list of stash items.
1912+type StashItems []StashItem
1913+
1914+// Len implements sort.Interface.
1915+func (cl StashItems) Len() int { return len(cl) }
1916+
1917+// Swap implements sort.Interface.
1918+func (cl StashItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
1919+
1920+// Less implements sort.Interface.
1921+func (cl StashItems) Less(i, j int) bool {
1922+ return cl[i].Index < cl[j].Index
1923+}
1924+
1925+// StashItemDelegate is a delegate for stash items.
1926+type StashItemDelegate struct {
1927+ common *common.Common
1928+}
1929+
1930+// Height returns the height of the stash item list. Implements list.ItemDelegate.
1931+func (d StashItemDelegate) Height() int { return 1 }
1932+
1933+// Spacing implements list.ItemDelegate.
1934+func (d StashItemDelegate) Spacing() int { return 0 }
1935+
1936+// Update implements list.ItemDelegate.
1937+func (d StashItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
1938+ item, ok := m.SelectedItem().(StashItem)
1939+ if !ok {
1940+ return nil
1941+ }
1942+
1943+ switch msg := msg.(type) {
1944+ case tea.KeyMsg:
1945+ switch {
1946+ case key.Matches(msg, d.common.KeyMap.Copy):
1947+ return copyCmd(item.Title(), fmt.Sprintf("Stash message %q copied to clipboard", item.Title()))
1948+ }
1949+ }
1950+
1951+ return nil
1952+}
1953+
1954+// Render implements list.ItemDelegate.
1955+func (d StashItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
1956+ item, ok := listItem.(StashItem)
1957+ if !ok {
1958+ return
1959+ }
1960+
1961+ s := d.common.Styles.Stash
1962+
1963+ st := s.Normal.Message
1964+ selector := " "
1965+ if index == m.Index() {
1966+ selector = "> "
1967+ st = s.Active.Message
1968+ }
1969+
1970+ selector = s.Selector.Render(selector)
1971+ title := st.Render(item.Title())
1972+ fmt.Fprint(w, d.common.Zone.Mark(
1973+ item.ID(),
1974+ common.TruncateString(fmt.Sprintf("%s%s",
1975+ selector,
1976diff --git a/server/ui/pages/selection/selection.go b/server/ui/pages/selection/selection.go
1977index b85573d3343faa69c42985df000a84d663e2317f..b84b0a33dd6cc0f681fbba9071aa13f6d2c8790a 100644
1978--- a/server/ui/pages/selection/selection.go
1979+++ b/server/ui/pages/selection/selection.go
1980@@ -201,7 +201,7 @@ func (s *Selection) Init() tea.Cmd {
1981 sortedItems := make(Items, 0)
1982 for _, r := range repos {
1983 if r.Name() == ".soft-serve" {
1984- readme, path, err := backend.Readme(r)
1985+ readme, path, err := backend.Readme(r, nil)
1986 if err != nil {
1987 continue
1988 }
1989diff --git a/server/ui/styles/styles.go b/server/ui/styles/styles.go
1990index 63805bbbead4012bce1c5c6ff3c11a24448228ec..fbaab60151e696557ba5325efdcd939cdbe83307 100644
1991--- a/server/ui/styles/styles.go
1992+++ b/server/ui/styles/styles.go
1993@@ -89,16 +89,22 @@ type Styles struct {
1994
1995 Ref struct {
1996 Normal struct {
1997- Item lipgloss.Style
1998- ItemTag lipgloss.Style
1999+ Base lipgloss.Style
2000+ Item lipgloss.Style
2001+ ItemTag lipgloss.Style
2002+ ItemDesc lipgloss.Style
2003+ ItemHash lipgloss.Style
2004 }
2005 Active struct {
2006- Item lipgloss.Style
2007- ItemTag lipgloss.Style
2008+ Base lipgloss.Style
2009+ Item lipgloss.Style
2010+ ItemTag lipgloss.Style
2011+ ItemDesc lipgloss.Style
2012+ ItemHash lipgloss.Style
2013 }
2014 ItemSelector lipgloss.Style
2015- ItemBranch lipgloss.Style
2016 Paginator lipgloss.Style
2017+ Selector lipgloss.Style
2018 }
2019
2020 Tree struct {
2021@@ -117,6 +123,21 @@ type Styles struct {
2022 Selector lipgloss.Style
2023 FileContent lipgloss.Style
2024 Paginator lipgloss.Style
2025+ Blame struct {
2026+ Hash lipgloss.Style
2027+ Message lipgloss.Style
2028+ }
2029+ }
2030+
2031+ Stash struct {
2032+ Normal struct {
2033+ Message lipgloss.Style
2034+ }
2035+ Active struct {
2036+ Message lipgloss.Style
2037+ }
2038+ Title lipgloss.Style
2039+ Selector lipgloss.Style
2040 }
2041
2042 Spinner lipgloss.Style
2043@@ -124,8 +145,6 @@ type Styles struct {
2044
2045 NoContent lipgloss.Style
2046
2047- NoItems lipgloss.Style
2048-
2049 StatusBar lipgloss.Style
2050 StatusBarKey lipgloss.Style
2051 StatusBarValue lipgloss.Style
2052@@ -137,6 +156,11 @@ type Styles struct {
2053 TabInactive lipgloss.Style
2054 TabActive lipgloss.Style
2055 TabSeparator lipgloss.Style
2056+
2057+ Code struct {
2058+ LineDigit lipgloss.Style
2059+ LineBar lipgloss.Style
2060+ }
2061 }
2062
2063 // DefaultStyles returns default styles for the UI.
2064@@ -227,7 +251,7 @@ func DefaultStyles() *Styles {
2065 Margin(1, 0)
2066
2067 s.Repo.Header = lipgloss.NewStyle().
2068- Height(2).
2069+ MaxHeight(2).
2070 Border(lipgloss.NormalBorder(), false, false, true, false).
2071 BorderForeground(lipgloss.Color("236"))
2072
2073@@ -348,7 +372,9 @@ func DefaultStyles() *Styles {
2074 s.Ref.Active.Item = lipgloss.NewStyle().
2075 Foreground(highlightColorDim)
2076
2077- s.Ref.ItemBranch = lipgloss.NewStyle()
2078+ s.Ref.Normal.Base = lipgloss.NewStyle()
2079+
2080+ s.Ref.Active.Base = lipgloss.NewStyle()
2081
2082 s.Ref.Normal.ItemTag = lipgloss.NewStyle().
2083 Foreground(lipgloss.Color("39"))
2084@@ -361,8 +387,25 @@ func DefaultStyles() *Styles {
2085 Bold(true).
2086 Foreground(highlightColor)
2087
2088+ s.Ref.Normal.ItemDesc = lipgloss.NewStyle().
2089+ Faint(true)
2090+
2091+ s.Ref.Active.ItemDesc = lipgloss.NewStyle().
2092+ Foreground(highlightColor).
2093diff --git a/server/web/git_lfs.go b/server/web/git_lfs.go
2094index d73f5a44208a8a28a1c4624e81130918d2353909..4d25f76e95b2721c1c64c46fefa53b84c38741c0 100644
2095--- a/server/web/git_lfs.go
2096+++ b/server/web/git_lfs.go
2097@@ -683,52 +683,51 @@ func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) {
2098 },
2099 })
2100 return
2101- } else {
2102- locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
2103- if err != nil {
2104- logger.Error("error getting locks", "err", err)
2105- renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
2106- Message: "internal server error",
2107- })
2108- return
2109- }
2110+ }
2111
2112- lockList := make([]lfs.Lock, len(locks))
2113- users := map[int64]models.User{}
2114- for i, lock := range locks {
2115- owner, ok := users[lock.UserID]
2116- if !ok {
2117- owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)
2118- if err != nil {
2119- logger.Error("error getting lock owner", "err", err)
2120- renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
2121- Message: "internal server error",
2122- })
2123- return
2124- }
2125- users[lock.UserID] = owner
2126- }
2127+ locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
2128+ if err != nil {
2129+ logger.Error("error getting locks", "err", err)
2130+ renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
2131+ Message: "internal server error",
2132+ })
2133+ return
2134+ }
2135
2136- lockList[i] = lfs.Lock{
2137- ID: strconv.FormatInt(lock.ID, 10),
2138- Path: lock.Path,
2139- LockedAt: lock.CreatedAt,
2140- Owner: lfs.Owner{
2141- Name: owner.Username,
2142- },
2143+ lockList := make([]lfs.Lock, len(locks))
2144+ users := map[int64]models.User{}
2145+ for i, lock := range locks {
2146+ owner, ok := users[lock.UserID]
2147+ if !ok {
2148+ owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)
2149+ if err != nil {
2150+ logger.Error("error getting lock owner", "err", err)
2151+ renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
2152+ Message: "internal server error",
2153+ })
2154+ return
2155 }
2156+ users[lock.UserID] = owner
2157 }
2158
2159- resp := lfs.LockListResponse{
2160- Locks: lockList,
2161- }
2162- if len(locks) == limit {
2163- resp.NextCursor = strconv.Itoa(cursor + 1)
2164+ lockList[i] = lfs.Lock{
2165+ ID: strconv.FormatInt(lock.ID, 10),
2166+ Path: lock.Path,
2167+ LockedAt: lock.CreatedAt,
2168+ Owner: lfs.Owner{
2169+ Name: owner.Username,
2170+ },
2171 }
2172+ }
2173
2174- renderJSON(w, http.StatusOK, resp)
2175- return
2176+ resp := lfs.LockListResponse{
2177+ Locks: lockList,
2178 }
2179+ if len(locks) == limit {
2180+ resp.NextCursor = strconv.Itoa(cursor + 1)
2181+ }
2182+
2183+ renderJSON(w, http.StatusOK, resp)
2184 }
2185
2186 // POST: /<repo>.git/info/lfs/objects/locks/verify