heroku-dash 0.1.0
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 +21 -0
- package/README.md +230 -0
- package/oclif.manifest.json +105 -0
- package/package.json +37 -0
- package/src/api.js +128 -0
- package/src/commands/dash.js +47 -0
- package/src/demo.js +31 -0
- package/src/hierarchy.js +27 -0
- package/src/project.js +72 -0
- package/src/ui/dashboard.js +638 -0
- package/src/ui/details.js +34 -0
- package/src/ui/text.js +8 -0
- package/src/ui/theme.js +86 -0
- package/src/ui/views.js +127 -0
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import blessed from 'blessed'
|
|
2
|
+
import clipboard from 'clipboardy'
|
|
3
|
+
import {spawn} from 'node:child_process'
|
|
4
|
+
import {errorMessage} from '../api.js'
|
|
5
|
+
import {resolveHierarchy} from '../hierarchy.js'
|
|
6
|
+
import {appRows, clean, single, sortApps, STAGES, TABS} from './views.js'
|
|
7
|
+
import {detailContent, isValueClick} from './details.js'
|
|
8
|
+
import {badge, icons, paint, palette, rowLabel, SCANNER_INTERVAL, scannerFrame, shortcut, stageStyles, tabIcons} from './theme.js'
|
|
9
|
+
|
|
10
|
+
const SIDEBAR_WIDTH = '22%'
|
|
11
|
+
const frame = () => ({border: {type: 'line'}, style: {fg: palette.fg, bg: palette.bg, border: {fg: palette.border}, focus: {border: {fg: palette.accent}}}})
|
|
12
|
+
|
|
13
|
+
export class Dashboard {
|
|
14
|
+
constructor({api, catalog, context, refresh = 30, demo = false, screen, writeClipboard = clipboard.write}) {
|
|
15
|
+
Object.assign(this, {api, catalog, context, refresh, demo, writeClipboard})
|
|
16
|
+
this.screen = screen ?? blessed.screen({smartCSR: true, fullUnicode: true, title: 'heroku dash', dockBorders: true, autoPadding: true})
|
|
17
|
+
this.tab = 0
|
|
18
|
+
this.mode = 'pipelines'
|
|
19
|
+
this.team = context.team ?? null
|
|
20
|
+
this.breadcrumbTeam = null
|
|
21
|
+
this.pipeline = context.pipeline ?? null
|
|
22
|
+
this.app = null
|
|
23
|
+
this.rows = []
|
|
24
|
+
this.updatingRows = false
|
|
25
|
+
this.navItems = []
|
|
26
|
+
this.generation = 0
|
|
27
|
+
this.navGeneration = 0
|
|
28
|
+
this.config = null
|
|
29
|
+
this.revealed = new Set()
|
|
30
|
+
this.copying = false
|
|
31
|
+
this.busy = false
|
|
32
|
+
this.loading = new Map()
|
|
33
|
+
this.loadingFrame = 0
|
|
34
|
+
this.loadingTimer = null
|
|
35
|
+
this.closed = false
|
|
36
|
+
this.filter = ''
|
|
37
|
+
this.message = context.reason
|
|
38
|
+
this.messageTone = 'muted'
|
|
39
|
+
this.widgets()
|
|
40
|
+
this.bindings()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
widgets() {
|
|
44
|
+
const parent = this.screen
|
|
45
|
+
this.header = blessed.box({parent, top: 0, height: 3, left: 0, right: 0, padding: {left: 2}, tags: false, style: {fg: palette.fg, bg: palette.panel}})
|
|
46
|
+
this.nav = blessed.list({parent, top: 3, bottom: 4, left: 0, width: SIDEBAR_WIDTH, ...frame(), label: ` ${icons.pipelines} Pipelines `, keys: true, mouse: true, tags: false,
|
|
47
|
+
scrollbar: {ch: '│', style: {bg: palette.border}}, style: {...frame().style, selected: {bg: palette.selected, fg: 'white', bold: true}, item: {fg: palette.fg}}})
|
|
48
|
+
this.tabs = blessed.box({parent, top: 3, height: 3, left: SIDEBAR_WIDTH, right: 0, ...frame(), padding: {left: 1}, style: {...frame().style, fg: palette.accent}})
|
|
49
|
+
this.summary = blessed.box({parent, top: 6, height: 5, left: SIDEBAR_WIDTH, right: 0, padding: {left: 2, right: 1}, style: {fg: palette.fg, bg: palette.bg}})
|
|
50
|
+
this.main = blessed.list({parent, top: 11, height: '40%-4', left: SIDEBAR_WIDTH, right: 0, ...frame(), label: ` ${icons.apps} Apps `, keys: true, mouse: true, tags: false,
|
|
51
|
+
scrollbar: {ch: '│', style: {bg: palette.border}}, style: {...frame().style, selected: {bg: palette.selected, fg: 'white'}, item: {fg: palette.fg}}})
|
|
52
|
+
this.detail = blessed.box({parent, top: '40%+7', bottom: 4, left: SIDEBAR_WIDTH, right: 0, ...frame(), label: ` ${icons.overview} Details `, padding: {left: 1, right: 1}, scrollable: true, alwaysScroll: true, keys: true, vi: true, mouse: true, tags: false,
|
|
53
|
+
scrollbar: {ch: '│', style: {bg: palette.border}}})
|
|
54
|
+
this.status = blessed.box({parent, bottom: 2, height: 2, left: 0, right: 0, padding: {left: 1}, tags: false, style: {fg: palette.muted, bg: palette.bg}})
|
|
55
|
+
this.footer = blessed.box({parent, bottom: 0, height: 2, left: 0, right: 0, padding: {left: 1}, tags: false, style: {fg: palette.fg, bg: palette.panel},
|
|
56
|
+
content: `${[['t', 'teams'], ['p', 'pipelines'], ['a', 'apps'], ['/', 'filter'], ['Enter', 'open'], ['Esc', 'back'], ['Tab', 'focus']].map(([key, text]) => shortcut(key, text)).join(' ')}\n${[['j/k', 'move'], ['1–7 / [ ] / h l', 'views'], ['R', 'refresh'], ['o', 'browser'], ['?', 'help'], ['q', 'quit']].map(([key, text]) => shortcut(key, text)).join(' ')}`})
|
|
57
|
+
this.small = blessed.box({parent, top: 0, left: 0, right: 0, bottom: 0, hidden: true, style: {fg: palette.fg, bg: palette.bg}, valign: 'middle', align: 'center', content: 'heroku dash\n\nPlease resize your terminal to at least 80 × 24.\n\nq / Ctrl-C to quit'})
|
|
58
|
+
this.screen.on('resize', () => this.render())
|
|
59
|
+
this.screen.once('destroy', () => this.close())
|
|
60
|
+
this.main.on('select item', () => {
|
|
61
|
+
if (this.updatingRows) return
|
|
62
|
+
this.drawDetail()
|
|
63
|
+
})
|
|
64
|
+
this.main.on('select', item => {
|
|
65
|
+
if (this.modal) return
|
|
66
|
+
const selected = this.rows[this.main.getItemIndex(item)]
|
|
67
|
+
if (selected?.kind === 'app') void this.openApp(selected.value, this.pipeline)
|
|
68
|
+
})
|
|
69
|
+
this.detail.on('click', mouse => {
|
|
70
|
+
if (this.closed || this.modal || this.small.visible) return
|
|
71
|
+
const row = this.rows[this.main.selected]
|
|
72
|
+
if (this.revealed.has(row?.key) && isValueClick(this.detail, row, mouse)) void this.copyConfig()
|
|
73
|
+
})
|
|
74
|
+
this.nav.on('select', item => {
|
|
75
|
+
if (this.modal) return
|
|
76
|
+
const selected = this.navItems[this.nav.getItemIndex(item)]
|
|
77
|
+
if (selected) void this.navigate(selected)
|
|
78
|
+
})
|
|
79
|
+
this.nav.focus()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
bindings() {
|
|
83
|
+
// Bind list movement explicitly: Blessed's vi mode also treats l as Enter,
|
|
84
|
+
// which would open a sidebar item while switching to the next app view.
|
|
85
|
+
for (const list of [this.nav, this.main]) {
|
|
86
|
+
list.key(['j'], () => { list.down(); this.render() })
|
|
87
|
+
list.key(['k'], () => { list.up(); this.render() })
|
|
88
|
+
}
|
|
89
|
+
const key = (keys, action) => this.screen.key(keys, (...args) => {
|
|
90
|
+
if (!this.modal && !this.closed) action(...args)
|
|
91
|
+
})
|
|
92
|
+
this.screen.key(['C-c'], () => this.close())
|
|
93
|
+
this.screen.key(['C-l'], () => { this.screen.realloc(); this.render() })
|
|
94
|
+
key(['q'], () => this.close())
|
|
95
|
+
key(['tab'], () => {
|
|
96
|
+
const panes = [this.nav, this.main, this.detail]
|
|
97
|
+
panes[(panes.indexOf(this.screen.focused) + 1) % panes.length].focus()
|
|
98
|
+
this.render()
|
|
99
|
+
})
|
|
100
|
+
key(['S-tab'], () => {
|
|
101
|
+
const panes = [this.nav, this.main, this.detail]
|
|
102
|
+
panes[(panes.indexOf(this.screen.focused) + 2) % panes.length].focus()
|
|
103
|
+
this.render()
|
|
104
|
+
})
|
|
105
|
+
key(['t'], () => this.setMode('teams'))
|
|
106
|
+
key(['p'], () => this.setMode('pipelines'))
|
|
107
|
+
key(['a'], () => this.setMode('apps'))
|
|
108
|
+
key(['/'], () => void this.filterNav())
|
|
109
|
+
key(['escape'], () => void this.back())
|
|
110
|
+
key(['R'], () => void this.reload())
|
|
111
|
+
key(['[', 'left', 'h'], () => this.changeTab((this.tab + TABS.length - 1) % TABS.length))
|
|
112
|
+
key([']', 'right', 'l'], () => this.changeTab((this.tab + 1) % TABS.length))
|
|
113
|
+
for (let i = 0; i < TABS.length; i++) key([String(i + 1)], () => this.changeTab(i))
|
|
114
|
+
key(['v'], () => {
|
|
115
|
+
const selected = this.rows[this.main.selected]
|
|
116
|
+
if (this.app && TABS[this.tab] === 'Config' && selected?.kind === 'config') {
|
|
117
|
+
if (this.revealed.has(selected.key)) this.revealed.delete(selected.key)
|
|
118
|
+
else this.revealed.add(selected.key)
|
|
119
|
+
this.drawApp()
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
key(['s'], () => void this.scale())
|
|
123
|
+
key(['y'], () => void this.copyConfig())
|
|
124
|
+
key(['e'], () => void this.editConfig(false))
|
|
125
|
+
key(['n'], () => void this.editConfig(true))
|
|
126
|
+
key(['d'], () => void this.deleteConfig())
|
|
127
|
+
key(['m'], () => void this.maintenance())
|
|
128
|
+
key(['o'], () => this.openBrowser())
|
|
129
|
+
key(['?'], () => this.help())
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async start() {
|
|
133
|
+
if (this.context.team) this.mode = 'pipelines'
|
|
134
|
+
this.drawNav()
|
|
135
|
+
if (this.context.app) await this.openApp(this.context.app, this.context.pipeline)
|
|
136
|
+
else if (this.pipeline) await this.openPipeline(this.pipeline)
|
|
137
|
+
else this.drawLanding()
|
|
138
|
+
const warnings = [...this.catalog.warnings, ...this.context.warnings ?? []]
|
|
139
|
+
if (warnings.length) this.setStatus(warnings.join(' | '), 'warning')
|
|
140
|
+
if (this.refresh && !this.closed) this.timer = setInterval(() => {
|
|
141
|
+
if (this.app && !this.modal && !this.busy && !this.closed) void this.loadApp(true)
|
|
142
|
+
}, this.refresh * 1000)
|
|
143
|
+
this.render()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
setStatus(message, tone = 'info') { this.message = single(message); this.messageTone = tone; this.render() }
|
|
147
|
+
|
|
148
|
+
beginLoading(key, label) {
|
|
149
|
+
if (this.closed) return () => {}
|
|
150
|
+
const operation = {label}
|
|
151
|
+
this.loading.set(key, operation)
|
|
152
|
+
this.syncLoadingAnimation()
|
|
153
|
+
this.render()
|
|
154
|
+
return () => {
|
|
155
|
+
// A superseded request must not clear the indicator for its replacement.
|
|
156
|
+
if (this.loading.get(key) !== operation) return
|
|
157
|
+
this.loading.delete(key)
|
|
158
|
+
this.syncLoadingAnimation()
|
|
159
|
+
this.render()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
syncLoadingAnimation() {
|
|
164
|
+
if (this.closed || !this.loading.size) {
|
|
165
|
+
clearInterval(this.loadingTimer)
|
|
166
|
+
this.loadingTimer = null
|
|
167
|
+
this.loadingFrame = 0
|
|
168
|
+
} else if (!this.loadingTimer) {
|
|
169
|
+
this.loadingTimer = setInterval(() => {
|
|
170
|
+
this.loadingFrame++
|
|
171
|
+
// Redraw only the status content: don't reset list selection, scroll
|
|
172
|
+
// position, or an input prompt while the user continues navigating.
|
|
173
|
+
this.drawStatus()
|
|
174
|
+
this.screen.render()
|
|
175
|
+
}, SCANNER_INTERVAL)
|
|
176
|
+
this.loadingTimer.unref()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
drawStatus() {
|
|
181
|
+
const current = [...this.loading.values()].at(-1)
|
|
182
|
+
if (current) {
|
|
183
|
+
this.status.setContent(`${scannerFrame(this.loadingFrame)} ${paint(single(current.label), 'info')}`)
|
|
184
|
+
} else {
|
|
185
|
+
const icon = {error: 'error', warning: 'warning', success: 'success', info: 'overview', muted: 'clock'}[this.messageTone]
|
|
186
|
+
this.status.setContent(badge(icon, this.message ?? '', this.messageTone))
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
render() {
|
|
191
|
+
if (this.closed) return
|
|
192
|
+
const team = this.app || this.pipeline ? this.breadcrumbTeam?.name ?? 'Loading team…' : this.team?.name
|
|
193
|
+
const pipeline = this.pipeline?.name ?? (this.app ? this.data ? this.data.errors.coupling ? 'Pipeline unavailable' : 'No pipeline' : 'Loading pipeline…' : null)
|
|
194
|
+
const scope = [['teams', team], ['pipelines', pipeline], ['apps', this.app?.name]]
|
|
195
|
+
.filter(([, name]) => name).map(([icon, name]) => badge(icon, name, 'fg')).join(` ${paint(icons.chevron, 'muted')} `)
|
|
196
|
+
this.header.setContent(`${paint(`${icons.heroku} HEROKU DASH`, 'accent', true)} ${this.demo ? `${badge('staging', 'DEMO', 'info')} ` : ''}${this.api.readOnly ? badge('lock', 'READ ONLY', 'info') : badge('globe', 'LIVE', 'success')}\n${scope || badge('globe', 'All accessible resources', 'muted')}`)
|
|
197
|
+
const tabs = compact => TABS.map((tab, i) => paint(i === this.tab ? `[${i + 1} ${icons[tabIcons[i]]} ${tab}]` : `${i + 1} ${icons[tabIcons[i]]}${compact ? '' : ` ${tab}`}`, i === this.tab ? 'accent' : 'muted', i === this.tab)).join(' ')
|
|
198
|
+
const fullTabs = tabs(false)
|
|
199
|
+
const compact = blessed.unicode.strWidth(clean(fullTabs)) > this.tabs.width - 4
|
|
200
|
+
this.tabs.setContent(this.app ? compact ? tabs(true) : fullTabs : `${badge('pipelines', 'PIPELINE WORKSPACE')} ${paint('· Enter an app', 'muted')}`)
|
|
201
|
+
this.drawStatus()
|
|
202
|
+
if (this.screen.width < 80 || this.screen.height < 24) { this.small.show(); this.small.setFront() }
|
|
203
|
+
else this.small.hide()
|
|
204
|
+
this.screen.render()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
setMode(mode) {
|
|
208
|
+
this.mode = mode
|
|
209
|
+
this.filter = ''
|
|
210
|
+
this.drawNav()
|
|
211
|
+
this.nav.focus()
|
|
212
|
+
this.render()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
drawNav() {
|
|
216
|
+
let items
|
|
217
|
+
if (this.mode === 'teams') items = [{name: 'All teams / personal', id: null}, ...this.catalog.teams]
|
|
218
|
+
else if (this.mode === 'pipelines') items = this.catalog.pipelines.filter(p => !this.team || p.owner?.id === this.team.id)
|
|
219
|
+
else items = this.catalog.apps.filter(a => !this.team || a.team?.id === this.team.id || a.team?.name === this.team.name)
|
|
220
|
+
this.navItems = items.filter(item => item.name.toLowerCase().includes(this.filter.toLowerCase()))
|
|
221
|
+
this.nav.setLabel(` ${icons[this.mode]} ${this.mode.toUpperCase()}${this.filter ? ` ${icons.search} ${single(this.filter)}` : ''} `)
|
|
222
|
+
const tone = {teams: 'info', pipelines: 'accent', apps: 'cyan'}[this.mode]
|
|
223
|
+
this.nav.setItems(this.navItems.length ? this.navItems.map(item => rowLabel({label: item.name, icon: item.id ? this.mode : 'globe', tone})) : [rowLabel({label: 'No matching items', icon: 'search', tone: 'muted'})])
|
|
224
|
+
const id = this.mode === 'pipelines' ? this.pipeline?.id : this.mode === 'apps' ? this.app?.id : this.team?.id
|
|
225
|
+
const index = this.navItems.findIndex(item => item.id === id)
|
|
226
|
+
this.nav.select(Math.max(0, index))
|
|
227
|
+
this.render()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async navigate(selected) {
|
|
231
|
+
if (this.mode === 'teams') {
|
|
232
|
+
this.team = selected.id ? selected : null
|
|
233
|
+
this.pipeline = null
|
|
234
|
+
this.clearApp()
|
|
235
|
+
this.mode = 'pipelines'
|
|
236
|
+
this.filter = ''
|
|
237
|
+
this.drawNav()
|
|
238
|
+
this.drawLanding()
|
|
239
|
+
this.setStatus(`Browsing ${this.team?.name ?? 'all teams and personal apps'}. Press a for apps.`)
|
|
240
|
+
} else if (this.mode === 'pipelines') await this.openPipeline(selected)
|
|
241
|
+
else await this.openApp(selected)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
clearApp() {
|
|
245
|
+
this.generation++
|
|
246
|
+
for (const key of ['app', 'pipeline', 'config']) this.loading.delete(key)
|
|
247
|
+
this.syncLoadingAnimation()
|
|
248
|
+
this.app = null
|
|
249
|
+
this.breadcrumbTeam = null
|
|
250
|
+
this.data = null
|
|
251
|
+
this.config = null
|
|
252
|
+
this.configError = null
|
|
253
|
+
this.revealed.clear()
|
|
254
|
+
this.busy = false
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
drawLanding() {
|
|
258
|
+
this.summary.setContent(`${badge('heroku', 'Your Heroku workspace')}\n\n${paint('Browse teams, pipelines, and apps with t / p / a. Select an item and press Enter.', 'muted')}`)
|
|
259
|
+
this.main.setLabel(` ${icons.heroku} Welcome `)
|
|
260
|
+
this.setRows([{icon: 'pipelines', label: 'Choose a pipeline or app in the sidebar', detail: 'Navigation\n\nTab cycles between sidebar, list, and details.\nj/k or arrow keys move through lists.\n/ filters the sidebar.\n? displays all shortcuts.\n\nUse --app, --pipeline, --remote, or --team to choose a starting context.'}])
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async openPipeline(pipeline) {
|
|
264
|
+
this.clearApp()
|
|
265
|
+
this.pipeline = pipeline
|
|
266
|
+
const owner = pipeline.owner ?? this.catalog.pipelines.find(item => item.id === pipeline.id)?.owner
|
|
267
|
+
this.breadcrumbTeam = owner?.type === 'team' ? this.catalog.teams.find(item => item.id === owner.id) ?? null : owner ? {name: 'Personal'} : null
|
|
268
|
+
const generation = this.generation
|
|
269
|
+
this.busy = true
|
|
270
|
+
this.summary.setContent(`${badge('pipelines', pipeline.name)}\n\n${badge('refresh', 'Loading pipeline apps…', 'info')}`)
|
|
271
|
+
this.main.setLabel(` ${icons.apps} Pipeline apps `)
|
|
272
|
+
this.setRows([])
|
|
273
|
+
const finishLoading = this.beginLoading('pipeline', `Loading pipeline ${pipeline.name}…`)
|
|
274
|
+
try {
|
|
275
|
+
const [appsResult, hierarchy] = await Promise.all([
|
|
276
|
+
this.api.pipelineApps(pipeline.id).then(apps => ({apps}), error => ({error})),
|
|
277
|
+
resolveHierarchy(this.api, this.catalog, {pipeline}),
|
|
278
|
+
])
|
|
279
|
+
if (this.closed || generation !== this.generation) return
|
|
280
|
+
this.pipeline = hierarchy.pipeline
|
|
281
|
+
this.breadcrumbTeam = hierarchy.team
|
|
282
|
+
if (appsResult.error) throw appsResult.error
|
|
283
|
+
const apps = sortApps(appsResult.apps)
|
|
284
|
+
this.pipelineApps = apps
|
|
285
|
+
this.summary.setContent(`${badge('pipelines', pipeline.name)}\n\n${STAGES.map(stage => badge(stageStyles[stage].icon, `${stage}: ${apps.filter(a => a.stage === stage).length}`, stageStyles[stage].tone)).join(' ')}`)
|
|
286
|
+
this.setRows(apps.length ? apps.map(app => ({kind: 'app', value: app, ...stageStyles[app.stage], emphasis: app.stage.toUpperCase(),
|
|
287
|
+
label: `${app.stage.toUpperCase().padEnd(13)} ${single(app.name)} · ${app.region?.name ?? '—'}`,
|
|
288
|
+
detail: `${single(app.name)}\n\nStage: ${app.stage}\nTeam: ${single(app.team?.name ?? 'Personal / shared')}\nRegion: ${single(app.region?.name)}\nStack: ${single(app.stack?.name)}\n\nEnter to view resources, add-ons, config, settings, releases, and metrics.`,
|
|
289
|
+
})) : [{icon: 'apps', tone: 'muted', label: 'This pipeline has no apps', detail: 'Press a to browse accessible apps.'}])
|
|
290
|
+
this.main.focus()
|
|
291
|
+
this.message = `Pipeline loaded. Select an app and press Enter.${hierarchy.errors.hierarchy ? ` · ${hierarchy.errors.hierarchy}` : ''}`
|
|
292
|
+
this.messageTone = hierarchy.errors.hierarchy ? 'warning' : 'success'
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (generation === this.generation) {
|
|
295
|
+
this.setRows([{icon: 'error', tone: 'error', label: 'Unable to load pipeline', detail: errorMessage(error)}])
|
|
296
|
+
this.message = errorMessage(error)
|
|
297
|
+
this.messageTone = 'error'
|
|
298
|
+
}
|
|
299
|
+
} finally {
|
|
300
|
+
if (generation === this.generation) { this.busy = false; this.render() }
|
|
301
|
+
finishLoading()
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async openApp(app, pipeline = null) {
|
|
306
|
+
this.clearApp()
|
|
307
|
+
this.app = app
|
|
308
|
+
this.pipeline = pipeline
|
|
309
|
+
this.breadcrumbTeam = app.team ?? null
|
|
310
|
+
this.tab = 0
|
|
311
|
+
this.summary.setContent(`${badge('apps', app.name, 'cyan')}\n\n${badge('refresh', 'Loading app data…', 'info')}`)
|
|
312
|
+
this.setRows([])
|
|
313
|
+
this.main.focus()
|
|
314
|
+
await this.loadApp()
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async loadApp(automatic = false) {
|
|
318
|
+
if (!this.app || this.busy || this.closed) return false
|
|
319
|
+
const generation = this.generation
|
|
320
|
+
const app = this.app
|
|
321
|
+
this.busy = true
|
|
322
|
+
const finishLoading = this.beginLoading('app', `${this.data ? 'Refreshing' : 'Loading'} app ${app.name}…`)
|
|
323
|
+
try {
|
|
324
|
+
const data = await this.api.appData(app.id)
|
|
325
|
+
if (this.closed || generation !== this.generation) return
|
|
326
|
+
const hierarchy = await resolveHierarchy(this.api, this.catalog, {app: data.app, pipeline: data.coupling?.pipeline})
|
|
327
|
+
if (this.closed || generation !== this.generation) return
|
|
328
|
+
this.pipeline = hierarchy.pipeline
|
|
329
|
+
this.breadcrumbTeam = hierarchy.team
|
|
330
|
+
Object.assign(data.errors, hierarchy.errors)
|
|
331
|
+
this.data = data
|
|
332
|
+
this.app = data.app
|
|
333
|
+
this.message = `${automatic ? 'Auto-refreshed' : 'Updated'} ${new Date(data.fetchedAt).toLocaleTimeString()}${Object.keys(data.errors).length ? ' · Some sections unavailable; see Overview.' : ''}`
|
|
334
|
+
this.messageTone = Object.keys(data.errors).length ? 'warning' : 'success'
|
|
335
|
+
this.drawApp()
|
|
336
|
+
return true
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (generation === this.generation) {
|
|
339
|
+
this.message = `${errorMessage(error)}${this.data ? ' · Showing previous snapshot.' : ''}`
|
|
340
|
+
this.messageTone = 'error'
|
|
341
|
+
if (!this.data) this.setRows([{icon: 'error', tone: 'error', label: 'Unable to load app', detail: errorMessage(error)}])
|
|
342
|
+
}
|
|
343
|
+
return false
|
|
344
|
+
} finally {
|
|
345
|
+
if (generation === this.generation) { this.busy = false; this.render() }
|
|
346
|
+
finishLoading()
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
drawApp() {
|
|
351
|
+
if (!this.data) return
|
|
352
|
+
const {app, formation, errors} = this.data
|
|
353
|
+
this.summary.setContent(`${badge('apps', app.name, 'cyan')} ${app.maintenance ? badge('warning', 'MAINTENANCE', 'warning') : badge('success', 'ACTIVE', 'success')}\n${badge('teams', app.team?.name ?? 'Personal / shared', 'muted')} · ${badge('globe', app.region?.name, 'info')} · ${badge('stack', app.stack?.name, 'muted')}\n${badge('resources', errors.formation ? 'Dynos unavailable' : `${formation.reduce((sum, f) => sum + f.quantity, 0)} configured dynos`, errors.formation ? 'warning' : 'fg')} · ${badge('addons', `${this.data.addons.length} add-ons`, 'fg')} · ${badge('refresh', this.refresh ? `refresh ${this.refresh}s` : 'manual refresh', 'muted')}`)
|
|
354
|
+
this.main.setLabel(` ${icons[tabIcons[this.tab]]} ${TABS[this.tab]} `)
|
|
355
|
+
this.setRows(appRows(TABS[this.tab], this.data, {config: this.config, configError: this.configError, revealed: this.revealed}), true)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
setRows(rows, preserve = false) {
|
|
359
|
+
const selected = preserve ? this.main.selected : 0
|
|
360
|
+
// Blessed's setItems temporarily selects row zero. Ignore those synthetic
|
|
361
|
+
// selection events until the intended row is restored, so details aren't
|
|
362
|
+
// rendered for a temporary selection or a partially updated list.
|
|
363
|
+
this.updatingRows = true
|
|
364
|
+
try {
|
|
365
|
+
this.rows = rows
|
|
366
|
+
this.main.setItems(rows.map(rowLabel))
|
|
367
|
+
this.main.select(Math.min(selected, Math.max(0, rows.length - 1)))
|
|
368
|
+
} finally {
|
|
369
|
+
this.updatingRows = false
|
|
370
|
+
}
|
|
371
|
+
this.drawDetail()
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
drawDetail() {
|
|
375
|
+
const row = this.rows[this.main.selected]
|
|
376
|
+
this.detail.setLabel(` ${icons[row?.icon] ?? icons.overview} Details `)
|
|
377
|
+
this.detail.setContent(detailContent(row))
|
|
378
|
+
this.detail.setScroll(0)
|
|
379
|
+
this.render()
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
changeTab(index) {
|
|
383
|
+
if (!this.app) return
|
|
384
|
+
this.tab = index
|
|
385
|
+
this.revealed.clear()
|
|
386
|
+
this.main.select(0)
|
|
387
|
+
this.drawApp()
|
|
388
|
+
if (TABS[index] === 'Config' && !this.config) void this.loadConfig()
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async loadConfig() {
|
|
392
|
+
if (!this.app || this.closed) return
|
|
393
|
+
const generation = this.generation
|
|
394
|
+
const sequence = this.configSequence = (this.configSequence ?? 0) + 1
|
|
395
|
+
const finishLoading = this.beginLoading('config', `Loading config vars for ${this.app.name}…`)
|
|
396
|
+
try {
|
|
397
|
+
const config = await this.api.config(this.app.id)
|
|
398
|
+
if (this.closed || generation !== this.generation || sequence !== this.configSequence) return
|
|
399
|
+
this.config = config
|
|
400
|
+
this.configError = null
|
|
401
|
+
} catch (error) {
|
|
402
|
+
if (generation !== this.generation || sequence !== this.configSequence) return
|
|
403
|
+
this.configError = errorMessage(error)
|
|
404
|
+
} finally {
|
|
405
|
+
finishLoading()
|
|
406
|
+
}
|
|
407
|
+
this.drawApp()
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async back() {
|
|
411
|
+
if (this.app && this.pipeline) await this.openPipeline(this.pipeline)
|
|
412
|
+
else if (this.app || this.pipeline) {
|
|
413
|
+
this.clearApp()
|
|
414
|
+
this.pipeline = null
|
|
415
|
+
this.drawLanding()
|
|
416
|
+
this.nav.focus()
|
|
417
|
+
} else {
|
|
418
|
+
this.filter = ''
|
|
419
|
+
this.drawNav()
|
|
420
|
+
this.nav.focus()
|
|
421
|
+
}
|
|
422
|
+
this.render()
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async reload() {
|
|
426
|
+
if (this.busy) return
|
|
427
|
+
if (this.app) {
|
|
428
|
+
this.revealed.clear()
|
|
429
|
+
await this.loadApp()
|
|
430
|
+
if (TABS[this.tab] === 'Config') await this.loadConfig()
|
|
431
|
+
} else if (this.pipeline) await this.openPipeline(this.pipeline)
|
|
432
|
+
else {
|
|
433
|
+
const sequence = ++this.navGeneration
|
|
434
|
+
this.setStatus('Refreshing teams, pipelines, and apps…')
|
|
435
|
+
const finishLoading = this.beginLoading('catalog', 'Refreshing teams, pipelines, and apps…')
|
|
436
|
+
try {
|
|
437
|
+
const catalog = await this.api.catalog()
|
|
438
|
+
if (this.closed || sequence !== this.navGeneration) return
|
|
439
|
+
this.catalog = catalog
|
|
440
|
+
this.drawNav()
|
|
441
|
+
this.setStatus(catalog.warnings.join(' | ') || 'Workspace refreshed.', catalog.warnings.length ? 'warning' : 'success')
|
|
442
|
+
} catch (error) { this.setStatus(errorMessage(error), 'error') }
|
|
443
|
+
finally { finishLoading() }
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async filterNav() {
|
|
448
|
+
const query = await this.prompt(`Filter ${this.mode}`, 'Filter the sidebar by name. Leave blank to clear.', this.filter)
|
|
449
|
+
if (query === null) return
|
|
450
|
+
this.filter = query
|
|
451
|
+
this.drawNav()
|
|
452
|
+
this.nav.focus()
|
|
453
|
+
this.render()
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
prompt(title, description, initial = '', {secret = false, tone = 'accent', icon = 'keyboard'} = {}) {
|
|
457
|
+
if (this.closed) return Promise.resolve(null)
|
|
458
|
+
return new Promise(resolve => {
|
|
459
|
+
const previous = this.screen.focused
|
|
460
|
+
const modal = blessed.box({parent: this.screen, top: 'center', left: 'center', width: '85%', height: 14, ...frame(), label: ` ${icons[secret ? 'lock' : icon]} ${single(title)} `, style: {...frame().style, border: {fg: palette[tone]}}})
|
|
461
|
+
this.modal = modal
|
|
462
|
+
blessed.box({parent: modal, top: 1, left: 2, right: 2, height: 6, content: clean(description), tags: false, style: {fg: palette.fg, bg: palette.bg}})
|
|
463
|
+
const input = blessed.textbox({parent: modal, top: 8, left: 2, right: 2, height: 3, ...frame(), inputOnFocus: true, censor: secret, value: initial})
|
|
464
|
+
blessed.text({parent: modal, bottom: 0, left: 2, content: `${shortcut('Enter', 'continue')} ${shortcut('Esc', 'cancel')} ${shortcut('Ctrl-U', 'clear')}`, style: {bg: palette.bg}})
|
|
465
|
+
let finished = false
|
|
466
|
+
const finish = value => {
|
|
467
|
+
if (finished) return
|
|
468
|
+
finished = true
|
|
469
|
+
this.cancelPrompt = null
|
|
470
|
+
input.clearValue()
|
|
471
|
+
modal.destroy()
|
|
472
|
+
this.modal = null
|
|
473
|
+
if (!this.closed) { previous?.focus(); this.render() }
|
|
474
|
+
resolve(value)
|
|
475
|
+
}
|
|
476
|
+
this.cancelPrompt = () => finish(null)
|
|
477
|
+
input.on('submit', value => finish(value))
|
|
478
|
+
input.on('cancel', () => finish(null))
|
|
479
|
+
input.key(['C-c'], () => this.close())
|
|
480
|
+
input.key(['C-u'], () => { input.clearValue(); this.render() })
|
|
481
|
+
input.focus()
|
|
482
|
+
this.render()
|
|
483
|
+
})
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
writable() {
|
|
487
|
+
if (this.api.readOnly) { this.setStatus('Read-only mode: remote changes are disabled.', 'warning'); return false }
|
|
488
|
+
if (!this.app || !this.data || this.busy) return false
|
|
489
|
+
return true
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async confirm(app, description) {
|
|
493
|
+
const value = await this.prompt('Confirm remote change', `${description}\n\nTarget: ${app.name}\nType the exact app name to apply this change.`, '', {icon: 'warning', tone: 'warning'})
|
|
494
|
+
if (value === null) { this.setStatus('Change cancelled.'); return null }
|
|
495
|
+
if (value !== app.name) { this.setStatus('App name did not match. Nothing changed.', 'warning'); return null }
|
|
496
|
+
return value
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async mutate(action) {
|
|
500
|
+
// Lock navigation while a confirmed write is in flight. Its target and the
|
|
501
|
+
// subsequent refresh must remain the app named in the confirmation.
|
|
502
|
+
const modal = blessed.box({parent: this.screen, top: 'center', left: 'center', width: '70%', height: 5, ...frame(),
|
|
503
|
+
content: `\n ${badge('refresh', 'Applying confirmed change…', 'info')}`})
|
|
504
|
+
this.modal = modal
|
|
505
|
+
this.busy = true
|
|
506
|
+
this.setStatus('Applying change…')
|
|
507
|
+
const finishLoading = this.beginLoading('mutation', 'Applying confirmed change…')
|
|
508
|
+
try {
|
|
509
|
+
await action()
|
|
510
|
+
if (this.closed) return
|
|
511
|
+
this.busy = false
|
|
512
|
+
this.config = null
|
|
513
|
+
this.revealed.clear()
|
|
514
|
+
const refreshed = await this.loadApp()
|
|
515
|
+
if (TABS[this.tab] === 'Config') await this.loadConfig()
|
|
516
|
+
this.setStatus(refreshed ? 'Change applied. App data refreshed.' : `Change applied, but refresh failed. ${this.message}`, refreshed ? 'success' : 'warning')
|
|
517
|
+
} catch (error) { this.setStatus(errorMessage(error), 'error') }
|
|
518
|
+
finally { modal.destroy(); this.modal = null; this.busy = false; finishLoading(); this.render() }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async scale() {
|
|
522
|
+
if (TABS[this.tab] !== 'Resources' || !this.writable()) return
|
|
523
|
+
const row = this.rows[this.main.selected]
|
|
524
|
+
if (row?.kind !== 'formation') { this.setStatus(`Select a process type (${icons.resources}) to scale.`); return }
|
|
525
|
+
const app = this.app
|
|
526
|
+
const formation = row.value
|
|
527
|
+
const quantity = await this.prompt('Scale dynos · quantity', `${app.name} / ${formation.type}\nCurrent: ${formation.quantity} × ${formation.size}\nEnter desired quantity (0 stops this process).`, String(formation.quantity))
|
|
528
|
+
if (quantity === null) return
|
|
529
|
+
if (!/^\d+$/.test(quantity) || !Number.isSafeInteger(Number(quantity))) { this.setStatus('Quantity must be a non-negative integer.', 'warning'); return }
|
|
530
|
+
const size = await this.prompt('Scale dynos · size', `${app.name} / ${formation.type}\nEnter a Heroku dyno size (for example Standard-1X).`, formation.size)
|
|
531
|
+
if (size === null) return
|
|
532
|
+
if (!size.trim()) { this.setStatus('Dyno size cannot be blank.', 'warning'); return }
|
|
533
|
+
const confirmation = await this.confirm(app, `Scale ${formation.type}: ${formation.quantity} × ${formation.size} → ${quantity} × ${size.trim()}.\nThis can restart dynos and change billing.`)
|
|
534
|
+
if (confirmation) await this.mutate(() => this.api.scale(app.name, formation.type, Number(quantity), size, confirmation))
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async copyConfig() {
|
|
538
|
+
if (TABS[this.tab] !== 'Config' || !this.app || !this.config || this.copying) return
|
|
539
|
+
const row = this.rows[this.main.selected]
|
|
540
|
+
if (row?.kind !== 'config' || typeof this.config[row.key] !== 'string') return
|
|
541
|
+
const generation = this.generation
|
|
542
|
+
this.copying = true
|
|
543
|
+
this.setStatus(`Copying ${row.key} to clipboard…`)
|
|
544
|
+
try {
|
|
545
|
+
// Copy the original value, not its masked, truncated, or sanitized display.
|
|
546
|
+
await this.writeClipboard(this.config[row.key])
|
|
547
|
+
if (!this.closed && generation === this.generation) this.setStatus(`Copied ${row.key} to clipboard.`, 'success')
|
|
548
|
+
} catch {
|
|
549
|
+
// Clipboard backend errors may include stdin. Never display that output.
|
|
550
|
+
if (!this.closed && generation === this.generation) this.setStatus('Could not copy value. Check your desktop session and clipboard tools; see README.', 'error')
|
|
551
|
+
} finally {
|
|
552
|
+
this.copying = false
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async editConfig(isNew) {
|
|
557
|
+
if (TABS[this.tab] !== 'Config' || !this.writable() || !this.config) return
|
|
558
|
+
const app = this.app
|
|
559
|
+
let key = this.rows[this.main.selected]?.key
|
|
560
|
+
if (isNew) key = await this.prompt('New config variable', `${app.name}\nEnter the variable name.`)
|
|
561
|
+
if (!key) return
|
|
562
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { this.setStatus('Invalid config variable name.', 'warning'); return }
|
|
563
|
+
const value = await this.prompt('Config variable · value', `${app.name} / ${key}\nEnter a new single-line value (input is masked; blank is an empty string).`, '', {secret: true})
|
|
564
|
+
if (value === null) return
|
|
565
|
+
const confirmation = await this.confirm(app, `${Object.hasOwn(this.config, key) ? 'Replace' : 'Create'} config variable ${key}.\nThis creates a release and restarts the app.`)
|
|
566
|
+
if (confirmation) await this.mutate(() => this.api.setConfig(app.name, key, value, confirmation))
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async deleteConfig() {
|
|
570
|
+
if (TABS[this.tab] !== 'Config' || !this.writable()) return
|
|
571
|
+
const key = this.rows[this.main.selected]?.key
|
|
572
|
+
if (!key) return
|
|
573
|
+
const app = this.app
|
|
574
|
+
const confirmation = await this.confirm(app, `Delete config variable ${key}.\nThis creates a release and restarts the app.`)
|
|
575
|
+
if (confirmation) await this.mutate(() => this.api.setConfig(app.name, key, null, confirmation))
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async maintenance() {
|
|
579
|
+
if (TABS[this.tab] !== 'Settings' || !this.writable()) return
|
|
580
|
+
const app = this.app
|
|
581
|
+
const enabled = !app.maintenance
|
|
582
|
+
const confirmation = await this.confirm(app, `${enabled ? 'Enable' : 'Disable'} maintenance mode.\n${enabled ? 'The app will serve the maintenance page.' : 'The app will resume serving requests.'}`)
|
|
583
|
+
if (confirmation) await this.mutate(() => this.api.maintenance(app.name, enabled, confirmation))
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
openBrowser() {
|
|
587
|
+
if (this.demo) { this.setStatus('Browser links are disabled in the offline demo.'); return }
|
|
588
|
+
let url
|
|
589
|
+
if (this.app) {
|
|
590
|
+
const path = ['activity', 'resources', 'resources', 'settings', 'settings', 'activity', 'metrics'][this.tab]
|
|
591
|
+
url = `https://dashboard.heroku.com/apps/${encodeURIComponent(this.app.name)}/${path}`
|
|
592
|
+
} else if (this.pipeline) url = `https://dashboard.heroku.com/pipelines/${encodeURIComponent(this.pipeline.id)}`
|
|
593
|
+
else if (this.team) url = `https://dashboard.heroku.com/teams/${encodeURIComponent(this.team.name)}/apps`
|
|
594
|
+
else url = 'https://dashboard.heroku.com/apps'
|
|
595
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open'
|
|
596
|
+
const child = spawn(command, process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url], {stdio: 'ignore'})
|
|
597
|
+
child.on('error', error => this.setStatus(`Unable to open browser: ${error.message}`, 'error'))
|
|
598
|
+
child.on('exit', code => this.setStatus(code === 0 ? 'Opened Heroku dashboard in your browser.' : `Browser exited with code ${code}.`, code === 0 ? 'success' : 'error'))
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
help() {
|
|
602
|
+
const previous = this.screen.focused
|
|
603
|
+
const modal = blessed.box({parent: this.screen, top: 'center', left: 'center', width: '85%', height: '85%', ...frame(), label: ` ${icons.keyboard} Keyboard shortcuts `, padding: {left: 2, top: 1}, scrollable: true, keys: true, vi: true,
|
|
604
|
+
content: 'NAVIGATION\n t / p / a Browse teams / pipelines / apps\n j / k, ↑ / ↓ Move selection or scroll details\n Enter Open selected team, pipeline, or app\n Tab / Shift-Tab Focus next / previous pane\n / Filter sidebar by name\n Esc Return to pipeline / workspace; clear filter\n 1–7 Select app view\n h / l, [ / ] Previous / next app view (also ← / →)\n R Refresh current app, pipeline, or workspace\n o Open current view in web dashboard\n q / Ctrl-C Quit\n\nAPP ACTIONS\n s Scale selected Resources process type\n v Reveal / hide selected config variable\n y Copy selected config value to clipboard\n e / n / d Replace / create / delete config variable\n m Toggle maintenance in Settings\n\nRemote changes require typing the exact target app name.\n--read-only disables every mutation at the API boundary.\nConfig values are masked and fetched only on opening Config.\nEach variable toggles independently; moving rows keeps values visible.\nLeaving the tab or app hides revealed values.\nCopying works while masked and in read-only mode.\n\nMetrics show dyno health and recent deployment outcomes.\nMemory / CPU / latency charts require the web dashboard.\n\nPress Esc, ?, or q to close help.'})
|
|
605
|
+
this.modal = modal
|
|
606
|
+
modal.key(['escape', '?', 'q'], () => { modal.destroy(); this.modal = null; previous?.focus(); this.render() })
|
|
607
|
+
modal.focus()
|
|
608
|
+
this.render()
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
close() {
|
|
612
|
+
if (this.closed) return
|
|
613
|
+
this.closed = true
|
|
614
|
+
clearInterval(this.timer)
|
|
615
|
+
this.loading.clear()
|
|
616
|
+
this.syncLoadingAnimation()
|
|
617
|
+
this.generation++
|
|
618
|
+
this.cancelPrompt?.()
|
|
619
|
+
this.config = null
|
|
620
|
+
if (!this.screen.destroyed) this.screen.destroy()
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export async function runDashboard(options) {
|
|
625
|
+
const dashboard = new Dashboard(options)
|
|
626
|
+
const finished = new Promise(resolve => dashboard.screen.once('destroy', resolve))
|
|
627
|
+
const stop = () => dashboard.close()
|
|
628
|
+
process.once('SIGTERM', stop)
|
|
629
|
+
process.once('SIGINT', stop)
|
|
630
|
+
try {
|
|
631
|
+
await dashboard.start()
|
|
632
|
+
await finished
|
|
633
|
+
} finally {
|
|
634
|
+
dashboard.close()
|
|
635
|
+
process.removeListener('SIGTERM', stop)
|
|
636
|
+
process.removeListener('SIGINT', stop)
|
|
637
|
+
}
|
|
638
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import blessed from 'blessed'
|
|
2
|
+
import {clean} from './text.js'
|
|
3
|
+
import {paint} from './theme.js'
|
|
4
|
+
|
|
5
|
+
export function detailContent(row) {
|
|
6
|
+
const text = clean(row?.detail ?? '')
|
|
7
|
+
if (!row?.valueRange) return text
|
|
8
|
+
const {start, end} = row.valueRange
|
|
9
|
+
return `${text.slice(0, start)}${paint(text.slice(start, end), 'cyan')}${text.slice(end)}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isValueClick(detail, row, mouse) {
|
|
13
|
+
if (row?.kind !== 'config' || !row.valueRange || mouse.button !== 'left') return false
|
|
14
|
+
const pos = detail.lpos
|
|
15
|
+
if (!pos) return false
|
|
16
|
+
const left = pos.xi + detail.ileft
|
|
17
|
+
const top = pos.yi + detail.itop
|
|
18
|
+
const right = pos.xl - detail.iright - (detail.scrollbar ? 1 : 0)
|
|
19
|
+
const bottom = pos.yl - detail.ibottom
|
|
20
|
+
if (mouse.x < left || mouse.x >= right || mouse.y < top || mouse.y >= bottom) return false
|
|
21
|
+
|
|
22
|
+
// Use Blessed's rendered-line map rather than reimplementing wrapping. It
|
|
23
|
+
// accounts for resizing, tabs, Unicode widths, and the current scroll offset.
|
|
24
|
+
const wrappedLine = mouse.y - top + pos.base
|
|
25
|
+
const sourceLine = detail._clines.rtof[wrappedLine]
|
|
26
|
+
const text = clean(row.detail)
|
|
27
|
+
const first = text.slice(0, row.valueRange.start).split('\n').length - 1
|
|
28
|
+
const last = text.slice(0, row.valueRange.end).split('\n').length - 1
|
|
29
|
+
if (sourceLine === undefined || sourceLine < first || sourceLine > last) return false
|
|
30
|
+
|
|
31
|
+
// Exclude the unused area after short lines, plus borders and the scrollbar.
|
|
32
|
+
const line = clean(detail._clines[wrappedLine])
|
|
33
|
+
return mouse.x - left < blessed.unicode.strWidth(line)
|
|
34
|
+
}
|