bare-tui 0.0.0 → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +13 -0
- package/README.md +244 -0
- package/ansi.js +34 -0
- package/commands.js +51 -0
- package/components/autocomplete.js +208 -0
- package/components/filepicker.js +232 -0
- package/components/help.js +117 -0
- package/components/list.js +172 -0
- package/components/paginator.js +111 -0
- package/components/progress.js +78 -0
- package/components/spinner.js +70 -0
- package/components/stopwatch.js +88 -0
- package/components/table.js +130 -0
- package/components/textarea.js +279 -0
- package/components/textinput.js +135 -0
- package/components/timer.js +91 -0
- package/components/viewport.js +104 -0
- package/docs/autocomplete.md +80 -0
- package/docs/filepicker.md +80 -0
- package/docs/help.md +45 -0
- package/docs/list.md +47 -0
- package/docs/paginator.md +43 -0
- package/docs/progress.md +36 -0
- package/docs/spinner.md +54 -0
- package/docs/stopwatch.md +52 -0
- package/docs/table.md +52 -0
- package/docs/textarea.md +43 -0
- package/docs/textinput.md +51 -0
- package/docs/timer.md +57 -0
- package/docs/viewport.md +39 -0
- package/index.d.ts +1 -0
- package/index.js +63 -0
- package/key.js +29 -0
- package/messages.js +62 -0
- package/mouse.js +102 -0
- package/package.json +59 -1
- package/program.js +313 -0
- package/renderer.js +67 -0
- package/style.js +469 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// autocomplete — a single-line input with a filtered suggestion menu.
|
|
2
|
+
//
|
|
3
|
+
// A composition over textinput (the same pattern list uses for its filter): it
|
|
4
|
+
// folds keys into an embedded field, then offers a menu of suggestions that
|
|
5
|
+
// match what's been typed. Typical use is a slash-command palette — the menu
|
|
6
|
+
// opens only once the line begins with a trigger character (default '/'), so
|
|
7
|
+
// ordinary prose typed into the same field never pops a menu.
|
|
8
|
+
//
|
|
9
|
+
// const ac = autocomplete.create({
|
|
10
|
+
// prompt: '> ',
|
|
11
|
+
// placeholder: 'message, or / for commands',
|
|
12
|
+
// suggestions: [
|
|
13
|
+
// { name: 'help', desc: 'show help' },
|
|
14
|
+
// { name: 'clear', desc: 'clear the screen' }
|
|
15
|
+
// ]
|
|
16
|
+
// }).focus()
|
|
17
|
+
//
|
|
18
|
+
// The component never consumes enter — the parent decides what it means. A
|
|
19
|
+
// common pattern is "enter submits the line, but the highlighted command when
|
|
20
|
+
// the menu is open", which both completes and runs in one keystroke:
|
|
21
|
+
//
|
|
22
|
+
// if (key.matches(msg, 'enter')) {
|
|
23
|
+
// const s = ac.open && ac.selectedSuggestion()
|
|
24
|
+
// return [this, submit(s ? '/' + s.name : ac.value)]
|
|
25
|
+
// }
|
|
26
|
+
// const [f, cmd] = ac.update(msg); this.input = f; return [this, cmd]
|
|
27
|
+
//
|
|
28
|
+
// While the menu is open ↑/↓ move the highlight, tab accepts it (completing the
|
|
29
|
+
// line), and esc dismisses it; every other key edits the field. The view()
|
|
30
|
+
// draws the input line only — call menuView() to render the dropdown wherever
|
|
31
|
+
// it fits (above a bottom-anchored prompt, below a top one).
|
|
32
|
+
const key = require('../key')
|
|
33
|
+
const ansi = require('../ansi')
|
|
34
|
+
const { style } = require('../style')
|
|
35
|
+
const textinput = require('./textinput')
|
|
36
|
+
|
|
37
|
+
const dim = (s) => ansi.modifierDim + s + ansi.modifierReset
|
|
38
|
+
|
|
39
|
+
const keys = {
|
|
40
|
+
up: key.binding({ keys: ['up', 'ctrl+p'], help: { key: '↑', desc: 'prev' } }),
|
|
41
|
+
down: key.binding({ keys: ['down', 'ctrl+n'], help: { key: '↓', desc: 'next' } }),
|
|
42
|
+
accept: key.binding({ keys: ['tab'], help: { key: 'tab', desc: 'accept' } }),
|
|
43
|
+
dismiss: key.binding({ keys: ['esc'], help: { key: 'esc', desc: 'dismiss' } })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Normalise a suggestion to { name, desc }. Bare strings become name-only.
|
|
47
|
+
function normalize(s) {
|
|
48
|
+
if (typeof s === 'string') return { name: s, desc: '' }
|
|
49
|
+
return { name: String(s.name ?? ''), desc: String(s.desc ?? '') }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class Autocomplete {
|
|
53
|
+
constructor(opts = {}) {
|
|
54
|
+
this.trigger = opts.trigger ?? '/'
|
|
55
|
+
this.suggestions = (opts.suggestions || []).map(normalize)
|
|
56
|
+
this.maxVisible = opts.maxVisible || 6
|
|
57
|
+
this.width = opts.width || 0 // 0 = size the menu to its content
|
|
58
|
+
|
|
59
|
+
this.input = textinput.create({
|
|
60
|
+
value: opts.value || '',
|
|
61
|
+
placeholder: opts.placeholder || '',
|
|
62
|
+
prompt: opts.prompt || '',
|
|
63
|
+
focused: !!opts.focused
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
this.selected = 0 // highlight within the current matches
|
|
67
|
+
this.dismissed = false // esc / accept hides the menu until the value changes
|
|
68
|
+
this._lastValue = this.input.value
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get value() {
|
|
72
|
+
return this.input.value
|
|
73
|
+
}
|
|
74
|
+
get focused() {
|
|
75
|
+
return this.input.focused
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
focus() {
|
|
79
|
+
this.input.focus()
|
|
80
|
+
return this
|
|
81
|
+
}
|
|
82
|
+
blur() {
|
|
83
|
+
this.input.blur()
|
|
84
|
+
return this
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
setValue(v) {
|
|
88
|
+
this.input.setValue(v)
|
|
89
|
+
this._lastValue = this.input.value
|
|
90
|
+
return this
|
|
91
|
+
}
|
|
92
|
+
reset() {
|
|
93
|
+
this.input.reset()
|
|
94
|
+
this.selected = 0
|
|
95
|
+
this.dismissed = false
|
|
96
|
+
this._lastValue = ''
|
|
97
|
+
return this
|
|
98
|
+
}
|
|
99
|
+
setSuggestions(list) {
|
|
100
|
+
this.suggestions = (list || []).map(normalize)
|
|
101
|
+
return this
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The suggestions matching the typed text, or [] when no menu is warranted.
|
|
105
|
+
// A menu is warranted once the line begins with the trigger; the text after
|
|
106
|
+
// it is a case-insensitive prefix filter over suggestion names.
|
|
107
|
+
matches() {
|
|
108
|
+
const v = this.input.value
|
|
109
|
+
if (!v.startsWith(this.trigger)) return []
|
|
110
|
+
const typed = v.slice(this.trigger.length).toLowerCase()
|
|
111
|
+
return this.suggestions.filter((s) => s.name.toLowerCase().startsWith(typed))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Whether the dropdown is currently showing.
|
|
115
|
+
get open() {
|
|
116
|
+
return this.input.focused && !this.dismissed && this.matches().length > 0
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The highlighted suggestion, or null.
|
|
120
|
+
selectedSuggestion() {
|
|
121
|
+
const m = this.matches()
|
|
122
|
+
return m.length ? m[Math.min(this.selected, m.length - 1)] : null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Complete the line to the highlighted suggestion and close the menu.
|
|
126
|
+
accept() {
|
|
127
|
+
const s = this.selectedSuggestion()
|
|
128
|
+
if (!s) return this
|
|
129
|
+
this.input.setValue(this.trigger + s.name + ' ')
|
|
130
|
+
this.input.cursor = this.input.value.length
|
|
131
|
+
this.dismissed = true
|
|
132
|
+
this._lastValue = this.input.value
|
|
133
|
+
return this
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
update(msg) {
|
|
137
|
+
if (!msg || msg.type !== 'key' || !this.input.focused) return [this, null]
|
|
138
|
+
|
|
139
|
+
// Menu navigation takes priority over field editing while it's open.
|
|
140
|
+
if (this.open) {
|
|
141
|
+
if (key.matches(msg, keys.up)) return [this._move(-1), null]
|
|
142
|
+
if (key.matches(msg, keys.down)) return [this._move(1), null]
|
|
143
|
+
if (key.matches(msg, keys.accept)) return [this.accept(), null]
|
|
144
|
+
if (key.matches(msg, keys.dismiss)) {
|
|
145
|
+
this.dismissed = true
|
|
146
|
+
return [this, null]
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const [input, cmd] = this.input.update(msg)
|
|
151
|
+
this.input = input
|
|
152
|
+
// Any edit to the text re-opens the menu and re-clamps the highlight.
|
|
153
|
+
if (this.input.value !== this._lastValue) {
|
|
154
|
+
this.dismissed = false
|
|
155
|
+
this._lastValue = this.input.value
|
|
156
|
+
this.selected = 0
|
|
157
|
+
}
|
|
158
|
+
return [this, cmd]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
_move(dir) {
|
|
162
|
+
const n = this.matches().length
|
|
163
|
+
if (n) this.selected = (this.selected + dir + n) % n
|
|
164
|
+
return this
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
view() {
|
|
168
|
+
return this.input.view()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The dropdown, or '' when closed. Rows are `/name desc`, the highlight in
|
|
172
|
+
// reverse video; a footer notes any matches scrolled out of view.
|
|
173
|
+
menuView() {
|
|
174
|
+
if (!this.open) return ''
|
|
175
|
+
const all = this.matches()
|
|
176
|
+
const sel = Math.min(this.selected, all.length - 1)
|
|
177
|
+
|
|
178
|
+
// Keep the highlight inside a maxVisible-row window.
|
|
179
|
+
const start = Math.max(0, Math.min(sel - this.maxVisible + 1, all.length - this.maxVisible))
|
|
180
|
+
const top = all.length > this.maxVisible ? Math.max(0, start) : 0
|
|
181
|
+
const shown = all.slice(top, top + this.maxVisible)
|
|
182
|
+
|
|
183
|
+
const nameW = Math.max(...all.map((s) => (this.trigger + s.name).length))
|
|
184
|
+
const rows = shown.map((s, i) => {
|
|
185
|
+
const isSel = top + i === sel
|
|
186
|
+
const label = (this.trigger + s.name).padEnd(nameW)
|
|
187
|
+
const text = s.desc ? label + ' ' + s.desc : label
|
|
188
|
+
if (isSel) {
|
|
189
|
+
return style()
|
|
190
|
+
.foreground('black')
|
|
191
|
+
.background('magenta')
|
|
192
|
+
.render(' ' + text + ' ')
|
|
193
|
+
}
|
|
194
|
+
return ' ' + style().foreground('magenta').render(label) + (s.desc ? ' ' + dim(s.desc) : '')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
if (all.length > shown.length) {
|
|
198
|
+
rows.push(dim(` …${all.length - shown.length} more`))
|
|
199
|
+
}
|
|
200
|
+
return rows.join('\n')
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function create(opts) {
|
|
205
|
+
return new Autocomplete(opts)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = { create, Autocomplete, keys }
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// filepicker — browse a filesystem and pick a file.
|
|
2
|
+
//
|
|
3
|
+
// Dependency-injected so the framework core stays filesystem-free: pass your
|
|
4
|
+
// own { fs, path } to create(), or let it lazily require('bare-fs') /
|
|
5
|
+
// require('bare-path') — that require only runs when you actually construct a
|
|
6
|
+
// filepicker, so consumers who don't use it never pull those modules in.
|
|
7
|
+
//
|
|
8
|
+
// const fp = filepicker.create({ height: 12 }) // real fs
|
|
9
|
+
// const fp = filepicker.create({ ...filepicker.mock(tree), cwd: '/' }) // tests
|
|
10
|
+
//
|
|
11
|
+
// Directory reads happen through Cmds (async), surfacing as Msgs:
|
|
12
|
+
// { type: 'filepicker.entries', dir, entries }
|
|
13
|
+
// { type: 'filepicker.error', dir, error }
|
|
14
|
+
// { type: 'filepicker.select', path } // a file was chosen
|
|
15
|
+
//
|
|
16
|
+
// The only fs surface used is fs.readdir(dir, { withFileTypes: true }, cb); the
|
|
17
|
+
// only path surface is path.join and path.dirname. The mock implements exactly
|
|
18
|
+
// that, so tests need no real I/O.
|
|
19
|
+
const key = require('../key')
|
|
20
|
+
const { style } = require('../style')
|
|
21
|
+
|
|
22
|
+
const dirStyle = (s) => style().foreground('cyan').render(s)
|
|
23
|
+
const selectedStyle = (s) => style().reverse(true).render(s)
|
|
24
|
+
const dim = (s) => style().faint(true).render(s)
|
|
25
|
+
|
|
26
|
+
const keys = {
|
|
27
|
+
up: key.binding({ keys: ['up', 'k'], help: { key: '↑/k', desc: 'up' } }),
|
|
28
|
+
down: key.binding({ keys: ['down', 'j'], help: { key: '↓/j', desc: 'down' } }),
|
|
29
|
+
open: key.binding({ keys: ['enter', 'right', 'l'], help: { key: '↵', desc: 'open' } }),
|
|
30
|
+
back: key.binding({ keys: ['backspace', 'left', 'h'], help: { key: '⌫', desc: 'up dir' } })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function listDir(fs, dir) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
fs.readdir(dir, { withFileTypes: true }, (err, ents) => {
|
|
36
|
+
if (err) return reject(err)
|
|
37
|
+
resolve(ents.map((e) => ({ name: e.name, directory: e.isDirectory() })))
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sortEntries(entries, showHidden) {
|
|
43
|
+
return entries
|
|
44
|
+
.filter((e) => showHidden || !e.name.startsWith('.'))
|
|
45
|
+
.sort((a, b) => {
|
|
46
|
+
if (a.directory !== b.directory) return a.directory ? -1 : 1
|
|
47
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class FilePicker {
|
|
52
|
+
constructor(opts = {}) {
|
|
53
|
+
this.fs = opts.fs
|
|
54
|
+
this.path = opts.path
|
|
55
|
+
this.cwd = opts.cwd
|
|
56
|
+
this.height = opts.height || 12
|
|
57
|
+
this.showHidden = !!opts.showHidden
|
|
58
|
+
|
|
59
|
+
this.entries = []
|
|
60
|
+
this.cursor = 0
|
|
61
|
+
this.offset = 0
|
|
62
|
+
this.selected = null
|
|
63
|
+
this.error = null
|
|
64
|
+
this.loading = true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
init() {
|
|
68
|
+
return this._read(this.cwd)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
selectedPath() {
|
|
72
|
+
return this.selected
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A Cmd that reads `dir` and resolves to an entries (or error) Msg.
|
|
76
|
+
_read(dir) {
|
|
77
|
+
const fs = this.fs
|
|
78
|
+
const showHidden = this.showHidden
|
|
79
|
+
return () =>
|
|
80
|
+
listDir(fs, dir).then(
|
|
81
|
+
(entries) => ({
|
|
82
|
+
type: 'filepicker.entries',
|
|
83
|
+
dir,
|
|
84
|
+
entries: sortEntries(entries, showHidden)
|
|
85
|
+
}),
|
|
86
|
+
(err) => ({
|
|
87
|
+
type: 'filepicker.error',
|
|
88
|
+
dir,
|
|
89
|
+
error: (err && err.message) || String(err)
|
|
90
|
+
})
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
update(msg) {
|
|
95
|
+
if (!msg) return [this, null]
|
|
96
|
+
|
|
97
|
+
if (msg.type === 'filepicker.entries' && msg.dir === this.cwd) {
|
|
98
|
+
this.entries = msg.entries
|
|
99
|
+
this.cursor = 0
|
|
100
|
+
this.offset = 0
|
|
101
|
+
this.loading = false
|
|
102
|
+
this.error = null
|
|
103
|
+
return [this, null]
|
|
104
|
+
}
|
|
105
|
+
if (msg.type === 'filepicker.error' && msg.dir === this.cwd) {
|
|
106
|
+
this.error = msg.error
|
|
107
|
+
this.entries = []
|
|
108
|
+
this.loading = false
|
|
109
|
+
return [this, null]
|
|
110
|
+
}
|
|
111
|
+
if (msg.type === 'key') return this._key(msg)
|
|
112
|
+
return [this, null]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_key(msg) {
|
|
116
|
+
if (key.matches(msg, keys.up)) this._move(-1)
|
|
117
|
+
else if (key.matches(msg, keys.down)) this._move(1)
|
|
118
|
+
else if (key.matches(msg, keys.back)) return this._open(this.path.dirname(this.cwd))
|
|
119
|
+
else if (key.matches(msg, keys.open)) {
|
|
120
|
+
const entry = this.entries[this.cursor]
|
|
121
|
+
if (!entry) return [this, null]
|
|
122
|
+
const full = this.path.join(this.cwd, entry.name)
|
|
123
|
+
if (entry.directory) return this._open(full)
|
|
124
|
+
this.selected = full
|
|
125
|
+
return [this, () => ({ type: 'filepicker.select', path: full })]
|
|
126
|
+
}
|
|
127
|
+
return [this, null]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
_open(dir) {
|
|
131
|
+
this.cwd = dir
|
|
132
|
+
this.loading = true
|
|
133
|
+
return [this, this._read(dir)]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
_move(delta) {
|
|
137
|
+
if (!this.entries.length) return
|
|
138
|
+
this.cursor = Math.max(0, Math.min(this.cursor + delta, this.entries.length - 1))
|
|
139
|
+
if (this.cursor < this.offset) this.offset = this.cursor
|
|
140
|
+
else if (this.cursor >= this.offset + this.height) {
|
|
141
|
+
this.offset = this.cursor - this.height + 1
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
view() {
|
|
146
|
+
const out = [
|
|
147
|
+
style()
|
|
148
|
+
.bold(true)
|
|
149
|
+
.render(this.cwd || '')
|
|
150
|
+
]
|
|
151
|
+
const rows = []
|
|
152
|
+
|
|
153
|
+
if (this.loading) rows.push(dim(' loading…'))
|
|
154
|
+
else if (this.error) rows.push(dim(' ⚠ ' + this.error))
|
|
155
|
+
else if (!this.entries.length) rows.push(dim(' (empty)'))
|
|
156
|
+
else {
|
|
157
|
+
const end = Math.min(this.offset + this.height, this.entries.length)
|
|
158
|
+
for (let p = this.offset; p < end; p++) {
|
|
159
|
+
const entry = this.entries[p]
|
|
160
|
+
const label = entry.directory ? entry.name + '/' : entry.name
|
|
161
|
+
const text = (p === this.cursor ? '› ' : ' ') + label
|
|
162
|
+
rows.push(p === this.cursor ? selectedStyle(text) : entry.directory ? dirStyle(text) : text)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
while (rows.length < this.height) rows.push('')
|
|
166
|
+
|
|
167
|
+
return out.concat(rows).join('\n')
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function create(opts = {}) {
|
|
172
|
+
// Lazy require: only consumers that build a filepicker load bare-fs/bare-path.
|
|
173
|
+
const fs = opts.fs || require('bare-fs')
|
|
174
|
+
const path = opts.path || require('bare-path')
|
|
175
|
+
const cwd = opts.cwd || path.resolve('.')
|
|
176
|
+
return new FilePicker({ ...opts, fs, path, cwd })
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── mock filesystem ──────────────────────────────────────────────────────
|
|
180
|
+
//
|
|
181
|
+
// filepicker.mock(tree) returns { fs, path, root } backed by a plain object:
|
|
182
|
+
// keys are entry names, an object value is a directory, anything else a file.
|
|
183
|
+
//
|
|
184
|
+
// const m = filepicker.mock({ docs: { 'a.md': null }, 'readme.txt': null })
|
|
185
|
+
// const fp = filepicker.create({ fs: m.fs, path: m.path, cwd: m.root })
|
|
186
|
+
|
|
187
|
+
const mockPath = {
|
|
188
|
+
sep: '/',
|
|
189
|
+
join: (...parts) => parts.join('/').replace(/\/{2,}/g, '/') || '/',
|
|
190
|
+
dirname: (p) => {
|
|
191
|
+
const segs = p.replace(/\/+$/, '').split('/')
|
|
192
|
+
segs.pop()
|
|
193
|
+
const d = segs.join('/')
|
|
194
|
+
return d === '' ? '/' : d
|
|
195
|
+
},
|
|
196
|
+
basename: (p) => p.replace(/\/+$/, '').split('/').pop() || '/',
|
|
197
|
+
resolve: (p) => p
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function resolveNode(tree, root, p) {
|
|
201
|
+
if (p === root) return tree
|
|
202
|
+
let rel = p
|
|
203
|
+
if (root !== '/' && p.startsWith(root)) rel = p.slice(root.length)
|
|
204
|
+
const segs = rel.split('/').filter(Boolean)
|
|
205
|
+
let node = tree
|
|
206
|
+
for (const s of segs) {
|
|
207
|
+
if (node && typeof node === 'object' && s in node) node = node[s]
|
|
208
|
+
else return undefined
|
|
209
|
+
}
|
|
210
|
+
return node
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function mock(tree, opts = {}) {
|
|
214
|
+
const root = opts.root || '/'
|
|
215
|
+
const fs = {
|
|
216
|
+
readdir(dir, options, cb) {
|
|
217
|
+
if (typeof options === 'function') cb = options
|
|
218
|
+
const node = resolveNode(tree, root, dir)
|
|
219
|
+
if (!node || typeof node !== 'object') {
|
|
220
|
+
return cb(new Error('ENOTDIR: ' + dir))
|
|
221
|
+
}
|
|
222
|
+
const ents = Object.keys(node).map((name) => {
|
|
223
|
+
const isDir = !!(node[name] && typeof node[name] === 'object')
|
|
224
|
+
return { name, isDirectory: () => isDir }
|
|
225
|
+
})
|
|
226
|
+
cb(null, ents)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { fs, path: mockPath, root }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = { create, FilePicker, mock }
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// help — renders keybinding hints from key.binding({ keys, help }) objects.
|
|
2
|
+
//
|
|
3
|
+
// Unlike the other components this one is a *view helper*, not a loop model:
|
|
4
|
+
// its view(keymap) takes the bindings to show (mirroring bubbles/help, where
|
|
5
|
+
// help.View(keymap) is called from the parent's View). Bindings without a
|
|
6
|
+
// `help` entry are skipped, so internal keys stay hidden.
|
|
7
|
+
//
|
|
8
|
+
// const h = help.create()
|
|
9
|
+
// h.view([keys.up, keys.down, keys.quit]) // short: one line
|
|
10
|
+
// h.showAll = true
|
|
11
|
+
// h.view([[keys.up, keys.down], [keys.quit]]) // full: aligned columns
|
|
12
|
+
//
|
|
13
|
+
// A keymap can be an array of bindings, an object of bindings (values are
|
|
14
|
+
// used), or an object exposing shortHelp()/fullHelp() for full control.
|
|
15
|
+
const { style, width, truncate } = require('../style')
|
|
16
|
+
|
|
17
|
+
const defaultStyles = {
|
|
18
|
+
key: (s) => s,
|
|
19
|
+
desc: (s) => style().faint(true).render(s),
|
|
20
|
+
sep: (s) => style().faint(true).render(s)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toBindings(x) {
|
|
24
|
+
if (!x) return []
|
|
25
|
+
return Array.isArray(x) ? x : Object.values(x)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Resolve a keymap into the binding list (short) or list-of-columns (full).
|
|
29
|
+
function resolve(keymap, showAll) {
|
|
30
|
+
if (Array.isArray(keymap)) {
|
|
31
|
+
if (!showAll) return keymap
|
|
32
|
+
// Full mode: an array of arrays is already columns; a flat list is one.
|
|
33
|
+
return keymap.length && Array.isArray(keymap[0]) ? keymap : [keymap]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (showAll) {
|
|
37
|
+
if (typeof keymap.fullHelp === 'function') return keymap.fullHelp()
|
|
38
|
+
if (keymap.full) return keymap.full
|
|
39
|
+
const short =
|
|
40
|
+
typeof keymap.shortHelp === 'function'
|
|
41
|
+
? keymap.shortHelp()
|
|
42
|
+
: keymap.short || toBindings(keymap)
|
|
43
|
+
return [short]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (typeof keymap.shortHelp === 'function') return keymap.shortHelp()
|
|
47
|
+
if (keymap.short) return keymap.short
|
|
48
|
+
return toBindings(keymap)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function helpful(binding) {
|
|
52
|
+
return binding && binding.help && binding.help.key
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class Help {
|
|
56
|
+
constructor(opts = {}) {
|
|
57
|
+
this.width = opts.width || 0 // 0 = no truncation
|
|
58
|
+
this.showAll = !!opts.showAll
|
|
59
|
+
this.separator = opts.separator || ' • '
|
|
60
|
+
this.styles = { ...defaultStyles, ...(opts.styles || {}) }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
setWidth(n) {
|
|
64
|
+
this.width = n
|
|
65
|
+
return this
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
view(keymap) {
|
|
69
|
+
if (this.showAll) {
|
|
70
|
+
const columns = resolve(keymap, true)
|
|
71
|
+
.map((group) => this._column(group))
|
|
72
|
+
.filter((c) => c.length)
|
|
73
|
+
if (!columns.length) return ''
|
|
74
|
+
|
|
75
|
+
const blocks = []
|
|
76
|
+
columns.forEach((c, i) => {
|
|
77
|
+
if (i) blocks.push(' ') // gutter between columns
|
|
78
|
+
blocks.push(c)
|
|
79
|
+
})
|
|
80
|
+
return style.joinHorizontal(style.position.top, ...blocks)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return this._short(resolve(keymap, false))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_short(bindings) {
|
|
87
|
+
const items = bindings.filter(helpful)
|
|
88
|
+
if (!items.length) return ''
|
|
89
|
+
|
|
90
|
+
const sep = this.styles.sep(this.separator)
|
|
91
|
+
let line = items
|
|
92
|
+
.map((b) => this.styles.key(b.help.key) + ' ' + this.styles.desc(b.help.desc))
|
|
93
|
+
.join(sep)
|
|
94
|
+
|
|
95
|
+
if (this.width > 0 && width(line) > this.width) line = truncate(line, this.width)
|
|
96
|
+
return line
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_column(bindings) {
|
|
100
|
+
const items = bindings.filter(helpful)
|
|
101
|
+
if (!items.length) return ''
|
|
102
|
+
|
|
103
|
+
const keyW = Math.max(...items.map((b) => width(b.help.key)))
|
|
104
|
+
return items
|
|
105
|
+
.map((b) => {
|
|
106
|
+
const gap = ' '.repeat(keyW - width(b.help.key) + 2)
|
|
107
|
+
return this.styles.key(b.help.key) + gap + this.styles.desc(b.help.desc)
|
|
108
|
+
})
|
|
109
|
+
.join('\n')
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function create(opts) {
|
|
114
|
+
return new Help(opts)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { create, Help }
|