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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/types/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
5 changes: 5 additions & 0 deletions src/ui/input-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 7 additions & 3 deletions src/ui/renderer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -64,7 +66,9 @@ export class UIRenderer {
terminalWidth,
loadingProgress,
auditProgress,
options
options,
focusedGroupVisualIndex,
groupAggregateLookup
)
}

Expand Down
40 changes: 35 additions & 5 deletions src/ui/renderer/package-list/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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[] = []

Expand Down Expand Up @@ -195,20 +204,26 @@ export function renderInterface(
chalk.gray('Clear')
}
} else {
const groupHint =
focusedGroupVisualIndex !== null
? ' ' + chalk.bold.white('Space ') + chalk.gray('Collapse')
: ''
if (totalVisualItems > maxVisibleItems) {
statusLine =
chalk.gray(
`Showing ${chalk.white(startItem)}-${chalk.white(endItem)} of ${chalk.white(totalPackages)} packages`
) +
' ' +
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
}
}

Expand All @@ -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)
}
Expand Down
100 changes: 92 additions & 8 deletions src/ui/renderer/package-list/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ''
}
16 changes: 14 additions & 2 deletions src/ui/session/action-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 9 additions & 3 deletions src/ui/session/interactive-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -276,7 +280,9 @@ export async function runInteractiveSession(
terminalWidth,
loadingProgress,
auditProgress,
packageListRenderOptions
packageListRenderOptions,
focusedGroupVisualIndex,
groupAggregateLookup
)

renderViewport(lines, terminalWidth, terminalHeight, bgCode)
Expand Down
1 change: 1 addition & 0 deletions src/ui/state/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Loading
Loading