From 6587e9006618ef2bd599e687bcfcdba678c56d5b Mon Sep 17 00:00:00 2001 From: Mihhail Solovjov Date: Sun, 24 May 2026 16:56:34 +0300 Subject: [PATCH 1/4] feat(ui): implement package grouping by scope with tree structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for rendering packages grouped under their npm scope. Introduces new RenderableItem type for group headers, adds renderGroupHeader function, and modifies renderPackageLine to display tree characters (├ └) for grouped items while stripping scope prefixes from package names. Updates UIRenderer and renderInterface to accept focusedGroupVisualIndex for highlighting the current group. --- src/types/ui.ts | 3 +- src/ui/renderer/index.ts | 6 ++- src/ui/renderer/package-list/interface.ts | 14 +++++-- src/ui/renderer/package-list/rows.ts | 47 +++++++++++++++++++---- 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/types/ui.ts b/src/types/ui.ts index 7312638..ae373f6 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[] } diff --git a/src/ui/renderer/index.ts b/src/ui/renderer/index.ts index cb6ad3e..cf528a4 100644 --- a/src/ui/renderer/index.ts +++ b/src/ui/renderer/index.ts @@ -47,7 +47,8 @@ export class UIRenderer { terminalWidth: number = 80, loadingProgress?: PackageLoadProgress, auditProgress?: AuditProgress, - options?: PackageListRenderOptions + options?: PackageListRenderOptions, + focusedGroupVisualIndex: number | null = null ): string[] { return PackageList.renderInterface( states, @@ -64,7 +65,8 @@ export class UIRenderer { terminalWidth, loadingProgress, auditProgress, - options + options, + focusedGroupVisualIndex ) } diff --git a/src/ui/renderer/package-list/interface.ts b/src/ui/renderer/package-list/interface.ts index 6cf4404..04156df 100644 --- a/src/ui/renderer/package-list/interface.ts +++ b/src/ui/renderer/package-list/interface.ts @@ -12,6 +12,7 @@ import { padLineToWidth, renderPackageLine, renderSectionHeader, + renderGroupHeader, renderSpacer, PackageListRenderOptions, } from './rows' @@ -31,7 +32,8 @@ export function renderInterface( terminalWidth: number = 80, loadingProgress?: PackageLoadProgress, auditProgress?: AuditProgress, - options: PackageListRenderOptions = {} + options: PackageListRenderOptions = {}, + focusedGroupVisualIndex: number | null = null ): string[] { const output: string[] = [] @@ -235,13 +237,19 @@ 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 + output.push(renderGroupHeader(item.scope, item.memberIndices.length, isCurrent)) } 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..631e2b4 100644 --- a/src/ui/renderer/package-list/rows.ts +++ b/src/ui/renderer/package-list/rows.ts @@ -33,12 +33,21 @@ 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 ? getThemeColor('border')(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 +66,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 +136,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 +213,19 @@ export function renderSectionHeader( return ' ' + colorFn.bold(title) } +export function renderGroupHeader( + scope: string, + memberCount: number, + isCurrentRow: boolean +): string { + const prefix = isCurrentRow ? getThemeColor('success')('❯ ') : ' ' + const scopeText = isCurrentRow + ? chalk.bold(getThemeColor('packageAuthor')(scope)) + : chalk.bold(getThemeColor('textSecondary')(scope)) + const count = getThemeColor('textSecondary')(`(${memberCount})`) + return `${prefix}${scopeText} ${count}` +} + export function renderSpacer(): string { return '' } From ee18fdb1dfb8d939b07db0f9d39af0aa3a6f004d Mon Sep 17 00:00:00 2001 From: Mihhail Solovjov Date: Sun, 24 May 2026 16:56:51 +0300 Subject: [PATCH 2/4] feat(state): add scope grouping logic and group navigation support Introduce buildScopeGroupedItems to cluster packages by npm scope when sharing a majority version above threshold. Update NavigationManager with focusedGroupVisualIndex tracking, navigable focus points for headers and packages, and revised movement logic. Extend StateManager with buildAndSetRenderableItems, group header accessors, applyGroupSelection, and cycleGroupSelection for bulk operations. --- src/ui/state/index.ts | 1 + src/ui/state/navigation-manager.ts | 123 +++++++++++++++++++++-------- src/ui/state/scope-grouping.ts | 99 +++++++++++++++++++++++ src/ui/state/state-manager.ts | 66 ++++++++++++++++ 4 files changed, 258 insertions(+), 31 deletions(-) create mode 100644 src/ui/state/scope-grouping.ts 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..1e36161 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 + focusedGroupVisualIndex: number | null // When non-null, a group header is focused } export class NavigationManager { @@ -18,6 +19,7 @@ export class NavigationManager { previousRow: -1, scrollOffset: 0, previousScrollOffset: 0, + focusedGroupVisualIndex: 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.focusedGroupVisualIndex = null } setScrollOffset(offset: number): void { @@ -46,6 +49,17 @@ export class NavigationManager { setRenderableItems(items: RenderableItem[]): void { this.renderableItems = items + // Clear group focus if it no longer points at a valid group header + if (this.state.focusedGroupVisualIndex !== null) { + const idx = this.state.focusedGroupVisualIndex + if (idx >= items.length || items[idx]?.type !== 'group-header') { + this.state.focusedGroupVisualIndex = null + } + } + } + + clearGroupFocus(): void { + this.state.focusedGroupVisualIndex = null } setMaxVisibleItems(maxVisible: number): void { @@ -73,58 +87,105 @@ 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 - } - } + getFocusedGroupVisualIndex(): number | null { + return this.state.focusedGroupVisualIndex + } - // Get all package items with their visual indices - const packageItems: { visualIndex: number; packageIndex: number }[] = [] + // Build ordered list of navigable focus points (packages and group headers) + private getNavigableFocusPoints(): { + visualIndex: number + kind: 'package' | 'group' + packageIndex?: number + }[] { + const points: { visualIndex: number; kind: 'package' | 'group'; packageIndex?: number }[] = [] 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' }) } } + 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.focusedGroupVisualIndex !== null) { + currentPos = focusPoints.findIndex( + (p) => p.kind === 'group' && p.visualIndex === this.state.focusedGroupVisualIndex + ) } 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.focusedGroupVisualIndex = target.visualIndex + } else { + this.state.focusedGroupVisualIndex = 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 { + if (this.state.focusedGroupVisualIndex !== null) { + this.ensureVisualIndexVisible(this.state.focusedGroupVisualIndex, 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 { diff --git a/src/ui/state/scope-grouping.ts b/src/ui/state/scope-grouping.ts new file mode 100644 index 0000000..0aec464 --- /dev/null +++ b/src/ui/state/scope-grouping.ts @@ -0,0 +1,99 @@ +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 function buildScopeGroupedItems(states: PackageSelectionState[]): RenderableItem[] { + if (states.length === 0) return [] + + 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 versionCounts = new Map() + indices.forEach((i) => { + const v = states[i].currentVersionSpecifier + versionCounts.set(v, (versionCounts.get(v) || 0) + 1) + }) + + let majorityVersion = '' + let majorityCount = 0 + for (const [v, c] of versionCounts) { + if (c > majorityCount) { + majorityVersion = v + majorityCount = c + } + } + + if (majorityCount < GROUP_THRESHOLD) { + indices.forEach((i) => { + items.push({ type: 'package', state: states[i], originalIndex: i }) + }) + continue + } + + const members = indices.filter((i) => states[i].currentVersionSpecifier === majorityVersion) + const outliers = indices.filter((i) => states[i].currentVersionSpecifier !== majorityVersion) + + items.push({ type: 'group-header', scope, memberIndices: members }) + members.forEach((i, idx) => { + items.push({ + type: 'package', + state: states[i], + originalIndex: i, + groupPosition: idx === members.length - 1 ? 'last' : 'middle', + }) + }) + outliers.forEach((i) => { + items.push({ type: 'package', state: states[i], originalIndex: i }) + }) + } + + emitStandaloneUpTo(states.length) + + return items +} diff --git a/src/ui/state/state-manager.ts b/src/ui/state/state-manager.ts index b74cda3..ffa14f0 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 @@ -103,6 +104,71 @@ export class StateManager { this.navigationManager.setRenderableItems(items) } + buildAndSetRenderableItems(filteredStates: PackageSelectionState[]): RenderableItem[] { + const items = buildScopeGroupedItems(filteredStates) + this.setRenderableItems(items) + return items + } + + getFocusedGroupVisualIndex(): number | null { + return this.navigationManager.getFocusedGroupVisualIndex() + } + + getFocusedGroupHeader(): { scope: string; memberIndices: number[] } | null { + const idx = this.navigationManager.getFocusedGroupVisualIndex() + if (idx === null) return null + const item = this.renderState.renderableItems[idx] + if (!item || item.type !== 'group-header') return null + return { scope: item.scope, memberIndices: item.memberIndices } + } + + 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) + + const counts = { none: 0, range: 0, latest: 0 } + members.forEach((m) => counts[m.selectedOption]++) + const dominant = + counts.latest > counts.range && counts.latest > counts.none + ? 'latest' + : counts.range > counts.none + ? '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) + } + // Navigation delegation navigateUp(totalItems: number): void { this.navigationManager.navigateUp(totalItems) From acdf028b7da4309f6b2191414f8e898f33427aad Mon Sep 17 00:00:00 2001 From: Mihhail Solovjov Date: Sun, 24 May 2026 16:57:10 +0300 Subject: [PATCH 3/4] feat(ui): integrate group cycling into selection actions Update action dispatcher to check focusedGroupVisualIndex and delegate left/right to cycleGroupSelection when a group header is active. Rebuild renderable items each frame via buildAndSetRenderableItems in the interactive session and forward the focused group index to renderInterface. --- src/ui/session/action-dispatcher.ts | 12 ++++++++++-- src/ui/session/interactive-session.ts | 9 ++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/ui/session/action-dispatcher.ts b/src/ui/session/action-dispatcher.ts index a3c9170..daff2f1 100644 --- a/src/ui/session/action-dispatcher.ts +++ b/src/ui/session/action-dispatcher.ts @@ -55,10 +55,18 @@ 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 'bulk_select_minor': stateManager.bulkSelectMinor(filteredStates) diff --git a/src/ui/session/interactive-session.ts b/src/ui/session/interactive-session.ts index fff34ac..c6d1bb5 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,15 @@ 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 lines = renderer.renderInterface( filteredStates, uiState.currentRow, uiState.scrollOffset, uiState.maxVisibleItems, uiState.forceFullRender, - [], + renderableItems, activeFilterLabel, packageManager, uiState.filterMode, @@ -276,7 +278,8 @@ export async function runInteractiveSession( terminalWidth, loadingProgress, auditProgress, - packageListRenderOptions + packageListRenderOptions, + focusedGroupVisualIndex ) renderViewport(lines, terminalWidth, terminalHeight, bgCode) From a5a45bf7957811e06b923ddca243af0b9df75fab Mon Sep 17 00:00:00 2001 From: Mihhail Solovjov Date: Sun, 24 May 2026 19:55:21 +0300 Subject: [PATCH 4/4] feat(ui): add collapsible scope groups with aggregate summaries - Bind space key to toggle collapse on focused group headers - Render group headers with selection counts, pending loads, update availability, and vulnerability indicators - Refactor navigation manager to track focused groups by scope for stable focus across rebuilds - Extend scope grouping and state manager to support collapsedScopes and compute group aggregates - Add unit tests for navigation, grouping logic, and state manager group operations --- src/types/ui.ts | 2 +- src/ui/input-handler.ts | 5 + src/ui/renderer/index.ts | 8 +- src/ui/renderer/package-list/interface.ts | 30 ++++- src/ui/renderer/package-list/rows.ts | 61 ++++++++- src/ui/session/action-dispatcher.ts | 4 + src/ui/session/interactive-session.ts | 5 +- src/ui/state/navigation-manager.ts | 74 ++++++----- src/ui/state/scope-grouping.ts | 47 +++---- src/ui/state/state-manager.ts | 95 ++++++++++++-- test/unit/ui/navigation-manager.test.ts | 94 ++++++++++++++ test/unit/ui/scope-grouping.test.ts | 129 +++++++++++++++++++ test/unit/ui/state-manager-grouping.test.ts | 133 ++++++++++++++++++++ 13 files changed, 603 insertions(+), 84 deletions(-) create mode 100644 test/unit/ui/navigation-manager.test.ts create mode 100644 test/unit/ui/scope-grouping.test.ts create mode 100644 test/unit/ui/state-manager-grouping.test.ts diff --git a/src/types/ui.ts b/src/types/ui.ts index ae373f6..f561fe0 100644 --- a/src/types/ui.ts +++ b/src/types/ui.ts @@ -39,4 +39,4 @@ export type RenderableItem = | { type: 'header'; title: string; sectionType: 'main' | 'peer' | 'optional' } | { type: 'spacer' } | { type: 'package'; state: PackageSelectionState; originalIndex: number; groupPosition?: 'middle' | 'last' } - | { type: 'group-header'; scope: string; memberIndices: number[] } + | { 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 cf528a4..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 @@ -48,7 +48,8 @@ export class UIRenderer { loadingProgress?: PackageLoadProgress, auditProgress?: AuditProgress, options?: PackageListRenderOptions, - focusedGroupVisualIndex: number | null = null + focusedGroupVisualIndex: number | null = null, + groupAggregateLookup?: GroupAggregateLookup ): string[] { return PackageList.renderInterface( states, @@ -66,7 +67,8 @@ export class UIRenderer { loadingProgress, auditProgress, options, - focusedGroupVisualIndex + focusedGroupVisualIndex, + groupAggregateLookup ) } diff --git a/src/ui/renderer/package-list/interface.ts b/src/ui/renderer/package-list/interface.ts index 04156df..b6febdd 100644 --- a/src/ui/renderer/package-list/interface.ts +++ b/src/ui/renderer/package-list/interface.ts @@ -15,8 +15,14 @@ import { renderGroupHeader, renderSpacer, PackageListRenderOptions, + GroupHeaderAggregate, } from './rows' +export type GroupAggregateLookup = ( + scope: string, + memberIndices: number[] +) => GroupHeaderAggregate + export function renderInterface( states: PackageSelectionState[], currentRow: number, @@ -33,7 +39,8 @@ export function renderInterface( loadingProgress?: PackageLoadProgress, auditProgress?: AuditProgress, options: PackageListRenderOptions = {}, - focusedGroupVisualIndex: number | null = null + focusedGroupVisualIndex: number | null = null, + groupAggregateLookup?: GroupAggregateLookup ): string[] { const output: string[] = [] @@ -197,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( @@ -204,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 } } @@ -239,7 +252,16 @@ export function renderInterface( output.push(renderSpacer()) } else if (item.type === 'group-header') { const isCurrent = focusedGroupVisualIndex === i - output.push(renderGroupHeader(item.scope, item.memberIndices.length, isCurrent)) + 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 diff --git a/src/ui/renderer/package-list/rows.ts b/src/ui/renderer/package-list/rows.ts index 631e2b4..c195818 100644 --- a/src/ui/renderer/package-list/rows.ts +++ b/src/ui/renderer/package-list/rows.ts @@ -40,7 +40,9 @@ export function renderPackageLine( const isGrouped = groupPosition !== undefined const treeChar = groupPosition === 'last' ? '└ ' : groupPosition === 'middle' ? '├ ' : '' - const treeDecor = isGrouped ? getThemeColor('border')(treeChar) : '' + const treeDecor = isGrouped + ? (isCurrentRow ? getThemeColor('success') : getThemeColor('packageAuthor'))(treeChar) + : '' const displayedFullName = isGrouped ? state.name.slice(state.name.indexOf('/') + 1) @@ -213,17 +215,68 @@ 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 + 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('textSecondary')(scope)) + : chalk.bold(getThemeColor('packageAuthor')(scope)) const count = getThemeColor('textSecondary')(`(${memberCount})`) - return `${prefix}${scopeText} ${count}` + + 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 { diff --git a/src/ui/session/action-dispatcher.ts b/src/ui/session/action-dispatcher.ts index daff2f1..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 = { @@ -68,6 +69,9 @@ export function dispatchAction(action: InputAction, ctx: DispatchContext): void stateManager.updateSelection(filteredStates, 'right') } break + case 'toggle_group_collapse': + if (!stateManager.toggleFocusedGroupCollapse()) return + break case 'bulk_select_minor': stateManager.bulkSelectMinor(filteredStates) break diff --git a/src/ui/session/interactive-session.ts b/src/ui/session/interactive-session.ts index c6d1bb5..7bca1d6 100644 --- a/src/ui/session/interactive-session.ts +++ b/src/ui/session/interactive-session.ts @@ -263,6 +263,8 @@ export async function runInteractiveSession( 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, @@ -279,7 +281,8 @@ export async function runInteractiveSession( loadingProgress, auditProgress, packageListRenderOptions, - focusedGroupVisualIndex + focusedGroupVisualIndex, + groupAggregateLookup ) renderViewport(lines, terminalWidth, terminalHeight, bgCode) diff --git a/src/ui/state/navigation-manager.ts b/src/ui/state/navigation-manager.ts index 1e36161..9344fe4 100644 --- a/src/ui/state/navigation-manager.ts +++ b/src/ui/state/navigation-manager.ts @@ -5,7 +5,7 @@ export interface NavigationState { previousRow: number scrollOffset: number // Scroll offset in visual rows (includes headers/spacers) previousScrollOffset: number - focusedGroupVisualIndex: number | null // When non-null, a group header is focused + focusedGroupScope: string | null // When non-null, a group header is focused (by scope identity) } export class NavigationManager { @@ -19,7 +19,7 @@ export class NavigationManager { previousRow: -1, scrollOffset: 0, previousScrollOffset: 0, - focusedGroupVisualIndex: null, + focusedGroupScope: null, } this.maxVisibleItems = maxVisibleItems } @@ -39,7 +39,7 @@ export class NavigationManager { setCurrentRow(row: number): void { this.state.previousRow = this.state.currentRow this.state.currentRow = row - this.state.focusedGroupVisualIndex = null + this.state.focusedGroupScope = null } setScrollOffset(offset: number): void { @@ -49,17 +49,19 @@ export class NavigationManager { setRenderableItems(items: RenderableItem[]): void { this.renderableItems = items - // Clear group focus if it no longer points at a valid group header - if (this.state.focusedGroupVisualIndex !== null) { - const idx = this.state.focusedGroupVisualIndex - if (idx >= items.length || items[idx]?.type !== 'group-header') { - this.state.focusedGroupVisualIndex = null + // 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.focusedGroupVisualIndex = null + this.state.focusedGroupScope = null } setMaxVisibleItems(maxVisible: number): void { @@ -72,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) { @@ -87,8 +86,23 @@ export class NavigationManager { return 0 } + getFocusedGroupScope(): string | null { + return this.state.focusedGroupScope + } + getFocusedGroupVisualIndex(): number | null { - return this.state.focusedGroupVisualIndex + 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 + } + } + return null + } + + setFocusedGroupScope(scope: string | null): void { + this.state.focusedGroupScope = scope } // Build ordered list of navigable focus points (packages and group headers) @@ -96,14 +110,20 @@ export class NavigationManager { visualIndex: number kind: 'package' | 'group' packageIndex?: number + scope?: string }[] { - const points: { visualIndex: number; kind: 'package' | 'group'; packageIndex?: number }[] = [] + 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') { points.push({ visualIndex: i, kind: 'package', packageIndex: item.originalIndex }) } else if (item.type === 'group-header') { - points.push({ visualIndex: i, kind: 'group' }) + points.push({ visualIndex: i, kind: 'group', scope: item.scope }) } } return points @@ -126,9 +146,9 @@ export class NavigationManager { if (focusPoints.length === 0) return let currentPos = -1 - if (this.state.focusedGroupVisualIndex !== null) { + if (this.state.focusedGroupScope !== null) { currentPos = focusPoints.findIndex( - (p) => p.kind === 'group' && p.visualIndex === this.state.focusedGroupVisualIndex + (p) => p.kind === 'group' && p.scope === this.state.focusedGroupScope ) } else { currentPos = focusPoints.findIndex( @@ -148,9 +168,9 @@ export class NavigationManager { const target = focusPoints[newPos] if (target.kind === 'group') { - this.state.focusedGroupVisualIndex = target.visualIndex + this.state.focusedGroupScope = target.scope! } else { - this.state.focusedGroupVisualIndex = null + this.state.focusedGroupScope = null this.state.currentRow = target.packageIndex! } } @@ -170,8 +190,9 @@ export class NavigationManager { } private ensureFocusVisible(totalPackages: number): void { - if (this.state.focusedGroupVisualIndex !== null) { - this.ensureVisualIndexVisible(this.state.focusedGroupVisualIndex, totalPackages) + const groupVisualIndex = this.getFocusedGroupVisualIndex() + if (groupVisualIndex !== null) { + this.ensureVisualIndexVisible(groupVisualIndex, totalPackages) } else { this.ensureVisible(this.state.currentRow, totalPackages) } @@ -189,40 +210,31 @@ export class NavigationManager { } 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 index 0aec464..d3b59f0 100644 --- a/src/ui/state/scope-grouping.ts +++ b/src/ui/state/scope-grouping.ts @@ -8,8 +8,16 @@ function getScope(name: string): string | null { return slash === -1 ? null : name.slice(0, slash) } -export function buildScopeGroupedItems(states: PackageSelectionState[]): RenderableItem[] { +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() @@ -54,43 +62,24 @@ export function buildScopeGroupedItems(states: PackageSelectionState[]): Rendera continue } - const versionCounts = new Map() - indices.forEach((i) => { - const v = states[i].currentVersionSpecifier - versionCounts.set(v, (versionCounts.get(v) || 0) + 1) + const isCollapsed = collapsed.has(scope) + items.push({ + type: 'group-header', + scope, + memberIndices: [...indices], + collapsed: isCollapsed, }) - let majorityVersion = '' - let majorityCount = 0 - for (const [v, c] of versionCounts) { - if (c > majorityCount) { - majorityVersion = v - majorityCount = c - } - } + if (isCollapsed) continue - if (majorityCount < GROUP_THRESHOLD) { - indices.forEach((i) => { - items.push({ type: 'package', state: states[i], originalIndex: i }) - }) - continue - } - - const members = indices.filter((i) => states[i].currentVersionSpecifier === majorityVersion) - const outliers = indices.filter((i) => states[i].currentVersionSpecifier !== majorityVersion) - - items.push({ type: 'group-header', scope, memberIndices: members }) - members.forEach((i, idx) => { + indices.forEach((i, idx) => { items.push({ type: 'package', state: states[i], originalIndex: i, - groupPosition: idx === members.length - 1 ? 'last' : 'middle', + groupPosition: idx === indices.length - 1 ? 'last' : 'middle', }) }) - outliers.forEach((i) => { - items.push({ type: 'package', state: states[i], originalIndex: i }) - }) } emitStandaloneUpTo(states.length) diff --git a/src/ui/state/state-manager.ts b/src/ui/state/state-manager.ts index ffa14f0..ae3d45f 100644 --- a/src/ui/state/state-manager.ts +++ b/src/ui/state/state-manager.ts @@ -46,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) { @@ -105,7 +106,9 @@ export class StateManager { } buildAndSetRenderableItems(filteredStates: PackageSelectionState[]): RenderableItem[] { - const items = buildScopeGroupedItems(filteredStates) + const items = buildScopeGroupedItems(filteredStates, { + collapsedScopes: this.collapsedScopes, + }) this.setRenderableItems(items) return items } @@ -114,15 +117,41 @@ export class StateManager { return this.navigationManager.getFocusedGroupVisualIndex() } - getFocusedGroupHeader(): { scope: string; memberIndices: number[] } | null { - const idx = this.navigationManager.getFocusedGroupVisualIndex() - if (idx === null) return null - const item = this.renderState.renderableItems[idx] - if (!item || item.type !== 'group-header') return null - return { scope: item.scope, memberIndices: item.memberIndices } + 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 } - applyGroupSelection(filteredStates: PackageSelectionState[], option: 'none' | 'range' | 'latest'): void { + 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) => { @@ -146,12 +175,15 @@ export class StateManager { 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 = - counts.latest > counts.range && counts.latest > counts.none + const dominant: 'none' | 'range' | 'latest' = + counts.latest >= counts.range && counts.latest >= counts.none && counts.latest > 0 ? 'latest' - : counts.range > counts.none + : counts.range >= counts.none && counts.range > 0 ? 'range' : 'none' @@ -169,6 +201,47 @@ export class StateManager { 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) + }) +})