Electron Adventures: Episode 50: Refresh

taw

Tomasz Wegrzanowski

Posted on September 12, 2021

Electron Adventures: Episode 50: Refresh

It is pretty much the point of a file manager that it will change the files on your computer, but so far the app only fetched the list of files when we navigated to a new directory, and never refreshed it.

It's time to add functionality to refresh, but as usual things are more complex than they seem.

When to refresh

The minimum version is this:

  • app needs to refresh automatically after making any filesystem change
  • app needs to refresh when user requests it - I put it at Ctrl+R, as Cmd+R already reloads Electron app, and it's extremely useful to keep that functionality in place

Back when Orthodox File Managers were created, that was the whole list. Nowadays all operating systems have some sort of functionality of letting apps "watch" filesystem for changes, so it would just need to register that it's interested in some files or directories, and then it would receive a callback when that happens. Every operating system does it differently, and there are many gotchas and performance considerations, but a library like chokidar handles most of such issues already.

Well, for most parts. Notifications aren't always available, and apps using chokidar like VSCode often don't implement even simple fallbacks, and are notably much worse in situations where notifications aren't available. For something that affects me a lot, there are many VSCode bugs when using it with a filesystem mounted over sshfs, and the refresh button VSCode has to ask it to manually refresh the filesystem somehow doesn't work too well. Unless I close the whole window and open it again, VSCode still believes some files exist even if they were removed days ago.

In any case, we won't be adding watch functionality yet, we'll just request after operations, or by user request.

How to refresh

We don't want to treat refresh the same as navigation to a new directory. As much as possible, we want to keep the list of selected files and currently focused file intact. However we also need to take into account that focused file or one of selected files might disappear.

For selected file it's obvious what to do - just have fewer files selected.

If focused file disappears we can either do the simple thing and just reset focus on first entry, or we do more complex thing and try to find a next or a previous file that is still there. This more complex behavior is useful if you want to delete a few things - deleting a file won't throw you all the way to beginning of the list, forcing you to scroll back where you were, and that's why file managers typically do this. But for now let's keep thing simple.

Add Refresh command

First we can add an entry to src/commands.js. This will tell Keyboard and CommandPalette about it.

Adding this to src/commands.js:

    {
      name: "Refresh",
      shortcuts: [{key: "R", ctrl: true}],
      action: ["bothPanels", "refresh"],
    },
Enter fullscreen mode Exit fullscreen mode

Many file managers only refresh the active panel when you do some file operation, and there are some use cases for that, but we'll just do the simple thing and refresh both.

Define bothPanels target

In src/App.svelte we need to define what it means to send to bothPanels. The answer is not exactly surprising;

function emitToBothPanels(...args) {
  eventBus.emit("left", ...args)
  eventBus.emit("right", ...args)
}
eventBus.handle("bothPanels", {"*": emitToBothPanels})
Enter fullscreen mode Exit fullscreen mode

Trigger refresh when directory is created

Before we get to the refresh logic, let's remember to trigger refresh when directory is created.

We'll change src/MkdirDialog.svelte to call bothPanels.refresh():

let bothPanels = eventBus.target("bothPanels")

function submit() {
  app.closeDialog()
  if (dir !== "") {
    let target = path.join(base, dir)
    window.api.createDirectory(target)
    bothPanels.refresh()
  }
}
Enter fullscreen mode Exit fullscreen mode

Refresh logic

The last file we need to update is src/Panel.svelte.

We already implemented functionality to set the initial focused element on navigation, and we can reuse it. All we need is add similar logic for selected elements, and that turns out to be even easier.

Let's start by modifying what happens when we trigger files fetching:

  let initialFocus
  let initialSelected = []

  $: filesPromise = window.api.directoryContents(directory)
  $: filesPromise.then(x => {
    files = x
    setInitialSelected()
    setInitialFocus()
  })
Enter fullscreen mode Exit fullscreen mode

selected is a list of indexes, while initialSelected will be a list of names. Arguably we could change our mind again, and make selected and focused a list of names, but we'd still need this kind of handlers, just to make sure these elements exist, so code wouldn't actually get much simpler.

  function setInitialSelected() {
    selected = []
    files.forEach((file, idx) => {
      if (initialSelected.includes(file.name)) {
        selected.push(idx)
      }
    })
    initialSelected = []
  }
Enter fullscreen mode Exit fullscreen mode

Triggerring refresh

Now we just need to trigger it, and that should work right?

  function refresh() {
    initialFocus = focused?.name
    initialSelected = selected.map(i => files[i].name)
    directory = directory
  }
Enter fullscreen mode Exit fullscreen mode

Svelte can be told that variable should be treated as updated when you do x = x. This is necessary for arrays and objects, as they can changed without assignments by methods like push.

Documentation doesn't state this anywhere at all (and I asked them to update the docs at least), but x = x does not work for primitive values. Svelte checks if variable got changed to the same value, and in such case it doesn't trigger an update. There also doesn't seem to be any trick to force it to.

So we need to trigger it manually. In our case it's simple enough:

  function refresh() {
    initialFocus = focused?.name
    initialSelected = selected.map(i => files[i].name)
    filesPromise = window.api.directoryContents(directory)
  }
Enter fullscreen mode Exit fullscreen mode

Why not use Set?

If you've been paying attention you might have noticed that code dealing with selections is O(n^2). And there's easy way to make it O(n) - use Set instead of arrays.

And that's what I'd do if I was using a language with properly working sets like Ruby or Python, but Javascript has the worst implementation of sets I've ever seen:

  • no union (new Set([...a, ...b]) as a somewhat tolerable workaround)
  • no intersection (the closest is really nasty code like new Set([...a].filter(x => b.has(x))))
  • no symmetric difference (the code is too miserable to even mention here)
  • no toggling element
  • no map, filter, or any other functions - convert to array and back again
  • if you convert set to JSON, you get {}, all elements are completely thrown away! One would thing that JSON.stringify(new Set([1,2,3])) would be either [1,2,3] or an exception, but it's {} instead. This makes debugging code with javascript Sets a huge pain.

They did such a half-assed job adding Set I'm baffled why they even bothered. So at some point I'd probably need to switch to Sets or to hashes, but I try to avoid that as long as working with plain arrays is practical.

Hopefully they fix at least some of these issues.

Result

Here's the results:

Episode 50 Screenshot

In the next episode, we'll teach the file manager how to delete files.

As usual, all the code for the episode is here.

💖 💪 🙅 🚩
taw
Tomasz Wegrzanowski

Posted on September 12, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related