Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/bump-select-prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@varlock/bumpy': minor
---

Redesign `bumpy add` interactive UI with a unified bump level selector. Packages are shown in two groups (changed/unchanged), navigated with arrow keys, and bump levels cycled with left/right. Changed packages default to patch.
63 changes: 36 additions & 27 deletions packages/bumpy/src/commands/add.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { resolve } from 'node:path';
import { relative, resolve } from 'node:path';
import pc from 'picocolors';
import { log } from '../utils/logger.ts';
import { p, unwrap } from '../utils/clack.ts';
Expand All @@ -9,6 +9,9 @@ import { getBumpyDir, loadConfig } from '../core/config.ts';
import { discoverPackages } from '../core/workspace.ts';
import { DependencyGraph } from '../core/dep-graph.ts';
import { matchGlob } from '../core/config.ts';
import { getChangedFiles } from '../core/git.ts';
import { bumpSelectPrompt } from '../prompts/bump-select.ts';
import type { BumpSelectItem } from '../prompts/bump-select.ts';
import type { BumpType, BumpTypeWithIsolated, BumpFileRelease, BumpFileReleaseCascade } from '../types.ts';

interface AddOptions {
Expand All @@ -18,13 +21,6 @@ interface AddOptions {
empty?: boolean;
}

const BUMP_CHOICES: { label: string; value: BumpTypeWithIsolated; hint?: string }[] = [
{ label: 'patch', value: 'patch' },
{ label: 'minor', value: 'minor' },
{ label: 'major', value: 'major' },
{ label: 'patch (isolated)', value: 'patch-isolated', hint: 'skips propagation' },
];

const CASCADE_CHOICES: { label: string; value: BumpType }[] = [
{ label: 'patch', value: 'patch' },
{ label: 'minor', value: 'minor' },
Expand Down Expand Up @@ -67,27 +63,40 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise<voi
process.exit(1);
}

const selected = unwrap(
await p.multiselect<string>({
message: 'Which packages should be included in this bump file?',
options: [...pkgs.values()].map((pkg) => ({
label: pkg.name,
value: pkg.name,
hint: pkg.version,
})),
required: true,
}),
);
// Detect which packages have changed on this branch
const baseBranch = config.baseBranch;
const changedFiles = getChangedFiles(rootDir, baseBranch);
const changedPackageNames = new Set<string>();
for (const file of changedFiles) {
for (const [name, pkg] of pkgs) {
const pkgRelDir = relative(rootDir, pkg.dir);
if (file.startsWith(pkgRelDir + '/')) {
changedPackageNames.add(name);
}
}
}

releases = [];
for (const name of selected) {
const bumpType = unwrap(
await p.select<BumpTypeWithIsolated>({
message: `Bump type for ${pc.cyan(name)}`,
options: BUMP_CHOICES,
}),
);
// Build items for the bump select prompt
const bumpSelectItems: BumpSelectItem[] = [...pkgs.values()].map((pkg) => ({
name: pkg.name,
version: pkg.version,
changed: changedPackageNames.has(pkg.name),
}));

const bumpSelectResult = await bumpSelectPrompt(bumpSelectItems);
if (typeof bumpSelectResult === 'symbol') {
p.cancel('Aborted');
process.exit(0);
}
const bumpSelections = bumpSelectResult;

if (bumpSelections.length === 0) {
p.cancel('No packages selected.');
process.exit(0);
}

releases = [];
for (const { name, type: bumpType } of bumpSelections) {
const release: BumpFileRelease = { name, type: bumpType };

// Offer cascade options if the package has dependents and bump is not isolated
Expand Down
219 changes: 219 additions & 0 deletions packages/bumpy/src/prompts/bump-select.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import * as readline from 'node:readline';
import pc from 'picocolors';
import type { BumpTypeWithIsolated } from '../types.ts';

export type BumpLevel = BumpTypeWithIsolated | 'none';

const LEVELS: BumpLevel[] = ['none', 'patch', 'minor', 'major'];

export interface BumpSelectItem {
name: string;
version: string;
changed: boolean;
}

export interface BumpSelectResult {
name: string;
type: BumpTypeWithIsolated;
}

/**
* Custom interactive prompt for selecting bump levels for multiple packages.
* - Up/Down arrows to navigate between packages
* - Left/Right arrows to change the bump level
* - Changed packages default to "patch", unchanged to "none"
* - Enter to confirm
* - Ctrl+C / Escape to cancel
*/
export async function bumpSelectPrompt(items: BumpSelectItem[]): Promise<BumpSelectResult[] | symbol> {
// Build display order: changed first, then unchanged
const changedEntries = items.map((item, idx) => ({ item, idx })).filter(({ item }) => item.changed);
const unchangedEntries = items.map((item, idx) => ({ item, idx })).filter(({ item }) => !item.changed);
const displayOrder = [...changedEntries, ...unchangedEntries];

// State
let cursor = 0;
const levels: BumpLevel[] = items.map((item) => (item.changed ? 'patch' : 'none'));

return new Promise<BumpSelectResult[] | symbol>((resolve) => {
const { stdin, stdout } = process;
const rl = readline.createInterface({ input: stdin, terminal: true });

// Hide cursor
stdout.write('\x1B[?25l');

let renderedLines = 0;

function render(final = false) {
// Clear previous render
if (renderedLines > 0) {
stdout.write(`\x1B[${renderedLines}A`); // Move up
stdout.write('\x1B[0J'); // Clear from cursor to end
}

const lines: string[] = [];

if (final) {
lines.push(`${pc.green('◇')} Bump levels selected`);
const selected = displayOrder.filter(({ idx }) => levels[idx] !== 'none');
if (selected.length === 0) {
lines.push(`${pc.dim('│')} ${pc.dim('(none)')}`);
} else {
for (const { item, idx } of selected) {
lines.push(`${pc.dim('│')} ${pc.cyan(item.name)} ${pc.dim('→')} ${pc.bold(levels[idx])}`);
}
}
lines.push(pc.dim('│'));
} else {
lines.push(`${pc.cyan('◆')} Select bump levels`);
lines.push(`${pc.dim('│')} ${pc.dim('↑/↓ navigate · ←/→ change level · enter to confirm')}`);
lines.push(`${pc.dim('│')} ${pc.dim('0 clear current · x clear all · r reset all to defaults')}`);
lines.push(pc.dim('│'));

let displayIdx = 0;

if (changedEntries.length > 0) {
lines.push(`${pc.dim('│')} ${pc.underline('Changed')}`);
for (const { item, idx } of changedEntries) {
lines.push(formatRow(item, levels[idx]!, cursor === displayIdx));
displayIdx++;
}
if (unchangedEntries.length > 0) {
lines.push(pc.dim('│'));
}
}

if (unchangedEntries.length > 0) {
lines.push(`${pc.dim('│')} ${pc.underline('Unchanged')}`);
for (const { item, idx } of unchangedEntries) {
lines.push(formatRow(item, levels[idx]!, cursor === displayIdx));
displayIdx++;
}
}

lines.push(pc.dim('│'));
const selectedCount = levels.filter((l) => l !== 'none').length;
lines.push(`${pc.dim('│')} ${pc.dim(`${selectedCount} package${selectedCount !== 1 ? 's' : ''} selected`)}`);
lines.push(`${pc.dim('└')}`);
}

const output = lines.join('\n') + '\n';
stdout.write(output);
renderedLines = lines.length;
}

function cleanup() {
rl.close();
stdout.write('\x1B[?25h'); // Show cursor
if (stdin.isTTY) stdin.setRawMode(false);
}

function finish(result: BumpSelectResult[] | symbol) {
render(true);
cleanup();
resolve(result);
}

if (stdin.isTTY) stdin.setRawMode(true);
stdin.resume();

render();

stdin.on('keypress', (_str: string | undefined, key: readline.Key) => {
if (!key) return;

if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
cleanup();
// Clear the render
if (renderedLines > 0) {
stdout.write(`\x1B[${renderedLines}A`);
stdout.write('\x1B[0J');
}
stdout.write(`${pc.red('■')} Cancelled\n`);
const cancelSymbol = Symbol('cancel');
resolve(cancelSymbol);
return;
}

if (key.name === 'return') {
const results: BumpSelectResult[] = [];
for (let i = 0; i < items.length; i++) {
if (levels[i] !== 'none') {
results.push({ name: items[i]!.name, type: levels[i] as BumpTypeWithIsolated });
}
}
finish(results);
return;
}

if (key.name === 'up' || key.name === 'k') {
cursor = (cursor - 1 + displayOrder.length) % displayOrder.length;
} else if (key.name === 'down' || key.name === 'j') {
cursor = (cursor + 1) % displayOrder.length;
} else if (key.name === 'right' || key.name === 'l') {
const entry = displayOrder[cursor]!;
const currentLevel = LEVELS.indexOf(levels[entry.idx]!);
if (currentLevel < LEVELS.length - 1) {
levels[entry.idx] = LEVELS[currentLevel + 1]!;
}
} else if (key.name === 'left' || key.name === 'h') {
const entry = displayOrder[cursor]!;
const currentLevel = LEVELS.indexOf(levels[entry.idx]!);
if (currentLevel > 0) {
levels[entry.idx] = LEVELS[currentLevel - 1]!;
}
} else if (_str === '0' || key.name === 'backspace') {
// Set current item to none
const entry = displayOrder[cursor]!;
levels[entry.idx] = 'none';
} else if (_str === 'r') {
// Reset all to defaults (changed=patch, unchanged=none)
for (let i = 0; i < items.length; i++) {
levels[i] = items[i]!.changed ? 'patch' : 'none';
}
} else if (_str === 'x') {
// Clear all — set everything to none
for (let i = 0; i < items.length; i++) {
levels[i] = 'none';
}
}

render();
});

// Enable keypress events
readline.emitKeypressEvents(stdin, rl);
});
}

function formatRow(item: BumpSelectItem, level: BumpLevel, focused: boolean): string {
const prefix = pc.dim('│');
const pointer = focused ? pc.cyan('›') : ' ';
const nameStr = focused ? pc.cyan(item.name) : item.name;
const versionStr = pc.dim(`(${item.version})`);
const levelStr = formatLevel(level, focused);

return `${prefix} ${pointer} ${nameStr} ${versionStr} ${levelStr}`;
}

function formatLevel(level: BumpLevel, focused: boolean): string {
if (!focused) {
if (level === 'none') return pc.dim('·');
if (level === 'major') return pc.red(level);
if (level === 'minor') return pc.yellow(level);
return pc.green(level);
}

// Show the level selector when focused
const parts = LEVELS.map((l) => {
if (l === level) {
if (l === 'none') return pc.bold(pc.dim('[none]'));
if (l === 'major') return pc.bold(pc.red(`[${l}]`));
if (l === 'minor') return pc.bold(pc.yellow(`[${l}]`));
return pc.bold(pc.green(`[${l}]`));
}
return pc.dim(l);
});

return `◄ ${parts.join(pc.dim(' · '))} ►`;
}