Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 45d4b6b207 feat: enhance list command with last modified timestamps and sorting
- Add lastModified field showing when each change was last modified
- Default sort order is now "recent" (most recently modified first)
- Add --sort option to choose between "recent" and "name" ordering
- Add --json option for programmatic access with structured output
- Fall back to directory mtime for empty change directories
- Display relative time (e.g., "2h ago", "3d ago") in human output
2025-12-30 17:21:04 +11:00
2 changed files with 103 additions and 10 deletions
+5 -2
View File
@@ -96,11 +96,14 @@ program
.description('List items (changes by default). Use --specs to list specs.')
.option('--specs', 'List specs instead of changes')
.option('--changes', 'List changes explicitly (default)')
.action(async (options?: { specs?: boolean; changes?: boolean }) => {
.option('--sort <order>', 'Sort order: "recent" (default) or "name"', 'recent')
.option('--json', 'Output as JSON (for programmatic use)')
.action(async (options?: { specs?: boolean; changes?: boolean; sort?: string; json?: boolean }) => {
try {
const listCommand = new ListCommand();
const mode: 'changes' | 'specs' = options?.specs ? 'specs' : 'changes';
await listCommand.execute('.', mode);
const sort = options?.sort === 'name' ? 'name' : 'recent';
await listCommand.execute('.', mode, { sort, json: options?.json });
} catch (error) {
console.log(); // Empty line for spacing
ora().fail(`Error: ${(error as Error).message}`);
+98 -8
View File
@@ -9,13 +9,78 @@ interface ChangeInfo {
name: string;
completedTasks: number;
totalTasks: number;
lastModified: Date;
}
interface ListOptions {
sort?: 'recent' | 'name';
json?: boolean;
}
/**
* Get the most recent modification time of any file in a directory (recursive).
* Falls back to the directory's own mtime if no files are found.
*/
async function getLastModified(dirPath: string): Promise<Date> {
let latest: Date | null = null;
async function walk(dir: string): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else {
const stat = await fs.stat(fullPath);
if (latest === null || stat.mtime > latest) {
latest = stat.mtime;
}
}
}
}
await walk(dirPath);
// If no files found, use the directory's own modification time
if (latest === null) {
const dirStat = await fs.stat(dirPath);
return dirStat.mtime;
}
return latest;
}
/**
* Format a date as relative time (e.g., "2 hours ago", "3 days ago")
*/
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 30) {
return date.toLocaleDateString();
} else if (diffDays > 0) {
return `${diffDays}d ago`;
} else if (diffHours > 0) {
return `${diffHours}h ago`;
} else if (diffMins > 0) {
return `${diffMins}m ago`;
} else {
return 'just now';
}
}
export class ListCommand {
async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes'): Promise<void> {
async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes', options: ListOptions = {}): Promise<void> {
const { sort = 'recent', json = false } = options;
if (mode === 'changes') {
const changesDir = path.join(targetPath, 'openspec', 'changes');
// Check if changes directory exists
try {
await fs.access(changesDir);
@@ -30,24 +95,48 @@ export class ListCommand {
.map(entry => entry.name);
if (changeDirs.length === 0) {
console.log('No active changes found.');
if (json) {
console.log(JSON.stringify({ changes: [] }));
} else {
console.log('No active changes found.');
}
return;
}
// Collect information about each change
const changes: ChangeInfo[] = [];
for (const changeDir of changeDirs) {
const progress = await getTaskProgressForChange(changesDir, changeDir);
const changePath = path.join(changesDir, changeDir);
const lastModified = await getLastModified(changePath);
changes.push({
name: changeDir,
completedTasks: progress.completed,
totalTasks: progress.total
totalTasks: progress.total,
lastModified
});
}
// Sort alphabetically by name
changes.sort((a, b) => a.name.localeCompare(b.name));
// Sort by preference (default: recent first)
if (sort === 'recent') {
changes.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
} else {
changes.sort((a, b) => a.name.localeCompare(b.name));
}
// JSON output for programmatic use
if (json) {
const jsonOutput = changes.map(c => ({
name: c.name,
completedTasks: c.completedTasks,
totalTasks: c.totalTasks,
lastModified: c.lastModified.toISOString(),
status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress'
}));
console.log(JSON.stringify({ changes: jsonOutput }, null, 2));
return;
}
// Display results
console.log('Changes:');
@@ -56,7 +145,8 @@ export class ListCommand {
for (const change of changes) {
const paddedName = change.name.padEnd(nameWidth);
const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
console.log(`${padding}${paddedName} ${status}`);
const timeAgo = formatRelativeTime(change.lastModified);
console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`);
}
return;
}