Passing control to the user's favorite editor (such as VSCode) from the CLI

Chris Biscardi
InstructorChris Biscardi
Share this video with your friends

Social Share Links

Send Tweet
Published 3 years ago
Updated 3 years ago

What user experience do we want for the act of writing?

We have a few options, including letting users write directly in the console or giving them a prepared file that they can then open and write in.

Writing directly in the console doesn't give a user access to their text editor of choice. Which means lacking autocomplete, syntax highlighting, snippets, and other functionality.

Creating a file and handling the path back to the user doesn't quite work for us either, as that reduces the utility of the garden write command to the equivalent of running touch filename.md.

So for our use case, we'd ideally like to open the user's preferred editing environment, whatever that happens to be, then wait for them to be done with the file before continuing.

Since we're working with a CLI tool, we can safely assume that our users either know how to, or are willingly to learn how to, set environment variables. This means we can use the EDITOR variable to select an editor to use. This is the same way git commit works.


In Rust, there is a crate that handles not only EDITOR, but also various fallbacks per-platform. We'll take advantage of edit to call out to the user's choice of editor.

The quick usage of edit allows us to call the edit::edit function, and get the user's data.

pub fn write(garden_path: PathBuf, title: Option<String>) -> Result<()> {
    dbg!(garden_path, title);
    let template = "# ";
    let content_from_user = edit::edit(template).wrap_err("unable to read writing")?;
    dbg!(content_from_user);
    todo!()
}

This results in a filename that looks like: .tmpBy0Yun "somewhere else" on the filesystem, in a location the user would never reasonably find it. Ideally, if anything went wrong, the user would be able to take a look at the in-progress tempfile they were just working on, which should be in an easily discoverable place like the garden path.

We don't want to lose a user's work.

Additionally, the tempfile doesn't have a file extension, which means that the user's editor will be less likely to recognize it as a markdown file, so we want to add .md to the filepath.

use color_eyre::{eyre::WrapErr, Result};
use edit::{edit_file, Builder};
use std::io::{Read, Write};
use std::path::PathBuf;

const TEMPLATE: &[u8; 2] = b"# ";

pub fn write(garden_path: PathBuf, title: Option<String>) -> Result<()> {
    let (mut file, filepath) = Builder::new()
        .suffix(".md")
        .rand_bytes(5)
        .tempfile_in(&garden_path)
        .wrap_err("Failed to create wip file")?
        .keep()
        .wrap_err("Failed to keep tempfile")?;
    file.write_all(TEMPLATE)?;
    // let the user write whatever they want in their favorite editor
    // before returning to the cli and finishing up
    edit_file(filepath)?;
    // Read the user's changes back from the file into a string
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;

    dbg!(contents);
    todo!()
}

We will use the edit::Builder API to generate a random tempfile to let the user write content into. The suffix is going to be .md and the filename will be 5 random bytes. We also put the tempfile in the garden path, which ensures a user will be able to find it if necessary.

wrap_err (which requires the eyre::WrapErr trait in scope) wraps the potential error resulting from these calls with additional context, making the original error the source, and we can keep chaining after that. We keep the tempfile, which would otherwise be deleted when all handles closed, because if anything goes wrong after the user inputs data, we want to make sure we don't lose that data.

After the file is created, we write our template out to the file before passing control to the user. This requires the std::io::Write trait in scope. We use a const for the file template because it won't change. To make the TEMPLATE a const, we also need to give it a type, which is a two element byte array. Change the string to see how the byte array length is checked at compile time.

Since we have access to the file already, we can read the contents back into a string after the user is done editing. This requires having the std::io::Read trait in scope.

And now we've let the user write into a file which will stick around as long as we need it to, and importantly will stick around if any errors happen in the execution of the program, so we lose no user data and can remove the temporary file at the end of the program ourselves if all goes well.

Chris Biscardi: [0:00] What user experience do we want for the act of writing? At some point, we have to pass controls to the user, and they have to do something to put some writing down on the page. We have a few options, including letting users directly write into the console or giving them a prepared file that they can open and write in.

[0:21] Writing directly into the console doesn't give a user access to their text editor of choice, which means lacking autocomplete, syntax highlighting, snippets and other functionality they might be used to. Creating a file and handing the path back to the user doesn't quite work for us either as that reduces the utility of the garden write command to the equivalent of running touch filename.md.

[0:42] For our use case, we'd ideally like to open the user's preferred editing environment, whatever that happens to be, then wait for them to be done before continuing. Since we're working with a CLI tool, we can safely assume that our users either know how to or are willing to learn how to set environment variables.

[1:00] This means we can use the editor variable to select an editor to use. This is the same way that git commit works. Note that I have my editor set to EmacsClient right now.

[1:11] I won't be using EmacsClient today. I'll be using VS Code, and I'll show you how to set the editor variable in line with the command, in case you don't have one set either. In Rust, there's a query that handles not only editor, but also various fallbacks per platform. We'll take advantage of edit to call out to the user's choice of editor.

[1:36] The quick usage of edit shown in the documentation allows us to call the edit::edit function with a template and then it gives us back the data the user wrote, which we're debugging here. Note that the wrapper tray is not in scope, which is why we're getting an error here.

[1:55] If we set our editor variable in line to code and then cargo run write, we can see that our program exits early while the file is still open. That's because for VS Code we have to use code -w and then we can write some code here.

[2:16] Take note of the file name which is .tmp with a hash on the end, and it's in a completely different location. In this case, it's in a var/folders/hw, a bunch of gobbledygook and then .tmp. If we save this, close the file, it will return control to our program, which we can see was successful. Content from user is exactly what we wrote.

[2:46] We have no idea what this file really is as a user. The only reason we know is because VS Code happened to tell us why it was open. That tempfile is in a location no user would reasonably find it. Ideally, if anything went wrong, the user would be able to take a look at the in progress tempfile they were just working on which should be in an easily discoverable place like the garden path.

[3:10] Above all else, we don't want to lose a user's work. Additionally, the tempfile doesn't have a file extension which means that while it was writing markdown, the user's editor or my editor as VS Code is less likely to recognize it as a markdown file.

[3:27] We want to add .md to the file path because that's typically how editors determine what language highlighting to use and which mode to be in. What we're going to do is drop down to a lower level in the edit API using edit file and builder.

[3:46] Builder allows us to build a temporary file with the suffix of our choice, .md, some random bytes like the random bytes we saw in the file name earlier, a location for the tempfile, which is going to be in the garden path now.

[4:05] Instead of making it just a temporary file that closes out when this function goes away and gets deleted, we can keep it and make it persistent if anything goes wrong. This builder API returns us a mutable file and a file path.

[4:22] To get back the idea of inserting the template into the file for the user to then fill out, we can use write all on file. Note that write all is part of the write trait, so we need to bring that into the scope from standard I/O write.

[4:38] We've also made the template a CONST because it never changes. CONSTS are usually specified in all upper case. The type of this CONST is going to be a slice of U8 of length 2.

[4:53] If you don't know what a slice is, it's not super important right now. Just keep in mind that this is basically an array of len 2. We can use the B on the front of the string here to make it a byte array. Also, note that we have two characters in here which accounts for the len 2.

[5:12] Also, note that we have a couple of results that come in while we're using this builder API, and we've wrapped them in more context at each point that they could fail with WrapErr. Err allows us to keep chaining as long as we have succeeded in the previous call.

[5:30] If we fail here, we will return with an error from this function. If we fail here, we will return with a different error. If we change the string here, say add another couple characters, we can see that the length of the byte array is checked at compiled time.

[5:49] Instead of two elements, right now we have eight. We could change the type to be a len 8. If it's len 8, it will pass and compile. We don't need these extra characters so we going to get rid of them and return to len 2.

[6:08] Now we can use the edit file like before passing in the file path that we just constructed using a builder. This is now a much more specific file path that is in a much more discoverable place for the user. If this fails, again, we return the error using the question mark.

[6:28] Since we already have access to the file, we're going to read that to string. We need a string to put it into, so we'll create immutable contents that is a new string. If we pass a reference to the immutable contents, to the read_to_string function on file, what we end up with is contents filled with the file contents, which we can debug out.

[6:53] Note that we get some nice syntax highlighting as well as some heading highlighting because of the file extension. I even just got auto format on save which inserted that semicolon for me. We can see the file contents being logged out on line 27.

[7:10] Now we've let the user write using whatever editor they want and all the comforts of that editor, and we've also kept the tempfile around meaning that if anything goes wrong, as it just did because we panic, the temporary file lives in our garden where we can easily find it if we need to.

[7:31] This means that the user sees that something happened with the application that we've written. They can go into their garden and look for this tempfile and see if there is any of them laying around the disk, then they can recover them and choose to rename them and keep them if they wish.

~ 2 years ago

If you have trouble getting EDITOR to take effect, it may be because the edit crate looks at the VISUAL environment variable before it looks at EDITOR, so if VISUAL is set, it takes precedence.

~ a year ago

For people wondering why their code fails with Failed to create wip file, you need to create your ~/.garden directory first (or pass an existing one)!

Markdown supported.
Become a member to join the discussionEnroll Today