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
importtype{Option}from'@clack/prompts'
22
importtype{NuxtModule}from'./_utils'
33

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

1013
constTRAILING_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+
constROW_PREFIX_WIDTH=3+2+2
21+
/** Below this there is no room left for a description worth reading. */
22+
constMIN_DESCRIPTION_WIDTH=24
23+
1224
interfaceAutocompleteOptions{
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-
constclackOptions: 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+
functionbuildOptions(search: string): Option<string>[]{
64+
constroom=(process.stdout.columns||80)-ROW_PREFIX_WIDTH
65+
returnsortedModules.map((m)=>{
66+
constmatch=search ? fuzzysort.single(search,m.npm) : undefined
67+
constname=match ? match.highlight(part=>styleText('underline',part)).join('') : m.npm
68+
constdescription=m.description.replace(TRAILING_DOT_RE,'')
69+
constavailable=room-m.npm.length
70+
if(!description||available<MIN_DESCRIPTION_WIDTH){
71+
return{value: m.npm,label: name}
72+
}
73+
consttruncated=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+
functioncurrentSearch(prompt: unknown): string{
86+
constinput=(promptas{userInput?: unknown}).userInput
87+
returntypeofinput==='string' ? input : ''
88+
}
5089

5190
constmatches=newMap<string,Set<string>>()
5291
constfilter=(search: string,option: Option<string>): boolean=>{
@@ -62,7 +101,9 @@ export async function selectModulesAutocomplete(options: AutocompleteOptions): P
62101

63102
constresult=awaitautocompleteMultiselect({
64103
message,
65-
options: clackOptions,
104+
options(){
105+
returnbuildOptions(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
importtype{NuxtModule}from'../../../../src/commands/module/_utils'
22

3+
import{stripVTControlCharactersasstripAnsi}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
letcapturedOptions: 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
letcapturedOptions: 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+
letcapturedOptions: any[]=[]
374+
mockAutocompleteMultiselect.mockImplementation(async(opts: any)=>{
375+
capturedOptions=opts.options.call({userInput: ''})
376+
return[]
377+
})
378+
379+
const{ selectModulesAutocomplete }=awaitimport('../../../../src/commands/module/_autocomplete')
380+
381+
awaitselectModulesAutocomplete({
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+
letcapturedOptions: any[]=[]
401+
mockAutocompleteMultiselect.mockImplementation(async(opts: any)=>{
402+
capturedOptions=opts.options.call({userInput: 'img'})
403+
return[]
404+
})
405+
406+
const{ selectModulesAutocomplete }=awaitimport('../../../../src/commands/module/_autocomplete')
407+
408+
awaitselectModulesAutocomplete({
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+
letcapturedOptions: any[]=[]
424+
mockAutocompleteMultiselect.mockImplementation(async(opts: any)=>{
425+
capturedOptions=opts.options.call({userInput: ''})
426+
return[]
362427
})
428+
429+
const{ selectModulesAutocomplete }=awaitimport('../../../../src/commands/module/_autocomplete')
430+
431+
awaitselectModulesAutocomplete({
432+
modules: [createMockModule({npm: '@nuxt/test',description: 'A'.repeat(200)})],
433+
})
434+
435+
constlabel=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)