diff --git a/src/types/ui.ts b/src/types/ui.ts index 7312638..f561fe0 100644 --- a/src/types/ui.ts +++ b/src/types/ui.ts @@ -38,4 +38,5 @@ export interface GroupedPackages { export type RenderableItem = | { type: 'header'; title: string; sectionType: 'main' | 'peer' | 'optional' } | { type: 'spacer' } - | { type: 'package'; state: PackageSelectionState; originalIndex: number } + | { type: 'package'; state: PackageSelectionState; originalIndex: number; groupPosition?: 'middle' | 'last' } + | { type: 'group-header'; scope: string; memberIndices: number[]; collapsed: boolean } diff --git a/src/ui/input-handler.ts b/src/ui/input-handler.ts index 235d586..1a6fac2 100644 --- a/src/ui/input-handler.ts +++ b/src/ui/input-handler.ts @@ -35,6 +35,7 @@ export type InputAction = depType: 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies' } | { type: 'trigger_audit_scan' } + | { type: 'toggle_group_collapse' } export class InputHandler { private stateManager: StateManager @@ -257,6 +258,10 @@ export class InputHandler { this.onAction({ type: 'select_right' }) break + case 'space': + this.onAction({ type: 'toggle_group_collapse' }) + break + case 'return': // Check if any packages are selected const selectedCount = states.filter( diff --git a/src/ui/renderer/index.ts b/src/ui/renderer/index.ts index cb6ad3e..6d99120 100644 --- a/src/ui/renderer/index.ts +++ b/src/ui/renderer/index.ts @@ -9,7 +9,7 @@ import * as PackageList from './package-list' import * as Confirmation from './confirmation' import * as Modal from '../modal' import { InfoModalTab, ModalRenderResult } from '../modal' -import { PackageListRenderOptions } from './package-list' +import { PackageListRenderOptions, GroupAggregateLookup } from './package-list' /** * Main UI renderer class that composes all rendering parts @@ -47,7 +47,9 @@ export class UIRenderer { terminalWidth: number = 80, loadingProgress?: PackageLoadProgress, auditProgress?: AuditProgress, - options?: PackageListRenderOptions + options?: PackageListRenderOptions, + focusedGroupVisualIndex: number | null = null, + groupAggregateLookup?: GroupAggregateLookup ): string[] { return PackageList.renderInterface( states, @@ -64,7 +66,9 @@ export class UIRenderer { terminalWidth, loadingProgress, auditProgress, - options + options, + focusedGroupVisualIndex, + groupAggregateLookup ) } diff --git a/src/ui/renderer/package-list/interface.ts b/src/ui/renderer/package-list/interface.ts index 6cf4404..b6febdd 100644 --- a/src/ui/renderer/package-list/interface.ts +++ b/src/ui/renderer/package-list/interface.ts @@ -12,10 +12,17 @@ import { padLineToWidth, renderPackageLine, renderSectionHeader, + renderGroupHeader, renderSpacer, PackageListRenderOptions, + GroupHeaderAggregate, } from './rows' +export type GroupAggregateLookup = ( + scope: string, + memberIndices: number[] +) => GroupHeaderAggregate + export function renderInterface( states: PackageSelectionState[], currentRow: number, @@ -31,7 +38,9 @@ export function renderInterface( terminalWidth: number = 80, loadingProgress?: PackageLoadProgress, auditProgress?: AuditProgress, - options: PackageListRenderOptions = {} + options: PackageListRenderOptions = {}, + focusedGroupVisualIndex: number | null = null, + groupAggregateLookup?: GroupAggregateLookup ): string[] { const output: string[] = [] @@ -195,6 +204,10 @@ export function renderInterface( chalk.gray('Clear') } } else { + const groupHint = + focusedGroupVisualIndex !== null + ? ' ' + chalk.bold.white('Space ') + chalk.gray('Collapse') + : '' if (totalVisualItems > maxVisibleItems) { statusLine = chalk.gray( @@ -202,13 +215,15 @@ export function renderInterface( ) + ' ' + chalk.bold.white('Enter ') + - chalk.gray('Confirm') + chalk.gray('Confirm') + + groupHint } else { statusLine = chalk.gray(`Showing all ${chalk.white(totalPackages)} packages`) + ' ' + chalk.bold.white('Enter ') + - chalk.gray('Confirm') + chalk.gray('Confirm') + + groupHint } } @@ -235,13 +250,28 @@ export function renderInterface( output.push(renderSectionHeader(item.title, item.sectionType)) } else if (item.type === 'spacer') { output.push(renderSpacer()) + } else if (item.type === 'group-header') { + const isCurrent = focusedGroupVisualIndex === i + const aggregate = groupAggregateLookup?.(item.scope, item.memberIndices) + output.push( + renderGroupHeader( + item.scope, + item.memberIndices.length, + isCurrent, + item.collapsed, + aggregate + ) + ) } else if (item.type === 'package') { + const isCurrent = + focusedGroupVisualIndex === null && item.originalIndex === currentRow const line = renderPackageLine( item.state, item.originalIndex, - item.originalIndex === currentRow, + isCurrent, terminalWidth, - options + options, + item.groupPosition ) output.push(line) } diff --git a/src/ui/renderer/package-list/rows.ts b/src/ui/renderer/package-list/rows.ts index 872d9e4..c195818 100644 --- a/src/ui/renderer/package-list/rows.ts +++ b/src/ui/renderer/package-list/rows.ts @@ -33,12 +33,23 @@ export function renderPackageLine( _index: number, isCurrentRow: boolean, terminalWidth: number = 80, - options: PackageListRenderOptions = {} + options: PackageListRenderOptions = {}, + groupPosition?: 'middle' | 'last' ): string { const prefix = isCurrentRow ? getThemeColor('success')('❯ ') : ' ' + const isGrouped = groupPosition !== undefined + const treeChar = groupPosition === 'last' ? '└ ' : groupPosition === 'middle' ? '├ ' : '' + const treeDecor = isGrouped + ? (isCurrentRow ? getThemeColor('success') : getThemeColor('packageAuthor'))(treeChar) + : '' + + const displayedFullName = isGrouped + ? state.name.slice(state.name.indexOf('/') + 1) + : state.name + let packageName - if (state.name.startsWith('@')) { + if (!isGrouped && state.name.startsWith('@')) { const parts = state.name.split('/') if (parts.length >= 2) { const author = parts[0] @@ -57,7 +68,9 @@ export function renderPackageLine( : chalk.white(state.name) } } else { - packageName = isCurrentRow ? getThemeColor('packageName')(state.name) : chalk.white(state.name) + packageName = isCurrentRow + ? getThemeColor('packageName')(displayedFullName) + : chalk.white(displayedFullName) } const isCurrentSelected = state.selectedOption === 'none' @@ -125,29 +138,36 @@ export function renderPackageLine( Math.max(minPackageNameWidth, availableForPackageName) ) + const treeWidth = isGrouped ? 2 : 0 const badgeWidth = state.type === 'dependencies' ? 0 : 3 - const truncatedName = VersionUtils.truncateMiddle(state.name, packageNameWidth - 1 - badgeWidth) + const truncatedName = VersionUtils.truncateMiddle( + displayedFullName, + packageNameWidth - 1 - badgeWidth - treeWidth + ) const shouldShowDashes = (paddingAmount: number): boolean => paddingAmount > 2 const dashColor = isCurrentRow ? chalk.white : chalk.gray - const displayName = truncatedName !== state.name ? truncatedName : packageName + const displayName = truncatedName !== displayedFullName ? truncatedName : packageName const typeBadge = getTypeBadge(state.type) const shouldShowVulnerability = shouldDisplayVulnerabilityForDependency(state.type, options) const vulnBadge = shouldShowVulnerability ? getVulnerabilityBadge(state.vulnerability) : '' const vulnBadgeWidth = vulnBadge ? VersionUtils.getVisualLength(vulnBadge) + 1 : 0 const nameLength = VersionUtils.getVisualLength(truncatedName) - const namePadding = Math.max(0, packageNameWidth - nameLength - 1 - badgeWidth - vulnBadgeWidth) + const namePadding = Math.max( + 0, + packageNameWidth - nameLength - 1 - badgeWidth - vulnBadgeWidth - treeWidth + ) const nameDashes = shouldShowDashes(namePadding) ? dashColor('-').repeat(namePadding) : ' '.repeat(namePadding) const vulnSuffix = vulnBadge ? ` ${vulnBadge}` : '' const packageNameSection = typeBadge - ? `${displayName} ${nameDashes}${vulnSuffix}${typeBadge}` - : `${displayName} ${nameDashes}${vulnSuffix}` + ? `${treeDecor}${displayName} ${nameDashes}${vulnSuffix}${typeBadge}` + : `${treeDecor}${displayName} ${nameDashes}${vulnSuffix}` const currentSection = `${currentDot} ${currentVersion}` const currentSectionLength = VersionUtils.getVisualLength(currentSection) + 1 @@ -195,6 +215,70 @@ export function renderSectionHeader( return ' ' + colorFn.bold(title) } +export interface GroupHeaderAggregate { + total: number + ready: number + pending: number + selectedNone: number + selectedRange: number + selectedLatest: number + hasRangeAvailable: number + hasMajorAvailable: number + vulnerable: number +} + +export function renderGroupHeader( + scope: string, + memberCount: number, + isCurrentRow: boolean, + collapsed: boolean, + aggregate?: GroupHeaderAggregate +): string { + const prefix = isCurrentRow ? getThemeColor('success')('❯ ') : ' ' + const arrow = collapsed ? '▸' : '▾' + const arrowColored = isCurrentRow + ? getThemeColor('success')(arrow) + : getThemeColor('packageAuthor')(arrow) + const scopeText = isCurrentRow + ? chalk.bold(getThemeColor('packageAuthor')(scope)) + : chalk.bold(getThemeColor('packageAuthor')(scope)) + const count = getThemeColor('textSecondary')(`(${memberCount})`) + + let summary = '' + if (aggregate) { + const parts: string[] = [] + const selected = + aggregate.selectedRange + aggregate.selectedLatest + if (selected > 0) { + parts.push(getThemeColor('success')(`✓ ${selected}/${aggregate.total} selected`)) + } + if (aggregate.pending > 0) { + parts.push(getThemeColor('textSecondary')(`${aggregate.pending} loading`)) + } + const updatesAvailable = aggregate.hasRangeAvailable + aggregate.hasMajorAvailable + if (updatesAvailable > 0 && selected === 0) { + const rangeBit = + aggregate.hasRangeAvailable > 0 + ? getThemeColor('versionRange')(`${aggregate.hasRangeAvailable} minor`) + : '' + const latestBit = + aggregate.hasMajorAvailable > 0 + ? getThemeColor('versionLatest')(`${aggregate.hasMajorAvailable} major`) + : '' + const bits = [rangeBit, latestBit].filter(Boolean).join(getThemeColor('textSecondary')(' · ')) + if (bits) parts.push(bits) + } + if (aggregate.vulnerable > 0) { + parts.push(getThemeColor('error')(`⚠ ${aggregate.vulnerable}`)) + } + if (parts.length > 0) { + summary = ' ' + parts.join(getThemeColor('textSecondary')(' · ')) + } + } + + return `${prefix}${arrowColored} ${scopeText} ${count}${summary}` +} + export function renderSpacer(): string { return '' } diff --git a/src/ui/session/action-dispatcher.ts b/src/ui/session/action-dispatcher.ts index a3c9170..1d653c5 100644 --- a/src/ui/session/action-dispatcher.ts +++ b/src/ui/session/action-dispatcher.ts @@ -13,6 +13,7 @@ const INTERACTIVE_ACTIONS = new Set([ 'bulk_select_latest', 'bulk_unselect_all', 'toggle_dep_type_filter', + 'toggle_group_collapse', ]) export type DispatchContext = { @@ -55,10 +56,21 @@ export function dispatchAction(action: InputAction, ctx: DispatchContext): void stateManager.navigateDown(filteredStates.length) break case 'select_left': - stateManager.updateSelection(filteredStates, 'left') + if (stateManager.getFocusedGroupVisualIndex() !== null) { + stateManager.cycleGroupSelection(filteredStates, 'left') + } else { + stateManager.updateSelection(filteredStates, 'left') + } break case 'select_right': - stateManager.updateSelection(filteredStates, 'right') + if (stateManager.getFocusedGroupVisualIndex() !== null) { + stateManager.cycleGroupSelection(filteredStates, 'right') + } else { + stateManager.updateSelection(filteredStates, 'right') + } + break + case 'toggle_group_collapse': + if (!stateManager.toggleFocusedGroupCollapse()) return break case 'bulk_select_minor': stateManager.bulkSelectMinor(filteredStates) diff --git a/src/ui/session/interactive-session.ts b/src/ui/session/interactive-session.ts index fff34ac..7bca1d6 100644 --- a/src/ui/session/interactive-session.ts +++ b/src/ui/session/interactive-session.ts @@ -44,7 +44,7 @@ export async function runInteractiveSession( let previousViewportMode: 'list' | 'info-modal' | 'theme-modal' | null = null let previousModalViewportLineCount: number | null = null - stateManager.setRenderableItems([]) + // Renderable items get rebuilt on each render to stay in sync with filter/state changes const claimInteractiveScreen = () => { if (ownsAlternateScreen) return @@ -261,13 +261,17 @@ export async function runInteractiveSession( const terminalWidth = process.stdout.columns || 80 const terminalHeight = getTerminalHeight() const activeFilterLabel = stateManager.getActiveFilterLabel() + const renderableItems = stateManager.buildAndSetRenderableItems(filteredStates) + const focusedGroupVisualIndex = stateManager.getFocusedGroupVisualIndex() + const groupAggregateLookup = (_scope: string, memberIndices: number[]) => + stateManager.getGroupAggregate(filteredStates, memberIndices) const lines = renderer.renderInterface( filteredStates, uiState.currentRow, uiState.scrollOffset, uiState.maxVisibleItems, uiState.forceFullRender, - [], + renderableItems, activeFilterLabel, packageManager, uiState.filterMode, @@ -276,7 +280,9 @@ export async function runInteractiveSession( terminalWidth, loadingProgress, auditProgress, - packageListRenderOptions + packageListRenderOptions, + focusedGroupVisualIndex, + groupAggregateLookup ) renderViewport(lines, terminalWidth, terminalHeight, bgCode) diff --git a/src/ui/state/index.ts b/src/ui/state/index.ts index 757399b..82f549d 100644 --- a/src/ui/state/index.ts +++ b/src/ui/state/index.ts @@ -2,3 +2,4 @@ export { StateManager, type UIState } from './state-manager' export { NavigationManager, type NavigationState } from './navigation-manager' export { ModalManager, type ModalState } from './modal-manager' export { FilterManager, type FilterState } from './filter-manager' +export { buildScopeGroupedItems } from './scope-grouping' diff --git a/src/ui/state/navigation-manager.ts b/src/ui/state/navigation-manager.ts index a0023dc..9344fe4 100644 --- a/src/ui/state/navigation-manager.ts +++ b/src/ui/state/navigation-manager.ts @@ -5,6 +5,7 @@ export interface NavigationState { previousRow: number scrollOffset: number // Scroll offset in visual rows (includes headers/spacers) previousScrollOffset: number + focusedGroupScope: string | null // When non-null, a group header is focused (by scope identity) } export class NavigationManager { @@ -18,6 +19,7 @@ export class NavigationManager { previousRow: -1, scrollOffset: 0, previousScrollOffset: 0, + focusedGroupScope: null, } this.maxVisibleItems = maxVisibleItems } @@ -37,6 +39,7 @@ export class NavigationManager { setCurrentRow(row: number): void { this.state.previousRow = this.state.currentRow this.state.currentRow = row + this.state.focusedGroupScope = null } setScrollOffset(offset: number): void { @@ -46,6 +49,19 @@ export class NavigationManager { setRenderableItems(items: RenderableItem[]): void { this.renderableItems = items + // Clear group focus if the focused scope no longer exists as a group header + if (this.state.focusedGroupScope !== null) { + const stillExists = items.some( + (item) => item.type === 'group-header' && item.scope === this.state.focusedGroupScope + ) + if (!stillExists) { + this.state.focusedGroupScope = null + } + } + } + + clearGroupFocus(): void { + this.state.focusedGroupScope = null } setMaxVisibleItems(maxVisible: number): void { @@ -58,12 +74,9 @@ export class NavigationManager { // Convert package index to visual row index in renderable items packageIndexToVisualIndex(packageIndex: number): number { - // If no renderable items (flat mode), visual index equals package index if (this.renderableItems.length === 0) { return packageIndex } - - // Otherwise search in renderable items (grouped mode) for (let i = 0; i < this.renderableItems.length; i++) { const item = this.renderableItems[i] if (item.type === 'package' && item.originalIndex === packageIndex) { @@ -73,95 +86,155 @@ export class NavigationManager { return 0 } - // Find the next navigable package index in the given direction - private findNextPackageIndex( - currentPackageIndex: number, - direction: 'up' | 'down', - totalPackages: number - ): number { - if (this.renderableItems.length === 0) { - // Fallback to simple navigation if no renderable items - if (direction === 'up') { - return currentPackageIndex <= 0 ? totalPackages - 1 : currentPackageIndex - 1 - } else { - return currentPackageIndex >= totalPackages - 1 ? 0 : currentPackageIndex + 1 + getFocusedGroupScope(): string | null { + return this.state.focusedGroupScope + } + + getFocusedGroupVisualIndex(): number | null { + if (this.state.focusedGroupScope === null) return null + for (let i = 0; i < this.renderableItems.length; i++) { + const item = this.renderableItems[i] + if (item.type === 'group-header' && item.scope === this.state.focusedGroupScope) { + return i } } - - // Get all package items with their visual indices - const packageItems: { visualIndex: number; packageIndex: number }[] = [] + return null + } + + setFocusedGroupScope(scope: string | null): void { + this.state.focusedGroupScope = scope + } + + // Build ordered list of navigable focus points (packages and group headers) + private getNavigableFocusPoints(): { + visualIndex: number + kind: 'package' | 'group' + packageIndex?: number + scope?: string + }[] { + const points: { + visualIndex: number + kind: 'package' | 'group' + packageIndex?: number + scope?: string + }[] = [] for (let i = 0; i < this.renderableItems.length; i++) { const item = this.renderableItems[i] if (item.type === 'package') { - packageItems.push({ visualIndex: i, packageIndex: item.originalIndex }) + points.push({ visualIndex: i, kind: 'package', packageIndex: item.originalIndex }) + } else if (item.type === 'group-header') { + points.push({ visualIndex: i, kind: 'group', scope: item.scope }) } } + return points + } - if (packageItems.length === 0) return currentPackageIndex + // Find the next navigable focus in the given direction + private moveFocus(direction: 'up' | 'down', totalPackages: number): void { + if (this.renderableItems.length === 0) { + if (direction === 'up') { + this.state.currentRow = + this.state.currentRow <= 0 ? totalPackages - 1 : this.state.currentRow - 1 + } else { + this.state.currentRow = + this.state.currentRow >= totalPackages - 1 ? 0 : this.state.currentRow + 1 + } + return + } - // Find current position in packageItems - const currentPos = packageItems.findIndex((p) => p.packageIndex === currentPackageIndex) - if (currentPos === -1) return packageItems[0].packageIndex + const focusPoints = this.getNavigableFocusPoints() + if (focusPoints.length === 0) return - // Navigate with wrap-around at boundaries - if (direction === 'up') { - const newPos = currentPos <= 0 ? packageItems.length - 1 : currentPos - 1 - return packageItems[newPos].packageIndex + let currentPos = -1 + if (this.state.focusedGroupScope !== null) { + currentPos = focusPoints.findIndex( + (p) => p.kind === 'group' && p.scope === this.state.focusedGroupScope + ) } else { - const newPos = currentPos >= packageItems.length - 1 ? 0 : currentPos + 1 - return packageItems[newPos].packageIndex + currentPos = focusPoints.findIndex( + (p) => p.kind === 'package' && p.packageIndex === this.state.currentRow + ) + } + if (currentPos === -1) currentPos = 0 + + const newPos = + direction === 'up' + ? currentPos <= 0 + ? focusPoints.length - 1 + : currentPos - 1 + : currentPos >= focusPoints.length - 1 + ? 0 + : currentPos + 1 + + const target = focusPoints[newPos] + if (target.kind === 'group') { + this.state.focusedGroupScope = target.scope! + } else { + this.state.focusedGroupScope = null + this.state.currentRow = target.packageIndex! } } navigateUp(totalItems: number): void { if (totalItems === 0) return this.state.previousRow = this.state.currentRow - this.state.currentRow = this.findNextPackageIndex(this.state.currentRow, 'up', totalItems) - this.ensureVisible(this.state.currentRow, totalItems) + this.moveFocus('up', totalItems) + this.ensureFocusVisible(totalItems) } navigateDown(totalItems: number): void { if (totalItems === 0) return this.state.previousRow = this.state.currentRow - this.state.currentRow = this.findNextPackageIndex(this.state.currentRow, 'down', totalItems) - this.ensureVisible(this.state.currentRow, totalItems) + this.moveFocus('down', totalItems) + this.ensureFocusVisible(totalItems) + } + + private ensureFocusVisible(totalPackages: number): void { + const groupVisualIndex = this.getFocusedGroupVisualIndex() + if (groupVisualIndex !== null) { + this.ensureVisualIndexVisible(groupVisualIndex, totalPackages) + } else { + this.ensureVisible(this.state.currentRow, totalPackages) + } + } + + private ensureVisualIndexVisible(visualIndex: number, totalPackages: number): void { + const totalVisualItems = this.renderableItems.length || totalPackages + if (visualIndex < this.state.scrollOffset) { + this.state.scrollOffset = visualIndex + } else if (visualIndex >= this.state.scrollOffset + this.maxVisibleItems) { + this.state.scrollOffset = visualIndex - this.maxVisibleItems + 1 + } + const maxScroll = Math.max(0, totalVisualItems - this.maxVisibleItems) + this.state.scrollOffset = Math.max(0, Math.min(this.state.scrollOffset, maxScroll)) } private ensureVisible(packageIndex: number, totalPackages: number): void { - // Convert package index to visual index for scrolling const visualIndex = this.packageIndexToVisualIndex(packageIndex) const totalVisualItems = this.renderableItems.length || totalPackages - // Try to show section header if the current item is just below a header let targetVisualIndex = visualIndex if (visualIndex > 0) { const prevItem = this.renderableItems[visualIndex - 1] if (prevItem?.type === 'header') { targetVisualIndex = visualIndex - 1 } else if (visualIndex > 1) { - // Also check for spacer + header combo (for first package in non-first section) const prevPrevItem = this.renderableItems[visualIndex - 2] if (prevItem?.type === 'spacer' && prevPrevItem?.type === 'header') { - // Show spacer and header if possible targetVisualIndex = Math.max(0, visualIndex - 2) } } } - // Scrolling up: scroll up by 1 item if (targetVisualIndex < this.state.scrollOffset) { this.state.scrollOffset = targetVisualIndex - } - // Scrolling down: adjust scroll to keep item visible - else if (visualIndex >= this.state.scrollOffset + this.maxVisibleItems) { + } else if (visualIndex >= this.state.scrollOffset + this.maxVisibleItems) { this.state.scrollOffset = visualIndex - this.maxVisibleItems + 1 } - // Ensure scrollOffset doesn't go negative or beyond bounds const maxScroll = Math.max(0, totalVisualItems - this.maxVisibleItems) this.state.scrollOffset = Math.max(0, Math.min(this.state.scrollOffset, maxScroll)) - // Handle wrap-around: if we're at the last item and it's out of view, show it at bottom if ( visualIndex === totalVisualItems - 1 && visualIndex >= this.state.scrollOffset + this.maxVisibleItems diff --git a/src/ui/state/scope-grouping.ts b/src/ui/state/scope-grouping.ts new file mode 100644 index 0000000..d3b59f0 --- /dev/null +++ b/src/ui/state/scope-grouping.ts @@ -0,0 +1,88 @@ +import { PackageSelectionState, RenderableItem } from '../../types' + +const GROUP_THRESHOLD = 2 + +function getScope(name: string): string | null { + if (!name.startsWith('@')) return null + const slash = name.indexOf('/') + return slash === -1 ? null : name.slice(0, slash) +} + +export interface ScopeGroupingOptions { + collapsedScopes?: ReadonlySet +} + +export function buildScopeGroupedItems( + states: PackageSelectionState[], + options: ScopeGroupingOptions = {} +): RenderableItem[] { + if (states.length === 0) return [] + const collapsed = options.collapsedScopes ?? new Set() + + const scopeOrder: string[] = [] + const scopeBuckets = new Map() + const standalone: number[] = [] + + states.forEach((state, index) => { + const scope = getScope(state.name) + if (scope === null) { + standalone.push(index) + return + } + if (!scopeBuckets.has(scope)) { + scopeBuckets.set(scope, []) + scopeOrder.push(scope) + } + scopeBuckets.get(scope)!.push(index) + }) + + const items: RenderableItem[] = [] + let nextStandalone = 0 + + const emitStandaloneUpTo = (limitIndex: number) => { + while (nextStandalone < standalone.length && standalone[nextStandalone] < limitIndex) { + items.push({ + type: 'package', + state: states[standalone[nextStandalone]], + originalIndex: standalone[nextStandalone], + }) + nextStandalone++ + } + } + + for (const scope of scopeOrder) { + const indices = scopeBuckets.get(scope)! + const firstIndex = indices[0] + emitStandaloneUpTo(firstIndex) + + if (indices.length < GROUP_THRESHOLD) { + indices.forEach((i) => { + items.push({ type: 'package', state: states[i], originalIndex: i }) + }) + continue + } + + const isCollapsed = collapsed.has(scope) + items.push({ + type: 'group-header', + scope, + memberIndices: [...indices], + collapsed: isCollapsed, + }) + + if (isCollapsed) continue + + indices.forEach((i, idx) => { + items.push({ + type: 'package', + state: states[i], + originalIndex: i, + groupPosition: idx === indices.length - 1 ? 'last' : 'middle', + }) + }) + } + + emitStandaloneUpTo(states.length) + + return items +} diff --git a/src/ui/state/state-manager.ts b/src/ui/state/state-manager.ts index b74cda3..ae3d45f 100644 --- a/src/ui/state/state-manager.ts +++ b/src/ui/state/state-manager.ts @@ -3,6 +3,7 @@ import { NavigationManager } from './navigation-manager' import { ModalManager, InfoModalTab } from './modal-manager' import { FilterManager } from './filter-manager' import { ThemeManager } from './theme-manager' +import { buildScopeGroupedItems } from './scope-grouping' export interface DisplayState { maxVisibleItems: number @@ -45,6 +46,7 @@ export class StateManager { private themeManager: ThemeManager private displayState: DisplayState private renderState: RenderState + private collapsedScopes: Set = new Set() private readonly headerLines = 5 // title (with label) + empty + 1 instruction line + status + empty constructor(initialRow: number = 0, terminalHeight: number = 24) { @@ -103,6 +105,143 @@ export class StateManager { this.navigationManager.setRenderableItems(items) } + buildAndSetRenderableItems(filteredStates: PackageSelectionState[]): RenderableItem[] { + const items = buildScopeGroupedItems(filteredStates, { + collapsedScopes: this.collapsedScopes, + }) + this.setRenderableItems(items) + return items + } + + getFocusedGroupVisualIndex(): number | null { + return this.navigationManager.getFocusedGroupVisualIndex() + } + + getFocusedGroupScope(): string | null { + return this.navigationManager.getFocusedGroupScope() + } + + getFocusedGroupHeader(): { scope: string; memberIndices: number[]; collapsed: boolean } | null { + const scope = this.navigationManager.getFocusedGroupScope() + if (scope === null) return null + for (const item of this.renderState.renderableItems) { + if (item.type === 'group-header' && item.scope === scope) { + return { scope: item.scope, memberIndices: item.memberIndices, collapsed: item.collapsed } + } + } + return null + } + + isScopeCollapsed(scope: string): boolean { + return this.collapsedScopes.has(scope) + } + + toggleFocusedGroupCollapse(): boolean { + const scope = this.navigationManager.getFocusedGroupScope() + if (scope === null) return false + if (this.collapsedScopes.has(scope)) { + this.collapsedScopes.delete(scope) + } else { + this.collapsedScopes.add(scope) + } + this.renderState.forceFullRender = true + return true + } + + applyGroupSelection( + filteredStates: PackageSelectionState[], + option: 'none' | 'range' | 'latest' + ): void { + const group = this.getFocusedGroupHeader() + if (!group) return + group.memberIndices.forEach((idx) => { + const state = filteredStates[idx] + if (!state || state.loadState !== 'ready') return + if (option === 'range' && !state.hasRangeUpdate) return + if (option === 'latest' && !state.hasMajorUpdate) return + state.selectedOption = option + }) + } + + cycleGroupSelection(filteredStates: PackageSelectionState[], direction: 'left' | 'right'): void { + const group = this.getFocusedGroupHeader() + if (!group) return + + const members = group.memberIndices + .map((i) => filteredStates[i]) + .filter((s): s is PackageSelectionState => !!s && s.loadState === 'ready') + if (members.length === 0) return + + const hasRange = members.some((m) => m.hasRangeUpdate) + const hasMajor = members.some((m) => m.hasMajorUpdate) + + // Dominant = most common current selection among loaded members. + // Tie-break order: latest > range > none (so a half-and-half group between + // 'latest' and 'none' moves out of 'latest' on Left and into 'none' on Right). + const counts = { none: 0, range: 0, latest: 0 } + members.forEach((m) => counts[m.selectedOption]++) + const dominant: 'none' | 'range' | 'latest' = + counts.latest >= counts.range && counts.latest >= counts.none && counts.latest > 0 + ? 'latest' + : counts.range >= counts.none && counts.range > 0 + ? 'range' + : 'none' + + let next: 'none' | 'range' | 'latest' = dominant + if (direction === 'right') { + if (dominant === 'none') next = hasRange ? 'range' : hasMajor ? 'latest' : 'none' + else if (dominant === 'range') next = hasMajor ? 'latest' : 'none' + else next = 'none' + } else { + if (dominant === 'latest') next = hasRange ? 'range' : 'none' + else if (dominant === 'range') next = 'none' + else next = hasMajor ? 'latest' : hasRange ? 'range' : 'none' + } + + this.applyGroupSelection(filteredStates, next) + } + + // Aggregate selection summary for a group header — used by the renderer. + getGroupAggregate( + filteredStates: PackageSelectionState[], + memberIndices: number[] + ): { + total: number + ready: number + pending: number + selectedNone: number + selectedRange: number + selectedLatest: number + hasRangeAvailable: number + hasMajorAvailable: number + vulnerable: number + } { + const agg = { + total: memberIndices.length, + ready: 0, + pending: 0, + selectedNone: 0, + selectedRange: 0, + selectedLatest: 0, + hasRangeAvailable: 0, + hasMajorAvailable: 0, + vulnerable: 0, + } + memberIndices.forEach((i) => { + const s = filteredStates[i] + if (!s) return + if (s.loadState === 'ready') agg.ready++ + if (s.loadState === 'pending') agg.pending++ + if (s.hasRangeUpdate) agg.hasRangeAvailable++ + if (s.hasMajorUpdate) agg.hasMajorAvailable++ + if (s.vulnerability && s.vulnerability.count > 0) agg.vulnerable++ + if (s.selectedOption === 'none') agg.selectedNone++ + else if (s.selectedOption === 'range') agg.selectedRange++ + else if (s.selectedOption === 'latest') agg.selectedLatest++ + }) + return agg + } + // Navigation delegation navigateUp(totalItems: number): void { this.navigationManager.navigateUp(totalItems) diff --git a/test/unit/ui/navigation-manager.test.ts b/test/unit/ui/navigation-manager.test.ts new file mode 100644 index 0000000..53fb8d4 --- /dev/null +++ b/test/unit/ui/navigation-manager.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { NavigationManager } from '../../../src/ui/state/navigation-manager' +import { PackageSelectionState, RenderableItem } from '../../../src/types' +import { buildScopeGroupedItems } from '../../../src/ui/state/scope-grouping' + +const make = (name: string): PackageSelectionState => ({ + name, + packageJsonPath: '/repo/package.json', + packageJsonPaths: ['/repo/package.json'], + currentVersionSpecifier: '^1.0.0', + currentVersion: '1.0.0', + rangeVersion: '1.0.0', + latestVersion: '1.0.0', + selectedOption: 'none', + loadState: 'ready', + hasRangeUpdate: false, + hasMajorUpdate: false, + type: 'dependencies', +}) + +describe('NavigationManager group focus', () => { + it('moves focus onto a group header when navigating into a group', () => { + const states = [make('react'), make('@tiptap/react'), make('@tiptap/pm')] + const items = buildScopeGroupedItems(states) + const nav = new NavigationManager(0, 10) + nav.setRenderableItems(items) + + // Start at react (currentRow=0). navigateDown should reach @tiptap header next. + nav.navigateDown(states.length) + expect(nav.getFocusedGroupScope()).toBe('@tiptap') + + // Next down: into first @tiptap member (index 1 in states). + nav.navigateDown(states.length) + expect(nav.getFocusedGroupScope()).toBeNull() + expect(nav.getCurrentRow()).toBe(1) + }) + + it('keeps a focused group focused after rebuild if the scope still exists as a group', () => { + const states = [make('@tiptap/react'), make('@tiptap/pm'), make('@tiptap/extension-link')] + const itemsA = buildScopeGroupedItems(states) + const nav = new NavigationManager(0, 10) + nav.setRenderableItems(itemsA) + + // Move focus onto @tiptap header + nav.navigateUp(states.length) // wrap; the only navigable points are header + 3 packages, so up from idx 0 -> last package + // Re-navigate deterministically: down brings us to header (from package), down to first, etc. + nav.setRenderableItems(itemsA) + // Simulate the user landing on the header by directly setting it + nav.setFocusedGroupScope('@tiptap') + expect(nav.getFocusedGroupScope()).toBe('@tiptap') + + // Rebuild with the same items (e.g. filter that doesn't remove the group) + const itemsB = buildScopeGroupedItems(states) + nav.setRenderableItems(itemsB) + expect(nav.getFocusedGroupScope()).toBe('@tiptap') + }) + + it('clears focus if rebuild removes the focused scope', () => { + const states = [make('@tiptap/react'), make('@tiptap/pm')] + const items = buildScopeGroupedItems(states) + const nav = new NavigationManager(0, 10) + nav.setRenderableItems(items) + nav.setFocusedGroupScope('@tiptap') + + // After rebuild with the group filtered out, focus should clear. + const emptyItems: RenderableItem[] = [] + nav.setRenderableItems(emptyItems) + expect(nav.getFocusedGroupScope()).toBeNull() + }) + + it('packageIndexToVisualIndex finds the package row inside a group', () => { + const states = [make('react'), make('@tiptap/react'), make('@tiptap/pm')] + const items = buildScopeGroupedItems(states) + const nav = new NavigationManager(0, 10) + nav.setRenderableItems(items) + + // react is at visual index 0 + expect(nav.packageIndexToVisualIndex(0)).toBe(0) + // @tiptap/react is at visual index 2 (after header at 1) + expect(nav.packageIndexToVisualIndex(1)).toBe(2) + // @tiptap/pm is at visual index 3 + expect(nav.packageIndexToVisualIndex(2)).toBe(3) + }) + + it('falls back to flat navigation when there are no renderable items', () => { + const nav = new NavigationManager(0, 10) + nav.navigateDown(3) + expect(nav.getCurrentRow()).toBe(1) + nav.navigateDown(3) + nav.navigateDown(3) + // Wraps at the end + expect(nav.getCurrentRow()).toBe(0) + }) +}) diff --git a/test/unit/ui/scope-grouping.test.ts b/test/unit/ui/scope-grouping.test.ts new file mode 100644 index 0000000..e3afc52 --- /dev/null +++ b/test/unit/ui/scope-grouping.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { buildScopeGroupedItems } from '../../../src/ui/state/scope-grouping' +import { PackageSelectionState, RenderableItem } from '../../../src/types' + +const make = (overrides: Partial): PackageSelectionState => ({ + name: 'pkg', + packageJsonPath: '/repo/package.json', + packageJsonPaths: ['/repo/package.json'], + currentVersionSpecifier: '^1.0.0', + currentVersion: '1.0.0', + rangeVersion: '1.0.0', + latestVersion: '1.0.0', + selectedOption: 'none', + loadState: 'ready', + hasRangeUpdate: false, + hasMajorUpdate: false, + type: 'dependencies', + ...overrides, +}) + +describe('buildScopeGroupedItems', () => { + it('returns empty array for empty input', () => { + expect(buildScopeGroupedItems([])).toEqual([]) + }) + + it('does not group standalone (non-scoped) packages', () => { + const items = buildScopeGroupedItems([make({ name: 'react' }), make({ name: 'vue' })]) + expect(items.every((i) => i.type === 'package')).toBe(true) + expect(items).toHaveLength(2) + }) + + it('does not group a scope with only one member', () => { + const items = buildScopeGroupedItems([make({ name: '@apollo/client' })]) + expect(items).toHaveLength(1) + expect(items[0].type).toBe('package') + }) + + it('groups two packages sharing the same scope regardless of version', () => { + const items = buildScopeGroupedItems([ + make({ name: '@tiptap/react', currentVersionSpecifier: '^3.0.0' }), + make({ name: '@tiptap/pm', currentVersionSpecifier: '^3.1.0' }), + ]) + expect(items[0].type).toBe('group-header') + if (items[0].type === 'group-header') { + expect(items[0].scope).toBe('@tiptap') + expect(items[0].memberIndices).toEqual([0, 1]) + expect(items[0].collapsed).toBe(false) + } + // 1 header + 2 members + expect(items).toHaveLength(3) + expect(items[1].type).toBe('package') + expect(items[2].type).toBe('package') + if (items[1].type === 'package') expect(items[1].groupPosition).toBe('middle') + if (items[2].type === 'package') expect(items[2].groupPosition).toBe('last') + }) + + it('groups packages with mixed versions (no majority-version requirement)', () => { + // The 4 @graphql-codegen packages in the screenshot — all different versions. + const items = buildScopeGroupedItems([ + make({ name: '@graphql-codegen/cli', currentVersionSpecifier: '^6.3.1' }), + make({ name: '@graphql-codegen/schema-ast', currentVersionSpecifier: '^5.0.2' }), + make({ name: '@graphql-codegen/typescript', currentVersionSpecifier: '^5.0.10' }), + make({ name: '@graphql-codegen/typescript-operations', currentVersionSpecifier: '^5.1.0' }), + ]) + const header = items[0] + expect(header.type).toBe('group-header') + if (header.type === 'group-header') { + expect(header.memberIndices).toHaveLength(4) + } + expect(items.filter((i) => i.type === 'package')).toHaveLength(4) + }) + + it('hides member rows when a scope is in collapsedScopes', () => { + const items = buildScopeGroupedItems( + [make({ name: '@tiptap/react' }), make({ name: '@tiptap/pm' })], + { collapsedScopes: new Set(['@tiptap']) } + ) + expect(items).toHaveLength(1) + expect(items[0].type).toBe('group-header') + if (items[0].type === 'group-header') { + expect(items[0].collapsed).toBe(true) + // Even when collapsed, header retains member indices so cycling still works. + expect(items[0].memberIndices).toEqual([0, 1]) + } + }) + + it('interleaves standalone packages with scope groups, preserving input order', () => { + const items = buildScopeGroupedItems([ + make({ name: 'react' }), + make({ name: '@tiptap/react' }), + make({ name: '@tiptap/pm' }), + make({ name: 'vue' }), + ]) + // Expected: react (package), @tiptap header, 2 members, vue (package) + expect(items.map((i) => i.type)).toEqual([ + 'package', + 'group-header', + 'package', + 'package', + 'package', + ]) + }) + + it('handles two packages with the same name (e.g. dep + peerDep) as a 2-member group', () => { + // Mirrors the @apollo/client duplicate in the user's screenshot. + const items = buildScopeGroupedItems([ + make({ name: '@apollo/client', type: 'dependencies' }), + make({ name: '@apollo/client', type: 'peerDependencies' }), + ]) + expect(items[0].type).toBe('group-header') + if (items[0].type === 'group-header') { + expect(items[0].scope).toBe('@apollo') + expect(items[0].memberIndices).toEqual([0, 1]) + } + expect(items).toHaveLength(3) + }) + + it('preserves originalIndex on grouped package rows so they map back to filteredStates', () => { + const items = buildScopeGroupedItems([ + make({ name: 'standalone-a' }), + make({ name: '@tiptap/react' }), + make({ name: '@tiptap/pm' }), + ]) + const pkgItems = items.filter((i): i is Extract => + i.type === 'package' + ) + expect(pkgItems.map((p) => p.originalIndex)).toEqual([0, 1, 2]) + }) +}) diff --git a/test/unit/ui/state-manager-grouping.test.ts b/test/unit/ui/state-manager-grouping.test.ts new file mode 100644 index 0000000..4803a0b --- /dev/null +++ b/test/unit/ui/state-manager-grouping.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest' +import { StateManager } from '../../../src/ui/state' +import { PackageSelectionState } from '../../../src/types' + +const make = (overrides: Partial): PackageSelectionState => ({ + name: 'pkg', + packageJsonPath: '/repo/package.json', + packageJsonPaths: ['/repo/package.json'], + currentVersionSpecifier: '^1.0.0', + currentVersion: '1.0.0', + rangeVersion: '1.0.0', + latestVersion: '1.0.0', + selectedOption: 'none', + loadState: 'ready', + hasRangeUpdate: false, + hasMajorUpdate: false, + type: 'dependencies', + ...overrides, +}) + +describe('StateManager group operations', () => { + it('toggleFocusedGroupCollapse returns false when no group is focused', () => { + const sm = new StateManager(0, 30) + const states = [make({ name: 'react' })] + sm.buildAndSetRenderableItems(states) + expect(sm.toggleFocusedGroupCollapse()).toBe(false) + }) + + it('toggleFocusedGroupCollapse toggles collapsed state for the focused scope', () => { + const sm = new StateManager(0, 30) + const states = [ + make({ name: '@tiptap/react' }), + make({ name: '@tiptap/pm' }), + ] + sm.buildAndSetRenderableItems(states) + + // Focus the @tiptap group directly + ;(sm as any).navigationManager.setFocusedGroupScope('@tiptap') + + expect(sm.isScopeCollapsed('@tiptap')).toBe(false) + expect(sm.toggleFocusedGroupCollapse()).toBe(true) + expect(sm.isScopeCollapsed('@tiptap')).toBe(true) + + // After rebuild, the collapsed state persists and the group still has a header. + const items = sm.buildAndSetRenderableItems(states) + const header = items.find((i) => i.type === 'group-header') + expect(header).toBeDefined() + if (header && header.type === 'group-header') { + expect(header.collapsed).toBe(true) + } + // Only the header should remain (no member rows when collapsed). + expect(items.filter((i) => i.type === 'package')).toHaveLength(0) + + // Toggle back to expanded + expect(sm.toggleFocusedGroupCollapse()).toBe(true) + expect(sm.isScopeCollapsed('@tiptap')).toBe(false) + }) + + it('cycleGroupSelection right moves none -> range when any member has a range update', () => { + const sm = new StateManager(0, 30) + const states = [ + make({ + name: '@tiptap/react', + hasRangeUpdate: true, + rangeVersion: '3.23.6', + currentVersionSpecifier: '^3.23.4', + }), + make({ + name: '@tiptap/pm', + hasRangeUpdate: true, + rangeVersion: '3.23.6', + currentVersionSpecifier: '^3.23.4', + }), + ] + sm.buildAndSetRenderableItems(states) + ;(sm as any).navigationManager.setFocusedGroupScope('@tiptap') + + sm.cycleGroupSelection(states, 'right') + expect(states[0].selectedOption).toBe('range') + expect(states[1].selectedOption).toBe('range') + }) + + it('cycleGroupSelection only applies range to members that actually have a range update', () => { + const sm = new StateManager(0, 30) + const states = [ + make({ name: '@scope/a', hasRangeUpdate: true }), + make({ name: '@scope/b', hasRangeUpdate: false }), + ] + sm.buildAndSetRenderableItems(states) + ;(sm as any).navigationManager.setFocusedGroupScope('@scope') + + sm.cycleGroupSelection(states, 'right') + expect(states[0].selectedOption).toBe('range') + expect(states[1].selectedOption).toBe('none') // skipped — no range available + }) + + it('cycleGroupSelection skips members that are still loading', () => { + const sm = new StateManager(0, 30) + const states = [ + make({ name: '@scope/a', hasRangeUpdate: true }), + make({ name: '@scope/b', loadState: 'pending' }), + ] + sm.buildAndSetRenderableItems(states) + ;(sm as any).navigationManager.setFocusedGroupScope('@scope') + + sm.cycleGroupSelection(states, 'right') + expect(states[0].selectedOption).toBe('range') + expect(states[1].selectedOption).toBe('none') + }) + + it('getGroupAggregate counts selected, available, pending, and vulnerable members', () => { + const sm = new StateManager(0, 30) + const states = [ + make({ name: '@scope/a', selectedOption: 'range', hasRangeUpdate: true }), + make({ name: '@scope/b', selectedOption: 'latest', hasMajorUpdate: true, hasRangeUpdate: true }), + make({ name: '@scope/c', loadState: 'pending' }), + make({ + name: '@scope/d', + vulnerability: { count: 2, highestSeverity: 'high', advisories: [] }, + }), + ] + const agg = sm.getGroupAggregate(states, [0, 1, 2, 3]) + expect(agg.total).toBe(4) + expect(agg.ready).toBe(3) + expect(agg.pending).toBe(1) + expect(agg.selectedRange).toBe(1) + expect(agg.selectedLatest).toBe(1) + expect(agg.selectedNone).toBe(2) + expect(agg.hasRangeAvailable).toBe(2) + expect(agg.hasMajorAvailable).toBe(1) + expect(agg.vulnerable).toBe(1) + }) +})