From 0dfff1ef991eea7c6a80f4b9b28ffcced52b00db Mon Sep 17 00:00:00 2001 From: Hooray <304327508@qq.com> Date: Wed, 25 Mar 2026 03:00:49 +0800 Subject: [PATCH] feat: group nar scripts by package --- packages/nar-demo/package.json | 10 ++++ pnpm-workspace.yaml | 3 +- src/nar/cli.ts | 33 ++++++++----- src/nar/core.ts | 89 ++++++++++++++++++++++++++++++---- src/prompts/select-search.ts | 11 ++++- tests/nar/core.test.ts | 43 ++++++++++++++-- 6 files changed, 162 insertions(+), 27 deletions(-) create mode 100644 packages/nar-demo/package.json diff --git a/packages/nar-demo/package.json b/packages/nar-demo/package.json new file mode 100644 index 0000000..1f326c9 --- /dev/null +++ b/packages/nar-demo/package.json @@ -0,0 +1,10 @@ +{ + "name": "@demo/nar-demo", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "echo child-package-dev", + "build": "echo child-package-build", + "lint": "echo child-package-lint" + } +} \ No newline at end of file diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e211763..6539be8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ -packages: [] +packages: + - packages/* catalogs: dev: diff --git a/src/nar/cli.ts b/src/nar/cli.ts index 5198209..e73b2ed 100644 --- a/src/nar/cli.ts +++ b/src/nar/cli.ts @@ -8,8 +8,14 @@ import { detectProvider } from '../detect.ts' import { commandOverviewText } from '../help.ts' import { selectSearchPrompt } from '../prompts/select-search.ts' import { providers } from '../providers/index.ts' -import { buildScriptOptions, collectScripts, type ScriptEntry } from './core.ts' -import type { SearchOption } from '../prompts/search.ts' +import { + buildScriptOptions, + collectScripts, + getScriptGroupOrder, + isScriptEntry, + type ScriptEntry, + type ScriptOptionValue, +} from './core.ts' function printHelp(): void { console.log( @@ -78,7 +84,8 @@ async function run() { } const isMonorepo = packages.length > 1 - const allOptions = buildScriptOptions(scripts, isMonorepo) + const groupOrder = getScriptGroupOrder(scripts) + const allOptions = buildScriptOptions(scripts, isMonorepo, groupOrder) /** Tiebreaker: root scripts rank higher than workspace scripts */ const byRootFirst = ( @@ -94,17 +101,16 @@ async function run() { tiebreakers: [byRootFirst, byLengthAsc], }) - const optionMap = new Map(allOptions.map((opt) => [opt.value, opt])) - - const selected = await selectSearchPrompt({ + const selected = await selectSearchPrompt({ message: 'Run a script', options() { const input = (this.userInput ?? '').trim() if (!input) return allOptions - const results = fzf.find(input) - return results - .map((r) => optionMap.get(r.item)) - .filter((o): o is SearchOption => o != null) + return buildScriptOptions( + fzf.find(input).map((result) => result.item), + isMonorepo, + groupOrder, + ) }, filter: () => true, }) @@ -114,7 +120,12 @@ async function run() { process.exit(0) } - const entry = selected as ScriptEntry + if (!isScriptEntry(selected)) { + p.log.error('Please choose a runnable script.') + process.exit(1) + } + + const entry: ScriptEntry = selected try { await provider.runScript({ diff --git a/src/nar/core.ts b/src/nar/core.ts index 1477a5d..7d21505 100644 --- a/src/nar/core.ts +++ b/src/nar/core.ts @@ -11,6 +11,42 @@ export interface ScriptEntry { isRoot: boolean } +export interface ScriptGroupMarker { + kind: 'group' + id: string + label: string +} + +export type ScriptOptionValue = ScriptEntry | ScriptGroupMarker + +const ROOT_GROUP_ID = '__root__' + +function getGroupId(entry: ScriptEntry): string { + return entry.isRoot ? ROOT_GROUP_ID : entry.packageName +} + +function getGroupLabel(entry: ScriptEntry): string { + return entry.isRoot ? 'Root scripts' : entry.packageName +} + +export function getScriptGroupOrder(scripts: ScriptEntry[]): string[] { + const seen = new Set() + const order: string[] = [] + + for (const entry of scripts) { + const groupId = getGroupId(entry) + if (seen.has(groupId)) continue + seen.add(groupId) + order.push(groupId) + } + + return order +} + +export function isScriptEntry(value: ScriptOptionValue): value is ScriptEntry { + return 'scriptName' in value +} + /** * Collect all runnable scripts from workspace packages. * The first package is treated as root. @@ -35,13 +71,48 @@ export function collectScripts(packages: RepoPackageItem[]): ScriptEntry[] { export function buildScriptOptions( scripts: ScriptEntry[], isMonorepo: boolean, -): SearchOption[] { - return scripts.map((entry) => ({ - value: entry, - label: - isMonorepo && !entry.isRoot - ? `${c.magenta(entry.packageName)} ${c.dim('>')} ${entry.scriptName}` - : entry.scriptName, - hint: entry.command, - })) + groupOrder: string[] = getScriptGroupOrder(scripts), +): SearchOption[] { + if (!isMonorepo) { + return scripts.map((entry) => ({ + value: entry, + label: entry.scriptName, + hint: entry.command, + })) + } + + const groups = new Map[]>() + const groupLabels = new Map() + + for (const entry of scripts) { + const groupId = getGroupId(entry) + if (!groups.has(groupId)) groups.set(groupId, []) + groupLabels.set(groupId, getGroupLabel(entry)) + groups.get(groupId)!.push({ + value: entry, + label: entry.scriptName, + hint: entry.command, + }) + } + + const orderedGroupIds = [ + ...groupOrder.filter((groupId) => groups.has(groupId)), + ...[...groups.keys()].filter((groupId) => !groupOrder.includes(groupId)), + ] + + return orderedGroupIds.flatMap((groupId) => { + const label = groupLabels.get(groupId) ?? groupId + return [ + { + value: { + kind: 'group', + id: groupId, + label, + }, + label: c.bold(label), + disabled: true, + }, + ...groups.get(groupId)!, + ] + }) } diff --git a/src/prompts/select-search.ts b/src/prompts/select-search.ts index a136c3e..180a56c 100644 --- a/src/prompts/select-search.ts +++ b/src/prompts/select-search.ts @@ -34,6 +34,10 @@ export async function selectSearchPrompt( render(this: AutocompletePrompt>) { const input = this.userInput const allOptions = this.options + const totalSelectableOptions = allOptions.filter((o) => !o.disabled).length + const filteredSelectableOptions = this.filteredOptions.filter( + (o) => !o.disabled, + ).length const color = this.state === 'error' ? 'yellow' : 'cyan' const bar = styleText(color, S_BAR) @@ -47,11 +51,11 @@ export async function selectSearchPrompt( : cursor const matchInfo = - this.filteredOptions.length === allOptions.length + filteredSelectableOptions === totalSelectableOptions ? '' : styleText( 'dim', - ` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})`, + ` (${filteredSelectableOptions} match${filteredSelectableOptions === 1 ? '' : 'es'})`, ) switch (this.state) { @@ -71,6 +75,9 @@ export async function selectSearchPrompt( const styleOption = (option: SearchOption, active: boolean) => { const label = option.label ?? String(option.value ?? '') + if (option.disabled) { + return styleText(['bold', 'dim'], label) + } const hint = option.hint && active ? styleText('dim', ` (${option.hint})`) : '' const radio = active diff --git a/tests/nar/core.test.ts b/tests/nar/core.test.ts index 5482ff9..89930e5 100644 --- a/tests/nar/core.test.ts +++ b/tests/nar/core.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { buildScriptOptions, collectScripts } from '../../src/nar/core.ts' +import { + buildScriptOptions, + collectScripts, + getScriptGroupOrder, +} from '../../src/nar/core.ts' import type { RepoPackageItem } from '../../src/type.ts' // eslint-disable-next-line no-control-regex @@ -129,13 +133,17 @@ describe('buildScriptOptions', () => { it('omits package name for root scripts in monorepo', () => { const options = buildScriptOptions(entries, true) - expect(strip(options[0].label)).toBe('dev') + expect(strip(options[0].label)).toBe('Root scripts') + expect(options[0].disabled).toBe(true) + expect(strip(options[1].label)).toBe('dev') }) - it('prefixes package name for non-root scripts in monorepo', () => { + it('creates package group headers for non-root scripts in monorepo', () => { const options = buildScriptOptions(entries, true) - expect(strip(options[1].label)).toBe('@scope/pkg-a > build') + expect(strip(options[2].label)).toBe('@scope/pkg-a') + expect(options[2].disabled).toBe(true) + expect(strip(options[3].label)).toBe('build') }) it('sets command as hint', () => { @@ -156,4 +164,31 @@ describe('buildScriptOptions', () => { expect(buildScriptOptions([], false)).toEqual([]) expect(buildScriptOptions([], true)).toEqual([]) }) + + it('keeps group order as root first, then workspace packages in order', () => { + const packageBEntry = { + scriptName: 'lint', + command: 'eslint .', + packageName: '@scope/pkg-b', + cwd: '/workspace/pkg-b', + isRoot: false, + } + const fullEntries = [rootEntry, workspaceEntry, packageBEntry] + const filteredEntries = [packageBEntry, rootEntry, workspaceEntry] + + const options = buildScriptOptions( + filteredEntries, + true, + getScriptGroupOrder(fullEntries), + ) + + expect(options.map((option) => strip(option.label))).toEqual([ + 'Root scripts', + 'dev', + '@scope/pkg-a', + 'build', + '@scope/pkg-b', + 'lint', + ]) + }) })