@fromsvenwithlove/devops-issues-cli
Version:
AI-powered CLI tool and library for Azure DevOps work item management with Claude agents
280 lines (235 loc) • 7.97 kB
JavaScript
import chalk from 'chalk';
import { canHaveChildren } from '../utils/work-item-types.js';
import { keypressManager } from '../utils/keypress-manager.js';
export class TreeSelector {
constructor(workItems) {
this.originalItems = workItems;
this.tree = this.buildSelectableTree(workItems);
this.flatList = this.buildFlatList(this.tree);
this.selectedIndex = 0;
this.running = false;
this.selectedItem = null;
this.handlerId = null;
}
buildSelectableTree(workItems) {
// Filter only items that can have children
const eligibleItems = workItems.filter(item => canHaveChildren(item.type));
// Create a map for quick lookup
const itemMap = new Map();
eligibleItems.forEach(item => {
const id = item.id;
const title = item.title || item.fields?.['System.Title'] || 'No Title';
const type = item.type || item.fields?.['System.WorkItemType'] || 'Unknown';
const state = item.state || item.fields?.['System.State'] || 'Unknown';
const assignedTo = item.assignedTo || item.fields?.['System.AssignedTo']?.displayName || 'Unassigned';
itemMap.set(id, {
id: id,
title: title,
type: type,
state: state,
assignedTo: assignedTo,
children: [],
expanded: false,
level: 0,
canSelect: true
});
});
// Build parent-child relationships
const rootItems = [];
eligibleItems.forEach(item => {
const parentId = item.parentId || item.fields?.['System.Parent'];
const currentItem = itemMap.get(item.id);
if (parentId && itemMap.has(parentId)) {
const parent = itemMap.get(parentId);
parent.children.push(currentItem);
currentItem.parent = parent;
} else {
rootItems.push(currentItem);
}
});
// Set levels after all relationships are built
function setLevels(items, level = 0) {
items.forEach(item => {
item.level = level;
if (item.children.length > 0) {
setLevels(item.children, level + 1);
}
});
}
setLevels(rootItems);
// Expand root nodes by default
rootItems.forEach(item => {
item.expanded = true;
});
return rootItems;
}
buildFlatList(tree) {
const list = [];
function addToList(items, level = 0) {
items.forEach(item => {
item.level = level;
list.push(item);
if (item.expanded && item.children.length > 0) {
addToList(item.children, level + 1);
}
});
}
addToList(tree);
// Add cancel option
list.push({
id: null,
title: 'Cancel',
type: 'cancel',
level: 0,
canSelect: true,
isCancel: true
});
return list;
}
rebuildFlatList() {
this.flatList = this.buildFlatList(this.tree);
// Ensure selected index is still valid
if (this.selectedIndex >= this.flatList.length) {
this.selectedIndex = Math.max(0, this.flatList.length - 1);
}
}
getTypeIcon(type) {
const icons = {
'Epic': '📋',
'Feature': '🔹',
'User Story': '📝',
'Bug': '🐛',
'Task': '⚙️',
'cancel': '❌'
};
return icons[type] || '📄';
}
getStateColor(state) {
const colors = {
'Active': chalk.green,
'New': chalk.blue,
'Resolved': chalk.gray,
'Closed': chalk.gray,
'Done': chalk.gray
};
return colors[state] || chalk.white;
}
countChildren(item) {
return item.children ? item.children.reduce((count, child) => count + 1 + this.countChildren(child), 0) : 0;
}
renderTree() {
console.clear();
console.log(chalk.bold.blue('Select Parent Work Item\n'));
if (this.flatList.length <= 1) { // Only cancel option
console.log(chalk.yellow('No eligible parent work items found.'));
console.log(chalk.dim('Supported parent types: Epic, Feature, User Story, Task, Bug\n'));
console.log(chalk.dim('Press q to cancel'));
return;
}
this.flatList.forEach((item, index) => {
const isSelected = index === this.selectedIndex;
const prefix = isSelected ? chalk.cyan('> ') : ' ';
if (item.isCancel) {
const line = `${prefix}${this.getTypeIcon(item.type)} ${chalk.red(item.title)}`;
if (isSelected) {
console.log(chalk.inverse(line));
} else {
console.log(line);
}
return;
}
const indent = ' '.repeat(item.level);
const icon = this.getTypeIcon(item.type);
const stateColor = this.getStateColor(item.state);
const expandIcon = item.children.length > 0
? (item.expanded ? '▼ ' : '▶ ')
: '';
const childrenInfo = item.children.length > 0 && !item.expanded
? chalk.dim(` (${item.children.length} children)`)
: '';
const title = `${icon} ${expandIcon}${item.title} (#${item.id})${childrenInfo}`;
const state = stateColor(`[${item.state}]`);
const line = `${prefix}${indent}${title} ${state}`;
if (isSelected) {
console.log(chalk.inverse(line));
} else {
console.log(line);
}
});
console.log(chalk.dim('\nNavigation: ↑↓ select, → expand, ← collapse, Enter confirm, q cancel'));
}
async selectParent() {
return new Promise((resolve) => {
this.running = true;
// Generate unique handler ID
this.handlerId = `tree-selector-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Try to register with keypress manager
if (!keypressManager.addHandler(this.handlerId, this.createKeypressHandler(resolve))) {
console.log(chalk.red('Tree selector requires an interactive terminal. Please run in a proper terminal/console.'));
resolve(null);
return;
}
this.renderTree();
});
}
createKeypressHandler(resolve) {
return async (ch, key) => {
if (!this.running) return;
if (key && (key.name === 'q' || (key.ctrl && key.name === 'c'))) {
this.cleanup().then(() => resolve(null));
return;
}
if (key && key.name === 'up') {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.renderTree();
}
if (key && key.name === 'down') {
this.selectedIndex = Math.min(this.flatList.length - 1, this.selectedIndex + 1);
this.renderTree();
}
if (key && key.name === 'right') {
const item = this.flatList[this.selectedIndex];
if (item && item.children && item.children.length > 0 && !item.expanded) {
item.expanded = true;
this.rebuildFlatList();
this.renderTree();
}
}
if (key && key.name === 'left') {
const item = this.flatList[this.selectedIndex];
if (item && item.expanded) {
item.expanded = false;
this.rebuildFlatList();
this.renderTree();
}
}
if (key && key.name === 'return') {
const selectedItem = this.flatList[this.selectedIndex];
// Wait for cleanup to complete before resolving
this.cleanup().then(() => {
if (selectedItem.isCancel) {
resolve(null);
} else {
resolve(selectedItem.id);
}
});
return;
}
};
}
cleanup() {
this.running = false;
// Remove this handler from the global keypress manager
if (this.handlerId) {
keypressManager.removeHandler(this.handlerId);
this.handlerId = null;
}
// Return promise to allow waiting for cleanup completion
return new Promise(resolve => {
// Small delay to ensure cleanup completes
setTimeout(() => {
resolve();
}, 50);
});
}
}