-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathmenu.go
More file actions
434 lines (348 loc) · 11.7 KB
/
Copy pathmenu.go
File metadata and controls
434 lines (348 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
package console
import (
"bytes"
"errors"
"fmt"
"strings"
"sync"
"github.com/spf13/cobra"
"github.com/reeflective/console/internal/command"
"github.com/reeflective/console/internal/strutil"
"github.com/reeflective/console/internal/ui"
"github.com/reeflective/readline"
)
// Prompt - A prompt is a set of functions that return the strings to print
// for each prompt type. The console will call these functions to retrieve
// the prompt strings to print. Each menu has its own prompt.
type Prompt = ui.Prompt
// Menu - A menu is a simple way to seggregate commands based on
// the environment to which they belong. For instance, when using a menu
// specific to some host/user, or domain of activity, commands will vary.
type Menu struct {
name string
active bool
prompt *Prompt
console *Console
// Maps interrupt signals (CtrlC/IOF, etc) to specific error handlers.
interruptHandlers map[error]func(c *Console)
// ErrorHandler is called when an error is encountered.
//
// If not set, the error is printed to the console on os.Stderr.
ErrorHandler ErrorHandler
// Input/output channels
out *bytes.Buffer
// The root cobra command/parser is the one returned by the handler provided
// through the `menu.SetCommands()` function. This command is thus renewed after
// each command invocation/execution.
// You can still use it as you want, for instance to introspect the current command
// state of your menu.
*cobra.Command
// Command spawner
cmds Commands
// An error template to use to produce errors when a command is unavailable.
errFilteredTemplate string
// History sources peculiar to this menu.
historyNames []string
histories map[string]readline.History
// Per-menu overrides of the console newline behavior. When a *bool is nil
// (or emptyChars is nil), the corresponding Console default is used.
nlBefore *bool
nlAfter *bool
nlWhenEmpty *bool
emptyChars []rune
// Concurrency management
mutex *sync.RWMutex
}
func newMenu(name string, console *Console) *Menu {
menu := &Menu{
console: console,
name: name,
Command: &cobra.Command{},
out: bytes.NewBuffer(nil),
interruptHandlers: make(map[error]func(c *Console)),
histories: make(map[string]readline.History),
mutex: &sync.RWMutex{},
ErrorHandler: defaultErrorHandler,
}
// Prompt setup
prompt := (ui.NewPrompt(console.name, name, menu.out))
menu.prompt = (*Prompt)(prompt)
// Add a default in memory history to each menu
// This source is dropped if another source is added
// to the menu via `AddHistorySource()`.
histName := menu.defaultHistoryName()
hist := readline.NewInMemoryHistory()
menu.historyNames = append(menu.historyNames, histName)
menu.histories[histName] = hist
return menu
}
// Name returns the name of this menu.
func (m *Menu) Name() string {
return m.name
}
// Prompt returns the prompt object for this menu.
func (m *Menu) Prompt() *Prompt {
return m.prompt
}
// SetNewlineBefore overrides Console.NewlineBefore for this menu only.
func (m *Menu) SetNewlineBefore(v bool) {
m.mutex.Lock()
m.nlBefore = &v
m.mutex.Unlock()
}
// SetNewlineAfter overrides Console.NewlineAfter for this menu only.
func (m *Menu) SetNewlineAfter(v bool) {
m.mutex.Lock()
m.nlAfter = &v
m.mutex.Unlock()
}
// SetNewlineWhenEmpty overrides Console.NewlineWhenEmpty for this menu only.
func (m *Menu) SetNewlineWhenEmpty(v bool) {
m.mutex.Lock()
m.nlWhenEmpty = &v
m.mutex.Unlock()
}
// SetEmptyChars overrides Console.EmptyChars for this menu only. Passing no
// arguments clears the override, restoring the console default.
func (m *Menu) SetEmptyChars(chars ...rune) {
m.mutex.Lock()
m.emptyChars = chars
m.mutex.Unlock()
}
func (m *Menu) newlineBefore() bool {
m.mutex.RLock()
defer m.mutex.RUnlock()
if m.nlBefore != nil {
return *m.nlBefore
}
return m.console.NewlineBefore
}
func (m *Menu) newlineAfter() bool {
m.mutex.RLock()
defer m.mutex.RUnlock()
if m.nlAfter != nil {
return *m.nlAfter
}
return m.console.NewlineAfter
}
func (m *Menu) newlineWhenEmpty() bool {
m.mutex.RLock()
defer m.mutex.RUnlock()
if m.nlWhenEmpty != nil {
return *m.nlWhenEmpty
}
return m.console.NewlineWhenEmpty
}
func (m *Menu) emptyCharSet() []rune {
m.mutex.RLock()
defer m.mutex.RUnlock()
if m.emptyChars != nil {
return m.emptyChars
}
return m.console.EmptyChars
}
// AddHistorySource adds a source of history commands that will
// be accessible to the shell when the menu is active.
func (m *Menu) AddHistorySource(name string, source readline.History) {
m.mutex.Lock()
defer m.mutex.Unlock()
if len(m.histories) == 1 && m.historyNames[0] == m.defaultHistoryName() {
delete(m.histories, m.defaultHistoryName())
m.historyNames = make([]string, 0)
}
m.historyNames = append(m.historyNames, name)
m.histories[name] = source
}
// AddHistorySourceFile adds a new source of history populated from and writing
// to the specified "filepath" parameter. On the first call to this function,
// the default in-memory history source is removed.
func (m *Menu) AddHistorySourceFile(name string, filepath string) {
m.mutex.Lock()
defer m.mutex.Unlock()
if len(m.histories) == 1 && m.historyNames[0] == m.defaultHistoryName() {
delete(m.histories, m.defaultHistoryName())
m.historyNames = make([]string, 0)
}
m.historyNames = append(m.historyNames, name)
m.histories[name], _ = readline.NewHistoryFromFile(filepath)
}
// DeleteHistorySource removes a history source from the menu.
// This normally should only be used in two cases:
// - You want to replace the default in-memory history with another one.
// - You want to replace one of your history sources for some reason.
func (m *Menu) DeleteHistorySource(name string) {
if name == m.Name() {
if name != "" {
name = " (" + name + ")"
}
name = "local history" + name
}
delete(m.histories, name)
for i, hname := range m.historyNames {
if hname == name {
m.historyNames = append(m.historyNames[:i], m.historyNames[i+1:]...)
break
}
}
}
// TransientPrintf prints a message to the console, but only if the current
// menu is active. If the menu is not active, the message is buffered and will
// be printed the next time the menu is active.
//
// The message is printed as a transient message, meaning that it will be
// printed above the current prompt, effectively "pushing" the prompt down.
//
// If this function is called while a command is running, the console
// will simply print the log below the current line, and will not print
// the prompt. In any other case this function will work normally.
func (m *Menu) TransientPrintf(msg string, args ...any) (n int, err error) {
n, err = fmt.Fprintf(m.out, msg, args...)
if err != nil {
return
}
if !m.active {
fmt.Fprintf(m.out, "\n")
return
}
buf := m.out.String()
m.out.Reset()
return m.console.TransientPrintf("%s", buf)
}
// Printf prints a message to the console, but only if the current menu
// is active. If the menu is not active, the message is buffered and will
// be printed the next time the menu is active.
//
// Unlike TransientPrintf, this function will not print the message above
// the current prompt, but will instead print it below it.
//
// If this function is called while a command is running, the console
// will simply print the log below the current line, and will not print
// the prompt. In any other case this function will work normally.
func (m *Menu) Printf(msg string, args ...any) (n int, err error) {
n, err = fmt.Fprintf(m.out, msg, args...)
if err != nil {
return
}
if !m.active {
fmt.Fprintf(m.out, "\n")
return
}
buf := m.out.String()
m.out.Reset()
return m.console.Printf("%s", buf)
}
// CheckIsAvailable checks if a target command is marked as filtered
// by the console application registered/and or active filters (added
// with console.Hide/ShowCommand()).
// If filtered, returns a template-formatted error message showing the
// list of incompatible filters. If not filtered, no error is returned.
func (m *Menu) CheckIsAvailable(cmd *cobra.Command) error {
if cmd == nil {
return nil
}
filters := m.ActiveFiltersFor(cmd)
if len(filters) == 0 {
return nil
}
errTemplate := m.errorFilteredCommandTemplate(filters)
var bufErr strings.Builder
err := strutil.Template(&bufErr, errTemplate, map[string]interface{}{
"menu": m,
"cmd": cmd,
"filters": filters,
})
if err != nil {
return err
}
return errors.New(bufErr.String())
}
// ActiveFiltersFor returns all the active menu filters that a given command
// does not declare as compliant with (added with console.Hide/ShowCommand()).
func (m *Menu) ActiveFiltersFor(cmd *cobra.Command) []string {
// Snapshot the console filters once under a read lock, then walk the
// command tree lock-free. The previous version held a write lock and
// recursed into itself while holding it, which both serialized every
// completion/highlight render and risked a self-deadlock on the
// (non-reentrant) mutex whenever the parent-subtree branch was taken.
m.console.mutex.RLock()
consoleFilters := append([]string(nil), m.console.filters...)
m.console.mutex.RUnlock()
return command.ActiveFilters(cmd, consoleFilters)
}
// SetErrFilteredCommandTemplate sets the error template to be used
// when a called command can't be executed because it's mark filtered.
func (m *Menu) SetErrFilteredCommandTemplate(s string) {
m.errFilteredTemplate = s
}
// resetPreRun is called before each new read line loop and before arbitrary RunCommand() calls.
// This function is responsible for resetting the menu state to a clean state, regenerating the
// menu commands, and ensuring that the correct prompt is bound to the shell.
func (m *Menu) resetPreRun() {
m.mutex.Lock()
defer m.mutex.Unlock()
// Commands
m.regenerate()
// Reset or adjust any buffered command output.
m.resetCmdOutput()
// Prompt binding
prompt := (*ui.Prompt)(m.Prompt())
ui.BindPrompt(prompt, m.console.shell)
}
// resetCommands regenerates the menu command tree and re-applies filtering,
// without rebinding the prompt or touching the command-output buffer. It is the
// lighter reset used on the completion hot path, where the prompt is already
// bound and no command output was produced.
func (m *Menu) resetCommands() {
m.mutex.Lock()
defer m.mutex.Unlock()
m.regenerate()
}
// regenerate rebuilds the command tree and hides filtered commands.
// It assumes m.mutex is already held.
func (m *Menu) regenerate() {
if m.cmds != nil {
m.Command = m.cmds()
}
if m.Command == nil {
m.Command = &cobra.Command{
Annotations: make(map[string]string),
}
}
// Hide commands that are not available.
m.hideFilteredCommands(m.Command)
// The command tree just changed, so any memoized highlight is now stale.
m.console.hlCache.Store(nil)
}
// hide commands that are filtered so that they are not
// shown in the help strings or proposed as completions.
func (m *Menu) hideFilteredCommands(root *cobra.Command) {
m.console.mutex.RLock()
consoleFilters := append([]string(nil), m.console.filters...)
m.console.mutex.RUnlock()
command.HideFiltered(root, consoleFilters)
}
func (m *Menu) resetCmdOutput() {
buf := strings.TrimSpace(m.out.String())
// If our command has printed everything to stdout, nothing to do.
if len(buf) == 0 {
m.out.Reset()
return
}
// Add two newlines to the end of the buffer, so that the
// next command will be printed slightly below the current one.
m.out.WriteString("\n")
}
func (m *Menu) defaultHistoryName() string {
var name string
if m.name != "" {
name = " (" + m.name + ")"
}
return "local history" + name
}
func (m *Menu) errorFilteredCommandTemplate(filters []string) string {
if m.errFilteredTemplate != "" {
return m.errFilteredTemplate
}
return `Command {{.cmd.Name}} is only available for: {{range .filters }}
- {{.}} {{end}}`
}