1package tui
2
3import (
4 "encoding/json"
5 "fmt"
6 "log"
7 "smoothie/git"
8 "smoothie/tui/bubbles/commits"
9 "smoothie/tui/bubbles/selection"
10 "time"
11
12 "github.com/charmbracelet/bubbles/viewport"
13 tea "github.com/charmbracelet/bubbletea"
14 "github.com/charmbracelet/glamour"
15 "github.com/charmbracelet/lipgloss"
16 "github.com/gliderlabs/ssh"
17)
18
19type sessionState int
20
21const (
22 startState sessionState = iota
23 errorState
24 loadedState
25 quittingState
26 quitState
27)
28
29type MenuEntry struct {
30 Name string `json:"name"`
31 Repo string `json:"repo"`
32}
33
34type Config struct {
35 Name string `json:"name"`
36 ShowAllRepos bool `json:"show_all_repos"`
37 Menu []MenuEntry `json:"menu"`
38 RepoSource *git.RepoSource
39}
40
41type SessionConfig struct {
42 Width int
43 Height int
44 WindowChanges <-chan ssh.Window
45}
46
47type Bubble struct {
48 config *Config
49 state sessionState
50 error string
51 width int
52 height int
53 windowChanges <-chan ssh.Window
54 repoSource *git.RepoSource
55 repoMenu []MenuEntry
56 repos []*git.Repo
57 boxes []tea.Model
58 activeBox int
59 repoSelect *selection.Bubble
60 commitsLog *commits.Bubble
61 readmeViewport *ViewportBubble
62}
63
64func NewBubble(cfg *Config, sCfg *SessionConfig) *Bubble {
65 b := &Bubble{
66 config: cfg,
67 width: sCfg.Width,
68 height: sCfg.Height,
69 windowChanges: sCfg.WindowChanges,
70 repoSource: cfg.RepoSource,
71 boxes: make([]tea.Model, 2),
72 readmeViewport: &ViewportBubble{
73 Viewport: &viewport.Model{
74 Width: boxRightWidth - horizontalPadding - 2,
75 Height: sCfg.Height - verticalPadding - viewportHeightConstant,
76 },
77 },
78 }
79 b.state = startState
80 return b
81}
82
83func (b *Bubble) Init() tea.Cmd {
84 return tea.Batch(b.windowChangesCmd, b.loadGitCmd)
85}
86
87func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
88 cmds := make([]tea.Cmd, 0)
89 // Always allow state, error, info, window resize and quit messages
90 switch msg := msg.(type) {
91 case tea.KeyMsg:
92 switch msg.String() {
93 case "q", "ctrl+c":
94 return b, tea.Quit
95 case "tab":
96 b.activeBox = (b.activeBox + 1) % 2
97 }
98 case errMsg:
99 b.error = msg.Error()
100 b.state = errorState
101 return b, nil
102 case windowMsg:
103 cmds = append(cmds, b.windowChangesCmd)
104 case tea.WindowSizeMsg:
105 b.width = msg.Width
106 b.height = msg.Height
107 case selection.SelectedMsg:
108 b.activeBox = 1
109 cmds = append(cmds, b.getRepoCmd(b.repoMenu[msg.Index].Repo))
110 case selection.ActiveMsg:
111 cmds = append(cmds, b.getRepoCmd(b.repoMenu[msg.Index].Repo))
112 }
113 if b.state == loadedState {
114 ab, cmd := b.boxes[b.activeBox].Update(msg)
115 b.boxes[b.activeBox] = ab
116 if cmd != nil {
117 cmds = append(cmds, cmd)
118 }
119 }
120 return b, tea.Batch(cmds...)
121}
122
123func (b *Bubble) viewForBox(i int, width int) string {
124 var ls lipgloss.Style
125 if i == b.activeBox {
126 ls = activeBoxStyle.Width(width)
127 } else {
128 ls = inactiveBoxStyle.Width(width)
129 }
130 return ls.Render(b.boxes[i].View())
131}
132
133func (b *Bubble) View() string {
134 h := headerStyle.Width(b.width - horizontalPadding).Render(b.config.Name)
135 f := footerStyle.Render("")
136 s := ""
137 content := ""
138 switch b.state {
139 case loadedState:
140 lb := b.viewForBox(0, boxLeftWidth)
141 rb := b.viewForBox(1, boxRightWidth)
142 s += lipgloss.JoinHorizontal(lipgloss.Top, lb, rb)
143 case errorState:
144 s += errorStyle.Render(fmt.Sprintf("Bummer: %s", b.error))
145 default:
146 s = normalStyle.Render(fmt.Sprintf("Doing something weird %d", b.state))
147 }
148 content = h + "\n\n" + s + "\n" + f
149 return appBoxStyle.Render(content)
150}
151
152func glamourReadme(md string) string {
153 tr, err := glamour.NewTermRenderer(
154 glamour.WithAutoStyle(),
155 glamour.WithWordWrap(boxRightWidth-2),
156 )
157 if err != nil {
158 log.Fatal(err)
159 }
160 mdt, err := tr.Render(md)
161 if err != nil {
162 return md
163 }
164 return mdt
165}
166
167func loadConfig(rs *git.RepoSource) (*Config, error) {
168 cfg := &Config{}
169 cfg.RepoSource = rs
170 cr, err := rs.GetRepo("config")
171 if err != nil {
172 return nil, fmt.Errorf("cannot load config repo: %s", err)
173 }
174 cs, err := cr.LatestFile("config.json")
175 if err != nil {
176 return nil, fmt.Errorf("cannot load config.json: %s", err)
177 }
178 err = json.Unmarshal([]byte(cs), cfg)
179 if err != nil {
180 return nil, fmt.Errorf("bad json in config.json: %s", err)
181 }
182 return cfg, nil
183}
184
185func SessionHandler(reposPath string, repoPoll time.Duration) func(ssh.Session) (tea.Model, []tea.ProgramOption) {
186 rs := git.NewRepoSource(reposPath, glamourReadme)
187 err := createDefaultConfigRepo(rs)
188 if err != nil {
189 if err != nil {
190 log.Fatalf("cannot create config repo: %s", err)
191 }
192 }
193 appCfg, err := loadConfig(rs)
194 if err != nil {
195 if err != nil {
196 log.Printf("cannot load config: %s", err)
197 }
198 }
199 go func() {
200 for {
201 time.Sleep(repoPoll)
202 err := rs.LoadRepos()
203 if err != nil {
204 log.Printf("cannot load repos: %s", err)
205 continue
206 }
207 cfg, err := loadConfig(rs)
208 if err != nil {
209 if err != nil {
210 log.Printf("cannot load config: %s", err)
211 continue
212 }
213 }
214 appCfg = cfg
215 }
216 }()
217
218 return func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
219 if len(s.Command()) == 0 {
220 pty, changes, active := s.Pty()
221 if !active {
222 return nil, nil
223 }
224 cfg := &SessionConfig{
225 Width: pty.Window.Width,
226 Height: pty.Window.Height,
227 WindowChanges: changes,
228 }
229 return NewBubble(appCfg, cfg), []tea.ProgramOption{tea.WithAltScreen()}
230 }
231 return nil, nil
232 }
233}