Vim: Menus and selecting things

It’s been a while since the last time I wrote about vim, so I felt that it would be a good time to do it again. This time, the topic is all about building simple menus and selecting things.

I have often found myself in situations, where I might be working on remote servers, and need to complete tickets about tasks (e.g. quick description into a PAM ticket about why server XYZ was accessed). Often, it is entirely possible to access the API of the ticketing system from these servers, which means I do not necessarily need to pull up a browser, login, click through and search just to type “restarted apache”.

So the goal is to be able to bring up a menu that displays a list of items (like tickets) and offers us a choice.

Some assumptions

This document will assume that you are already familiar with bash, and that you understand the API of the ticketing system that you use. As this document focuses on building things with vimscript, the parts to do with calling the API will be abstracted away.

In general, it’s fairly easy to run a bash command and capture the results in vim:

let bash_command = 'bash -c "source /opt/scripts/ticket_funcs.sh && get_my_tasks"'
let choices = systemlist(bash_command)

systemlist() runs a shell command and returns its output as a list of strings, with each line as a separate item. This is basically what we can use as the basis of a selection menu. Call a ticketing system API, and get some kind of output that looks something like this:

id:abcd1234
description: Fictional Task

To illustrate what the output of this function looks like, you can also just try it in Command-line mode on a more readily available command, like ls:

:let files = systemlist('ls -la /opt')
:for line in files
:  echo line
total 16
:  endfor
drwxr-xr-x  4 root root 4096 Sep 21 21:00 .
drwxr-xr-x 23 root root 4096 Aug 20 15:50 ..
drwxr-xr-x  3 root root 4096 Jul 20 11:52 deployment
drwxr-xr-x 12 root root 4096 Jul 20 17:29 git
Press ENTER or type command to continue

Building the menu

I like to add an option like “cancel”, in case I change my mind, or selected the wrong thing by accident, so the first thing we need to understand is how to actually print a menu. The function for doing so is inputlist(). It takes a list of strings. Each element in the list is printed on a new line in the command area.

I like to add a description, and an option to cancel, so I usually put some simple description and first menu item like

let menu_lines = ['CHOOSE', '0. CANCEL']

Now, the important thing about inputlist() is that the input that you, the user gives is a number. However, inputlist() itself adds or provides none to whatever is fed into it. It just displays the list and returns the number entered by the user. It doesn’t number the entries for us, so we’ll add the numbers ourselves and use the returned value as an index into choices.

A simple way to handle it, is to create a shallow copy of the list using copy(), and use the map() function to prepend the numbers:

    let menu_lines += map(copy(choices), {idx, val -> (idx + 1) . '.' . val})

Then, we are ready to finally send this to inputlist():

let choice = inputlist(menu_lines)

Here, I prefer to capture the result into a global variable, which simply means that this variable will hang around after the function we’re building has executed. I might want to do multiple things with the ticket, like changing status, adding a comment, viewing its description, etc. so it makes sense to hang onto the result.

let g:selected_task_id = substitute(choices[choice - 1], '^id:', '', '')

The substitute() function here in my case exists because I didn’t remove the text id: from the ticket id response. It works much like it does in other languages, like awk.

Assembling the function

function! MyTickets()
    " let bash_command = 'bash -c "source /opt/scripts/ticket_funcs.sh && get_my_tasks"'
    " let choices = systemlist(bash_command)
    let choices = ['choice 1', 'choice 2', 'choice 3']

    if empty(choices)
        echo "No options were found"
        return
    endif

    let menu_lines = ['CHOOSE', '0. CANCEL']
    let menu_lines += map(copy(choices), {idx, val -> (idx + 1) . '.' . val})

    let choice = inputlist(menu_lines)

    if choice == 0 || choice > len(choices)
        echo "Never mind"
        return
    endif

    let g:selected_task_id = substitute(choices[choice - 1], '^id:', '', '')

    echo "\nIT IS: " . g:selected_task_id
endfunc

I suggest turning these into commands for easier recall, vim offers suggestions, so you don’t have to remember the whole command name this way:

command! TicketsList call MyTickets()

If you create functions that do require an argument, then the command definition needs to reflect it as well, e.g.

If you have CreateNewTicket(str) your command definition needs to be able to handle the argument you need to feed into the function:

command! -nargs=* TicketsCreateNew call CreateNewTicket(<q-args>)

See appendix on arguments if this seems cryptic to you.

Sidenote on creating new content

Viewing tickets is great, but what about adding a comment, or writing a new description for a ticket?

The real challenge is that the Command-line mode sort of stops being sufficient, because it’s not entirely pleasant to start writing multiline strings, or things like markdown in there.

What I tend to do in these situations, is to use a buffer, and then create a temporary file behind it. This creates a convenient temporary writing space for this task.

The commands :new and :vnew can be used for this. It will create a horizontal, or in the case of the second option, a vertical window split with a new buffer.

Then, the following sequence can be done:

let tmp_file = '/tmp/vim_comment_' . getpid() . '.txt'
execute 'silent 1,$w! '. tmp_file
let g:saved_content_file = tmp_file
let bash_command = 'bash -c "source /opt/scripts/ticket_funcs.sh && add_new_comment ' . g:selected_task_id . ' ' . g:saved_content_file.'"'
let result = system(bash_command)

Basically, getpid() just captures the current pid of vim, it’s just a way to create a filename, it isn’t really important to do this.

At the point where we declare tmp_file we only declare a path, but no file is created.

To actually create the tmp file, we must actually save the buffer:

execute 'silent 1,$w! ' . tmp_file

If this reads a little cryptic, then here’s the breakdown:

Conclusion

With a few simple tricks, it becomes fairly easy to interact with other tools through vim, reducing context switching, which can help stay focused on the things that matter. If you also find yourself spending a lot of time clicking around just to write a few lines of text into some ticket, then this can be a nice escape hatch.

APPENDIX

When defining a command, -nargs is used to indicate how many arguments the command can accept.

Then, the arguments themselves can be passed into the function the following ways: