Electron Adventures: Episode 32: Navigating Between Directories
Tags: javascript, electron, svelte
Published: 2021-08-26 02:17:39

Now that we have basical functionality running, let's make navigating between directories work.

### `preload.js`

First, we need a bit more information about the files. I took `preload.js` from episode 23, and added logic for handling of `..` and root directory here, as unnecessary complicated the frontend.

```javascript
let path = require("path")
let { readdir, stat, readlink } = require("fs/promises")
let { contextBridge } = require("electron")

let fileInfo = async (basePath, entry) => {
  let { name } = entry
  let fullPath = path.join(basePath, name)
  let linkTarget = null
  let fileStat

  if (entry.isSymbolicLink()) {
    linkTarget = await readlink(fullPath)
  }

  // This most commonly happens with broken symlinks
  // but could also happen if the file is deleted
  // while we're checking it as race condition
  try {
    fileStat = await stat(fullPath)
  } catch {
    return {
      name,
      type: "broken",
      linkTarget,
    }
  }

  let { size, mtime } = fileStat

  if (fileStat.isDirectory()) {
    return {
      name,
      type: "directory",
      mtime,
      linkTarget,
    }
  } else if (fileStat.isFile()) {
    return {
      name,
      type: "file",
      size,
      mtime,
      linkTarget,
    }
  } else {
    return {
      name,
      type: "special",
    }
  }
}

let directoryContents = async (path) => {
  let entries = await readdir(path, { withFileTypes: true })
  let fileInfos = await Promise.all(entries.map(entry => fileInfo(path, entry)))
  if (path !== "/") {
    fileInfos.unshift({
      name: "..",
      type: "directory",
    })
  }
  return fileInfos;
}

let currentDirectory = () => {
  return process.cwd()
}

contextBridge.exposeInMainWorld(
  "api", { directoryContents, currentDirectory }
)
```

### `Panel` API changes

`Panel` component had `directory` property, but we now want it to be able to change its directory. To make it clearer, I renamed it to `initialDirectory`, so in `App.svelte` template is changed by just renaming one property:

```html
<div class="ui">
  <header>
    File Manager
  </header>
  <Panel
    initialDirectory={directoryLeft}
    position="left"
    active={activePanel === "left"}
    onActivate={() => activePanel = "left"}
  />
  <Panel
    initialDirectory={directoryRight}
    position="right"
    active={activePanel === "right"}
    onActivate={() => activePanel = "right"}
  />
  <Footer />
</div>

<svelte:window on:keydown={handleKey}/>
```

### File Symbols

There's a lot of changes to `src/Panel.svelte`, so let's start with the simple one. Here's the updated template:

```html
<div class="panel {position}" class:active={active}>
  <header>{directory.split("/").slice(-1)[0]}</header>
  <div class="file-list" bind:this={fileListNode}>
    {#each files as file, idx}
      <div
        class="file"
        class:focused={idx === focusedIdx}
        class:selected={selected.includes(idx)}
        on:click|preventDefault={() => onclick(idx)}
        on:contextmenu|preventDefault={() => onrightclick(idx)}
        on:dblclick|preventDefault={() => ondoubleclick(idx)}
        bind:this={fileNodes[idx]}
      >
      {filySymbol(file)}{file.name}
    </div>
    {/each}
  </div>
</div>

<svelte:window on:keydown={handleKey}/>
```

There are two changes here. There's now a double click handler, and every file now has a file symbol in front of it. It terminal most file managers use a symbol like `/` for directories, `@` or `~` for symbolic links, and space for files. We probably should use some Unicode character, or some proper icon, but this will do for now.

File symbol function is simple enough:

```javascript
  let filySymbol = (file) => {
    if (file.type === "directory") {
      if (file.linkTarget) {
        return "~"
      } else {
        return "/"
      }
    } else if (file.type === "special") {
      return "-"
    } else {
      if (file.linkTarget) {
        return "@"
      } else {
        return "\xA0" // &nbsp;
      }
    }
  }
```

We cannot return `&nbsp;` as that would be converted to those 6 characters by Svelte, which handles XSS for us. Instead we need to use its Unicode value which is `00A0`.

### New event handlers

There are two event handlers - Enter key and double click, and they both do the same thing - if it's a directory they enter it. Otherwise they do nothing. The relevant code is in `enterCommand`, which assumes we're trying to enter focused element.

```javascript
  let ondoubleclick = (idx) => {
    onActivate()
    focusOn(idx)
    enterCommand()
  }
  let handleKey = (e) => {
    if (!active) {
      return
    }
    if (e.key === "ArrowDown") {
      focusOn(focusedIdx + 1)
    } else if (e.key === "ArrowUp") {
      focusOn(focusedIdx - 1)
    } else if (e.key === "PageDown") {
      focusOn(focusedIdx + pageSize())
    } else if (e.key === "PageUp") {
      focusOn(focusedIdx - pageSize())
    } else if (e.key === "Home") {
      focusOn(0)
    } else if (e.key === "End") {
      focusOn(filesCount - 1)
    } else if (e.key === " ") {
      flipSelected(focusedIdx)
      focusOn(focusedIdx + 1)
    } else if (e.key === "Enter") {
      enterCommand()
    } else {
      return
    }
    e.preventDefault()
  }
```

### Setting focus

As we'll be needing the second part, I split the function to focus on new element and scroll to it.

```javascript
  let scrollFocusedIntoView = () => {
    if (fileNodes[focusedIdx]) {
      fileNodes[focusedIdx].scrollIntoViewIfNeeded(true)
    }
  }
  let focusOn = (idx) => {
    focusedIdx = idx
    if (focusedIdx > filesCount - 1) {
      focusedIdx = filesCount - 1
    }
    if (focusedIdx < 0) {
      focusedIdx = 0
    }
    scrollFocusedIntoView()
  }
```

### Changing directories

I'll show to the code soon, but first let's talk about how navigation works.

* when component starts, it received `initialDirectory` - it should get files from that directory, and focus on first one
* when you navigate to a new directory, it received name of a new `directory` - it should get files from that directory, and focus on first one
* when navigating up, it receives name of new `directory` - however in this case it should focus on the directory we just came out of!

So for that reason we have `initialFocus` variable, which is either `null` or name of directory we came out of. And a bit of logic for handling it.

Because everything is async we need to do this in multiple steps:

* first we set `directory` and possibly `initialFocus`
* this makes Svelte run `filesPromise = window.api.directoryContents(directory)` reactively, as `directory` changed
* once this promise is resolved, we set `files` to what it returned and `selected` to `[]` as noting is selected. Then we call `setInitialFocus()` to handle focus. To avoid issues with Svelte reactivity possibly causing a loop, we have a separate function for that instead of trying to do all this inside promise callback.
* in `setInitialFocus` we find if `initialFocus` is set, and if yes, if we actually have such a file. If yes, we set `focusedIdx` to its index, otherwise we set `focusedIdx` to 0.
* now we want to scroll to it - unfortunately we only just set this, and it's not rendered yet
* so we use an async lifecycle method, `await tick()`, which will resolve when DOM has been updated
* after that we can finally call `scrollFocusedIntoView()`

So here's the rest of `src/Panel.svelte`, skipping functions which did not change for clarity:

```javascript
import { tick } from "svelte"

export let initialDirectory
export let position
export let active
export let onActivate

let directory = initialDirectory
let initialFocus
let files = []
let selected = []
let focusedIdx = 0
let fileNodes = []
let fileListNode

$: filesPromise = window.api.directoryContents(directory)
$: filesPromise.then(x => {
  files = x
  selected = []
  setInitialFocus()
})
$: filesCount = files.length
$: focused = files[focusedIdx]

let setInitialFocus = async () => {
  focusedIdx = 0
  if (initialFocus) {
    focusedIdx = files.findIndex(x => x.name === initialFocus)
    if (focusedIdx === -1) {
      focusedIdx = 0
    }
  } else {
    focusedIdx = 0
  }
  await tick()
  scrollFocusedIntoView()
}
let enterCommand = () => {
  if (focused?.type === "directory") {
    if (focused.name === "..") {
      initialFocus = directory.split("/").slice(-1)[0]
      directory = directory.split("/").slice(0, -1).join("/") || "/"
    } else {
      initialFocus = null
      directory += "/" + focused.name
    }
  }
}
```

Our component is getting quite complicated, and we're just getting started.

Perhaps we should split this component into child component that just displays the data, and its parent component that handles navigation.

### Result

Here's the results:

![Episode 32 Screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/g4wutqdqkq8l4r17p1lo.png)

In the next episode we'll refactor how we handle events, as we need a lot of extra functionality like modals, command palette, configurable shortcuts, and commands that need information from multiple components, and the current system will not get us there.

As usual, [all the code for the episode is here](https://github.com/taw/electron-adventures/tree/master/episode-32-navigating-between-directories).