Skip to content

Commit 48b5ed9

Browse files
authored
feat: enhance list command with last modified timestamps and sorting (#421)
- 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
1 parent fb7ff52 commit 48b5ed9

File tree

2 files changed

+103
-10
lines changed

2 files changed

+103
-10
lines changed

src/cli/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,14 @@ program
9696
.description('List items (changes by default). Use --specs to list specs.')
9797
.option('--specs', 'List specs instead of changes')
9898
.option('--changes', 'List changes explicitly (default)')
99-
.action(async (options?: { specs?: boolean; changes?: boolean }) => {
99+
.option('--sort <order>', 'Sort order: "recent" (default) or "name"', 'recent')
100+
.option('--json', 'Output as JSON (for programmatic use)')
101+
.action(async (options?: { specs?: boolean; changes?: boolean; sort?: string; json?: boolean }) => {
100102
try {
101103
const listCommand = new ListCommand();
102104
const mode: 'changes' | 'specs' = options?.specs ? 'specs' : 'changes';
103-
await listCommand.execute('.', mode);
105+
const sort = options?.sort === 'name' ? 'name' : 'recent';
106+
await listCommand.execute('.', mode, { sort, json: options?.json });
104107
} catch (error) {
105108
console.log(); // Empty line for spacing
106109
ora().fail(`Error: ${(error as Error).message}`);

src/core/list.ts

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,78 @@ interface ChangeInfo {
99
name: string;
1010
completedTasks: number;
1111
totalTasks: number;
12+
lastModified: Date;
13+
}
14+
15+
interface ListOptions {
16+
sort?: 'recent' | 'name';
17+
json?: boolean;
18+
}
19+
20+
/**
21+
* Get the most recent modification time of any file in a directory (recursive).
22+
* Falls back to the directory's own mtime if no files are found.
23+
*/
24+
async function getLastModified(dirPath: string): Promise<Date> {
25+
let latest: Date | null = null;
26+
27+
async function walk(dir: string): Promise<void> {
28+
const entries = await fs.readdir(dir, { withFileTypes: true });
29+
for (const entry of entries) {
30+
const fullPath = path.join(dir, entry.name);
31+
if (entry.isDirectory()) {
32+
await walk(fullPath);
33+
} else {
34+
const stat = await fs.stat(fullPath);
35+
if (latest === null || stat.mtime > latest) {
36+
latest = stat.mtime;
37+
}
38+
}
39+
}
40+
}
41+
42+
await walk(dirPath);
43+
44+
// If no files found, use the directory's own modification time
45+
if (latest === null) {
46+
const dirStat = await fs.stat(dirPath);
47+
return dirStat.mtime;
48+
}
49+
50+
return latest;
51+
}
52+
53+
/**
54+
* Format a date as relative time (e.g., "2 hours ago", "3 days ago")
55+
*/
56+
function formatRelativeTime(date: Date): string {
57+
const now = new Date();
58+
const diffMs = now.getTime() - date.getTime();
59+
const diffSecs = Math.floor(diffMs / 1000);
60+
const diffMins = Math.floor(diffSecs / 60);
61+
const diffHours = Math.floor(diffMins / 60);
62+
const diffDays = Math.floor(diffHours / 24);
63+
64+
if (diffDays > 30) {
65+
return date.toLocaleDateString();
66+
} else if (diffDays > 0) {
67+
return `${diffDays}d ago`;
68+
} else if (diffHours > 0) {
69+
return `${diffHours}h ago`;
70+
} else if (diffMins > 0) {
71+
return `${diffMins}m ago`;
72+
} else {
73+
return 'just now';
74+
}
1275
}
1376

1477
export class ListCommand {
15-
async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes'): Promise<void> {
78+
async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes', options: ListOptions = {}): Promise<void> {
79+
const { sort = 'recent', json = false } = options;
80+
1681
if (mode === 'changes') {
1782
const changesDir = path.join(targetPath, 'openspec', 'changes');
18-
83+
1984
// Check if changes directory exists
2085
try {
2186
await fs.access(changesDir);
@@ -30,24 +95,48 @@ export class ListCommand {
3095
.map(entry => entry.name);
3196

3297
if (changeDirs.length === 0) {
33-
console.log('No active changes found.');
98+
if (json) {
99+
console.log(JSON.stringify({ changes: [] }));
100+
} else {
101+
console.log('No active changes found.');
102+
}
34103
return;
35104
}
36105

37106
// Collect information about each change
38107
const changes: ChangeInfo[] = [];
39-
108+
40109
for (const changeDir of changeDirs) {
41110
const progress = await getTaskProgressForChange(changesDir, changeDir);
111+
const changePath = path.join(changesDir, changeDir);
112+
const lastModified = await getLastModified(changePath);
42113
changes.push({
43114
name: changeDir,
44115
completedTasks: progress.completed,
45-
totalTasks: progress.total
116+
totalTasks: progress.total,
117+
lastModified
46118
});
47119
}
48120

49-
// Sort alphabetically by name
50-
changes.sort((a, b) => a.name.localeCompare(b.name));
121+
// Sort by preference (default: recent first)
122+
if (sort === 'recent') {
123+
changes.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
124+
} else {
125+
changes.sort((a, b) => a.name.localeCompare(b.name));
126+
}
127+
128+
// JSON output for programmatic use
129+
if (json) {
130+
const jsonOutput = changes.map(c => ({
131+
name: c.name,
132+
completedTasks: c.completedTasks,
133+
totalTasks: c.totalTasks,
134+
lastModified: c.lastModified.toISOString(),
135+
status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress'
136+
}));
137+
console.log(JSON.stringify({ changes: jsonOutput }, null, 2));
138+
return;
139+
}
51140

52141
// Display results
53142
console.log('Changes:');
@@ -56,7 +145,8 @@ export class ListCommand {
56145
for (const change of changes) {
57146
const paddedName = change.name.padEnd(nameWidth);
58147
const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
59-
console.log(`${padding}${paddedName} ${status}`);
148+
const timeAgo = formatRelativeTime(change.lastModified);
149+
console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`);
60150
}
61151
return;
62152
}

0 commit comments

Comments
 (0)