Skip to content

Commit a0c01b6

Browse files
authored
feat(tasks): support grouping tasks by projects in monorepos (#276)
1 parent 9db4aae commit a0c01b6

10 files changed

Lines changed: 347 additions & 53 deletions

File tree

package.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,18 @@
809809
"icon": "$(add)",
810810
"enablement": "!isWeb"
811811
},
812+
{
813+
"command": "mise.groupTasksByProject",
814+
"title": "Mise: Group Tasks by Project",
815+
"icon": "$(folder-library)",
816+
"enablement": "!isWeb"
817+
},
818+
{
819+
"command": "mise.groupTasksBySource",
820+
"title": "Mise: Group Tasks by Source File",
821+
"icon": "$(list-tree)",
822+
"enablement": "!isWeb"
823+
},
812824
{
813825
"command": "mise.openToolDefinition",
814826
"title": "Mise: Open Tool Definition",
@@ -1074,6 +1086,16 @@
10741086
"when": "view == miseTasksView",
10751087
"group": "navigation@2"
10761088
},
1089+
{
1090+
"command": "mise.groupTasksByProject",
1091+
"when": "view == miseTasksView && mise.tasksCanGroupByProject && !mise.tasksGroupByProject",
1092+
"group": "navigation@3"
1093+
},
1094+
{
1095+
"command": "mise.groupTasksBySource",
1096+
"when": "view == miseTasksView && mise.tasksCanGroupByProject && mise.tasksGroupByProject",
1097+
"group": "navigation@3"
1098+
},
10771099
{
10781100
"command": "mise.listAllTools",
10791101
"when": "view == miseToolsView",

src/commands.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export const MISE_COPY_TOOL_INSTALL_PATH = "mise.copyToolInstallPath";
77
export const MISE_CREATE_FILE_TASK = "mise.createFileTask";
88
export const MISE_CREATE_TOML_TASK = "mise.createTomlTask";
99
export const MISE_CREATE_TOML_TASK_TOP_MENU = "mise.createTomlTaskTopMenu";
10+
export const MISE_GROUP_TASKS_BY_PROJECT = "mise.groupTasksByProject";
11+
export const MISE_GROUP_TASKS_BY_SOURCE = "mise.groupTasksBySource";
1012
export const MISE_INSTALL_ALL = "mise.installAll";
1113
export const MISE_INSTALL_TOOL = "mise.installTool";
1214
export const MISE_LIST_ALL_TOOLS = "mise.listAllTools";

src/providers/tasksProvider.ts

Lines changed: 176 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
MISE_CREATE_TOML_TASK,
88
MISE_CREATE_TOML_TASK_TOP_MENU,
99
MISE_EXPLAIN_TASK_CACHE,
10+
MISE_GROUP_TASKS_BY_PROJECT,
11+
MISE_GROUP_TASKS_BY_SOURCE,
1012
MISE_OPEN_FILE,
1113
MISE_OPEN_TASK_DEFINITION,
1214
MISE_RUN_TASK,
@@ -38,7 +40,13 @@ import {
3840
import { safeExec } from "../utils/shell";
3941
import { formatCacheSummary } from "../utils/taskCache";
4042
import type { MiseTaskInfo } from "../utils/taskInfoParser";
41-
import { getTaskDisplayName } from "../utils/taskNames";
43+
import {
44+
getTaskConfigRoot,
45+
getTaskDisplayName,
46+
getTaskProjectKey,
47+
getTaskProjectLabel,
48+
getTaskProjectRootPath,
49+
} from "../utils/taskNames";
4250
import { buildMiseErrorItems, type MiseErrorItem } from "./miseErrorItems";
4351

4452
export class MiseTasksProvider implements vscode.TreeDataProvider<TreeNode> {
@@ -49,8 +57,51 @@ export class MiseTasksProvider implements vscode.TreeDataProvider<TreeNode> {
4957
TreeNode | undefined | null | void
5058
> = this._onDidChangeTreeData.event;
5159

60+
private grouping: TaskTreeGrouping = "source";
61+
private preferredGrouping: TaskTreeGrouping | undefined;
62+
private hasMultipleProjects = false;
63+
5264
constructor(private miseService: MiseService) {}
5365

66+
setGrouping(grouping: TaskTreeGrouping): void {
67+
if (grouping === "project" && !this.hasMultipleProjects) {
68+
return;
69+
}
70+
this.preferredGrouping = grouping;
71+
const effectiveGrouping = this.getEffectiveGrouping();
72+
if (this.grouping === effectiveGrouping) {
73+
return;
74+
}
75+
this.grouping = effectiveGrouping;
76+
this.updateGroupingContext();
77+
this.refresh();
78+
}
79+
80+
private getEffectiveGrouping(): TaskTreeGrouping {
81+
return this.hasMultipleProjects
82+
? (this.preferredGrouping ?? "project")
83+
: "source";
84+
}
85+
86+
private updateGroupingAvailability(tasks: MiseTask[]) {
87+
this.hasMultipleProjects = new Set(tasks.map(getTaskProjectKey)).size > 1;
88+
this.grouping = this.getEffectiveGrouping();
89+
this.updateGroupingContext();
90+
}
91+
92+
private updateGroupingContext() {
93+
void vscode.commands.executeCommand(
94+
"setContext",
95+
"mise.tasksCanGroupByProject",
96+
this.hasMultipleProjects,
97+
);
98+
void vscode.commands.executeCommand(
99+
"setContext",
100+
"mise.tasksGroupByProject",
101+
this.hasMultipleProjects && this.grouping === "project",
102+
);
103+
}
104+
54105
refresh(): void {
55106
this._onDidChangeTreeData.fire();
56107
}
@@ -72,39 +123,70 @@ export class MiseTasksProvider implements vscode.TreeDataProvider<TreeNode> {
72123
this.miseService.getMiseConfigFiles(),
73124
]);
74125

75-
const groupedTasks = this.groupTasksBySource(tasks);
76-
for (const configFile of configFiles) {
77-
if (idiomaticFiles.has(path.basename(configFile.path))) {
78-
continue;
79-
}
126+
this.updateGroupingAvailability(tasks);
127+
const groupedTasks = this.groupTasks(tasks, currentWorkspaceFolderPath);
128+
// In project mode there is no unambiguous config file to create a task
129+
// in, so only source mode adds empty config-file groups.
130+
if (this.grouping === "source") {
131+
for (const configFile of configFiles) {
132+
if (idiomaticFiles.has(path.basename(configFile.path))) {
133+
continue;
134+
}
80135

81-
// only offer empty groups for toml files (tasks can be created there)
82-
if (!configFile.path.endsWith(".toml")) {
83-
continue;
84-
}
136+
// only offer empty groups for toml files (tasks can be created there)
137+
if (!configFile.path.endsWith(".toml")) {
138+
continue;
139+
}
85140

86-
const expandedPath = expandPath(configFile.path);
87-
const isRelativeToWorkspace = expandedPath.startsWith(
88-
currentWorkspaceFolderPath || "",
89-
);
90-
if (!groupedTasks[expandedPath] && isRelativeToWorkspace) {
91-
groupedTasks[expandedPath] = [];
141+
const expandedPath = expandPath(configFile.path);
142+
const isRelativeToWorkspace = expandedPath.startsWith(
143+
currentWorkspaceFolderPath || "",
144+
);
145+
if (!groupedTasks[expandedPath] && isRelativeToWorkspace) {
146+
groupedTasks[expandedPath] = [];
147+
}
92148
}
93149
}
94150

151+
const projectRoot =
152+
this.grouping === "project" && currentWorkspaceFolderPath
153+
? expandPath(currentWorkspaceFolderPath)
154+
: undefined;
155+
const hasMonorepoRootGroup = Boolean(
156+
projectRoot &&
157+
groupedTasks[projectRoot]?.some(
158+
(task) => getTaskConfigRoot(task) === "",
159+
),
160+
);
95161
return Object.entries(groupedTasks)
96-
.sort(([sourceA], [sourceB]) =>
97-
compareSourcePaths(sourceA, sourceB, currentWorkspaceFolderPath),
98-
)
99-
.map(
100-
([source, tasks]) =>
101-
new TasksSourceGroupItem(
102-
currentWorkspaceFolderPath || "",
103-
source,
104-
tasks,
105-
this.miseService.isConfigFileUnparsed(source),
106-
),
107-
);
162+
.sort(([sourceA], [sourceB]) => {
163+
// A project group is a directory, whereas source mode normally sorts
164+
// files within it. Give the monorepo-root directory an explicit rank.
165+
if (projectRoot && hasMonorepoRootGroup) {
166+
const isRootA = expandPath(sourceA) === projectRoot;
167+
const isRootB = expandPath(sourceB) === projectRoot;
168+
if (isRootA !== isRootB) {
169+
return isRootA ? -1 : 1;
170+
}
171+
}
172+
return compareSourcePaths(sourceA, sourceB, currentWorkspaceFolderPath);
173+
})
174+
.map(([source, tasks]) => {
175+
const isProjectGroup =
176+
this.grouping === "project" && Boolean(currentWorkspaceFolderPath);
177+
const projectLabel =
178+
isProjectGroup && tasks[0]
179+
? getTaskProjectLabel(tasks[0], currentWorkspaceFolderPath)
180+
: undefined;
181+
return new TasksSourceGroupItem(
182+
currentWorkspaceFolderPath || "",
183+
source,
184+
tasks,
185+
this.miseService.isConfigFileUnparsed(source),
186+
isProjectGroup,
187+
projectLabel,
188+
);
189+
});
108190
}
109191

110192
async getChildren(element?: TreeNode): Promise<TreeNode[]> {
@@ -149,7 +231,11 @@ export class MiseTasksProvider implements vscode.TreeDataProvider<TreeNode> {
149231

150232
async getParent(element: TreeNode): Promise<TreeNode | undefined> {
151233
if (element instanceof TaskItem) {
152-
const source = getTaskGroupSource(element.task);
234+
const source = getTaskGroupSource(
235+
element.task,
236+
this.grouping,
237+
this.miseService.getCurrentWorkspaceFolderPath(),
238+
);
153239
const groups = await this.getTasksSourceGroupItems();
154240
return groups.find((group) => group.source === source);
155241
}
@@ -166,11 +252,18 @@ export class MiseTasksProvider implements vscode.TreeDataProvider<TreeNode> {
166252
return new TaskItem(task, await getFileTaskIconUri(task));
167253
}
168254

169-
private groupTasksBySource(tasks: MiseTask[]): Record<string, MiseTask[]> {
255+
private groupTasks(
256+
tasks: MiseTask[],
257+
currentWorkspaceFolderPath: string | undefined,
258+
): Record<string, MiseTask[]> {
170259
const groupedTasks: Record<string, MiseTask[]> = {};
171260

172261
for (const task of tasks) {
173-
const source = getTaskGroupSource(task);
262+
const source = getTaskGroupSource(
263+
task,
264+
this.grouping,
265+
currentWorkspaceFolderPath,
266+
);
174267
if (!groupedTasks[source]) {
175268
groupedTasks[source] = [];
176269
}
@@ -374,9 +467,23 @@ export class MiseTasksProvider implements vscode.TreeDataProvider<TreeNode> {
374467
}
375468

376469
type TreeNode = TasksSourceGroupItem | TaskItem | MiseErrorItem;
470+
type TaskTreeGrouping = "source" | "project";
377471

378472
/** Key of the group a task is shown under in the tree */
379-
function getTaskGroupSource(task: MiseTask): string {
473+
function getTaskGroupSource(
474+
task: MiseTask,
475+
grouping: TaskTreeGrouping,
476+
currentWorkspaceFolderPath: string | undefined,
477+
): string {
478+
const projectRoot =
479+
grouping === "project" && currentWorkspaceFolderPath
480+
? (getTaskProjectRootPath(task, currentWorkspaceFolderPath) ??
481+
getTaskProjectKey(task))
482+
: undefined;
483+
if (projectRoot) {
484+
return projectRoot;
485+
}
486+
380487
return (
381488
(task.source.endsWith(".toml") || task.source.endsWith("package.json")
382489
? expandPath(task.source)
@@ -390,8 +497,11 @@ class TasksSourceGroupItem extends vscode.TreeItem {
390497
public readonly source: string,
391498
public readonly tasks: MiseTask[],
392499
unparsed = false,
500+
readonly isProjectGroup = false,
501+
readonly projectLabel: string | undefined = undefined,
393502
) {
394-
const pathShown = displayPathRelativeTo(source, currentWorkspaceFolderPath);
503+
const pathShown =
504+
projectLabel ?? displayPathRelativeTo(source, currentWorkspaceFolderPath);
395505

396506
super(
397507
unparsed
@@ -400,7 +510,11 @@ class TasksSourceGroupItem extends vscode.TreeItem {
400510
);
401511
// stable id so `TreeView.reveal` can match recreated items
402512
this.id = source;
403-
this.tooltip = unparsed ? UNPARSED_CONFIG_TOOLTIP : `Source: ${source}`;
513+
this.tooltip = unparsed
514+
? UNPARSED_CONFIG_TOOLTIP
515+
: projectLabel
516+
? `Project: ${projectLabel}`
517+
: `${isProjectGroup ? "Project" : "Source"}: ${source}`;
404518
if (unparsed) {
405519
this.description = UNPARSED_CONFIG_DESCRIPTION;
406520
this.iconPath = new vscode.ThemeIcon(
@@ -409,9 +523,11 @@ class TasksSourceGroupItem extends vscode.TreeItem {
409523
);
410524
}
411525

412-
this.contextValue = source.endsWith(".toml")
413-
? "miseTaskGroupEditable"
414-
: "miseTaskGroup";
526+
this.contextValue = isProjectGroup
527+
? "miseTaskProjectGroup"
528+
: source.endsWith(".toml")
529+
? "miseTaskGroupEditable"
530+
: "miseTaskGroup";
415531

416532
if (tasks.length === 0) {
417533
this.collapsibleState = vscode.TreeItemCollapsibleState.None;
@@ -427,7 +543,8 @@ class TasksSourceGroupItem extends vscode.TreeItem {
427543
// the "folder" id is resolved by the file icon theme, which may have
428544
// no folder icons; use a plain codicon for directories instead
429545
this.iconPath =
430-
source.endsWith(".toml") || source.endsWith("package.json")
546+
!isProjectGroup &&
547+
(source.endsWith(".toml") || source.endsWith("package.json"))
431548
? vscode.ThemeIcon.File
432549
: new vscode.ThemeIcon("symbol-folder");
433550
}
@@ -532,6 +649,27 @@ export function registerTasksCommands(
532649
tasksTreeView?: vscode.TreeView<TreeNode>,
533650
) {
534651
const miseService = taskProvider.getMiseService();
652+
// Match the dependency graph's in-memory view controls: source grouping is
653+
// restored whenever the extension is activated.
654+
void vscode.commands.executeCommand(
655+
"setContext",
656+
"mise.tasksCanGroupByProject",
657+
false,
658+
);
659+
void vscode.commands.executeCommand(
660+
"setContext",
661+
"mise.tasksGroupByProject",
662+
false,
663+
);
664+
665+
context.subscriptions.push(
666+
vscode.commands.registerCommand(MISE_GROUP_TASKS_BY_PROJECT, () => {
667+
taskProvider.setGrouping("project");
668+
}),
669+
vscode.commands.registerCommand(MISE_GROUP_TASKS_BY_SOURCE, () => {
670+
taskProvider.setGrouping("source");
671+
}),
672+
);
535673

536674
context.subscriptions.push(
537675
vscode.commands.registerCommand(

src/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ type depsArray = Array<
55
string | string[] | { task: string; optional?: boolean }
66
>;
77

8+
type MiseTaskOutputs = string[] | { auto: true };
9+
810
type MiseTask = {
911
name: string;
1012
/** e.g. `fmt` for a toml task, `//projects/frontend:test` for a workspace script task */
@@ -20,7 +22,7 @@ type MiseTask = {
2022
hide?: boolean;
2123
raw?: boolean;
2224
sources?: string[];
23-
outputs?: string[];
25+
outputs?: MiseTaskOutputs;
2426
shell?: string;
2527
quiet?: boolean;
2628
silent?: boolean;

src/utils/taskDisplay.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { describe, expect, it } from "bun:test";
2+
import { formatTaskOutputs } from "./taskDisplay";
3+
4+
describe("formatTaskOutputs", () => {
5+
it("formats the files array emitted by mise", () => {
6+
expect(formatTaskOutputs(["dist/**", "coverage/**"])).toBe(
7+
"dist/**, coverage/**",
8+
);
9+
});
10+
11+
it("formats auto-detected outputs", () => {
12+
expect(formatTaskOutputs({ auto: true })).toBe("Auto-detected");
13+
});
14+
});

src/utils/taskDisplay.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/** Display a mise task outputs field from `mise tasks ls --json`. */
2+
export function formatTaskOutputs(
3+
outputs: MiseTask["outputs"],
4+
): string | undefined {
5+
if (Array.isArray(outputs)) {
6+
return outputs.join(", ");
7+
}
8+
return outputs?.auto ? "Auto-detected" : undefined;
9+
}

0 commit comments

Comments
 (0)