Skip to content

Commit e621b9c

Browse files
committed
feat(module): show descriptions and highlight matches while searching
1 parent 75182da commit e621b9c

2 files changed

Lines changed: 126 additions & 10 deletions

File tree

packages/nuxt-cli/src/commands/module/_autocomplete.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { Option } from '@clack/prompts'
22
import type { NuxtModule } from './_utils'
33

4+
import process from 'node:process'
5+
6+
import { styleText } from 'node:util'
47
import { autocompleteMultiselect, isCancel } from '@clack/prompts'
58
import fuzzysort from 'fuzzysort'
69
import { hasTTY } from 'std-env'
@@ -9,6 +12,15 @@ import { logger } from '../../utils/logger'
912

1013
const TRAILING_DOT_RE = /\.$/
1114

15+
/**
16+
* Clack wraps each row at the terminal width less its `│ ` guide, and the row
17+
* itself opens with `◻ `; the last two columns are the gap this file puts
18+
* between the name and the description.
19+
*/
20+
const ROW_PREFIX_WIDTH = 3 + 2 + 2
21+
/** Below this there is no room left for a description worth reading. */
22+
const MIN_DESCRIPTION_WIDTH = 24
23+
1224
interface AutocompleteOptions {
1325
modules: NuxtModule[]
1426
message?: string
@@ -42,11 +54,38 @@ export async function selectModulesAutocomplete(options: AutocompleteOptions): P
4254
category: fuzzysort.prepare(m.category),
4355
}))
4456

45-
const clackOptions: Option<string>[] = sortedModules.map(m => ({
46-
value: m.npm,
47-
label: m.npm,
48-
hint: m.description.replace(TRAILING_DOT_RE, ''),
49-
}))
57+
/**
58+
* Clack only renders `hint` for the focused row, so the description lives in
59+
* the label instead and every row keeps it. Widths are measured on the plain
60+
* text: clack hard-wraps each row, and a label that overflows the terminal
61+
* costs a second line and half the visible list.
62+
*/
63+
function buildOptions(search: string): Option<string>[] {
64+
const room = (process.stdout.columns || 80) - ROW_PREFIX_WIDTH
65+
return sortedModules.map((m) => {
66+
const match = search ? fuzzysort.single(search, m.npm) : undefined
67+
const name = match ? match.highlight(part => styleText('underline', part)).join('') : m.npm
68+
const description = m.description.replace(TRAILING_DOT_RE, '')
69+
const available = room - m.npm.length
70+
if (!description || available < MIN_DESCRIPTION_WIDTH) {
71+
return { value: m.npm, label: name }
72+
}
73+
const truncated = description.length > available
74+
? `${description.slice(0, available - 1).trimEnd()}…`
75+
: description
76+
return { value: m.npm, label: `${name} ${styleText('dim', truncated)}` }
77+
})
78+
}
79+
80+
/**
81+
* `userInput` carries the text typed so far but is not on clack's public
82+
* prompt type, so a missing or renamed field degrades to unhighlighted rows
83+
* rather than throwing.
84+
*/
85+
function currentSearch(prompt: unknown): string {
86+
const input = (prompt as { userInput?: unknown }).userInput
87+
return typeof input === 'string' ? input : ''
88+
}
5089

5190
const matches = new Map<string, Set<string>>()
5291
const filter = (search: string, option: Option<string>): boolean => {
@@ -62,7 +101,9 @@ export async function selectModulesAutocomplete(options: AutocompleteOptions): P
62101

63102
const result = await autocompleteMultiselect({
64103
message,
65-
options: clackOptions,
104+
options() {
105+
return buildOptions(currentSearch(this))
106+
},
66107
filter,
67108
required: false,
68109
})

packages/nuxt-cli/test/unit/commands/module/_autocomplete.spec.ts

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { NuxtModule } from '../../../../src/commands/module/_utils'
22

3+
import { stripVTControlCharacters as stripAnsi } from 'node:util'
4+
35
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
46

57
// Mock std-env before importing the module
@@ -125,7 +127,7 @@ describe('selectModulesAutocomplete', () => {
125127
let capturedOptions: any[] = []
126128

127129
mockAutocompleteMultiselect.mockImplementation(async (opts: any) => {
128-
capturedOptions = opts.options
130+
capturedOptions = opts.options.call({ userInput: '' })
129131
return []
130132
})
131133

@@ -345,7 +347,7 @@ describe('selectModulesAutocomplete', () => {
345347

346348
let capturedOptions: any[] = []
347349
mockAutocompleteMultiselect.mockImplementation(async (opts: any) => {
348-
capturedOptions = opts.options
350+
capturedOptions = opts.options.call({ userInput: '' })
349351
return []
350352
})
351353

@@ -357,9 +359,82 @@ describe('selectModulesAutocomplete', () => {
357359

358360
expect(capturedOptions[0]).toEqual({
359361
value: '@nuxt/test',
360-
label: '@nuxt/test',
361-
hint: 'A test module', // trailing period removed
362+
label: expect.stringContaining('@nuxt/test'),
363+
})
364+
// trailing period removed
365+
expect(stripAnsi(capturedOptions[0].label)).toBe('@nuxt/test A test module')
366+
})
367+
368+
it('should show a description on every option, not only the focused one', async () => {
369+
vi.doMock('std-env', () => ({
370+
hasTTY: true,
371+
}))
372+
373+
let capturedOptions: any[] = []
374+
mockAutocompleteMultiselect.mockImplementation(async (opts: any) => {
375+
capturedOptions = opts.options.call({ userInput: '' })
376+
return []
377+
})
378+
379+
const { selectModulesAutocomplete } = await import('../../../../src/commands/module/_autocomplete')
380+
381+
await selectModulesAutocomplete({
382+
modules: [
383+
createMockModule({ npm: '@nuxt/one', description: 'First module' }),
384+
createMockModule({ npm: '@nuxt/two', description: 'Second module' }),
385+
],
386+
})
387+
388+
expect(capturedOptions.map((o: any) => stripAnsi(o.label))).toEqual([
389+
'@nuxt/one First module',
390+
'@nuxt/two Second module',
391+
])
392+
expect(capturedOptions.every((o: any) => o.hint === undefined)).toBe(true)
393+
})
394+
395+
it('should highlight the matched part of the name as the user types', async () => {
396+
vi.doMock('std-env', () => ({
397+
hasTTY: true,
398+
}))
399+
400+
let capturedOptions: any[] = []
401+
mockAutocompleteMultiselect.mockImplementation(async (opts: any) => {
402+
capturedOptions = opts.options.call({ userInput: 'img' })
403+
return []
404+
})
405+
406+
const { selectModulesAutocomplete } = await import('../../../../src/commands/module/_autocomplete')
407+
408+
await selectModulesAutocomplete({
409+
modules: [createMockModule({ npm: '@nuxt/image', description: 'Images' })],
410+
})
411+
412+
expect(capturedOptions[0].label).not.toBe(stripAnsi(capturedOptions[0].label))
413+
expect(stripAnsi(capturedOptions[0].label)).toBe('@nuxt/image Images')
414+
})
415+
416+
it('should truncate descriptions to the terminal width', async () => {
417+
vi.doMock('std-env', () => ({
418+
hasTTY: true,
419+
}))
420+
421+
Object.defineProperty(process.stdout, 'columns', { value: 60, writable: true, configurable: true })
422+
423+
let capturedOptions: any[] = []
424+
mockAutocompleteMultiselect.mockImplementation(async (opts: any) => {
425+
capturedOptions = opts.options.call({ userInput: '' })
426+
return []
362427
})
428+
429+
const { selectModulesAutocomplete } = await import('../../../../src/commands/module/_autocomplete')
430+
431+
await selectModulesAutocomplete({
432+
modules: [createMockModule({ npm: '@nuxt/test', description: 'A'.repeat(200) })],
433+
})
434+
435+
const label = stripAnsi(capturedOptions[0].label)
436+
expect(label.length).toBeLessThanOrEqual(60 - 5)
437+
expect(label.endsWith('…')).toBe(true)
363438
})
364439

365440
it('should set required to false', async () => {

0 commit comments

Comments
 (0)