DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on • Updated on

Electron Adventures: Episode 32: Navigating Between Directories

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.

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 }
)
Enter fullscreen mode Exit fullscreen mode

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:

<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}/>
Enter fullscreen mode Exit fullscreen mode

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:

<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}/>
Enter fullscreen mode Exit fullscreen mode

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:

  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;
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

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.

  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()
  }
Enter fullscreen mode Exit fullscreen mode

Setting focus

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

  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()
  }
Enter fullscreen mode Exit fullscreen mode

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:

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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.

Discussion (0)