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 {
3840import { safeExec } from "../utils/shell" ;
3941import { formatCacheSummary } from "../utils/taskCache" ;
4042import 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" ;
4250import { buildMiseErrorItems , type MiseErrorItem } from "./miseErrorItems" ;
4351
4452export 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
376469type 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 (
0 commit comments