How do I assign unique commands to dynamically-created menu items?


#1

Hi everyone,

I’m new to atom package development, so basic advice would be appreciated. I’m using MenuManager to handle the addition of new menu items that are dynamically iterated from an array of elements.

For example, the generated menu might look like:

  • Task XYZ
  • Task AAA
  • Task 321
  • … (number of items vary)

It’s impractical to hardcode a command to each item, as the number of items vary depending on my array. I can have each one of them point to the same command, but how can I then send unique identifiers to the command to process? Is that possible? If not, what’s the way to do this?


#2

I would probably add the command when you add the new menu item. A small problem that needs to be solved is keeping a reference to the disposable for the menu item and a reference to the disposable for the command, so you can also remove both at the same time when the task is no longer needed.

Completely untested code that attempts to do the above:

packageName: require('../package.json').name

...

activate: ->
  ...
  @subscriptions = new CompositeDisposable
  @subscriptions.add @taskMenuItems = new CompositeDisposable
  @subscriptions.add @taskCommands = new CompositeDisposable
  @tasks = []
  ...

deactivate: ->
  @subscriptions.dispose()

onNewTask: (taskName) ->
  newTaskMenuItem = atom.menu.add [
    {
      label: @camelCase @packageName
      submenu : [
        {
          label: @camelCase taskName
          command: "#{@packageName}:#{taskName}"
        }
      ]
    }
  ]
  newTaskCommand = atom.commands.add 'atom-workspace', "#{@packageName}:#{taskName}", => @executeTaskFor(taskName)
  @tasks.push {
    name: taskName
    taskMenuItem: newTaskMenuItem
    taskCommand: newTaskCommand
  }
  @taskMenuItems.add newTaskMenuItem
  @taskCommands.add newTaskCommand

onTaskRemoved: (taskName) ->
  for task, i in @tasks
    if task.name is taskName
      task.taskMenuItem.dispose()
      task.taskCommand.dispose()
      @tasks.splice i, 1
      break

camelCase: (word) ->
  re = /(\b[a-z](?!\s))/g
  word.replace re, (letter) ->
    letter.toUpperCase()

The @tasks array contains objects with a name, taskMenuItem and taskCommand. This lets you dispose of the menu items and commands when the task is no longer needed.

The ::camelCase method is of course completely optional, but it makes menu items start with uppercase letters, which I think is visually pleasing.

Maybe there’s a more elegant solution, but I hope this helps you get started. :slight_smile:


#3

@Alchiadus, fantastic! Thanks for this. :+1:

With a bit of a tweak here and there, I got a basic implementation working. I’ll have to read up more on CompositeDisposable, and the disposal of the commands/tasks you demo’ed.

The part that did the trick for me was understanding how #{...} string interpolation works – I totally missed that and spent hours hunting down the Coffeescript definition for it. Link for reference is here, if anyone is wondering about it. Yes, I’m that new to this. :sweat_smile:

Great idea on retrieving packageName from package.json, too!


#4

This is strange: in my adaptation I’m setting up onNewTask not as a singular function, but as a for...loop that iterates through taskNames stored in a simple array – say ['MenuCommand1','MenuCommand2','MenuCommand3']. I’d assume that it’d work, but no…

When placed in the for…loop, the newTaskCommand = atom.commands.add... line will now always send the last taskName as the argument to every menu item command. Effectively, the menu items MenuCommand1, MenuCommand2 and MenuCommand3 now call the same function that’s pinned to MenuCommand3.

Trying a while…loop and making use of a counter has the same result.

My menu items are going to be generated dynamically; without the possibility of using loops, how can this be done?

A snippet from my adaptation (non-relevant bits stripped):

@processMenuTasks: (taskArray) ->

    for taskName in taskArray ->
      newTaskMenuItem = atom.menu.add [
        {
          label: 'MyPackage'
          submenu : [
            {
              label:  taskName
              command: "#{@packageName}:#{taskName}"
            }]}]
    
      newTaskCommand = atom.commands.add 'atom-workspace', "#{@packageName}:#{taskName}", => @executeTaskFor(taskName)

@executeTaskFor: (taskName) ->
    console.log taskName
    # each menu item outputs the final taskName...

What could be wrong?

[EDIT] I’ll add that the newTaskMenuItem function had no issues – menu items do get individually labelled correctly.


#5

That happens because javascript doesn’t have block scope (well, es6 has it, but not coffeescript) but function scope; taskName is defined in the scope of the processMenuTasks function. At the time the command’s callback is called, you will have already looped over all values in taskArray, and so taskName's value will be the last value of the array. You can fix this by wrapping a function around each iteration of the for loop:

for taskName in taskArray
  do (taskName) ->
    newTaskMenuItem = ...
    newTaskCommand = atom.commands.add ..., -> executeTaskFor(taskName)

Here, a separate taskName will be remembered for each iteration of the for loop and thus for each command.


#6

Thank you @olmokramer, that was exactly what I was experiencing, and your suggestion of the wrapping function resolved the issue. :grinning:

(I had to bind the do function to access all my other variables)


#7

This makes a local scope in CS: do ->. It’s one of the main reasons do was added.


#8

Yes, I didn’t argue you can’t create a local scope in CS. But it isn’t block scope in the sense that each code block has its own scope automatically. You have to define a new function to get that.


#9

It only takes four characters. It’s not that much different than typing var in js. I find I rarely have to use it.


#10

This is an old thread but it seems to be the only of its kind and I’m trying to do something similar without success. My activate method looks like this:

activate: ->
    @subscriptions = new CompositeDisposable

    for target, val of targets
      do ->
        @subscriptions.add atom.commands.add 'atom-workspace',
          "mypackage:#{target}": -> @performAction(target)

When reloading the window, Atom complains with “Cannot read property ‘add’ of undefined”, suggesting that @subscriptions is undefined. What am I doing wrong?

Thanks!


#11

Found it: I need to pass @subscriptions, target and @performAction to the function wrapper:

do (@subscriptions, target, @performAction) ->
        @subscriptions.add atom.commands.add 'atom-workspace',
          "mypackage:#{target}": -> @performAction(target)