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