From 1abddef5548756c19106159a6b96e4b2126be8af Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Sat, 18 Apr 2026 14:34:49 +0500 Subject: [PATCH] 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. --- src/CommandTreeProvider.ts | 40 ++++++++-- src/discovery/make.ts | 61 +++++++++++++++- src/models/TaskItem.ts | 14 ++++ src/test/e2e/treeview.e2e.test.ts | 117 ++++++++++++++++++++++++++++++ src/tree/folderTree.ts | 8 +- src/tree/nodeFactory.ts | 36 ++++++++- 6 files changed, 262 insertions(+), 14 deletions(-) diff --git a/src/CommandTreeProvider.ts b/src/CommandTreeProvider.ts index 3b4aa0a..0824479 100644 --- a/src/CommandTreeProvider.ts +++ b/src/CommandTreeProvider.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode"; +import { isPhonyTask, isPrivateTask } from "./models/TaskItem"; import type { CommandItem, CategoryDef } from "./models/TaskItem"; import type { CommandTreeItem } from "./models/TaskItem"; import type { DiscoveryResult } from "./discovery"; @@ -6,7 +7,7 @@ import { discoverAllTasks, flattenTasks, getExcludePatterns, CATEGORY_DEFS } fro import { TagConfig } from "./config/TagConfig"; import { logger } from "./utils/logger"; import { buildNestedFolderItems } from "./tree/folderTree"; -import { createCommandNode, createCategoryNode } from "./tree/nodeFactory"; +import { createCategoryNode, createTaskNodes } from "./tree/nodeFactory"; import { getAllRows } from "./db/db"; import type { CommandRow } from "./db/db"; import { getDbOrThrow } from "./db/lifecycle"; @@ -166,7 +167,7 @@ export class CommandTreeProvider implements vscode.TreeDataProvider createCommandNode(t)); + const children = createTaskNodes(sorted); return createCategoryNode({ label: `${def.label} (${tasks.length})`, children, @@ -183,15 +184,44 @@ export class CommandTreeProvider implements vscode.TreeDataProvider number { const order = this.getSortOrder(); if (order === "folder") { - return (a, b) => a.category.localeCompare(b.category) || a.label.localeCompare(b.label); + return (a, b) => + a.category.localeCompare(b.category) || + this.comparePrivateTasks(a, b) || + this.compareMakeTaskPriority(a, b) || + a.label.localeCompare(b.label); } if (order === "type") { - return (a, b) => a.type.localeCompare(b.type) || a.label.localeCompare(b.label); + return (a, b) => + a.type.localeCompare(b.type) || + this.comparePrivateTasks(a, b) || + this.compareMakeTaskPriority(a, b) || + a.label.localeCompare(b.label); } - return (a, b) => a.label.localeCompare(b.label); + return (a, b) => this.comparePrivateTasks(a, b) || this.compareMakeTaskPriority(a, b) || a.label.localeCompare(b.label); } private applyTagFilter(tasks: CommandItem[]): CommandItem[] { diff --git a/src/discovery/make.ts b/src/discovery/make.ts index 9d2f23d..652185c 100644 --- a/src/discovery/make.ts +++ b/src/discovery/make.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; import * as path from "path"; -import type { CommandItem, IconDef, CategoryDef } from "../models/TaskItem"; +import type { CommandItem, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; import { readFileContent } from "../utils/fileUtils"; @@ -28,6 +28,7 @@ export async function discoverMakeTargets(workspaceRoot: string, excludePatterns for (const file of allFiles) { const content = await readFileContent(file); + const phonyTargets = parsePhonyTargets(content); const targets = parseMakeTargets(content); const makeDir = path.dirname(file.fsPath); const category = simplifyPath(file.fsPath, workspaceRoot); @@ -38,7 +39,7 @@ export async function discoverMakeTargets(workspaceRoot: string, excludePatterns continue; } - commands.push({ + const command: MutableCommandItem = { id: generateCommandId("make", file.fsPath, name), label: name, type: "make", @@ -48,7 +49,13 @@ export async function discoverMakeTargets(workspaceRoot: string, excludePatterns filePath: file.fsPath, tags: [], line, - }); + }; + + if (phonyTargets.has(name)) { + command.isPhony = true; + } + + commands.push(command); } } @@ -60,6 +67,54 @@ interface MakeTarget { readonly line: number; } +function addPhonyTargets(line: string, phonyTargets: Set): void { + for (const name of line.split(/\s+/)) { + if (name !== "") { + phonyTargets.add(name); + } + } +} + +function trimContinuation(line: string): string { + return line.endsWith("\\") ? line.slice(0, -1).trim() : line; +} + +function isContinuationLine(line: string): boolean { + return line.endsWith("\\"); +} + +function readPhonyLine(line: string): string | undefined { + const trimmed = line.trim(); + if (!trimmed.startsWith(".PHONY:")) { + return undefined; + } + return trimmed.slice(".PHONY:".length).trim(); +} + +function parsePhonyTargets(content: string): ReadonlySet { + const phonyTargets = new Set(); + let collecting = false; + + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (collecting) { + addPhonyTargets(trimContinuation(trimmed), phonyTargets); + collecting = isContinuationLine(trimmed); + continue; + } + + const phonyLine = readPhonyLine(line); + if (phonyLine === undefined) { + continue; + } + + addPhonyTargets(trimContinuation(phonyLine), phonyTargets); + collecting = isContinuationLine(phonyLine); + } + + return phonyTargets; +} + /** * Parses Makefile to extract target names and their line numbers. */ diff --git a/src/models/TaskItem.ts b/src/models/TaskItem.ts index a529e56..8d4f3cf 100644 --- a/src/models/TaskItem.ts +++ b/src/models/TaskItem.ts @@ -96,6 +96,7 @@ export interface CommandItem { readonly description?: string; readonly summary?: string; readonly securityWarning?: string; + readonly isPhony?: boolean; readonly line?: number; } @@ -115,6 +116,7 @@ export interface MutableCommandItem { description?: string; summary?: string; securityWarning?: string; + isPhony?: boolean; line?: number; } @@ -217,3 +219,15 @@ export function simplifyPath(filePath: string, workspaceRoot: string): string { export function generateCommandId(type: CommandType, filePath: string, name: string): string { return `${type}:${filePath}:${name}`; } + +function supportsPrivateTaskStyling(type: CommandType): boolean { + return type === "make" || type === "mise"; +} + +export function isPrivateTask(task: CommandItem): boolean { + return supportsPrivateTaskStyling(task.type) && task.label.startsWith("_"); +} + +export function isPhonyTask(task: CommandItem): boolean { + return task.type === "make" && task.isPhony === true; +} diff --git a/src/test/e2e/treeview.e2e.test.ts b/src/test/e2e/treeview.e2e.test.ts index 21db6bb..5ef5d90 100644 --- a/src/test/e2e/treeview.e2e.test.ts +++ b/src/test/e2e/treeview.e2e.test.ts @@ -252,6 +252,7 @@ suite("TreeView E2E Tests", () => { suite("Private Make And Mise Tasks", () => { const makeRelativePath = "private-targets/Makefile"; const miseRelativePath = "private-targets/mise.toml"; + const privateDivider = "---------------- private ----------------"; const publicLabels = ["alpha_public", "zeta_public"]; const privateLabels = ["_beta_private", "_omega_private"]; @@ -269,6 +270,19 @@ suite("TreeView E2E Tests", () => { ); } + async function getFolderChildrenForCategory(categoryLabel: string, folderLabel: string): Promise { + const provider = getCommandTreeProvider(); + const categories = await provider.getChildren(); + const category = categories.find((item) => getLabelString(item.label).includes(categoryLabel)); + assert.ok(category !== undefined, `Should find category ${categoryLabel}`); + + const children = await provider.getChildren(category); + const folder = children.find((item) => getLabelString(item.label) === folderLabel); + assert.ok(folder !== undefined, `Should find folder ${folderLabel}`); + + return await provider.getChildren(folder); + } + setup(async function () { this.timeout(15000); @@ -321,6 +335,14 @@ suite("TreeView E2E Tests", () => { const items = await getItemsForFile("make", makeRelativePath); const labels = items.map((item) => getLabelString(item.label)); + const folderChildren = await getFolderChildrenForCategory("Make Targets", "private-targets"); + const folderLabels = folderChildren.map((item) => getLabelString(item.label)); + + assert.deepStrictEqual( + folderLabels, + [...publicLabels, privateDivider, ...privateLabels], + "Make targets should insert a divider between public and _-prefixed private targets" + ); assert.deepStrictEqual( labels, @@ -350,6 +372,14 @@ suite("TreeView E2E Tests", () => { const items = await getItemsForFile("mise", miseRelativePath); const labels = items.map((item) => getLabelString(item.label)); + const folderChildren = await getFolderChildrenForCategory("Mise Tasks", "private-targets"); + const folderLabels = folderChildren.map((item) => getLabelString(item.label)); + + assert.deepStrictEqual( + folderLabels, + [...publicLabels, privateDivider, ...privateLabels], + "Mise tasks should insert a divider between public and _-prefixed private tasks" + ); assert.deepStrictEqual( labels, @@ -374,4 +404,91 @@ suite("TreeView E2E Tests", () => { } }); }); + + suite("Make Target Conventions", () => { + const makeRelativePath = "make-conventions/Makefile"; + const privateDivider = "---------------- private ----------------"; + + async function getFolderChildrenForCategory(categoryLabel: string, folderLabel: string): Promise { + const provider = getCommandTreeProvider(); + const categories = await provider.getChildren(); + const category = categories.find((item) => getLabelString(item.label).includes(categoryLabel)); + assert.ok(category !== undefined, `Should find category ${categoryLabel}`); + + const children = await provider.getChildren(category); + const folder = children.find((item) => getLabelString(item.label) === folderLabel); + assert.ok(folder !== undefined, `Should find folder ${folderLabel}`); + + return await provider.getChildren(folder); + } + + async function getMakeItemsForFile(relativePath: string): Promise { + const provider = getCommandTreeProvider(); + const items = await collectLeafItems(provider); + return items.filter( + (item) => isCommandItem(item.data) && item.data.type === "make" && item.data.filePath.endsWith(relativePath) + ); + } + + setup(async function () { + this.timeout(15000); + + writeFile( + makeRelativePath, + [ + ".PHONY: help build _private", + "", + "aaa_file:", + '\t@echo "file target"', + "", + "help:", + '\t@echo "help target"', + "", + "build:", + '\t@echo "build target"', + "", + "%.o: %.c", + '\t@echo "pattern rule"', + "", + ".DEFAULT:", + '\t@echo "special target"', + "", + "_private:", + '\t@echo "private target"', + ].join("\n") + ); + + await refreshTasks(); + }); + + teardown(async function () { + this.timeout(15000); + deleteFile(makeRelativePath); + await refreshTasks(); + }); + + test("make help is pinned to the top, phony targets sort before non-phony ones, and special targets stay hidden", async function () { + this.timeout(15000); + + const folderChildren = await getFolderChildrenForCategory("Make Targets", "make-conventions"); + const folderLabels = folderChildren.map((item) => getLabelString(item.label)); + const items = await getMakeItemsForFile(makeRelativePath); + const labels = items.map((item) => getLabelString(item.label)); + + assert.deepStrictEqual( + folderLabels, + ["help", "build", "aaa_file", privateDivider, "_private"], + "Make targets should pin help first, prefer phony public targets over non-phony ones, and separate private targets" + ); + + assert.deepStrictEqual( + labels, + ["help", "build", "aaa_file", "_private"], + "Only invokable make targets should remain after hiding special and pattern rules" + ); + + assert.ok(!labels.includes("%.o"), "Pattern rules should be hidden from Make discovery"); + assert.ok(!labels.includes(".DEFAULT"), "Dot-prefixed special targets should be hidden from Make discovery"); + }); + }); }); diff --git a/src/tree/folderTree.ts b/src/tree/folderTree.ts index d3b8433..3e6bcfb 100644 --- a/src/tree/folderTree.ts +++ b/src/tree/folderTree.ts @@ -2,7 +2,7 @@ import type { CommandItem } from "../models/TaskItem"; import type { CommandTreeItem } from "../models/TaskItem"; import type { DirNode } from "./dirTree"; import { groupByFullDir, buildDirTree, needsFolderWrapper, getFolderLabel } from "./dirTree"; -import { createCommandNode, createFolderNode } from "./nodeFactory"; +import { createFolderNode, createTaskNodes } from "./nodeFactory"; /** * Renders a DirNode as a folder CommandTreeItem. @@ -20,7 +20,7 @@ function renderFolder({ }): CommandTreeItem { const label = getFolderLabel(node.dir, parentDir); const folderId = `${parentTreeId}/${label}`; - const taskItems = sortTasks(node.tasks).map((t) => createCommandNode(t)); + const taskItems = createTaskNodes(sortTasks(node.tasks)); const subItems = node.subdirs.map((sub) => renderFolder({ node: sub, @@ -66,7 +66,7 @@ export function buildNestedFolderItems({ }) ); } - result.push(...sortTasks(node.tasks).map((t) => createCommandNode(t))); + result.push(...createTaskNodes(sortTasks(node.tasks))); } else if (needsFolderWrapper(node, rootNodes.length)) { result.push( renderFolder({ @@ -77,7 +77,7 @@ export function buildNestedFolderItems({ }) ); } else { - result.push(...sortTasks(node.tasks).map((t) => createCommandNode(t))); + result.push(...createTaskNodes(sortTasks(node.tasks))); } } diff --git a/src/tree/nodeFactory.ts b/src/tree/nodeFactory.ts index 0fdd319..048903d 100644 --- a/src/tree/nodeFactory.ts +++ b/src/tree/nodeFactory.ts @@ -1,14 +1,24 @@ import * as vscode from "vscode"; +import { isPrivateTask } from "../models/TaskItem"; import type { CommandItem, CommandType, IconDef } from "../models/TaskItem"; import { CommandTreeItem } from "../models/TaskItem"; import { ICON_REGISTRY } from "../discovery"; const DEFAULT_FOLDER_ICON = new vscode.ThemeIcon("folder"); +const PRIVATE_TASK_COLOR = new vscode.ThemeColor("descriptionForeground"); +const PRIVATE_TASK_DIVIDER = "---------------- private ----------------"; function toThemeIcon(def: IconDef): vscode.ThemeIcon { return new vscode.ThemeIcon(def.icon, new vscode.ThemeColor(def.color)); } +function getTaskIcon(task: CommandItem): vscode.ThemeIcon { + if (isPrivateTask(task)) { + return new vscode.ThemeIcon(ICON_REGISTRY[task.type].icon, PRIVATE_TASK_COLOR); + } + return toThemeIcon(ICON_REGISTRY[task.type]); +} + function resolveContextValue(task: CommandItem): string { const isQuick = task.tags.includes("quick"); const isMarkdown = task.type === "markdown"; @@ -48,8 +58,9 @@ function buildTooltip(task: CommandItem): vscode.MarkdownString { } function buildDescription(task: CommandItem): string { + const privateMarker = isPrivateTask(task) ? " private" : ""; const tagStr = task.tags.length > 0 ? ` [${task.tags.join(", ")}]` : ""; - return `${task.category}${tagStr}`; + return `${task.category}${privateMarker}${tagStr}`; } export function createCommandNode(task: CommandItem): CommandTreeItem { @@ -62,7 +73,7 @@ export function createCommandNode(task: CommandItem): CommandTreeItem { id: task.id, contextValue: resolveContextValue(task), tooltip: buildTooltip(task), - iconPath: toThemeIcon(ICON_REGISTRY[task.type]), + iconPath: getTaskIcon(task), description: buildDescription(task), command: { command: "vscode.open", @@ -75,6 +86,17 @@ export function createCommandNode(task: CommandItem): CommandTreeItem { }); } +export function createTaskNodes(tasks: CommandItem[]): CommandTreeItem[] { + const firstPrivateIndex = tasks.findIndex((task) => isPrivateTask(task)); + if (firstPrivateIndex <= 0 || firstPrivateIndex === tasks.length) { + return tasks.map((task) => createCommandNode(task)); + } + + const publicNodes = tasks.slice(0, firstPrivateIndex).map((task) => createCommandNode(task)); + const privateNodes = tasks.slice(firstPrivateIndex).map((task) => createCommandNode(task)); + return [...publicNodes, createDividerNode(PRIVATE_TASK_DIVIDER), ...privateNodes]; +} + export function createCategoryNode({ label, children, @@ -122,3 +144,13 @@ export function createPlaceholderNode(message: string): CommandTreeItem { contextValue: "placeholder", }); } + +export function createDividerNode(label: string): CommandTreeItem { + return new CommandTreeItem({ + label, + data: { nodeType: "folder" }, + children: [], + id: `divider:${label}`, + contextValue: "divider", + }); +}