1476c3050dd6ebe9f26d673c6d70daa4e4b41d4f
- Author
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Committer
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/cmd/soft/serve.go b/cmd/soft/serve.go
2index ecdd628744766a522b901747035c2913533858fc..6ceebbad6f6defdfa670493e6ed83351c1822f64 100644
3--- a/cmd/soft/serve.go
4+++ b/cmd/soft/serve.go
5@@ -8,8 +8,8 @@ import (
6 "syscall"
7 "time"
8
9- "github.com/charmbracelet/soft-serve/config"
10 "github.com/charmbracelet/soft-serve/server"
11+ "github.com/charmbracelet/soft-serve/server/config"
12 "github.com/spf13/cobra"
13 )
14
15diff --git a/internal/config/git.go b/config/auth.go
16rename from internal/config/git.go
17rename to config/auth.go
18diff --git a/config/config.go b/config/config.go
19index dc60e2dad1f2cd802a98d1ca3894c6db4bc0d8ba..d64fa9fddf8c7feb87841f3ced52e23e756cbfb7 100644
20--- a/config/config.go
21+++ b/config/config.go
22@@ -1,53 +1,279 @@
23 package config
24
25 import (
26+ "bytes"
27+ "errors"
28+ "io/fs"
29 "log"
30 "path/filepath"
31+ "strings"
32+ "sync"
33+ "text/template"
34+ "time"
35
36- "github.com/caarlos0/env/v6"
37+ "golang.org/x/crypto/ssh"
38+ "gopkg.in/yaml.v3"
39+
40+ "fmt"
41+ "os"
42+
43+ "github.com/charmbracelet/soft-serve/server/config"
44+ "github.com/go-git/go-billy/v5/memfs"
45+ ggit "github.com/go-git/go-git/v5"
46+ "github.com/go-git/go-git/v5/plumbing/object"
47+ "github.com/go-git/go-git/v5/plumbing/transport"
48+ "github.com/go-git/go-git/v5/storage/memory"
49 )
50
51-// Callbacks provides an interface that can be used to run callbacks on different events.
52-type Callbacks interface {
53- Tui(action string)
54- Push(repo string)
55- Fetch(repo string)
56+// Config is the Soft Serve configuration.
57+type Config struct {
58+ Name string `yaml:"name"`
59+ Host string `yaml:"host"`
60+ Port int `yaml:"port"`
61+ AnonAccess string `yaml:"anon-access"`
62+ AllowKeyless bool `yaml:"allow-keyless"`
63+ Users []User `yaml:"users"`
64+ Repos []MenuRepo `yaml:"repos"`
65+ Source *RepoSource `yaml:"-"`
66+ Cfg *config.Config `yaml:"-"`
67+ mtx sync.Mutex
68 }
69
70-// Config is the configuration for Soft Serve.
71-type Config struct {
72- BindAddr string `env:"SOFT_SERVE_BIND_ADDRESS" envDefault:""`
73- Host string `env:"SOFT_SERVE_HOST" envDefault:"localhost"`
74- Port int `env:"SOFT_SERVE_PORT" envDefault:"23231"`
75- KeyPath string `env:"SOFT_SERVE_KEY_PATH"`
76- RepoPath string `env:"SOFT_SERVE_REPO_PATH" envDefault:".repos"`
77- InitialAdminKeys []string `env:"SOFT_SERVE_INITIAL_ADMIN_KEY" envSeparator:"\n"`
78- Callbacks Callbacks
79- ErrorLog *log.Logger
80+// User contains user-level configuration for a repository.
81+type User struct {
82+ Name string `yaml:"name"`
83+ Admin bool `yaml:"admin"`
84+ PublicKeys []string `yaml:"public-keys"`
85+ CollabRepos []string `yaml:"collab-repos"`
86 }
87
88-// DefaultConfig returns a Config with the values populated with the defaults
89-// or specified environment variables.
90-func DefaultConfig() *Config {
91- cfg := &Config{ErrorLog: log.Default()}
92- if err := env.Parse(cfg); err != nil {
93- log.Fatalln(err)
94+// Repo contains repository configuration information.
95+type MenuRepo struct {
96+ Name string `yaml:"name"`
97+ Repo string `yaml:"repo"`
98+ Note string `yaml:"note"`
99+ Private bool `yaml:"private"`
100+ Readme string `yaml:"readme"`
101+}
102+
103+// NewConfig creates a new internal Config struct.
104+func NewConfig(cfg *config.Config) (*Config, error) {
105+ var anonAccess string
106+ var yamlUsers string
107+ var displayHost string
108+ host := cfg.Host
109+ port := cfg.Port
110+
111+ pks := make([]string, 0)
112+ for _, k := range cfg.InitialAdminKeys {
113+ if bts, err := os.ReadFile(k); err == nil {
114+ // pk is a file, set its contents as pk
115+ k = string(bts)
116+ }
117+ var pk = strings.TrimSpace(k)
118+ if pk == "" {
119+ continue
120+ }
121+ // it is a valid ssh key, nothing to do
122diff --git a/config/config_test.go b/config/config_test.go
123index 6a8eb7e4890f4ff535b7523491936a6273590755..12ddd8c244a7a556f1498dace6b7da61a1e5d706 100644
124--- a/config/config_test.go
125+++ b/config/config_test.go
126@@ -1,19 +1,37 @@
127 package config
128
129 import (
130- "os"
131 "testing"
132
133+ "github.com/charmbracelet/soft-serve/server/config"
134 "github.com/matryer/is"
135 )
136
137-func TestParseMultipleKeys(t *testing.T) {
138+func TestMultipleInitialKeys(t *testing.T) {
139+ cfg, err := NewConfig(&config.Config{
140+ RepoPath: t.TempDir(),
141+ KeyPath: t.TempDir(),
142+ InitialAdminKeys: []string{
143+ "testdata/k1.pub",
144+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
145+ },
146+ })
147 is := is.New(t)
148- is.NoErr(os.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEY", "testdata/k1.pub\ntestdata/k2.pub"))
149- t.Cleanup(func() { is.NoErr(os.Unsetenv("SOFT_SERVE_INITIAL_ADMIN_KEY")) })
150- cfg := DefaultConfig()
151- is.Equal(cfg.InitialAdminKeys, []string{
152- "testdata/k1.pub",
153- "testdata/k2.pub",
154+ is.NoErr(err)
155+ err = cfg.Reload()
156+ is.NoErr(err)
157+ is.Equal(cfg.Users[0].PublicKeys, []string{
158+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b",
159+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
160+ }) // should have both keys
161+}
162+
163+func TestEmptyInitialKeys(t *testing.T) {
164+ cfg, err := NewConfig(&config.Config{
165+ RepoPath: t.TempDir(),
166+ KeyPath: t.TempDir(),
167 })
168+ is := is.New(t)
169+ is.NoErr(err)
170+ is.Equal(len(cfg.Users), 0) // should not have any users
171 }
172diff --git a/internal/config/defaults.go b/config/defaults.go
173rename from internal/config/defaults.go
174rename to config/defaults.go
175diff --git a/internal/git/git.go b/config/git.go
176rename from internal/git/git.go
177rename to config/git.go
178index 89762d524c558aecdf42552c0e75fb20a0abb7a7..857a5898ed970bb586042225cd5c84c5686055dd 100644
179--- a/internal/git/git.go
180+++ b/config/git.go
181@@ -1,4 +1,4 @@
182-package git
183+package config
184
185 import (
186 "errors"
187@@ -17,13 +17,17 @@ var ErrMissingRepo = errors.New("missing repo")
188
189 // Repo represents a Git repository.
190 type Repo struct {
191- path string
192- repository *git.Repository
193- readme string
194- readmePath string
195- head *git.Reference
196- refs []*git.Reference
197- patchCache *lru.Cache
198+ name string
199+ description string
200+ path string
201+ repository *git.Repository
202+ readme string
203+ readmePath string
204+ head *git.Reference
205+ headCommit string
206+ refs []*git.Reference
207+ patchCache *lru.Cache
208+ private bool
209 }
210
211 // open opens a Git repository.
212@@ -48,16 +52,34 @@ func (rs *RepoSource) open(path string) (*Repo, error) {
213 return r, nil
214 }
215
216+// IsPrivate returns true if the repository is private.
217+func (r *Repo) IsPrivate() bool {
218+ return r.private
219+}
220+
221 // Path returns the path to the repository.
222 func (r *Repo) Path() string {
223 return r.path
224 }
225
226-// GetName returns the name of the repository.
227-func (r *Repo) Name() string {
228+// Repo returns the repository directory name.
229+func (r *Repo) Repo() string {
230 return filepath.Base(r.path)
231 }
232
233+// Name returns the name of the repository.
234+func (r *Repo) Name() string {
235+ if r.name == "" {
236+ return r.Repo()
237+ }
238+ return r.name
239+}
240+
241+// Description returns the description for a repository.
242+func (r *Repo) Description() string {
243+ return r.description
244+}
245+
246 // Readme returns the readme and its path for the repository.
247 func (r *Repo) Readme() (readme string, path string) {
248 return r.readme, r.readmePath
249@@ -124,6 +146,22 @@ func (r *Repo) CountCommits(ref *git.Reference) (int64, error) {
250 return tc, nil
251 }
252
253+// Commit returns the commit for a given hash.
254+func (r *Repo) Commit(hash string) (*git.Commit, error) {
255+ if hash == "HEAD" && r.headCommit != "" {
256+ hash = r.headCommit
257+ }
258+ c, err := r.repository.CatFileCommit(hash)
259+ if err != nil {
260+ return nil, err
261+ }
262+ r.headCommit = c.ID.String()
263+ return &git.Commit{
264+ Commit: c,
265+ Hash: git.Hash(c.ID.String()),
266+ }, nil
267+}
268+
269 // CommitsByPage returns the commits for a repository.
270 func (r *Repo) CommitsByPage(ref *git.Reference, page, size int) (git.Commits, error) {
271 return r.repository.CommitsByPage(ref, page, size)
272@@ -201,6 +239,7 @@ func (rs *RepoSource) LoadRepo(name string) error {
273 rp := filepath.Join(rs.Path, name)
274 r, err := rs.open(rp)
275 if err != nil {
276+ log.Printf("error opening repository %s: %s", name, err)
277 return err
278 }
279 rs.repos[name] = r
280diff --git a/internal/config/testdata/k1.pub b/config/testdata/k1.pub
281rename from internal/config/testdata/k1.pub
282rename to config/testdata/k1.pub
283diff --git a/examples/setuid/main.go b/examples/setuid/main.go
284index 4c05e5fb2e84d37eef637fd03625a78fb4c0ae50..0c58fbb8347581193de163564c52eaf50a5aff2a 100644
285--- a/examples/setuid/main.go
286+++ b/examples/setuid/main.go
287@@ -18,8 +18,8 @@ import (
288 "syscall"
289 "time"
290
291- "github.com/charmbracelet/soft-serve/config"
292 "github.com/charmbracelet/soft-serve/server"
293+ "github.com/charmbracelet/soft-serve/server/config"
294 )
295
296 var (
297diff --git a/go.mod b/go.mod
298index ffc7aed12ac21ebb49549ca399fd16bc2f1e7789..cafe191325967236e1c65a01c49a41399b04d5bf 100755
299--- a/go.mod
300+++ b/go.mod
301@@ -6,8 +6,8 @@ require (
302 github.com/alecthomas/chroma v0.10.0
303 github.com/caarlos0/env/v6 v6.9.1
304 github.com/charmbracelet/bubbles v0.11.0
305- github.com/charmbracelet/bubbletea v0.22.0
306- github.com/charmbracelet/glamour v0.4.0
307+ github.com/charmbracelet/bubbletea v0.21.0
308+ github.com/charmbracelet/glamour v0.5.0
309 github.com/charmbracelet/lipgloss v0.5.0
310 github.com/charmbracelet/wish v0.5.0
311 github.com/dustin/go-humanize v1.0.0
312@@ -22,6 +22,7 @@ require (
313 )
314
315 require (
316+ github.com/aymanbagabas/go-osc52 v1.0.3
317 github.com/charmbracelet/keygen v0.3.0
318 github.com/gobwas/glob v0.2.3
319 github.com/gogs/git-module v1.6.1
320@@ -68,6 +69,6 @@ require (
321 github.com/yuin/goldmark-emoji v1.0.1 // indirect
322 golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
323 golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect
324- golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
325+ golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect
326 gopkg.in/warnings.v0 v0.1.2 // indirect
327 )
328diff --git a/go.sum b/go.sum
329index 33dfda0d298ab4e4aecf23c4bf0868352a12a29f..b1d4a988d5ba46c69c78def1438ff64ea030728b 100644
330--- a/go.sum
331+++ b/go.sum
332@@ -17,6 +17,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
333 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
334 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
335 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
336+github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg=
337+github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
338 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
339 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
340 github.com/caarlos0/env/v6 v6.9.1 h1:zOkkjM0F6ltnQ5eBX6IPI41UP/KDGEK7rRPwGCNos8k=
341@@ -27,11 +29,10 @@ github.com/caarlos0/sshmarshal v0.1.0/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQ
342 github.com/charmbracelet/bubbles v0.11.0 h1:fBLyY0PvJnd56Vlu5L84JJH6f4axhgIJ9P3NET78f0Q=
343 github.com/charmbracelet/bubbles v0.11.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
344 github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM=
345+github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI=
346 github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
347-github.com/charmbracelet/bubbletea v0.22.0 h1:E1BTNSE3iIrq0G0X6TjGAmrQ32cGCbFDPcIuImikrUc=
348-github.com/charmbracelet/bubbletea v0.22.0/go.mod h1:aoVIwlNlr5wbCB26KhxfrqAn0bMp4YpJcoOelbxApjs=
349-github.com/charmbracelet/glamour v0.4.0 h1:scR+smyB7WdmrlIaff6IVlm48P48JaNM7JypM/VGl4k=
350-github.com/charmbracelet/glamour v0.4.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc=
351+github.com/charmbracelet/glamour v0.5.0 h1:wu15ykPdB7X6chxugG/NNfDUbyyrCLV9XBalj5wdu3g=
352+github.com/charmbracelet/glamour v0.5.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc=
353 github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
354 github.com/charmbracelet/keygen v0.3.0 h1:mXpsQcH7DDlST5TddmXNXjS0L7ECk4/kLQYyBcsan2Y=
355 github.com/charmbracelet/keygen v0.3.0/go.mod h1:1ukgO8806O25lUZ5s0IrNur+RlwTBERlezdgW71F5rM=
356@@ -215,8 +216,9 @@ golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIj
357 golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
358 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
359 golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
360-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
361 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
362+golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8=
363+golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
364 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
365 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
366 golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
367diff --git a/internal/config/config.go b/internal/config/config.go
368deleted file mode 100644
369index 86cbc52b734dc1281c6ed0f6debc59f5523aa2cf..0000000000000000000000000000000000000000
370--- a/internal/config/config.go
371+++ /dev/null
372@@ -1,277 +0,0 @@
373-package config
374-
375-import (
376- "bytes"
377- "errors"
378- "io/fs"
379- "log"
380- "path/filepath"
381- "strings"
382- "sync"
383- "text/template"
384- "time"
385-
386- "golang.org/x/crypto/ssh"
387- "gopkg.in/yaml.v3"
388-
389- "fmt"
390- "os"
391-
392- "github.com/charmbracelet/soft-serve/config"
393- "github.com/charmbracelet/soft-serve/internal/git"
394- "github.com/go-git/go-billy/v5/memfs"
395- ggit "github.com/go-git/go-git/v5"
396- "github.com/go-git/go-git/v5/plumbing/object"
397- "github.com/go-git/go-git/v5/plumbing/transport"
398- "github.com/go-git/go-git/v5/storage/memory"
399-)
400-
401-// Config is the Soft Serve configuration.
402-type Config struct {
403- Name string `yaml:"name"`
404- Host string `yaml:"host"`
405- Port int `yaml:"port"`
406- AnonAccess string `yaml:"anon-access"`
407- AllowKeyless bool `yaml:"allow-keyless"`
408- Users []User `yaml:"users"`
409- Repos []Repo `yaml:"repos"`
410- Source *git.RepoSource `yaml:"-"`
411- Cfg *config.Config `yaml:"-"`
412- mtx sync.Mutex
413-}
414-
415-// User contains user-level configuration for a repository.
416-type User struct {
417- Name string `yaml:"name"`
418- Admin bool `yaml:"admin"`
419- PublicKeys []string `yaml:"public-keys"`
420- CollabRepos []string `yaml:"collab-repos"`
421-}
422-
423-// Repo contains repository configuration information.
424-type Repo struct {
425- Name string `yaml:"name"`
426- Repo string `yaml:"repo"`
427- Note string `yaml:"note"`
428- Private bool `yaml:"private"`
429- Readme string `yaml:"readme"`
430-}
431-
432-// NewConfig creates a new internal Config struct.
433-func NewConfig(cfg *config.Config) (*Config, error) {
434- var anonAccess string
435- var yamlUsers string
436- var displayHost string
437- host := cfg.Host
438- port := cfg.Port
439-
440- pks := make([]string, 0)
441- for _, k := range cfg.InitialAdminKeys {
442- if bts, err := os.ReadFile(k); err == nil {
443- // pk is a file, set its contents as pk
444- k = string(bts)
445- }
446- var pk = strings.TrimSpace(k)
447- if pk == "" {
448- continue
449- }
450- // it is a valid ssh key, nothing to do
451- if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk)); err != nil {
452- return nil, fmt.Errorf("invalid initial admin key %q: %w", k, err)
453- }
454- pks = append(pks, pk)
455- }
456-
457- rs := git.NewRepoSource(cfg.RepoPath)
458- c := &Config{
459- Cfg: cfg,
460- }
461- c.Host = cfg.Host
462- c.Port = port
463- c.Source = rs
464- if len(pks) == 0 {
465- anonAccess = "read-write"
466- } else {
467- anonAccess = "no-access"
468- }
469- if host == "" {
470- displayHost = "localhost"
471- } else {
472diff --git a/internal/config/config_test.go b/internal/config/config_test.go
473deleted file mode 100644
474index 724cb5c8329588a1c68a7569c06c5e9d36b2383a..0000000000000000000000000000000000000000
475--- a/internal/config/config_test.go
476+++ /dev/null
477@@ -1,37 +0,0 @@
478-package config
479-
480-import (
481- "testing"
482-
483- "github.com/charmbracelet/soft-serve/config"
484- "github.com/matryer/is"
485-)
486-
487-func TestMultipleInitialKeys(t *testing.T) {
488- cfg, err := NewConfig(&config.Config{
489- RepoPath: t.TempDir(),
490- KeyPath: t.TempDir(),
491- InitialAdminKeys: []string{
492- "testdata/k1.pub",
493- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
494- },
495- })
496- is := is.New(t)
497- is.NoErr(err)
498- err = cfg.Reload()
499- is.NoErr(err)
500- is.Equal(cfg.Users[0].PublicKeys, []string{
501- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b",
502- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
503- }) // should have both keys
504-}
505-
506-func TestEmptyInitialKeys(t *testing.T) {
507- cfg, err := NewConfig(&config.Config{
508- RepoPath: t.TempDir(),
509- KeyPath: t.TempDir(),
510- })
511- is := is.New(t)
512- is.NoErr(err)
513- is.Equal(len(cfg.Users), 0) // should not have any users
514-}
515diff --git a/internal/tui/bubble.go b/internal/tui/bubble.go
516deleted file mode 100644
517index 436dbc4db92ec62b0a84c49478aa9da1260dd5e4..0000000000000000000000000000000000000000
518--- a/internal/tui/bubble.go
519+++ /dev/null
520@@ -1,234 +0,0 @@
521-package tui
522-
523-import (
524- "fmt"
525- "strings"
526-
527- tea "github.com/charmbracelet/bubbletea"
528- "github.com/charmbracelet/lipgloss"
529- "github.com/charmbracelet/soft-serve/internal/config"
530- "github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
531- "github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection"
532- "github.com/charmbracelet/soft-serve/internal/tui/style"
533- "github.com/charmbracelet/soft-serve/tui/common"
534- "github.com/gliderlabs/ssh"
535-)
536-
537-const (
538- repoNameMaxWidth = 32
539-)
540-
541-type sessionState int
542-
543-const (
544- startState sessionState = iota
545- errorState
546- loadedState
547- quittingState
548- quitState
549-)
550-
551-type SessionConfig struct {
552- Width int
553- Height int
554- InitialRepo string
555- Session ssh.Session
556-}
557-
558-type MenuEntry struct {
559- Name string `json:"name"`
560- Note string `json:"note"`
561- Repo string `json:"repo"`
562- bubble *repo.Bubble
563-}
564-
565-type Bubble struct {
566- config *config.Config
567- styles *style.Styles
568- state sessionState
569- error string
570- width int
571- height int
572- initialRepo string
573- repoMenu []MenuEntry
574- boxes []tea.Model
575- activeBox int
576- repoSelect *selection.Bubble
577- session ssh.Session
578-
579- // remember the last resize so we can re-send it when selecting a different repo.
580- lastResize tea.WindowSizeMsg
581-}
582-
583-func NewBubble(cfg *config.Config, sCfg *SessionConfig) *Bubble {
584- b := &Bubble{
585- config: cfg,
586- styles: style.DefaultStyles(),
587- width: sCfg.Width,
588- height: sCfg.Height,
589- repoMenu: make([]MenuEntry, 0),
590- boxes: make([]tea.Model, 2),
591- initialRepo: sCfg.InitialRepo,
592- session: sCfg.Session,
593- }
594- b.state = startState
595- return b
596-}
597-
598-func (b *Bubble) Init() tea.Cmd {
599- return b.setupCmd
600-}
601-
602-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
603- cmds := make([]tea.Cmd, 0)
604- switch msg := msg.(type) {
605- case tea.KeyMsg:
606- switch msg.String() {
607- case "q", "ctrl+c":
608- return b, tea.Quit
609- case "tab", "shift+tab":
610- b.activeBox = (b.activeBox + 1) % 2
611- }
612- case errMsg:
613- b.error = msg.Error()
614- b.state = errorState
615- return b, nil
616- case tea.WindowSizeMsg:
617- b.lastResize = msg
618- b.width = msg.Width
619- b.height = msg.Height
620diff --git a/internal/tui/bubbles/repo/bubble.go b/internal/tui/bubbles/repo/bubble.go
621deleted file mode 100644
622index 6e9a34a99a99db365955758f5ae776f1dbfbdd22..0000000000000000000000000000000000000000
623--- a/internal/tui/bubbles/repo/bubble.go
624+++ /dev/null
625@@ -1,137 +0,0 @@
626-package repo
627-
628-import (
629- "fmt"
630- "strconv"
631-
632- tea "github.com/charmbracelet/bubbletea"
633- "github.com/charmbracelet/lipgloss"
634- "github.com/charmbracelet/soft-serve/git"
635- "github.com/charmbracelet/soft-serve/internal/tui/style"
636- gitui "github.com/charmbracelet/soft-serve/tui"
637- "github.com/charmbracelet/soft-serve/tui/common"
638- "github.com/muesli/reflow/truncate"
639- "github.com/muesli/reflow/wrap"
640-)
641-
642-const (
643- repoNameMaxWidth = 32
644-)
645-
646-type Bubble struct {
647- name string
648- host string
649- port int
650- repo common.GitRepo
651- styles *style.Styles
652- width int
653- widthMargin int
654- height int
655- heightMargin int
656- box *gitui.Bubble
657-
658- Active bool
659-}
660-
661-func NewBubble(repo common.GitRepo, host string, port int, styles *style.Styles, width, wm, height, hm int) *Bubble {
662- b := &Bubble{
663- name: repo.Name(),
664- host: host,
665- port: port,
666- width: width,
667- widthMargin: wm,
668- height: height,
669- heightMargin: hm,
670- styles: styles,
671- }
672- b.repo = repo
673- b.box = gitui.NewBubble(repo, styles, width, wm+styles.RepoBody.GetHorizontalBorderSize(), height, hm+lipgloss.Height(b.headerView())-styles.RepoBody.GetVerticalBorderSize())
674- return b
675-}
676-
677-func (b *Bubble) Init() tea.Cmd {
678- return b.box.Init()
679-}
680-
681-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
682- switch msg := msg.(type) {
683- case tea.WindowSizeMsg:
684- if msg.Width == b.width && msg.Height == b.height {
685- return b, nil
686- }
687- b.width = msg.Width
688- b.height = msg.Height
689- }
690- box, cmd := b.box.Update(msg)
691- b.box = box.(*gitui.Bubble)
692- return b, cmd
693-}
694-
695-func (b *Bubble) Help() []common.HelpEntry {
696- return b.box.Help()
697-}
698-
699-func (b Bubble) headerView() string {
700- // Render repo title
701- title := b.name
702- if title == "config" {
703- title = "Home"
704- }
705- title = truncate.StringWithTail(title, repoNameMaxWidth, "…")
706- title = b.styles.RepoTitle.Render(title)
707-
708- // Render clone command
709- var note string
710- if b.name == "config" {
711- note = ""
712- } else {
713- note = fmt.Sprintf("git clone %s", b.sshAddress())
714- }
715- noteWidth := b.width -
716- b.widthMargin -
717- lipgloss.Width(title) -
718- b.styles.RepoTitleBox.GetHorizontalFrameSize()
719- // Hard-wrap the clone command only, without the usual word-wrapping. since
720- // a long repo name isn't going to be a series of space-separated "words",
721- // we'll always want it to be perfectly hard-wrapped.
722- note = wrap.String(note, noteWidth-b.styles.RepoNote.GetHorizontalFrameSize())
723- note = b.styles.RepoNote.Copy().Width(noteWidth).Render(note)
724-
725diff --git a/internal/tui/bubbles/selection/bubble.go b/internal/tui/bubbles/selection/bubble.go
726deleted file mode 100644
727index 8be37dcf3b2a1e78974bc22c7c1caa830f5a8390..0000000000000000000000000000000000000000
728--- a/internal/tui/bubbles/selection/bubble.go
729+++ /dev/null
730@@ -1,107 +0,0 @@
731-package selection
732-
733-import (
734- "strings"
735-
736- tea "github.com/charmbracelet/bubbletea"
737- "github.com/charmbracelet/lipgloss"
738- "github.com/charmbracelet/soft-serve/internal/tui/style"
739- "github.com/charmbracelet/soft-serve/tui/common"
740- "github.com/muesli/reflow/truncate"
741-)
742-
743-type SelectedMsg struct {
744- Name string
745- Index int
746-}
747-
748-type ActiveMsg struct {
749- Name string
750- Index int
751-}
752-
753-type Bubble struct {
754- Items []string
755- SelectedItem int
756- styles *style.Styles
757-}
758-
759-func NewBubble(items []string, styles *style.Styles) *Bubble {
760- return &Bubble{
761- Items: items,
762- styles: styles,
763- }
764-}
765-
766-func (b *Bubble) Init() tea.Cmd {
767- return nil
768-}
769-
770-func (b Bubble) View() string {
771- s := strings.Builder{}
772- repoNameMaxWidth := b.styles.Menu.GetWidth() - // menu width
773- b.styles.Menu.GetHorizontalPadding() - // menu padding
774- lipgloss.Width(b.styles.MenuCursor.String()) - // cursor
775- b.styles.MenuItem.GetHorizontalFrameSize() // menu item gaps
776- for i, item := range b.Items {
777- item := truncate.StringWithTail(item, uint(repoNameMaxWidth), "…")
778- if i == b.SelectedItem {
779- s.WriteString(b.styles.MenuCursor.String())
780- s.WriteString(b.styles.SelectedMenuItem.Render(item))
781- } else {
782- s.WriteString(b.styles.MenuItem.Render(item))
783- }
784- if i < len(b.Items)-1 {
785- s.WriteRune('\n')
786- }
787- }
788- return s.String()
789-}
790-
791-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
792- cmds := make([]tea.Cmd, 0)
793- switch msg := msg.(type) {
794- case tea.KeyMsg:
795- switch msg.String() {
796- case "k", "up":
797- if b.SelectedItem > 0 {
798- b.SelectedItem--
799- cmds = append(cmds, b.sendActiveMessage)
800- }
801- case "j", "down":
802- if b.SelectedItem < len(b.Items)-1 {
803- b.SelectedItem++
804- cmds = append(cmds, b.sendActiveMessage)
805- }
806- case "enter":
807- cmds = append(cmds, b.sendSelectedMessage)
808- }
809- }
810- return b, tea.Batch(cmds...)
811-}
812-
813-func (b *Bubble) Help() []common.HelpEntry {
814- return []common.HelpEntry{
815- {Key: "↑/↓", Value: "navigate"},
816- }
817-}
818-
819-func (b *Bubble) sendActiveMessage() tea.Msg {
820- if b.SelectedItem >= 0 && b.SelectedItem < len(b.Items) {
821- return ActiveMsg{
822- Name: b.Items[b.SelectedItem],
823- Index: b.SelectedItem,
824- }
825- }
826- return nil
827-}
828-
829-func (b *Bubble) sendSelectedMessage() tea.Msg {
830diff --git a/internal/tui/commands.go b/internal/tui/commands.go
831deleted file mode 100644
832index 5be3c9d17de291b7063d87290732a3710a721c19..0000000000000000000000000000000000000000
833--- a/internal/tui/commands.go
834+++ /dev/null
835@@ -1,121 +0,0 @@
836-package tui
837-
838-import (
839- "fmt"
840-
841- tea "github.com/charmbracelet/bubbletea"
842- "github.com/charmbracelet/lipgloss"
843- "github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
844- "github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection"
845- "github.com/charmbracelet/soft-serve/tui/common"
846- gm "github.com/charmbracelet/wish/git"
847-)
848-
849-type errMsg struct{ err error }
850-
851-func (e errMsg) Error() string {
852- return e.err.Error()
853-}
854-
855-func (b *Bubble) setupCmd() tea.Msg {
856- if b.config == nil || b.config.Source == nil {
857- return errMsg{err: fmt.Errorf("config not set")}
858- }
859- mes, err := b.menuEntriesFromSource()
860- if err != nil {
861- return errMsg{err}
862- }
863- if len(mes) == 0 {
864- return errMsg{fmt.Errorf("no repos found")}
865- }
866- b.repoMenu = mes
867- rs := make([]string, 0)
868- for _, m := range mes {
869- rs = append(rs, m.Name)
870- }
871- b.repoSelect = selection.NewBubble(rs, b.styles)
872- b.boxes[0] = b.repoSelect
873-
874- // Jump to an initial repo
875- ir := -1
876- if b.initialRepo != "" {
877- for i, me := range b.repoMenu {
878- if me.Repo == b.initialRepo {
879- ir = i
880- }
881- }
882- }
883- if ir == -1 {
884- b.boxes[1] = b.repoMenu[0].bubble
885- b.activeBox = 0
886- } else {
887- b.boxes[1] = b.repoMenu[ir].bubble
888- b.repoSelect.SelectedItem = ir
889- b.activeBox = 1
890- }
891-
892- b.state = loadedState
893- return nil
894-}
895-
896-func (b *Bubble) menuEntriesFromSource() ([]MenuEntry, error) {
897- mes := make([]MenuEntry, 0)
898- for _, cr := range b.config.Repos {
899- acc := b.config.AuthRepo(cr.Repo, b.session.PublicKey())
900- if acc == gm.NoAccess && cr.Repo != "config" {
901- continue
902- }
903- if cr.Private && acc < gm.ReadOnlyAccess {
904- continue
905- }
906- me, err := b.newMenuEntry(cr.Name, cr.Repo)
907- if err != nil {
908- return nil, err
909- }
910- mes = append(mes, me)
911- }
912- for _, r := range b.config.Source.AllRepos() {
913- var found bool
914- rn := r.Name()
915- for _, me := range mes {
916- if me.Repo == rn {
917- found = true
918- }
919- }
920- if !found {
921- acc := b.config.AuthRepo(rn, b.session.PublicKey())
922- if acc == gm.NoAccess {
923- continue
924- }
925- me, err := b.newMenuEntry(rn, rn)
926- if err != nil {
927- return nil, err
928- }
929- mes = append(mes, me)
930- }
931- }
932- return mes, nil
933-}
934-
935diff --git a/internal/tui/session.go b/internal/tui/session.go
936deleted file mode 100644
937index 7501769e1c752703dd0b4d6e4e1779f5b3688bd9..0000000000000000000000000000000000000000
938--- a/internal/tui/session.go
939+++ /dev/null
940@@ -1,37 +0,0 @@
941-package tui
942-
943-import (
944- "fmt"
945-
946- tea "github.com/charmbracelet/bubbletea"
947- "github.com/charmbracelet/soft-serve/internal/config"
948- "github.com/gliderlabs/ssh"
949-)
950-
951-// SessionHandler handles the bubble tea session.
952-func SessionHandler(cfg *config.Config) func(ssh.Session) (tea.Model, []tea.ProgramOption) {
953- return func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
954- pty, _, active := s.Pty()
955- if !active {
956- fmt.Println("not active")
957- return nil, nil
958- }
959- cmd := s.Command()
960- scfg := &SessionConfig{Session: s}
961- switch len(cmd) {
962- case 0:
963- scfg.InitialRepo = ""
964- case 1:
965- scfg.InitialRepo = cmd[0]
966- }
967- scfg.Width = pty.Window.Width
968- scfg.Height = pty.Window.Height
969- if cfg.Cfg.Callbacks != nil {
970- cfg.Cfg.Callbacks.Tui("view")
971- }
972- return NewBubble(cfg, scfg), []tea.ProgramOption{
973- tea.WithAltScreen(),
974- tea.WithoutCatchPanics(),
975- }
976- }
977-}
978diff --git a/server/cmd/cat.go b/server/cmd/cat.go
979index 18ff5678c982d9e4b73cab235c331c9d57d78d3b..75008918675b09849c19b662e83b0ccf6e205f34 100644
980--- a/server/cmd/cat.go
981+++ b/server/cmd/cat.go
982@@ -7,8 +7,8 @@ import (
983 "github.com/alecthomas/chroma/lexers"
984 gansi "github.com/charmbracelet/glamour/ansi"
985 "github.com/charmbracelet/lipgloss"
986- "github.com/charmbracelet/soft-serve/internal/git"
987- "github.com/charmbracelet/soft-serve/tui/common"
988+ "github.com/charmbracelet/soft-serve/config"
989+ "github.com/charmbracelet/soft-serve/ui/common"
990 gitwish "github.com/charmbracelet/wish/git"
991 "github.com/muesli/termenv"
992 "github.com/spf13/cobra"
993@@ -40,10 +40,10 @@ func CatCommand() *cobra.Command {
994 if auth < gitwish.ReadOnlyAccess {
995 return ErrUnauthorized
996 }
997- var repo *git.Repo
998+ var repo *config.Repo
999 repoExists := false
1000 for _, rp := range ac.Source.AllRepos() {
1001- if rp.Name() == rn {
1002+ if rp.Repo() == rn {
1003 repoExists = true
1004 repo = rp
1005 break
1006@@ -109,7 +109,7 @@ func withFormatting(p, c string) (string, error) {
1007 Language: lang,
1008 }
1009 r := strings.Builder{}
1010- styles := common.DefaultStyles()
1011+ styles := common.StyleConfig()
1012 styles.CodeBlock.Margin = &zero
1013 rctx := gansi.NewRenderContext(gansi.Options{
1014 Styles: styles,
1015diff --git a/server/cmd/cmd.go b/server/cmd/cmd.go
1016index 157a1c2bbbe82fd3f0d3b398e2050fe4c4281e20..684fe7ec65fb17559bceb05151208158b02bb134 100644
1017--- a/server/cmd/cmd.go
1018+++ b/server/cmd/cmd.go
1019@@ -3,11 +3,26 @@ package cmd
1020 import (
1021 "fmt"
1022
1023- appCfg "github.com/charmbracelet/soft-serve/internal/config"
1024+ appCfg "github.com/charmbracelet/soft-serve/config"
1025 "github.com/gliderlabs/ssh"
1026 "github.com/spf13/cobra"
1027 )
1028
1029+// ContextKey is a type that can be used as a key in a context.
1030+type ContextKey string
1031+
1032+// String returns the string representation of the ContextKey.
1033+func (c ContextKey) String() string {
1034+ return "soft-serve cli context key " + string(c)
1035+}
1036+
1037+var (
1038+ // ConfigCtxKey is the key for the config in the context.
1039+ ConfigCtxKey = ContextKey("config")
1040+ // SessionCtxKey is the key for the session in the context.
1041+ SessionCtxKey = ContextKey("session")
1042+)
1043+
1044 var (
1045 // ErrUnauthorized is returned when the user is not authorized to perform action.
1046 ErrUnauthorized = fmt.Errorf("Unauthorized")
1047@@ -64,7 +79,7 @@ func RootCommand() *cobra.Command {
1048
1049 func fromContext(cmd *cobra.Command) (*appCfg.Config, ssh.Session) {
1050 ctx := cmd.Context()
1051- ac := ctx.Value("config").(*appCfg.Config)
1052- s := ctx.Value("session").(ssh.Session)
1053+ ac := ctx.Value(ConfigCtxKey).(*appCfg.Config)
1054+ s := ctx.Value(SessionCtxKey).(ssh.Session)
1055 return ac, s
1056 }
1057diff --git a/server/cmd/git.go b/server/cmd/git.go
1058index fd2c3be87505141eb56f3c2dd625fcd43097c84f..d9a60070e6bdbf3ab3d1419d758bf93be75fc2c8 100644
1059--- a/server/cmd/git.go
1060+++ b/server/cmd/git.go
1061@@ -4,7 +4,7 @@ import (
1062 "io"
1063 "os/exec"
1064
1065- "github.com/charmbracelet/soft-serve/internal/git"
1066+ "github.com/charmbracelet/soft-serve/config"
1067 gitwish "github.com/charmbracelet/wish/git"
1068 "github.com/spf13/cobra"
1069 )
1070@@ -23,11 +23,11 @@ func GitCommand() *cobra.Command {
1071 if len(args) < 1 {
1072 return runGit(nil, s, s, "")
1073 }
1074- var repo *git.Repo
1075+ var repo *config.Repo
1076 rn := args[0]
1077 repoExists := false
1078 for _, rp := range ac.Source.AllRepos() {
1079- if rp.Name() == rn {
1080+ if rp.Repo() == rn {
1081 repoExists = true
1082 repo = rp
1083 break
1084diff --git a/server/cmd/list.go b/server/cmd/list.go
1085index f07a2a68f3600357e0020ea6269a954714740220..e1b239b186f8d453fbbc810efa4b3c07d630f4b8 100644
1086--- a/server/cmd/list.go
1087+++ b/server/cmd/list.go
1088@@ -33,7 +33,7 @@ func ListCommand() *cobra.Command {
1089 }
1090 if path == "" || path == "." || path == "/" {
1091 for _, r := range ac.Source.AllRepos() {
1092- fmt.Fprintln(s, r.Name())
1093+ fmt.Fprintln(s, r.Repo())
1094 }
1095 return nil
1096 }
1097diff --git a/server/config/config.go b/server/config/config.go
1098new file mode 100644
1099index 0000000000000000000000000000000000000000..dc60e2dad1f2cd802a98d1ca3894c6db4bc0d8ba
1100--- /dev/null
1101+++ b/server/config/config.go
1102@@ -0,0 +1,53 @@
1103+package config
1104+
1105+import (
1106+ "log"
1107+ "path/filepath"
1108+
1109+ "github.com/caarlos0/env/v6"
1110+)
1111+
1112+// Callbacks provides an interface that can be used to run callbacks on different events.
1113+type Callbacks interface {
1114+ Tui(action string)
1115+ Push(repo string)
1116+ Fetch(repo string)
1117+}
1118+
1119+// Config is the configuration for Soft Serve.
1120+type Config struct {
1121+ BindAddr string `env:"SOFT_SERVE_BIND_ADDRESS" envDefault:""`
1122+ Host string `env:"SOFT_SERVE_HOST" envDefault:"localhost"`
1123+ Port int `env:"SOFT_SERVE_PORT" envDefault:"23231"`
1124+ KeyPath string `env:"SOFT_SERVE_KEY_PATH"`
1125+ RepoPath string `env:"SOFT_SERVE_REPO_PATH" envDefault:".repos"`
1126+ InitialAdminKeys []string `env:"SOFT_SERVE_INITIAL_ADMIN_KEY" envSeparator:"\n"`
1127+ Callbacks Callbacks
1128+ ErrorLog *log.Logger
1129+}
1130+
1131+// DefaultConfig returns a Config with the values populated with the defaults
1132+// or specified environment variables.
1133+func DefaultConfig() *Config {
1134+ cfg := &Config{ErrorLog: log.Default()}
1135+ if err := env.Parse(cfg); err != nil {
1136+ log.Fatalln(err)
1137+ }
1138+ if cfg.KeyPath == "" {
1139+ // NB: cross-platform-compatible path
1140+ cfg.KeyPath = filepath.Join(".ssh", "soft_serve_server_ed25519")
1141+ }
1142+ return cfg.WithCallbacks(nil)
1143+}
1144+
1145+// WithCallbacks applies the given Callbacks to the configuration.
1146+func (c *Config) WithCallbacks(callbacks Callbacks) *Config {
1147+ c.Callbacks = callbacks
1148+ return c
1149+}
1150+
1151+// WithErrorLogger sets the error logger for the configuration.
1152+func (c *Config) WithErrorLogger(logger *log.Logger) *Config {
1153+ c.ErrorLog = logger
1154+ return c
1155+}
1156diff --git a/server/config/config_test.go b/server/config/config_test.go
1157new file mode 100644
1158index 0000000000000000000000000000000000000000..6a8eb7e4890f4ff535b7523491936a6273590755
1159--- /dev/null
1160+++ b/server/config/config_test.go
1161@@ -0,0 +1,19 @@
1162+package config
1163+
1164+import (
1165+ "os"
1166+ "testing"
1167+
1168+ "github.com/matryer/is"
1169+)
1170+
1171+func TestParseMultipleKeys(t *testing.T) {
1172+ is := is.New(t)
1173+ is.NoErr(os.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEY", "testdata/k1.pub\ntestdata/k2.pub"))
1174+ t.Cleanup(func() { is.NoErr(os.Unsetenv("SOFT_SERVE_INITIAL_ADMIN_KEY")) })
1175+ cfg := DefaultConfig()
1176+ is.Equal(cfg.InitialAdminKeys, []string{
1177+ "testdata/k1.pub",
1178+ "testdata/k2.pub",
1179+ })
1180+}
1181diff --git a/server/middleware.go b/server/middleware.go
1182index 44ea5c7d612899a68c6e258832d132eba7071d9e..a4969f5fe5582929bc7ed6ddca571d225c4514d0 100644
1183--- a/server/middleware.go
1184+++ b/server/middleware.go
1185@@ -4,7 +4,7 @@ import (
1186 "context"
1187 "fmt"
1188
1189- appCfg "github.com/charmbracelet/soft-serve/internal/config"
1190+ appCfg "github.com/charmbracelet/soft-serve/config"
1191 "github.com/charmbracelet/soft-serve/server/cmd"
1192 "github.com/charmbracelet/wish"
1193 "github.com/gliderlabs/ssh"
1194@@ -19,8 +19,8 @@ func softMiddleware(ac *appCfg.Config) wish.Middleware {
1195 if active {
1196 return
1197 }
1198- ctx := context.WithValue(s.Context(), "config", ac) //nolint:revive
1199- ctx = context.WithValue(ctx, "session", s) //nolint:revive
1200+ ctx := context.WithValue(s.Context(), cmd.ConfigCtxKey, ac)
1201+ ctx = context.WithValue(ctx, cmd.SessionCtxKey, s)
1202
1203 use := "ssh"
1204 port := ac.Port
1205diff --git a/server/middleware_test.go b/server/middleware_test.go
1206index 94ac3ebbfef8dd25bbdcf392ec31e48f4f6301f4..6895d3d10d9483fb0b202973a84e388a5f8a6bc8 100644
1207--- a/server/middleware_test.go
1208+++ b/server/middleware_test.go
1209@@ -4,8 +4,8 @@ import (
1210 "os"
1211 "testing"
1212
1213- sconfig "github.com/charmbracelet/soft-serve/config"
1214- "github.com/charmbracelet/soft-serve/internal/config"
1215+ "github.com/charmbracelet/soft-serve/config"
1216+ sconfig "github.com/charmbracelet/soft-serve/server/config"
1217 "github.com/charmbracelet/wish/testsession"
1218 "github.com/gliderlabs/ssh"
1219 "github.com/matryer/is"
1220diff --git a/server/server.go b/server/server.go
1221index 708aa3e2fa0c1b261ddc6456f7569e143b5e45eb..cbf40fd66a1f4aacd8e74c9677e05bbb52496d23 100644
1222--- a/server/server.go
1223+++ b/server/server.go
1224@@ -6,15 +6,15 @@ import (
1225 "log"
1226 "net"
1227
1228- "github.com/charmbracelet/soft-serve/config"
1229- appCfg "github.com/charmbracelet/soft-serve/internal/config"
1230- "github.com/charmbracelet/soft-serve/internal/tui"
1231+ appCfg "github.com/charmbracelet/soft-serve/config"
1232+ "github.com/charmbracelet/soft-serve/server/config"
1233 "github.com/charmbracelet/wish"
1234 bm "github.com/charmbracelet/wish/bubbletea"
1235 gm "github.com/charmbracelet/wish/git"
1236 lm "github.com/charmbracelet/wish/logging"
1237 rm "github.com/charmbracelet/wish/recover"
1238 "github.com/gliderlabs/ssh"
1239+ "github.com/muesli/termenv"
1240 )
1241
1242 // Server is the Soft Serve server.
1243@@ -37,10 +37,10 @@ func NewServer(cfg *config.Config) *Server {
1244 mw := []wish.Middleware{
1245 rm.MiddlewareWithLogger(
1246 cfg.ErrorLog,
1247- lm.Middleware(),
1248 softMiddleware(ac),
1249- bm.Middleware(tui.SessionHandler(ac)),
1250+ bm.MiddlewareWithProgramHandler(SessionHandler(ac), termenv.ANSI256),
1251 gm.Middleware(cfg.RepoPath, ac),
1252+ lm.Middleware(),
1253 ),
1254 }
1255 s, err := wish.NewServer(
1256diff --git a/server/server_test.go b/server/server_test.go
1257index 0756e7f53d6ea1c9e8c5bf382c6de4d5ad71802c..d7dcf9b8b21cedaba940b88c709c172b94bd9bfc 100644
1258--- a/server/server_test.go
1259+++ b/server/server_test.go
1260@@ -7,7 +7,7 @@ import (
1261 "testing"
1262
1263 "github.com/charmbracelet/keygen"
1264- "github.com/charmbracelet/soft-serve/config"
1265+ "github.com/charmbracelet/soft-serve/server/config"
1266 "github.com/gliderlabs/ssh"
1267 "github.com/go-git/go-git/v5"
1268 gconfig "github.com/go-git/go-git/v5/config"
1269diff --git a/server/session.go b/server/session.go
1270new file mode 100644
1271index 0000000000000000000000000000000000000000..de6f2d4c3d33a0fb2feb01181c8b5d6d04a3dc12
1272--- /dev/null
1273+++ b/server/session.go
1274@@ -0,0 +1,57 @@
1275+package server
1276+
1277+import (
1278+ "fmt"
1279+
1280+ "github.com/aymanbagabas/go-osc52"
1281+ tea "github.com/charmbracelet/bubbletea"
1282+ appCfg "github.com/charmbracelet/soft-serve/config"
1283+ "github.com/charmbracelet/soft-serve/ui"
1284+ "github.com/charmbracelet/soft-serve/ui/common"
1285+ "github.com/charmbracelet/soft-serve/ui/keymap"
1286+ "github.com/charmbracelet/soft-serve/ui/styles"
1287+ bm "github.com/charmbracelet/wish/bubbletea"
1288+ "github.com/gliderlabs/ssh"
1289+)
1290+
1291+// SessionHandler is the soft-serve bubbletea ssh session handler.
1292+func SessionHandler(ac *appCfg.Config) bm.ProgramHandler {
1293+ return func(s ssh.Session) *tea.Program {
1294+ pty, _, active := s.Pty()
1295+ if !active {
1296+ return nil
1297+ }
1298+ cmd := s.Command()
1299+ initialRepo := ""
1300+ if len(cmd) == 1 {
1301+ initialRepo = cmd[0]
1302+ }
1303+ if ac.Cfg.Callbacks != nil {
1304+ ac.Cfg.Callbacks.Tui("new session")
1305+ }
1306+ envs := s.Environ()
1307+ envs = append(envs, fmt.Sprintf("TERM=%s", pty.Term))
1308+ output := osc52.NewOutput(s, envs)
1309+ c := common.Common{
1310+ Copy: output,
1311+ Styles: styles.DefaultStyles(),
1312+ KeyMap: keymap.DefaultKeyMap(),
1313+ Width: pty.Window.Width,
1314+ Height: pty.Window.Height,
1315+ }
1316+ m := ui.New(
1317+ ac,
1318+ s,
1319+ c,
1320+ initialRepo,
1321+ )
1322+ p := tea.NewProgram(m,
1323+ tea.WithInput(s),
1324+ tea.WithOutput(s),
1325+ tea.WithAltScreen(),
1326+ tea.WithoutCatchPanics(),
1327+ tea.WithMouseCellMotion(),
1328+ )
1329+ return p
1330+ }
1331+}
1332diff --git a/tui/about/bubble.go b/tui/about/bubble.go
1333deleted file mode 100644
1334index 9205268e97a49a16846cd81c4cea4d1fd28800e9..0000000000000000000000000000000000000000
1335--- a/tui/about/bubble.go
1336+++ /dev/null
1337@@ -1,122 +0,0 @@
1338-package about
1339-
1340-import (
1341- "github.com/charmbracelet/bubbles/viewport"
1342- tea "github.com/charmbracelet/bubbletea"
1343- "github.com/charmbracelet/soft-serve/git"
1344- "github.com/charmbracelet/soft-serve/internal/tui/style"
1345- "github.com/charmbracelet/soft-serve/tui/common"
1346- "github.com/charmbracelet/soft-serve/tui/refs"
1347- vp "github.com/charmbracelet/soft-serve/tui/viewport"
1348- "github.com/muesli/reflow/wrap"
1349-)
1350-
1351-type Bubble struct {
1352- readmeViewport *vp.ViewportBubble
1353- repo common.GitRepo
1354- styles *style.Styles
1355- height int
1356- heightMargin int
1357- width int
1358- widthMargin int
1359- ref *git.Reference
1360-}
1361-
1362-func NewBubble(repo common.GitRepo, styles *style.Styles, width, wm, height, hm int) *Bubble {
1363- b := &Bubble{
1364- readmeViewport: &vp.ViewportBubble{
1365- Viewport: &viewport.Model{},
1366- },
1367- repo: repo,
1368- styles: styles,
1369- widthMargin: wm,
1370- heightMargin: hm,
1371- }
1372- b.SetSize(width, height)
1373- return b
1374-}
1375-func (b *Bubble) Init() tea.Cmd {
1376- return b.reset
1377-}
1378-
1379-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1380- var cmds []tea.Cmd
1381- switch msg := msg.(type) {
1382- case tea.WindowSizeMsg:
1383- b.SetSize(msg.Width, msg.Height)
1384- // XXX: if we find that longer readmes take more than a few
1385- // milliseconds to render we may need to move Glamour rendering into a
1386- // command.
1387- md, err := b.glamourize()
1388- if err != nil {
1389- return b, nil
1390- }
1391- b.readmeViewport.Viewport.SetContent(md)
1392- case tea.KeyMsg:
1393- switch msg.String() {
1394- case "R":
1395- return b, b.reset
1396- }
1397- case refs.RefMsg:
1398- b.ref = msg
1399- return b, b.reset
1400- }
1401- rv, cmd := b.readmeViewport.Update(msg)
1402- b.readmeViewport = rv.(*vp.ViewportBubble)
1403- cmds = append(cmds, cmd)
1404- return b, tea.Batch(cmds...)
1405-}
1406-
1407-func (b *Bubble) SetSize(w, h int) {
1408- b.width = w
1409- b.height = h
1410- b.readmeViewport.Viewport.Width = w - b.widthMargin
1411- b.readmeViewport.Viewport.Height = h - b.heightMargin
1412-}
1413-
1414-func (b *Bubble) GotoTop() {
1415- b.readmeViewport.Viewport.GotoTop()
1416-}
1417-
1418-func (b *Bubble) View() string {
1419- return b.readmeViewport.View()
1420-}
1421-
1422-func (b *Bubble) Help() []common.HelpEntry {
1423- return nil
1424-}
1425-
1426-func (b *Bubble) glamourize() (string, error) {
1427- w := b.width - b.widthMargin - b.styles.RepoBody.GetHorizontalFrameSize()
1428- rm, rp := b.repo.Readme()
1429- if rm == "" {
1430- return b.styles.AboutNoReadme.Render("No readme found."), nil
1431- }
1432- f, err := common.RenderFile(rp, rm, w)
1433- if err != nil {
1434- return "", err
1435- }
1436- // For now, hard-wrap long lines in Glamour that would otherwise break the
1437diff --git a/tui/bubble.go b/tui/bubble.go
1438deleted file mode 100644
1439index e23442472c4260ccae1a77523e1e8071212380d9..0000000000000000000000000000000000000000
1440--- a/tui/bubble.go
1441+++ /dev/null
1442@@ -1,155 +0,0 @@
1443-package tui
1444-
1445-import (
1446- tea "github.com/charmbracelet/bubbletea"
1447- "github.com/charmbracelet/lipgloss"
1448- "github.com/charmbracelet/soft-serve/git"
1449- "github.com/charmbracelet/soft-serve/internal/tui/style"
1450- "github.com/charmbracelet/soft-serve/tui/about"
1451- "github.com/charmbracelet/soft-serve/tui/common"
1452- "github.com/charmbracelet/soft-serve/tui/log"
1453- "github.com/charmbracelet/soft-serve/tui/refs"
1454- "github.com/charmbracelet/soft-serve/tui/tree"
1455-)
1456-
1457-const (
1458- repoNameMaxWidth = 32
1459-)
1460-
1461-type state int
1462-
1463-const (
1464- aboutState state = iota
1465- refsState
1466- logState
1467- treeState
1468-)
1469-
1470-type Bubble struct {
1471- state state
1472- repo common.GitRepo
1473- height int
1474- heightMargin int
1475- width int
1476- widthMargin int
1477- style *style.Styles
1478- boxes []tea.Model
1479- ref *git.Reference
1480-}
1481-
1482-func NewBubble(repo common.GitRepo, styles *style.Styles, width, wm, height, hm int) *Bubble {
1483- b := &Bubble{
1484- repo: repo,
1485- state: aboutState,
1486- width: width,
1487- widthMargin: wm,
1488- height: height,
1489- heightMargin: hm,
1490- style: styles,
1491- boxes: make([]tea.Model, 4),
1492- }
1493- heightMargin := hm + lipgloss.Height(b.headerView())
1494- b.boxes[aboutState] = about.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin)
1495- b.boxes[refsState] = refs.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin)
1496- b.boxes[logState] = log.NewBubble(repo, b.style, width, wm, height, heightMargin)
1497- b.boxes[treeState] = tree.NewBubble(repo, b.style, width, wm, height, heightMargin)
1498- return b
1499-}
1500-
1501-func (b *Bubble) Init() tea.Cmd {
1502- return b.setupCmd
1503-}
1504-
1505-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1506- cmds := make([]tea.Cmd, 0)
1507- switch msg := msg.(type) {
1508- case tea.KeyMsg:
1509- if b.repo.Name() != "config" {
1510- switch msg.String() {
1511- case "R":
1512- b.state = aboutState
1513- case "B":
1514- b.state = refsState
1515- case "C":
1516- b.state = logState
1517- case "F":
1518- b.state = treeState
1519- }
1520- }
1521- case tea.WindowSizeMsg:
1522- b.width = msg.Width
1523- b.height = msg.Height
1524- for i, bx := range b.boxes {
1525- m, cmd := bx.Update(msg)
1526- b.boxes[i] = m
1527- if cmd != nil {
1528- cmds = append(cmds, cmd)
1529- }
1530- }
1531- case refs.RefMsg:
1532- b.state = treeState
1533- b.ref = msg
1534- for i, bx := range b.boxes {
1535- m, cmd := bx.Update(msg)
1536- b.boxes[i] = m
1537- if cmd != nil {
1538- cmds = append(cmds, cmd)
1539- }
1540- }
1541- }
1542diff --git a/tui/common/consts.go b/tui/common/consts.go
1543deleted file mode 100644
1544index c915df177fbfe6ca0c6c67c58ae246b39d633f6a..0000000000000000000000000000000000000000
1545--- a/tui/common/consts.go
1546+++ /dev/null
1547@@ -1,28 +0,0 @@
1548-package common
1549-
1550-import (
1551- "time"
1552-
1553- "github.com/charmbracelet/bubbles/key"
1554-)
1555-
1556-// Some constants were copied from https://docs.gitea.io/en-us/config-cheat-sheet/#git-git
1557-
1558-const (
1559- GlamourMaxWidth = 120
1560- RepoNameMaxWidth = 32
1561- MaxDiffLines = 1000
1562- MaxDiffFiles = 100
1563- MaxPatchWait = time.Second * 3
1564-)
1565-
1566-var (
1567- PrevPage = key.NewBinding(
1568- key.WithKeys("pgup", "b", "u"),
1569- key.WithHelp("pgup", "prev page"),
1570- )
1571- NextPage = key.NewBinding(
1572- key.WithKeys("pgdown", "f", "d"),
1573- key.WithHelp("pgdn", "next page"),
1574- )
1575-)
1576diff --git a/tui/common/error.go b/tui/common/error.go
1577deleted file mode 100644
1578index b9ecc9594b04ed7a793fee776af2f3835ffa6715..0000000000000000000000000000000000000000
1579--- a/tui/common/error.go
1580+++ /dev/null
1581@@ -1,36 +0,0 @@
1582-package common
1583-
1584-import (
1585- "errors"
1586-
1587- "github.com/charmbracelet/lipgloss"
1588- "github.com/charmbracelet/soft-serve/internal/tui/style"
1589-)
1590-
1591-var (
1592- ErrDiffTooLong = errors.New("diff is too long")
1593- ErrDiffFilesTooLong = errors.New("diff files are too long")
1594- ErrBinaryFile = errors.New("binary file")
1595- ErrFileTooLarge = errors.New("file is too large")
1596- ErrInvalidFile = errors.New("invalid file")
1597-)
1598-
1599-type ErrMsg struct {
1600- Err error
1601-}
1602-
1603-func (e ErrMsg) Error() string {
1604- return e.Err.Error()
1605-}
1606-
1607-func (e ErrMsg) View(s *style.Styles) string {
1608- return e.ViewWithPrefix(s, "")
1609-}
1610-
1611-func (e ErrMsg) ViewWithPrefix(s *style.Styles, prefix string) string {
1612- return lipgloss.JoinHorizontal(
1613- lipgloss.Top,
1614- s.ErrorTitle.Render(prefix),
1615- s.ErrorBody.Render(e.Error()),
1616- )
1617-}
1618diff --git a/tui/common/formatter.go b/tui/common/formatter.go
1619deleted file mode 100644
1620index 11143ec48a2694437cf63ae9e7728c2ad61198dc..0000000000000000000000000000000000000000
1621--- a/tui/common/formatter.go
1622+++ /dev/null
1623@@ -1,88 +0,0 @@
1624-package common
1625-
1626-import (
1627- "strings"
1628-
1629- "github.com/alecthomas/chroma/lexers"
1630- "github.com/charmbracelet/glamour"
1631- gansi "github.com/charmbracelet/glamour/ansi"
1632- "github.com/muesli/termenv"
1633-)
1634-
1635-var (
1636- RenderCtx = DefaultRenderCtx()
1637- Styles = DefaultStyles()
1638-)
1639-
1640-func DefaultStyles() gansi.StyleConfig {
1641- noColor := ""
1642- s := glamour.DarkStyleConfig
1643- s.Document.StylePrimitive.Color = &noColor
1644- s.CodeBlock.Chroma.Text.Color = &noColor
1645- s.CodeBlock.Chroma.Name.Color = &noColor
1646- return s
1647-}
1648-
1649-func DefaultRenderCtx() gansi.RenderContext {
1650- return gansi.NewRenderContext(gansi.Options{
1651- ColorProfile: termenv.TrueColor,
1652- Styles: DefaultStyles(),
1653- })
1654-}
1655-
1656-func NewRenderCtx(worldwrap int) gansi.RenderContext {
1657- return gansi.NewRenderContext(gansi.Options{
1658- ColorProfile: termenv.TrueColor,
1659- Styles: DefaultStyles(),
1660- WordWrap: worldwrap,
1661- })
1662-}
1663-
1664-func Glamourize(w int, md string) (string, error) {
1665- if w > GlamourMaxWidth {
1666- w = GlamourMaxWidth
1667- }
1668- tr, err := glamour.NewTermRenderer(
1669- glamour.WithStyles(DefaultStyles()),
1670- glamour.WithWordWrap(w),
1671- )
1672-
1673- if err != nil {
1674- return "", err
1675- }
1676- mdt, err := tr.Render(md)
1677- if err != nil {
1678- return "", err
1679- }
1680- return mdt, nil
1681-}
1682-
1683-func RenderFile(path, content string, width int) (string, error) {
1684- lexer := lexers.Fallback
1685- if path == "" {
1686- lexer = lexers.Analyse(content)
1687- } else {
1688- lexer = lexers.Match(path)
1689- }
1690- lang := ""
1691- if lexer != nil && lexer.Config() != nil {
1692- lang = lexer.Config().Name
1693- }
1694- formatter := &gansi.CodeBlockElement{
1695- Code: content,
1696- Language: lang,
1697- }
1698- if lang == "markdown" {
1699- md, err := Glamourize(width, content)
1700- if err != nil {
1701- return "", err
1702- }
1703- return md, nil
1704- }
1705- r := strings.Builder{}
1706- err := formatter.Render(&r, RenderCtx)
1707- if err != nil {
1708- return "", err
1709- }
1710- return r.String(), nil
1711-}
1712diff --git a/tui/common/git.go b/tui/common/git.go
1713deleted file mode 100644
1714index 43cb53ca46a632ef64b7685a5186ef9070449f0f..0000000000000000000000000000000000000000
1715--- a/tui/common/git.go
1716+++ /dev/null
1717@@ -1,16 +0,0 @@
1718-package common
1719-
1720-import (
1721- "github.com/charmbracelet/soft-serve/git"
1722-)
1723-
1724-type GitRepo interface {
1725- Name() string
1726- Readme() (string, string)
1727- HEAD() (*git.Reference, error)
1728- CommitsByPage(*git.Reference, int, int) (git.Commits, error)
1729- CountCommits(*git.Reference) (int64, error)
1730- Diff(*git.Commit) (*git.Diff, error)
1731- References() ([]*git.Reference, error)
1732- Tree(*git.Reference, string) (*git.Tree, error)
1733-}
1734diff --git a/tui/common/help.go b/tui/common/help.go
1735deleted file mode 100644
1736index 5bc1a9a8df577879b048fa0bceee2dad4089676d..0000000000000000000000000000000000000000
1737--- a/tui/common/help.go
1738+++ /dev/null
1739@@ -1,10 +0,0 @@
1740-package common
1741-
1742-type BubbleHelper interface {
1743- Help() []HelpEntry
1744-}
1745-
1746-type HelpEntry struct {
1747- Key string
1748- Value string
1749-}
1750diff --git a/tui/common/reset.go b/tui/common/reset.go
1751deleted file mode 100644
1752index fe92b47154c9fe0850563dea7e9f87550c281c36..0000000000000000000000000000000000000000
1753--- a/tui/common/reset.go
1754+++ /dev/null
1755@@ -1,7 +0,0 @@
1756-package common
1757-
1758-import tea "github.com/charmbracelet/bubbletea"
1759-
1760-type BubbleReset interface {
1761- Reset() tea.Msg
1762-}
1763diff --git a/tui/common/utils.go b/tui/common/utils.go
1764deleted file mode 100644
1765index 2cafee64e60735a350363506c578dcf2bb9cb193..0000000000000000000000000000000000000000
1766--- a/tui/common/utils.go
1767+++ /dev/null
1768@@ -1,17 +0,0 @@
1769-package common
1770-
1771-import "github.com/muesli/reflow/truncate"
1772-
1773-func TruncateString(s string, max int, tail string) string {
1774- if max < 0 {
1775- max = 0
1776- }
1777- return truncate.StringWithTail(s, uint(max), tail)
1778-}
1779-
1780-func Max(a, b int) int {
1781- if a > b {
1782- return a
1783- }
1784- return b
1785-}
1786diff --git a/tui/log/bubble.go b/tui/log/bubble.go
1787deleted file mode 100644
1788index 30c35069b9930fdb4b97c27e0d8b77117c9fe655..0000000000000000000000000000000000000000
1789--- a/tui/log/bubble.go
1790+++ /dev/null
1791@@ -1,383 +0,0 @@
1792-package log
1793-
1794-import (
1795- "fmt"
1796- "io"
1797- "strings"
1798- "time"
1799-
1800- "github.com/charmbracelet/bubbles/list"
1801- "github.com/charmbracelet/bubbles/spinner"
1802- "github.com/charmbracelet/bubbles/viewport"
1803- tea "github.com/charmbracelet/bubbletea"
1804- gansi "github.com/charmbracelet/glamour/ansi"
1805- "github.com/charmbracelet/soft-serve/git"
1806- "github.com/charmbracelet/soft-serve/internal/tui/style"
1807- "github.com/charmbracelet/soft-serve/tui/common"
1808- "github.com/charmbracelet/soft-serve/tui/refs"
1809- vp "github.com/charmbracelet/soft-serve/tui/viewport"
1810-)
1811-
1812-var (
1813- diffChroma = &gansi.CodeBlockElement{
1814- Code: "",
1815- Language: "diff",
1816- }
1817- waitBeforeLoading = time.Millisecond * 300
1818-)
1819-
1820-type itemsMsg struct{}
1821-
1822-type commitMsg *git.Commit
1823-
1824-type countMsg int64
1825-
1826-type sessionState int
1827-
1828-const (
1829- logState sessionState = iota
1830- commitState
1831- errorState
1832-)
1833-
1834-type item struct {
1835- *git.Commit
1836-}
1837-
1838-func (i item) Title() string {
1839- if i.Commit != nil {
1840- return strings.Split(i.Commit.Message, "\n")[0]
1841- }
1842- return ""
1843-}
1844-
1845-func (i item) FilterValue() string { return i.Title() }
1846-
1847-type itemDelegate struct {
1848- style *style.Styles
1849-}
1850-
1851-func (d itemDelegate) Height() int { return 1 }
1852-func (d itemDelegate) Spacing() int { return 0 }
1853-func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
1854-func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
1855- i, ok := listItem.(item)
1856- if !ok {
1857- return
1858- }
1859- if i.Commit == nil {
1860- return
1861- }
1862-
1863- hash := i.ID.String()
1864- leftMargin := d.style.LogItemSelector.GetMarginLeft() +
1865- d.style.LogItemSelector.GetWidth() +
1866- d.style.LogItemHash.GetMarginLeft() +
1867- d.style.LogItemHash.GetWidth() +
1868- d.style.LogItemInactive.GetMarginLeft()
1869- title := common.TruncateString(i.Title(), m.Width()-leftMargin, "…")
1870- if index == m.Index() {
1871- fmt.Fprint(w, d.style.LogItemSelector.Render(">")+
1872- d.style.LogItemHash.Bold(true).Render(hash[:7])+
1873- d.style.LogItemActive.Render(title))
1874- } else {
1875- fmt.Fprint(w, d.style.LogItemSelector.Render(" ")+
1876- d.style.LogItemHash.Render(hash[:7])+
1877- d.style.LogItemInactive.Render(title))
1878- }
1879-}
1880-
1881-type Bubble struct {
1882- repo common.GitRepo
1883- count int64
1884- list list.Model
1885- state sessionState
1886- commitViewport *vp.ViewportBubble
1887- ref *git.Reference
1888- style *style.Styles
1889- width int
1890- widthMargin int
1891diff --git a/tui/refs/bubble.go b/tui/refs/bubble.go
1892deleted file mode 100644
1893index 7a794c2b6af8fbe1c89f726541459edac60fda2d..0000000000000000000000000000000000000000
1894--- a/tui/refs/bubble.go
1895+++ /dev/null
1896@@ -1,185 +0,0 @@
1897-package refs
1898-
1899-import (
1900- "fmt"
1901- "io"
1902- "sort"
1903-
1904- "github.com/charmbracelet/bubbles/list"
1905- tea "github.com/charmbracelet/bubbletea"
1906- "github.com/charmbracelet/soft-serve/git"
1907- "github.com/charmbracelet/soft-serve/internal/tui/style"
1908- "github.com/charmbracelet/soft-serve/tui/common"
1909-)
1910-
1911-type RefMsg = *git.Reference
1912-
1913-type item struct {
1914- *git.Reference
1915-}
1916-
1917-func (i item) Short() string {
1918- return i.Reference.Name().Short()
1919-}
1920-
1921-func (i item) FilterValue() string { return i.Short() }
1922-
1923-type items []item
1924-
1925-func (cl items) Len() int { return len(cl) }
1926-func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
1927-func (cl items) Less(i, j int) bool {
1928- return cl[i].Short() < cl[j].Short()
1929-}
1930-
1931-type itemDelegate struct {
1932- style *style.Styles
1933-}
1934-
1935-func (d itemDelegate) Height() int { return 1 }
1936-func (d itemDelegate) Spacing() int { return 0 }
1937-func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
1938-func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
1939- s := d.style
1940- i, ok := listItem.(item)
1941- if !ok {
1942- return
1943- }
1944-
1945- ref := i.Short()
1946- if i.Reference.IsTag() {
1947- ref = s.RefItemTag.Render(ref)
1948- }
1949- ref = s.RefItemBranch.Render(ref)
1950- refMaxWidth := m.Width() -
1951- s.RefItemSelector.GetMarginLeft() -
1952- s.RefItemSelector.GetWidth() -
1953- s.RefItemInactive.GetMarginLeft()
1954- ref = common.TruncateString(ref, refMaxWidth, "…")
1955- if index == m.Index() {
1956- fmt.Fprint(w, s.RefItemSelector.Render(">")+
1957- s.RefItemActive.Render(ref))
1958- } else {
1959- fmt.Fprint(w, s.LogItemSelector.Render(" ")+
1960- s.RefItemInactive.Render(ref))
1961- }
1962-}
1963-
1964-type Bubble struct {
1965- repo common.GitRepo
1966- list list.Model
1967- style *style.Styles
1968- width int
1969- widthMargin int
1970- height int
1971- heightMargin int
1972- ref *git.Reference
1973-}
1974-
1975-func NewBubble(repo common.GitRepo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
1976- head, err := repo.HEAD()
1977- if err != nil {
1978- return nil
1979- }
1980- l := list.NewModel([]list.Item{}, itemDelegate{styles}, width-widthMargin, height-heightMargin)
1981- l.SetShowFilter(false)
1982- l.SetShowHelp(false)
1983- l.SetShowPagination(true)
1984- l.SetShowStatusBar(false)
1985- l.SetShowTitle(false)
1986- l.SetFilteringEnabled(false)
1987- l.DisableQuitKeybindings()
1988- b := &Bubble{
1989- repo: repo,
1990- style: styles,
1991- width: width,
1992- height: height,
1993- widthMargin: widthMargin,
1994- heightMargin: heightMargin,
1995- list: l,
1996diff --git a/tui/tree/bubble.go b/tui/tree/bubble.go
1997deleted file mode 100644
1998index 74a5ccb3c8b84181179ba2322cf2281d649233e4..0000000000000000000000000000000000000000
1999--- a/tui/tree/bubble.go
2000+++ /dev/null
2001@@ -1,341 +0,0 @@
2002-package tree
2003-
2004-import (
2005- "fmt"
2006- "io"
2007- "io/fs"
2008- "path/filepath"
2009- "strings"
2010-
2011- "github.com/charmbracelet/bubbles/list"
2012- "github.com/charmbracelet/bubbles/viewport"
2013- tea "github.com/charmbracelet/bubbletea"
2014- "github.com/charmbracelet/lipgloss"
2015- "github.com/charmbracelet/soft-serve/git"
2016- "github.com/charmbracelet/soft-serve/internal/tui/style"
2017- "github.com/charmbracelet/soft-serve/tui/common"
2018- "github.com/charmbracelet/soft-serve/tui/refs"
2019- vp "github.com/charmbracelet/soft-serve/tui/viewport"
2020- "github.com/dustin/go-humanize"
2021-)
2022-
2023-type fileMsg struct {
2024- content string
2025-}
2026-
2027-type sessionState int
2028-
2029-const (
2030- treeState sessionState = iota
2031- fileState
2032- errorState
2033-)
2034-
2035-type item struct {
2036- entry *git.TreeEntry
2037-}
2038-
2039-func (i item) Name() string {
2040- return i.entry.Name()
2041-}
2042-
2043-func (i item) Mode() fs.FileMode {
2044- return i.entry.Mode()
2045-}
2046-
2047-func (i item) FilterValue() string { return i.Name() }
2048-
2049-type items []item
2050-
2051-func (cl items) Len() int { return len(cl) }
2052-func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
2053-func (cl items) Less(i, j int) bool {
2054- if cl[i].entry.IsTree() && cl[j].entry.IsTree() {
2055- return cl[i].Name() < cl[j].Name()
2056- } else if cl[i].entry.IsTree() {
2057- return true
2058- } else if cl[j].entry.IsTree() {
2059- return false
2060- } else {
2061- return cl[i].Name() < cl[j].Name()
2062- }
2063-}
2064-
2065-type itemDelegate struct {
2066- style *style.Styles
2067-}
2068-
2069-func (d itemDelegate) Height() int { return 1 }
2070-func (d itemDelegate) Spacing() int { return 0 }
2071-func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
2072-func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
2073- s := d.style
2074- i, ok := listItem.(item)
2075- if !ok {
2076- return
2077- }
2078-
2079- name := i.Name()
2080- size := humanize.Bytes(uint64(i.entry.Size()))
2081- if i.entry.IsTree() {
2082- size = ""
2083- name = s.TreeFileDir.Render(name)
2084- }
2085- var cs lipgloss.Style
2086- mode := i.Mode()
2087- if index == m.Index() {
2088- cs = s.TreeItemActive
2089- fmt.Fprint(w, s.TreeItemSelector.Render(">"))
2090- } else {
2091- cs = s.TreeItemInactive
2092- fmt.Fprint(w, s.TreeItemSelector.Render(" "))
2093- }
2094- leftMargin := s.TreeItemSelector.GetMarginLeft() +
2095- s.TreeItemSelector.GetWidth() +
2096- s.TreeFileMode.GetMarginLeft() +
2097- s.TreeFileMode.GetWidth() +
2098- cs.GetMarginLeft()
2099- rightMargin := s.TreeFileSize.GetMarginLeft() + lipgloss.Width(size)
2100- name = common.TruncateString(name, m.Width()-leftMargin-rightMargin, "…")
2101diff --git a/tui/viewport/viewport_patch.go b/tui/viewport/viewport_patch.go
2102deleted file mode 100644
2103index 4163b5c40ee90025690f1f4d97d01ac12d310584..0000000000000000000000000000000000000000
2104--- a/tui/viewport/viewport_patch.go
2105+++ /dev/null
2106@@ -1,24 +0,0 @@
2107-package viewport
2108-
2109-import (
2110- "github.com/charmbracelet/bubbles/viewport"
2111- tea "github.com/charmbracelet/bubbletea"
2112-)
2113-
2114-type ViewportBubble struct {
2115- Viewport *viewport.Model
2116-}
2117-
2118-func (v *ViewportBubble) Init() tea.Cmd {
2119- return nil
2120-}
2121-
2122-func (v *ViewportBubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2123- vp, cmd := v.Viewport.Update(msg)
2124- v.Viewport = &vp
2125- return v, cmd
2126-}
2127-
2128-func (v *ViewportBubble) View() string {
2129- return v.Viewport.View()
2130-}
2131diff --git a/ui/common/common.go b/ui/common/common.go
2132new file mode 100644
2133index 0000000000000000000000000000000000000000..f18d0298b00422d343f382682c031b15301e02c0
2134--- /dev/null
2135+++ b/ui/common/common.go
2136@@ -0,0 +1,22 @@
2137+package common
2138+
2139+import (
2140+ "github.com/aymanbagabas/go-osc52"
2141+ "github.com/charmbracelet/soft-serve/ui/keymap"
2142+ "github.com/charmbracelet/soft-serve/ui/styles"
2143+)
2144+
2145+// Common is a struct all components should embed.
2146+type Common struct {
2147+ Copy *osc52.Output
2148+ Styles *styles.Styles
2149+ KeyMap *keymap.KeyMap
2150+ Width int
2151+ Height int
2152+}
2153+
2154+// SetSize sets the width and height of the common struct.
2155+func (c *Common) SetSize(width, height int) {
2156+ c.Width = width
2157+ c.Height = height
2158+}
2159diff --git a/ui/common/component.go b/ui/common/component.go
2160new file mode 100644
2161index 0000000000000000000000000000000000000000..ed8b9bf05cd39b52086a2a490b045231ac1fd96e
2162--- /dev/null
2163+++ b/ui/common/component.go
2164@@ -0,0 +1,13 @@
2165+package common
2166+
2167+import (
2168+ "github.com/charmbracelet/bubbles/help"
2169+ tea "github.com/charmbracelet/bubbletea"
2170+)
2171+
2172+// Component represents a Bubble Tea model that implements a SetSize function.
2173+type Component interface {
2174+ tea.Model
2175+ help.KeyMap
2176+ SetSize(width, height int)
2177+}
2178diff --git a/ui/common/error.go b/ui/common/error.go
2179new file mode 100644
2180index 0000000000000000000000000000000000000000..fe972980562270ccfb0c8f854e3a3360b4389bc7
2181--- /dev/null
2182+++ b/ui/common/error.go
2183@@ -0,0 +1,13 @@
2184+package common
2185+
2186+import tea "github.com/charmbracelet/bubbletea"
2187+
2188+// ErrorMsg is a Bubble Tea message that represents an error.
2189+type ErrorMsg error
2190+
2191+// ErrorCmd returns an ErrorMsg from error.
2192+func ErrorCmd(err error) tea.Cmd {
2193+ return func() tea.Msg {
2194+ return ErrorMsg(err)
2195+ }
2196+}
2197diff --git a/ui/common/style.go b/ui/common/style.go
2198new file mode 100644
2199index 0000000000000000000000000000000000000000..1e2c911a79fe31f07f144245667a9459d8091ac1
2200--- /dev/null
2201+++ b/ui/common/style.go
2202@@ -0,0 +1,19 @@
2203+package common
2204+
2205+import (
2206+ "github.com/charmbracelet/glamour"
2207+ gansi "github.com/charmbracelet/glamour/ansi"
2208+)
2209+
2210+// StyleConfig returns the default Glamour style configuration.
2211+func StyleConfig() gansi.StyleConfig {
2212+ noColor := ""
2213+ s := glamour.DarkStyleConfig
2214+ s.Document.StylePrimitive.Color = &noColor
2215+ s.CodeBlock.Chroma.Text.Color = &noColor
2216+ s.CodeBlock.Chroma.Name.Color = &noColor
2217+ // This fixes an issue with the default style config. For example
2218+ // highlighting empty spaces with red in Dockerfile type.
2219+ s.CodeBlock.Chroma.Error.BackgroundColor = &noColor
2220+ return s
2221+}
2222diff --git a/ui/common/utils.go b/ui/common/utils.go
2223new file mode 100644
2224index 0000000000000000000000000000000000000000..7c817a50ebcb6b92b04006837bde1bbd3ae732b0
2225--- /dev/null
2226+++ b/ui/common/utils.go
2227@@ -0,0 +1,11 @@
2228+package common
2229+
2230+import "github.com/muesli/reflow/truncate"
2231+
2232+// TruncateString is a convenient wrapper around truncate.TruncateString.
2233+func TruncateString(s string, max int) string {
2234+ if max < 0 {
2235+ max = 0
2236+ }
2237+ return truncate.StringWithTail(s, uint(max), "…")
2238+}
2239diff --git a/ui/components/code/code.go b/ui/components/code/code.go
2240new file mode 100644
2241index 0000000000000000000000000000000000000000..9c832bc836b7f1437a7488b86ca6f55d95af241a
2242--- /dev/null
2243+++ b/ui/components/code/code.go
2244@@ -0,0 +1,259 @@
2245+package code
2246+
2247+import (
2248+ "fmt"
2249+ "strings"
2250+ "sync"
2251+
2252+ "github.com/alecthomas/chroma/lexers"
2253+ tea "github.com/charmbracelet/bubbletea"
2254+ "github.com/charmbracelet/glamour"
2255+ gansi "github.com/charmbracelet/glamour/ansi"
2256+ "github.com/charmbracelet/lipgloss"
2257+ "github.com/charmbracelet/soft-serve/ui/common"
2258+ vp "github.com/charmbracelet/soft-serve/ui/components/viewport"
2259+ "github.com/muesli/termenv"
2260+)
2261+
2262+const (
2263+ tabWidth = 4
2264+)
2265+
2266+var (
2267+ lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
2268+ lineBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
2269+)
2270+
2271+// Code is a code snippet.
2272+type Code struct {
2273+ *vp.Viewport
2274+ common common.Common
2275+ content string
2276+ extension string
2277+ renderContext gansi.RenderContext
2278+ renderMutex sync.Mutex
2279+ styleConfig gansi.StyleConfig
2280+ showLineNumber bool
2281+
2282+ NoContentStyle lipgloss.Style
2283+ LineDigitStyle lipgloss.Style
2284+ LineBarStyle lipgloss.Style
2285+}
2286+
2287+// New returns a new Code.
2288+func New(c common.Common, content, extension string) *Code {
2289+ r := &Code{
2290+ common: c,
2291+ content: content,
2292+ extension: extension,
2293+ Viewport: vp.New(c),
2294+ NoContentStyle: c.Styles.CodeNoContent.Copy(),
2295+ LineDigitStyle: lineDigitStyle,
2296+ LineBarStyle: lineBarStyle,
2297+ }
2298+ st := common.StyleConfig()
2299+ r.styleConfig = st
2300+ r.renderContext = gansi.NewRenderContext(gansi.Options{
2301+ ColorProfile: termenv.TrueColor,
2302+ Styles: st,
2303+ })
2304+ r.SetSize(c.Width, c.Height)
2305+ return r
2306+}
2307+
2308+// SetShowLineNumber sets whether to show line numbers.
2309+func (r *Code) SetShowLineNumber(show bool) {
2310+ r.showLineNumber = show
2311+}
2312+
2313+// SetSize implements common.Component.
2314+func (r *Code) SetSize(width, height int) {
2315+ r.common.SetSize(width, height)
2316+ r.Viewport.SetSize(width, height)
2317+}
2318+
2319+// SetContent sets the content of the Code.
2320+func (r *Code) SetContent(c, ext string) tea.Cmd {
2321+ r.content = c
2322+ r.extension = ext
2323+ return r.Init()
2324+}
2325+
2326+// Init implements tea.Model.
2327+func (r *Code) Init() tea.Cmd {
2328+ w := r.common.Width
2329+ c := r.content
2330+ if c == "" {
2331+ r.Viewport.Model.SetContent(r.NoContentStyle.String())
2332+ return nil
2333+ }
2334+ f, err := r.renderFile(r.extension, c, w)
2335+ if err != nil {
2336+ return common.ErrorCmd(err)
2337+ }
2338+ r.Viewport.Model.SetContent(f)
2339+ return nil
2340+}
2341+
2342+// Update implements tea.Model.
2343+func (r *Code) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2344diff --git a/ui/components/footer/footer.go b/ui/components/footer/footer.go
2345new file mode 100644
2346index 0000000000000000000000000000000000000000..6d724eb230acdb06052b716e4962484f6daf7c3e
2347--- /dev/null
2348+++ b/ui/components/footer/footer.go
2349@@ -0,0 +1,85 @@
2350+package footer
2351+
2352+import (
2353+ "github.com/charmbracelet/bubbles/help"
2354+ "github.com/charmbracelet/bubbles/key"
2355+ tea "github.com/charmbracelet/bubbletea"
2356+ "github.com/charmbracelet/lipgloss"
2357+ "github.com/charmbracelet/soft-serve/ui/common"
2358+)
2359+
2360+// Footer is a Bubble Tea model that displays help and other info.
2361+type Footer struct {
2362+ common common.Common
2363+ help help.Model
2364+ keymap help.KeyMap
2365+}
2366+
2367+// New creates a new Footer.
2368+func New(c common.Common, keymap help.KeyMap) *Footer {
2369+ h := help.New()
2370+ h.Styles.ShortKey = c.Styles.HelpKey
2371+ h.Styles.ShortDesc = c.Styles.HelpValue
2372+ h.Styles.FullKey = c.Styles.HelpKey
2373+ h.Styles.FullDesc = c.Styles.HelpValue
2374+ f := &Footer{
2375+ common: c,
2376+ help: h,
2377+ keymap: keymap,
2378+ }
2379+ f.SetSize(c.Width, c.Height)
2380+ return f
2381+}
2382+
2383+// SetSize implements common.Component.
2384+func (f *Footer) SetSize(width, height int) {
2385+ f.common.SetSize(width, height)
2386+ f.help.Width = width -
2387+ f.common.Styles.Footer.GetHorizontalFrameSize()
2388+}
2389+
2390+// Init implements tea.Model.
2391+func (f *Footer) Init() tea.Cmd {
2392+ return nil
2393+}
2394+
2395+// Update implements tea.Model.
2396+func (f *Footer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2397+ return f, nil
2398+}
2399+
2400+// View implements tea.Model.
2401+func (f *Footer) View() string {
2402+ if f.keymap == nil {
2403+ return ""
2404+ }
2405+ s := f.common.Styles.Footer.Copy().
2406+ Width(f.common.Width)
2407+ helpView := f.help.View(f.keymap)
2408+ return s.Render(helpView)
2409+}
2410+
2411+// ShortHelp returns the short help key bindings.
2412+func (f *Footer) ShortHelp() []key.Binding {
2413+ return f.keymap.ShortHelp()
2414+}
2415+
2416+// FullHelp returns the full help key bindings.
2417+func (f *Footer) FullHelp() [][]key.Binding {
2418+ return f.keymap.FullHelp()
2419+}
2420+
2421+// ShowAll returns whether the full help is shown.
2422+func (f *Footer) ShowAll() bool {
2423+ return f.help.ShowAll
2424+}
2425+
2426+// SetShowAll sets whether the full help is shown.
2427+func (f *Footer) SetShowAll(show bool) {
2428+ f.help.ShowAll = show
2429+}
2430+
2431+// Height returns the height of the footer.
2432+func (f *Footer) Height() int {
2433+ return lipgloss.Height(f.View())
2434+}
2435diff --git a/ui/components/header/header.go b/ui/components/header/header.go
2436new file mode 100644
2437index 0000000000000000000000000000000000000000..b490ab1e3f64aac66777a2ec48bf79df10a1c822
2438--- /dev/null
2439+++ b/ui/components/header/header.go
2440@@ -0,0 +1,44 @@
2441+package header
2442+
2443+import (
2444+ "strings"
2445+
2446+ tea "github.com/charmbracelet/bubbletea"
2447+ "github.com/charmbracelet/soft-serve/ui/common"
2448+)
2449+
2450+// Header represents a header component.
2451+type Header struct {
2452+ common common.Common
2453+ text string
2454+}
2455+
2456+// New creates a new header component.
2457+func New(c common.Common, text string) *Header {
2458+ h := &Header{
2459+ common: c,
2460+ text: text,
2461+ }
2462+ return h
2463+}
2464+
2465+// SetSize implements common.Component.
2466+func (h *Header) SetSize(width, height int) {
2467+ h.common.SetSize(width, height)
2468+}
2469+
2470+// Init implements tea.Model.
2471+func (h *Header) Init() tea.Cmd {
2472+ return nil
2473+}
2474+
2475+// Update implements tea.Model.
2476+func (h *Header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2477+ return h, nil
2478+}
2479+
2480+// View implements tea.Model.
2481+func (h *Header) View() string {
2482+ s := h.common.Styles.Header.Copy().Width(h.common.Width)
2483+ return s.Render(strings.TrimSpace(h.text))
2484+}
2485diff --git a/ui/components/selector/selector.go b/ui/components/selector/selector.go
2486new file mode 100644
2487index 0000000000000000000000000000000000000000..ed044990e6d76473d015bb7b2c93b5352408bc02
2488--- /dev/null
2489+++ b/ui/components/selector/selector.go
2490@@ -0,0 +1,222 @@
2491+package selector
2492+
2493+import (
2494+ "github.com/charmbracelet/bubbles/key"
2495+ "github.com/charmbracelet/bubbles/list"
2496+ tea "github.com/charmbracelet/bubbletea"
2497+ "github.com/charmbracelet/soft-serve/ui/common"
2498+)
2499+
2500+// Selector is a list of items that can be selected.
2501+type Selector struct {
2502+ list.Model
2503+ common common.Common
2504+ active int
2505+ filterState list.FilterState
2506+}
2507+
2508+// IdentifiableItem is an item that can be identified by a string. Implements list.DefaultItem.
2509+type IdentifiableItem interface {
2510+ list.DefaultItem
2511+ ID() string
2512+}
2513+
2514+// ItemDelegate is a wrapper around list.ItemDelegate.
2515+type ItemDelegate interface {
2516+ list.ItemDelegate
2517+}
2518+
2519+// SelectMsg is a message that is sent when an item is selected.
2520+type SelectMsg struct{ IdentifiableItem }
2521+
2522+// ActiveMsg is a message that is sent when an item is active but not selected.
2523+type ActiveMsg struct{ IdentifiableItem }
2524+
2525+// New creates a new selector.
2526+func New(common common.Common, items []IdentifiableItem, delegate ItemDelegate) *Selector {
2527+ itms := make([]list.Item, len(items))
2528+ for i, item := range items {
2529+ itms[i] = item
2530+ }
2531+ l := list.New(itms, delegate, common.Width, common.Height)
2532+ s := &Selector{
2533+ Model: l,
2534+ common: common,
2535+ }
2536+ s.SetSize(common.Width, common.Height)
2537+ return s
2538+}
2539+
2540+// PerPage returns the number of items per page.
2541+func (s *Selector) PerPage() int {
2542+ return s.Model.Paginator.PerPage
2543+}
2544+
2545+// SetPage sets the current page.
2546+func (s *Selector) SetPage(page int) {
2547+ s.Model.Paginator.Page = page
2548+}
2549+
2550+// Page returns the current page.
2551+func (s *Selector) Page() int {
2552+ return s.Model.Paginator.Page
2553+}
2554+
2555+// TotalPages returns the total number of pages.
2556+func (s *Selector) TotalPages() int {
2557+ return s.Model.Paginator.TotalPages
2558+}
2559+
2560+// Select selects the item at the given index.
2561+func (s *Selector) Select(index int) {
2562+ s.Model.Select(index)
2563+}
2564+
2565+// SetShowTitle sets the show title flag.
2566+func (s *Selector) SetShowTitle(show bool) {
2567+ s.Model.SetShowTitle(show)
2568+}
2569+
2570+// SetShowHelp sets the show help flag.
2571+func (s *Selector) SetShowHelp(show bool) {
2572+ s.Model.SetShowHelp(show)
2573+}
2574+
2575+// SetShowStatusBar sets the show status bar flag.
2576+func (s *Selector) SetShowStatusBar(show bool) {
2577+ s.Model.SetShowStatusBar(show)
2578+}
2579+
2580+// DisableQuitKeybindings disables the quit keybindings.
2581+func (s *Selector) DisableQuitKeybindings() {
2582+ s.Model.DisableQuitKeybindings()
2583+}
2584+
2585+// SetShowFilter sets the show filter flag.
2586+func (s *Selector) SetShowFilter(show bool) {
2587+ s.Model.SetShowFilter(show)
2588+}
2589+