Deryks _dev

Full-Stack Web Developer & Technical Leader

<< Back / Lesson 16 / Part 2 / ~35 minutes

The Names in a Directory

Three stages, and the editor stops needing the terminal to find a file.

Reading a directory can fail twice — once when you open it, and again on every entry.
read_dir says so in the types, and ? makes saying so cheap.

Every file this editor has ever opened arrived as a command-line argument, decided before the window existed. Editor::open runs once, fromenv::args, and after that the list of buffers is fixed — there is no code path anywhere that adds one. This lesson adds the first.

By the end, Ctrl+O lists the directory you started the editor from, and clicking a name opens it in a new tab.

Before you start

You should be at the end of lesson 15:cargo test reports seventeen passing. Two of the three stages below end in a running window; the first ends in a test.

Type it, don't paste itSame as every lesson. It is about eighty lines this time, and one of them is a line you will be asked to justify in stage 1.

adding   removing   existing code, shown only for context

Ask the filesystem what is there

cargo test reports twenty passing.

Before any of this reaches the screen it is a question about data: given a directory, what is in it? That is a function, and a function can be tested — so this stage writes one and proves it, with no UI at all. Two edits.

First — the function, just above enum Pending:

editor/src/main.rsabove the Pending enum

/// The names in a directory, sorted. Not the paths — just the names.
fn list_dir(dir: &str) -> io::Result<Vec<String>> {
    let mut names = Vec::new();
    for entry in fs::read_dir(dir)? {
        names.push(entry?.file_name().to_string_lossy().into_owned());
    }
    names.sort();
    Ok(names)
}
#[derive(Clone, Copy)]
enum Pending {

Two failures, two ?s

Look at where the question marks are. There are two, and they are catching different things.

fs::read_dir(dir)? is the first. Opening a directory can fail — it might not exist, or you might not be allowed to look. That returnsio::Result<ReadDir>, and ? hands the error back to whoever called us.

entry? is the second, and it is the one worth slowing down for.ReadDir is an iterator, and each item isio::Result<DirEntry> — not DirEntry. Reading a directory is not one operation that succeeds or fails; it is a walk, and the walk can fail partway through, on the fourth entry of nine. The type says so. You cannot write this loop without deciding what happens when it does.

This is the shape you will meet again and again instd: an iterator of Result rather than aResult of an iterator. It exists because the failure is per-item, and flattening it away would be a lie.

The line you will have to justify

entry?.file_name() gives an OsString, not aString. A filename on Linux is a bag of bytes that is merely usuallyUTF-8, and String promises it always is. Those are different claims, so the types are different.

.to_string_lossy() resolves the difference by replacing anything that isn't valid UTF-8 with U+FFFD, the replacement character. It cannot fail — which is exactly the problem, because a name that has been altered no longer names the file.

Deferred on purpose — lesson 17 breaks thisWe are using it anyway, because String is what the rest of the editor already speaks and changing that is a lesson of its own. Lesson 17 creates a file whose name is not valid UTF-8, watches this picker fail to open it, and replacesString with PathBuf throughout. Write the lossy line knowing it is on borrowed time.

Second edit — three tests, at the end of mod tests:

editor/src/main.rsin mod tests, at the end

        assert_eq!(editor.unsaved(), 0);
    }
    #[test]
    fn list_dir_finds_the_crate_manifest() {
        let names = list_dir(".").unwrap();
        assert!(names.contains(&"Cargo.toml".to_owned()));
    }

    #[test]
    fn list_dir_sorts_its_names() {
        let names = list_dir(".").unwrap();
        let mut sorted = names.clone();
        sorted.sort();
        assert_eq!(names, sorted);
    }

    #[test]
    fn list_dir_reports_a_directory_that_is_not_there() {
        assert!(list_dir("no/such/directory").is_err());
    }
}

cargo test runs with the crate root as the working directory, so"." is the directory holding Cargo.toml. That is what makes the first test stable rather than a guess about your machine.

The third test is the one that would catch a rewrite that swallowed errors. It is easy to write list_dir so that an unreadable directory returns an empty Vec and looks, on screen, exactly like an empty directory. The test says those must stay distinguishable.

Expect one warning at this stageNothing calls list_dir from the program yet — only the tests do — socargo run and cargo clippy will tell you:warning: function `list_dir` is never used. That is correct, and it goes away in stage 2 when the shortcut calls it. It is not something you broke.

cargo test
→ test result: ok. 20 passed; 0 failed

Put the names on screen

Ctrl+O opens a panel listing the directory.

The listing is data the editor holds while a question is on screen — which is exactly the shape of confirm from lesson 15, and it gets the same treatment: anOption field, set when the question opens and cleared when it closes.Five edits.

First — somewhere to keep it:

editor/src/main.rsthe Editor struct

    quitting: bool,
    /// The directory listing on screen, if the picker is open.
    picker: Option<Vec<String>>,
    position: String,

Second — nothing is open at start-up:

editor/src/main.rsin Editor::open

            quitting: false,
            picker: None,
            position: "no cursor".to_owned(),

Third — the shortcut that fills it, next to the one that closes a tab:

editor/src/main.rsin fn ui, above the close shortcut

        let open_requested = ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::O));
        if open_requested {
            match list_dir(".") {
                Ok(names) => self.picker = Some(names),
                Err(error) => {
                    self.buffers[self.active].status =
                        format!("cannot read this directory: {error}");
                }
            }
        }
        let close_requested = ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::W));

The Err arm is why list_dir returns a Resultrather than an empty Vec. A directory you cannot read and a directory with nothing in it are different facts, and only one of them is worth telling the user about. It goes in the status bar, where lesson 3 put the last one.

Fourth — drawing it. This goes in impl Editor, just afterfn ask:

editor/src/main.rsin impl Editor, after fn ask

    /// Draw the file picker, and act on what was clicked.
    fn pick(&mut self, ui: &egui::Ui) {
        let mut cancelled = false;

        if let Some(names) = &self.picker {
            egui::Modal::new(egui::Id::new("picker")).show(ui.ctx(), |ui| {
                ui.heading("Open file");
                ui.add_space(8.0);
                for name in names {
                    ui.label(name);
                }
                ui.add_space(8.0);
                if ui.button("Cancel").clicked() {
                    cancelled = true;
                }
            });
        }

        if cancelled {
            self.picker = None;
        }
    }
    /// Write out whatever the pending action is about to throw away.

Why cancelled is a local, not a field

The closure reads self.picker, so it holds a borrow of selffor as long as it runs. Setting self.picker = None inside it would be a second, mutable borrow of the same thing — E0502, the errorlesson 15 dodged by building itsquestion string before the closure started.

Here the dodge is different and more useful: the closure writes to a plainbool on the stack, and the decision is acted on after the borrow ends. That islesson 14'sclosing variable again — decide while you're reading, act once you've stopped — and it is the third time this tutorial has reached for it. It is not a workaround for the borrow checker. It is what the borrow checker was pointing at.

Fifth — call it, at the end of fn ui:

editor/src/main.rsin fn ui, after the call to ask

        if let Some(pending) = self.confirm {
            self.ask(ui, pending);
        }
        self.pick(ui);
        self.frame_ms = frame_start.elapsed().as_secs_f32() * 1000.0;

Unlike ask, pick is called unconditionally and decides for itself whether there is anything to draw. Both are defensible; this one keeps the Option check next to the thing that clears it.

cargo run -- a.rs
→ press Ctrl+O
→ a panel titled Open file, listing Cargo.toml,src, target and whatever else is beside them
→ Cancel closes it. The names do nothing yet — they are labels.

Click a name to open it

Clicking a filename opens it in a new tab.

The listing works; the names are inert. Four edits, and three of them are inside fn pick.

First — the method every route into a file will eventually use. Inimpl Editor, above fn close:

editor/src/main.rsin impl Editor, above fn close

    /// Open a file into a new buffer, and switch to it.
    fn open_path(&mut self, path: &str) {
        self.buffers.push(Buffer::open(path));
        self.active = self.buffers.len() - 1;
    }
    /// Close a buffer, keeping `active` pointing at the file it was on.

Two lines, and it is the seam the rest of part 2 and the file tree in part 8 are built on. Every way of opening a file ends here, which is why it is a method and not four lines repeated in three places.

Second — somewhere to record the click:

editor/src/main.rsin fn pick, the top

    fn pick(&mut self, ui: &egui::Ui) {
        let mut chosen = None;
        let mut cancelled = false;
Easy to miss — the next edit changes a line you already haveIt turns ui.label(name) into a button. It is not an added line; it replaces one. Leave the label in place as well and you get every filename twice, once dead and once live.

Third — the names become buttons, and record what was clicked:

editor/src/main.rsin fn pick, inside the modal

                ui.add_space(8.0);
                for name in names {
                    ui.label(name);
                }
                for name in names {
                    if ui.button(name).clicked() {
                        chosen = Some(name.clone());
                    }
                }
                ui.add_space(8.0);

Fourth — act on it, at the bottom of the same function:

editor/src/main.rsin fn pick, the bottom

        if cancelled {
            self.picker = None;
        }
        if let Some(name) = chosen {
            self.open_path(&name);
            self.picker = None;
        } else if cancelled {
            self.picker = None;
        }
    }

name.clone() is doing real work there. name is a&String borrowed from self.picker, and the whole point of the pattern is that the borrow ends before open_path runs. Keeping the reference would keep the borrow alive and put you straight back into E0502. Cloning one filename is the price of getting out of the closure with the answer.

Deferred on purpose — lesson 17The name is used as a path, which works only because the directory being listed is the one the program is running in. Open the picker, click src, and you get a buffer for a directory — a file that will not read and cannot be saved. Lesson 17 adds the type that knows the difference.

cargo clippy --all-targets && cargo test
→ silent, test result: ok. 20 passed; 0 failed

cargo run -- a.rs
→ Ctrl+O, then click Cargo.toml
→ it opens in a new tab, and the tab bar has two entries
→ Ctrl+O again, Cancel: nothing is added

Where you ended up

700 lines, twenty tests green. One free function, one field, two methods, and a keyboard shortcut.

editor/src/main.rsabove the Pending enum

/// The names in a directory, sorted. Not the paths — just the names.
fn list_dir(dir: &str) -> io::Result<Vec<String>> {
    let mut names = Vec::new();
    for entry in fs::read_dir(dir)? {
        names.push(entry?.file_name().to_string_lossy().into_owned());
    }
    names.sort();
    Ok(names)
}
Between these: about 55 lines, untouchedPending, struct Buffer and its impl are exactly as lesson 15 left them.

editor/src/main.rsthe Editor struct — one new field

    quitting: bool,
    /// The directory listing on screen, if the picker is open.
    picker: Option<Vec<String>>,
    position: String,

editor/src/main.rsin impl Editor, above fn close

    /// Open a file into a new buffer, and switch to it.
    fn open_path(&mut self, path: &str) {
        self.buffers.push(Buffer::open(path));
        self.active = self.buffers.len() - 1;
    }

editor/src/main.rsin impl Editor, after fn ask

    /// Draw the file picker, and act on what was clicked.
    fn pick(&mut self, ui: &egui::Ui) {
        let mut chosen = None;
        let mut cancelled = false;

        if let Some(names) = &self.picker {
            egui::Modal::new(egui::Id::new("picker")).show(ui.ctx(), |ui| {
                ui.heading("Open file");
                ui.add_space(8.0);
                for name in names {
                    if ui.button(name).clicked() {
                        chosen = Some(name.clone());
                    }
                }
                ui.add_space(8.0);
                if ui.button("Cancel").clicked() {
                    cancelled = true;
                }
            });
        }

        if let Some(name) = chosen {
            self.open_path(&name);
            self.picker = None;
        } else if cancelled {
            self.picker = None;
        }
    }

editor/src/main.rsin fn ui — two regions, the rest elided

        let open_requested = ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::O));
        if open_requested {
            match list_dir(".") {
                Ok(names) => self.picker = Some(names),
                Err(error) => {
                    self.buffers[self.active].status =
                        format!("cannot read this directory: {error}");
                }
            }
        }the close shortcut, the tab row and the central panel, unchanged — about 55 lines
        self.pick(ui);

editor/src/main.rsin mod tests, at the end

    #[test]
    fn list_dir_finds_the_crate_manifest() {
        let names = list_dir(".").unwrap();
        assert!(names.contains(&"Cargo.toml".to_owned()));
    }

    #[test]
    fn list_dir_sorts_its_names() {
        let names = list_dir(".").unwrap();
        let mut sorted = names.clone();
        sorted.sort();
        assert_eq!(names, sorted);
    }

    #[test]
    fn list_dir_reports_a_directory_that_is_not_there() {
        assert!(list_dir("no/such/directory").is_err());
    }

Check yourself

Why does iterating a ReadDir hand you a Result for every entry?

  • Reading a directory can fail partway through it
  • Some entries are directories rather than plain files
  • A filename might not be valid Unicode on this platform
  • The iterator has to report when it reaches the end

Opening the directory is one fallible step and each entry is another — the walk can stop on the fourth of nine. Flattening that into oneResult at the front would claim a guarantee the filesystem never made.↑ Review: two failures, two ?s (stage 1)

Why does the clicked name go into a local variable instead of straight to open_path?

  • The closure is already borrowing what open_path mutates
  • Opening a file inside a closure would block the frame
  • The modal has to be closed before a buffer can be pushed
  • Closures cannot call methods that take &mut self

The closure reads self.picker; open_path needs&mut self. Recording the decision and acting after the borrow ends is the same move as lesson 14's closing — decide while reading, act once you have stopped.↑ Review: why cancelled is a local (stage 2)

A file in the directory has a name that isn't valid UTF-8. What does this picker do?

  • Shows an altered name that no longer opens the file
  • Skips the entry, so the name never appears
  • Returns an error from list_dir for that entry
  • Opens it correctly — the name is only for display

to_string_lossy substitutes U+FFFD for the invalid bytes and cannot fail. The name you see is not the name on disk, and handing it back as a path finds nothing. Lesson 17 makes the file first and the fix second.↑ Review: the line you will have to justify (stage 1)

Read this before lesson 17

Primary source:std::fs::read_dir— the function's own documentation, not a tutorial about it. Read the signature, then the "Errors" section, then the example.

This is the habit the mission asks for, practised on something small: the answer to "can this fail, and how" is in the type and the docs, and it took less time to read than to guess. OsStringis worth two minutes as well, for why it exists at all.