1// Package termui contains the interactive terminal UI
  2package termui
  3
  4import (
  5	"fmt"
  6
  7	"github.com/awesome-gocui/gocui"
  8	"github.com/pkg/errors"
  9
 10	"github.com/MichaelMure/git-bug/cache"
 11	buginput "github.com/MichaelMure/git-bug/commands/bug/input"
 12	"github.com/MichaelMure/git-bug/entity"
 13	"github.com/MichaelMure/git-bug/query"
 14	"github.com/MichaelMure/git-bug/util/text"
 15)
 16
 17var errTerminateMainloop = errors.New("terminate gocui mainloop")
 18
 19type termUI struct {
 20	g      *gocui.Gui
 21	gError chan error
 22	cache  *cache.RepoCache
 23
 24	activeWindow window
 25
 26	bugTable    *bugTable
 27	showBug     *showBug
 28	labelSelect *labelSelect
 29	msgPopup    *msgPopup
 30	inputPopup  *inputPopup
 31}
 32
 33func (tui *termUI) activateWindow(window window) error {
 34	if err := tui.activeWindow.disable(tui.g); err != nil {
 35		return err
 36	}
 37
 38	tui.activeWindow = window
 39
 40	return nil
 41}
 42
 43var ui *termUI
 44
 45type window interface {
 46	keybindings(g *gocui.Gui) error
 47	layout(g *gocui.Gui) error
 48	disable(g *gocui.Gui) error
 49}
 50
 51// Run will launch the termUI in the terminal
 52func Run(cache *cache.RepoCache) error {
 53	ui = &termUI{
 54		gError:      make(chan error, 1),
 55		cache:       cache,
 56		bugTable:    newBugTable(cache),
 57		showBug:     newShowBug(cache),
 58		labelSelect: newLabelSelect(),
 59		msgPopup:    newMsgPopup(),
 60		inputPopup:  newInputPopup(),
 61	}
 62
 63	ui.activeWindow = ui.bugTable
 64
 65	initGui(nil)
 66
 67	err := <-ui.gError
 68
 69	type errorStack interface {
 70		ErrorStack() string
 71	}
 72
 73	if err != nil && err != gocui.ErrQuit {
 74		if e, ok := err.(errorStack); ok {
 75			fmt.Println(e.ErrorStack())
 76		}
 77		return err
 78	}
 79
 80	return nil
 81}
 82
 83func initGui(action func(ui *termUI) error) {
 84	g, err := gocui.NewGui(gocui.Output256, false)
 85
 86	if err != nil {
 87		ui.gError <- err
 88		return
 89	}
 90
 91	ui.g = g
 92
 93	ui.g.SetManagerFunc(layout)
 94
 95	ui.g.InputEsc = true
 96
 97	err = keybindings(ui.g)
 98
 99	if err != nil {
100		ui.g.Close()
101		ui.g = nil
102		ui.gError <- err
103		return
104	}
105
106	if action != nil {
107		err = action(ui)
108		if err != nil {
109			ui.g.Close()
110			ui.g = nil
111			ui.gError <- err
112			return
113		}
114	}
115
116	err = g.MainLoop()
117
118	if err != nil && err != errTerminateMainloop {
119		if ui.g != nil {
120			ui.g.Close()
121		}
122		ui.gError <- err
123	}
124}
125
126func layout(g *gocui.Gui) error {
127	g.Cursor = false
128
129	if err := ui.activeWindow.layout(g); err != nil {
130		return err
131	}
132
133	if err := ui.msgPopup.layout(g); err != nil {
134		return err
135	}
136
137	if err := ui.inputPopup.layout(g); err != nil {
138		return err
139	}
140
141	return nil
142}
143
144func keybindings(g *gocui.Gui) error {
145	// Quit
146	if err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil {
147		return err
148	}
149
150	if err := ui.bugTable.keybindings(g); err != nil {
151		return err
152	}
153
154	if err := ui.showBug.keybindings(g); err != nil {
155		return err
156	}
157
158	if err := ui.labelSelect.keybindings(g); err != nil {
159		return err
160	}
161
162	if err := ui.msgPopup.keybindings(g); err != nil {
163		return err
164	}
165
166	if err := ui.inputPopup.keybindings(g); err != nil {
167		return err
168	}
169
170	return nil
171}
172
173func quit(g *gocui.Gui, v *gocui.View) error {
174	return gocui.ErrQuit
175}
176
177func newBugWithEditor(repo *cache.RepoCache) error {
178	// This is somewhat hacky.
179	// As there is no way to pause gocui, run the editor and restart gocui,
180	// we have to stop it entirely and start a new one later.
181	//
182	// - an error channel is used to route the returned error of this new
183	// 		instance into the original launch function
184	// - a custom error (errTerminateMainloop) is used to terminate the original
185	//		instance's mainLoop. This error is then filtered.
186
187	ui.g.Close()
188	ui.g = nil
189
190	title, message, err := buginput.BugCreateEditorInput(ui.cache, "", "")
191
192	if err != nil && err != buginput.ErrEmptyTitle {
193		return err
194	}
195
196	var b *cache.BugCache
197	if err == buginput.ErrEmptyTitle {
198		ui.msgPopup.Activate(msgPopupErrorTitle, "Empty title, aborting.")
199		initGui(nil)
200
201		return errTerminateMainloop
202	} else {
203		b, _, err = repo.Bugs().New(
204			text.CleanupOneLine(title),
205			text.Cleanup(message),
206		)
207		if err != nil {
208			return err
209		}
210
211		initGui(func(ui *termUI) error {
212			ui.showBug.SetBug(b)
213			return ui.activateWindow(ui.showBug)
214		})
215
216		return errTerminateMainloop
217	}
218}
219
220func addCommentWithEditor(bug *cache.BugCache) error {
221	// This is somewhat hacky.
222	// As there is no way to pause gocui, run the editor and restart gocui,
223	// we have to stop it entirely and start a new one later.
224	//
225	// - an error channel is used to route the returned error of this new
226	// 		instance into the original launch function
227	// - a custom error (errTerminateMainloop) is used to terminate the original
228	//		instance's mainLoop. This error is then filtered.
229
230	ui.g.Close()
231	ui.g = nil
232
233	message, err := buginput.BugCommentEditorInput(ui.cache, "")
234	if err != nil && err != buginput.ErrEmptyMessage {
235		return err
236	}
237	if err == buginput.ErrEmptyMessage {
238		ui.msgPopup.Activate(msgPopupErrorTitle, "Empty message, aborting.")
239	} else {
240		_, _, err := bug.AddComment(text.Cleanup(message))
241		if err != nil {
242			return err
243		}
244	}
245
246	initGui(nil)
247
248	return errTerminateMainloop
249}
250
251func editCommentWithEditor(bug *cache.BugCache, target entity.CombinedId, preMessage string) error {
252	// This is somewhat hacky.
253	// As there is no way to pause gocui, run the editor and restart gocui,
254	// we have to stop it entirely and start a new one later.
255	//
256	// - an error channel is used to route the returned error of this new
257	// 		instance into the original launch function
258	// - a custom error (errTerminateMainloop) is used to terminate the original
259	//		instance's mainLoop. This error is then filtered.
260
261	ui.g.Close()
262	ui.g = nil
263
264	message, err := buginput.BugCommentEditorInput(ui.cache, preMessage)
265	if err != nil && err != buginput.ErrEmptyMessage {
266		return err
267	}
268
269	if err == buginput.ErrEmptyMessage {
270		// TODO: Allow comments to be deleted?
271		ui.msgPopup.Activate(msgPopupErrorTitle, "Empty message, aborting.")
272	} else if message == preMessage {
273		ui.msgPopup.Activate(msgPopupErrorTitle, "No changes found, aborting.")
274	} else {
275		_, err := bug.EditComment(target, text.Cleanup(message))
276		if err != nil {
277			return err
278		}
279	}
280
281	initGui(nil)
282
283	return errTerminateMainloop
284}
285
286func setTitleWithEditor(bug *cache.BugCache) error {
287	// This is somewhat hacky.
288	// As there is no way to pause gocui, run the editor and restart gocui,
289	// we have to stop it entirely and start a new one later.
290	//
291	// - an error channel is used to route the returned error of this new
292	// 		instance into the original launch function
293	// - a custom error (errTerminateMainloop) is used to terminate the original
294	//		instance's mainLoop. This error is then filtered.
295
296	ui.g.Close()
297	ui.g = nil
298
299	snap := bug.Snapshot()
300
301	title, err := buginput.BugTitleEditorInput(ui.cache, snap.Title)
302
303	if err != nil && err != buginput.ErrEmptyTitle {
304		return err
305	}
306
307	if err == buginput.ErrEmptyTitle {
308		ui.msgPopup.Activate(msgPopupErrorTitle, "Empty title, aborting.")
309	} else if title == snap.Title {
310		ui.msgPopup.Activate(msgPopupErrorTitle, "No change, aborting.")
311	} else {
312		_, err := bug.SetTitle(text.CleanupOneLine(title))
313		if err != nil {
314			return err
315		}
316	}
317
318	initGui(nil)
319
320	return errTerminateMainloop
321}
322
323func editQueryWithEditor(bt *bugTable) error {
324	// This is somewhat hacky.
325	// As there is no way to pause gocui, run the editor and restart gocui,
326	// we have to stop it entirely and start a new one later.
327	//
328	// - an error channel is used to route the returned error of this new
329	// 		instance into the original launch function
330	// - a custom error (errTerminateMainloop) is used to terminate the original
331	//		instance's mainLoop. This error is then filtered.
332
333	ui.g.Close()
334	ui.g = nil
335
336	queryStr, err := buginput.QueryEditorInput(bt.repo, bt.queryStr)
337
338	if err != nil {
339		return err
340	}
341
342	bt.queryStr = queryStr
343
344	q, err := query.Parse(queryStr)
345
346	if err != nil {
347		ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
348	} else {
349		bt.query = q
350	}
351
352	initGui(nil)
353
354	return errTerminateMainloop
355}
356
357func maxInt(a, b int) int {
358	if a > b {
359		return a
360	}
361	return b
362}
363
364func minInt(a, b int) int {
365	if a > b {
366		return b
367	}
368	return a
369}