Skip to content

Commit 1abddef

Browse files
committed
Enhance task sorting and rendering for Make and Mise tasks, including support for private and phony tasks, and add divider for better visual separation in tree view.
1 parent ff6f311 commit 1abddef

6 files changed

Lines changed: 262 additions & 14 deletions

File tree

src/CommandTreeProvider.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import * as vscode from "vscode";
2+
import { isPhonyTask, isPrivateTask } from "./models/TaskItem";
23
import type { CommandItem, CategoryDef } from "./models/TaskItem";
34
import type { CommandTreeItem } from "./models/TaskItem";
45
import type { DiscoveryResult } from "./discovery";
56
import { discoverAllTasks, flattenTasks, getExcludePatterns, CATEGORY_DEFS } from "./discovery";
67
import { TagConfig } from "./config/TagConfig";
78
import { logger } from "./utils/logger";
89
import { buildNestedFolderItems } from "./tree/folderTree";
9-
import { createCommandNode, createCategoryNode } from "./tree/nodeFactory";
10+
import { createCategoryNode, createTaskNodes } from "./tree/nodeFactory";
1011
import { getAllRows } from "./db/db";
1112
import type { CommandRow } from "./db/db";
1213
import { getDbOrThrow } from "./db/lifecycle";
@@ -166,7 +167,7 @@ export class CommandTreeProvider implements vscode.TreeDataProvider<CommandTreeI
166167

167168
private buildFlatCategory(def: CategoryDef, tasks: CommandItem[]): CommandTreeItem {
168169
const sorted = this.sortTasks(tasks);
169-
const children = sorted.map((t) => createCommandNode(t));
170+
const children = createTaskNodes(sorted);
170171
return createCategoryNode({
171172
label: `${def.label} (${tasks.length})`,
172173
children,
@@ -183,15 +184,44 @@ export class CommandTreeProvider implements vscode.TreeDataProvider<CommandTreeI
183184
return [...tasks].sort(comparator);
184185
}
185186

187+
private comparePrivateTasks(a: CommandItem, b: CommandItem): number {
188+
return Number(isPrivateTask(a)) - Number(isPrivateTask(b));
189+
}
190+
191+
private compareHelpTasks(a: CommandItem, b: CommandItem): number {
192+
const isHelpA = a.type === "make" && a.label === "help";
193+
const isHelpB = b.type === "make" && b.label === "help";
194+
return Number(isHelpB) - Number(isHelpA);
195+
}
196+
197+
private comparePhonyTasks(a: CommandItem, b: CommandItem): number {
198+
return Number(isPhonyTask(b)) - Number(isPhonyTask(a));
199+
}
200+
201+
private compareMakeTaskPriority(a: CommandItem, b: CommandItem): number {
202+
if (a.type !== "make" || b.type !== "make") {
203+
return 0;
204+
}
205+
return this.compareHelpTasks(a, b) || this.comparePhonyTasks(a, b);
206+
}
207+
186208
private getComparator(): (a: CommandItem, b: CommandItem) => number {
187209
const order = this.getSortOrder();
188210
if (order === "folder") {
189-
return (a, b) => a.category.localeCompare(b.category) || a.label.localeCompare(b.label);
211+
return (a, b) =>
212+
a.category.localeCompare(b.category) ||
213+
this.comparePrivateTasks(a, b) ||
214+
this.compareMakeTaskPriority(a, b) ||
215+
a.label.localeCompare(b.label);
190216
}
191217
if (order === "type") {
192-
return (a, b) => a.type.localeCompare(b.type) || a.label.localeCompare(b.label);
218+
return (a, b) =>
219+
a.type.localeCompare(b.type) ||
220+
this.comparePrivateTasks(a, b) ||
221+
this.compareMakeTaskPriority(a, b) ||
222+
a.label.localeCompare(b.label);
193223
}
194-
return (a, b) => a.label.localeCompare(b.label);
224+
return (a, b) => this.comparePrivateTasks(a, b) || this.compareMakeTaskPriority(a, b) || a.label.localeCompare(b.label);
195225
}
196226

197227
private applyTagFilter(tasks: CommandItem[]): CommandItem[] {

src/discovery/make.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as vscode from "vscode";
22
import * as path from "path";
3-
import type { CommandItem, IconDef, CategoryDef } from "../models/TaskItem";
3+
import type { CommandItem, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem";
44
import { generateCommandId, simplifyPath } from "../models/TaskItem";
55
import { readFileContent } from "../utils/fileUtils";
66

@@ -28,6 +28,7 @@ export async function discoverMakeTargets(workspaceRoot: string, excludePatterns
2828

2929
for (const file of allFiles) {
3030
const content = await readFileContent(file);
31+
const phonyTargets = parsePhonyTargets(content);
3132
const targets = parseMakeTargets(content);
3233
const makeDir = path.dirname(file.fsPath);
3334
const category = simplifyPath(file.fsPath, workspaceRoot);
@@ -38,7 +39,7 @@ export async function discoverMakeTargets(workspaceRoot: string, excludePatterns
3839
continue;
3940
}
4041

41-
commands.push({
42+
const command: MutableCommandItem = {
4243
id: generateCommandId("make", file.fsPath, name),
4344
label: name,
4445
type: "make",
@@ -48,7 +49,13 @@ export async function discoverMakeTargets(workspaceRoot: string, excludePatterns
4849
filePath: file.fsPath,
4950
tags: [],
5051
line,
51-
});
52+
};
53+
54+
if (phonyTargets.has(name)) {
55+
command.isPhony = true;
56+
}
57+
58+
commands.push(command);
5259
}
5360
}
5461

@@ -60,6 +67,54 @@ interface MakeTarget {
6067
readonly line: number;
6168
}
6269

70+
function addPhonyTargets(line: string, phonyTargets: Set<string>): void {
71+
for (const name of line.split(/\s+/)) {
72+
if (name !== "") {
73+
phonyTargets.add(name);
74+
}
75+
}
76+
}
77+
78+
function trimContinuation(line: string): string {
79+
return line.endsWith("\\") ? line.slice(0, -1).trim() : line;
80+
}
81+
82+
function isContinuationLine(line: string): boolean {
83+
return line.endsWith("\\");
84+
}
85+
86+
function readPhonyLine(line: string): string | undefined {
87+
const trimmed = line.trim();
88+
if (!trimmed.startsWith(".PHONY:")) {
89+
return undefined;
90+
}
91+
return trimmed.slice(".PHONY:".length).trim();
92+
}
93+
94+
function parsePhonyTargets(content: string): ReadonlySet<string> {
95+
const phonyTargets = new Set<string>();
96+
let collecting = false;
97+
98+
for (const line of content.split("\n")) {
99+
const trimmed = line.trim();
100+
if (collecting) {
101+
addPhonyTargets(trimContinuation(trimmed), phonyTargets);
102+
collecting = isContinuationLine(trimmed);
103+
continue;
104+
}
105+
106+
const phonyLine = readPhonyLine(line);
107+
if (phonyLine === undefined) {
108+
continue;
109+
}
110+
111+
addPhonyTargets(trimContinuation(phonyLine), phonyTargets);
112+
collecting = isContinuationLine(phonyLine);
113+
}
114+
115+
return phonyTargets;
116+
}
117+
63118
/**
64119
* Parses Makefile to extract target names and their line numbers.
65120
*/

src/models/TaskItem.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export interface CommandItem {
9696
readonly description?: string;
9797
readonly summary?: string;
9898
readonly securityWarning?: string;
99+
readonly isPhony?: boolean;
99100
readonly line?: number;
100101
}
101102

@@ -115,6 +116,7 @@ export interface MutableCommandItem {
115116
description?: string;
116117
summary?: string;
117118
securityWarning?: string;
119+
isPhony?: boolean;
118120
line?: number;
119121
}
120122

@@ -217,3 +219,15 @@ export function simplifyPath(filePath: string, workspaceRoot: string): string {
217219
export function generateCommandId(type: CommandType, filePath: string, name: string): string {
218220
return `${type}:${filePath}:${name}`;
219221
}
222+
223+
function supportsPrivateTaskStyling(type: CommandType): boolean {
224+
return type === "make" || type === "mise";
225+
}
226+
227+
export function isPrivateTask(task: CommandItem): boolean {
228+
return supportsPrivateTaskStyling(task.type) && task.label.startsWith("_");
229+
}
230+
231+
export function isPhonyTask(task: CommandItem): boolean {
232+
return task.type === "make" && task.isPhony === true;
233+
}

src/test/e2e/treeview.e2e.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ suite("TreeView E2E Tests", () => {
252252
suite("Private Make And Mise Tasks", () => {
253253
const makeRelativePath = "private-targets/Makefile";
254254
const miseRelativePath = "private-targets/mise.toml";
255+
const privateDivider = "---------------- private ----------------";
255256
const publicLabels = ["alpha_public", "zeta_public"];
256257
const privateLabels = ["_beta_private", "_omega_private"];
257258

@@ -269,6 +270,19 @@ suite("TreeView E2E Tests", () => {
269270
);
270271
}
271272

273+
async function getFolderChildrenForCategory(categoryLabel: string, folderLabel: string): Promise<CommandTreeItem[]> {
274+
const provider = getCommandTreeProvider();
275+
const categories = await provider.getChildren();
276+
const category = categories.find((item) => getLabelString(item.label).includes(categoryLabel));
277+
assert.ok(category !== undefined, `Should find category ${categoryLabel}`);
278+
279+
const children = await provider.getChildren(category);
280+
const folder = children.find((item) => getLabelString(item.label) === folderLabel);
281+
assert.ok(folder !== undefined, `Should find folder ${folderLabel}`);
282+
283+
return await provider.getChildren(folder);
284+
}
285+
272286
setup(async function () {
273287
this.timeout(15000);
274288

@@ -321,6 +335,14 @@ suite("TreeView E2E Tests", () => {
321335

322336
const items = await getItemsForFile("make", makeRelativePath);
323337
const labels = items.map((item) => getLabelString(item.label));
338+
const folderChildren = await getFolderChildrenForCategory("Make Targets", "private-targets");
339+
const folderLabels = folderChildren.map((item) => getLabelString(item.label));
340+
341+
assert.deepStrictEqual(
342+
folderLabels,
343+
[...publicLabels, privateDivider, ...privateLabels],
344+
"Make targets should insert a divider between public and _-prefixed private targets"
345+
);
324346

325347
assert.deepStrictEqual(
326348
labels,
@@ -350,6 +372,14 @@ suite("TreeView E2E Tests", () => {
350372

351373
const items = await getItemsForFile("mise", miseRelativePath);
352374
const labels = items.map((item) => getLabelString(item.label));
375+
const folderChildren = await getFolderChildrenForCategory("Mise Tasks", "private-targets");
376+
const folderLabels = folderChildren.map((item) => getLabelString(item.label));
377+
378+
assert.deepStrictEqual(
379+
folderLabels,
380+
[...publicLabels, privateDivider, ...privateLabels],
381+
"Mise tasks should insert a divider between public and _-prefixed private tasks"
382+
);
353383

354384
assert.deepStrictEqual(
355385
labels,
@@ -374,4 +404,91 @@ suite("TreeView E2E Tests", () => {
374404
}
375405
});
376406
});
407+
408+
suite("Make Target Conventions", () => {
409+
const makeRelativePath = "make-conventions/Makefile";
410+
const privateDivider = "---------------- private ----------------";
411+
412+
async function getFolderChildrenForCategory(categoryLabel: string, folderLabel: string): Promise<CommandTreeItem[]> {
413+
const provider = getCommandTreeProvider();
414+
const categories = await provider.getChildren();
415+
const category = categories.find((item) => getLabelString(item.label).includes(categoryLabel));
416+
assert.ok(category !== undefined, `Should find category ${categoryLabel}`);
417+
418+
const children = await provider.getChildren(category);
419+
const folder = children.find((item) => getLabelString(item.label) === folderLabel);
420+
assert.ok(folder !== undefined, `Should find folder ${folderLabel}`);
421+
422+
return await provider.getChildren(folder);
423+
}
424+
425+
async function getMakeItemsForFile(relativePath: string): Promise<CommandTreeItem[]> {
426+
const provider = getCommandTreeProvider();
427+
const items = await collectLeafItems(provider);
428+
return items.filter(
429+
(item) => isCommandItem(item.data) && item.data.type === "make" && item.data.filePath.endsWith(relativePath)
430+
);
431+
}
432+
433+
setup(async function () {
434+
this.timeout(15000);
435+
436+
writeFile(
437+
makeRelativePath,
438+
[
439+
".PHONY: help build _private",
440+
"",
441+
"aaa_file:",
442+
'\t@echo "file target"',
443+
"",
444+
"help:",
445+
'\t@echo "help target"',
446+
"",
447+
"build:",
448+
'\t@echo "build target"',
449+
"",
450+
"%.o: %.c",
451+
'\t@echo "pattern rule"',
452+
"",
453+
".DEFAULT:",
454+
'\t@echo "special target"',
455+
"",
456+
"_private:",
457+
'\t@echo "private target"',
458+
].join("\n")
459+
);
460+
461+
await refreshTasks();
462+
});
463+
464+
teardown(async function () {
465+
this.timeout(15000);
466+
deleteFile(makeRelativePath);
467+
await refreshTasks();
468+
});
469+
470+
test("make help is pinned to the top, phony targets sort before non-phony ones, and special targets stay hidden", async function () {
471+
this.timeout(15000);
472+
473+
const folderChildren = await getFolderChildrenForCategory("Make Targets", "make-conventions");
474+
const folderLabels = folderChildren.map((item) => getLabelString(item.label));
475+
const items = await getMakeItemsForFile(makeRelativePath);
476+
const labels = items.map((item) => getLabelString(item.label));
477+
478+
assert.deepStrictEqual(
479+
folderLabels,
480+
["help", "build", "aaa_file", privateDivider, "_private"],
481+
"Make targets should pin help first, prefer phony public targets over non-phony ones, and separate private targets"
482+
);
483+
484+
assert.deepStrictEqual(
485+
labels,
486+
["help", "build", "aaa_file", "_private"],
487+
"Only invokable make targets should remain after hiding special and pattern rules"
488+
);
489+
490+
assert.ok(!labels.includes("%.o"), "Pattern rules should be hidden from Make discovery");
491+
assert.ok(!labels.includes(".DEFAULT"), "Dot-prefixed special targets should be hidden from Make discovery");
492+
});
493+
});
377494
});

src/tree/folderTree.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { CommandItem } from "../models/TaskItem";
22
import type { CommandTreeItem } from "../models/TaskItem";
33
import type { DirNode } from "./dirTree";
44
import { groupByFullDir, buildDirTree, needsFolderWrapper, getFolderLabel } from "./dirTree";
5-
import { createCommandNode, createFolderNode } from "./nodeFactory";
5+
import { createFolderNode, createTaskNodes } from "./nodeFactory";
66

77
/**
88
* Renders a DirNode as a folder CommandTreeItem.
@@ -20,7 +20,7 @@ function renderFolder({
2020
}): CommandTreeItem {
2121
const label = getFolderLabel(node.dir, parentDir);
2222
const folderId = `${parentTreeId}/${label}`;
23-
const taskItems = sortTasks(node.tasks).map((t) => createCommandNode(t));
23+
const taskItems = createTaskNodes(sortTasks(node.tasks));
2424
const subItems = node.subdirs.map((sub) =>
2525
renderFolder({
2626
node: sub,
@@ -66,7 +66,7 @@ export function buildNestedFolderItems({
6666
})
6767
);
6868
}
69-
result.push(...sortTasks(node.tasks).map((t) => createCommandNode(t)));
69+
result.push(...createTaskNodes(sortTasks(node.tasks)));
7070
} else if (needsFolderWrapper(node, rootNodes.length)) {
7171
result.push(
7272
renderFolder({
@@ -77,7 +77,7 @@ export function buildNestedFolderItems({
7777
})
7878
);
7979
} else {
80-
result.push(...sortTasks(node.tasks).map((t) => createCommandNode(t)));
80+
result.push(...createTaskNodes(sortTasks(node.tasks)));
8181
}
8282
}
8383

0 commit comments

Comments
 (0)