DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on • Updated on

Electron Adventures: Episode 30: File Manager: Now With Actual Files

So after a brief Vue detour, let's go back to our Svelte file manager. Right now it's displaying mock data, so we'd like to give it some actual functionality such as:

  • displaying actual files
  • displaying basic information about files
  • displaying which directory each panel shows
  • moving to a different directory
  • F10 or footer button to quit the app

We'll start where we left of it episode 27.

API functions

We already added functionality for listing contents of a directory in episode 17, so let's just copy those two files from there.

Here's updated index.js (just added preload line):

let { app, BrowserWindow } = require("electron")

function createWindow() {
  let win = new BrowserWindow({
    webPreferences: {
      preload: `${__dirname}/preload.js`,
    },
  })
  win.maximize()
  win.loadURL("http://localhost:5000/")
}

app.on("ready", createWindow)

app.on("window-all-closed", () => {
  app.quit()
})
Enter fullscreen mode Exit fullscreen mode

And here's preload.js we already did before. It's the simplest version without any such fancy things like support for symlinks, file sizes, last modified dates and so on. We'll bring it all together soon, but we have a lot to do here already.

let { readdir } = require("fs/promises")
let { contextBridge } = require("electron")

let directoryContents = async (path) => {
  let results = await readdir(path, { withFileTypes: true })
  return results.map(entry => ({
    name: entry.name,
    type: entry.isDirectory() ? "directory" : "file",
  }))
}

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

contextBridge.exposeInMainWorld(
  "api", { directoryContents, currentDirectory }
)
Enter fullscreen mode Exit fullscreen mode

F10 to quit

This isn't even related to rest of the changes, but I really wanted at least F10 shortcut and button to work, so here's the updated src/Footer.svelte:

<script>
  let quitCommand = (e) => {
    window.close()
  }

  let handleKey = (e) => {
    if (e.key === "F10") {
      e.preventDefault()
      quitCommand()
    }
  }
</script>

<footer>
  <button>F1 Help</button>
  <button>F2 Menu</button>
  <button>F3 View</button>
  <button>F4 Edit</button>
  <button>F5 Copy</button>
  <button>F6 Move</button>
  <button>F7 Mkdir</button>
  <button>F8 Delete</button>
  <button on:click={quitCommand}>F10 Quit</button>
</footer>

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

<style>
  footer {
    text-align: center;
    grid-area: footer;
  }

  button {
    font-family: inherit;
    font-size: inherit;
    background-color: #66b;
    color: inherit;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

window.close() is an old browser function, nothing Electron specific, but in actual browsers there are some security limitations of when you're allowed to call it, as a lot of that window management was abused by popup ads. Remember those?

Anyway, there's important thing to note here. A lot of Electron tutorial have logic in index.js like this:

  • if last window is closed, then quit the app (so far so good)
  • except on OSX, then keep the app active, and just relaunch a window if app reactivates

This is how many OSX apps behave, but it's a horrendous default, and we absolutely should not be doing this unless we have a good reason to. Most apps should simply quit when you close their last window, on any operating system.

Also if we wanted to support this OSX behavior, we'd need to add extra functionality to tell the app to quit - browser APIs can close windows, but it's some extra code to make apps quit. As it's extra code to do something we don't even want, we're not going to do this.

src/App.svelte

We need to adjust it in a few ways.

  • instead of passing files to each panel, we just pass directory we want it to display
  • for left panel we start it with window.api.currentDirectory() - source code of our app
  • for right panel we start it with window.api.currentDirectory() + "/node_modules" - node_modules for our app
  • list of files might be bigger than the screen, and we don't want to scroll the whole, just each panel separately, so we adjust grid css from grid-template-rows: auto 1fr auto to grid-template-rows: auto minmax(0, 1fr) auto. You can check this for some discussion on this. It's honestly not the best part of display: grid, but we have a workaround.

The rest of the code is unchanged:

<script>
  import Panel from "./Panel.svelte"
  import Footer from "./Footer.svelte"

  let activePanel = "left"
  let directoryLeft = window.api.currentDirectory()
  let directoryRight = window.api.currentDirectory() + "/node_modules"
  let handleKey = (e) => {
    if (e.key === "Tab") {
      if (activePanel === "left") {
        activePanel = "right"
      } else {
        activePanel = "left"
      }
      e.preventDefault()
    }
  }
</script>

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

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

<style>
  :global(body) {
    background-color: #226;
    color: #fff;
    font-family: monospace;
    margin: 0;
    font-size: 16px;
  }
  .ui {
    width: 100vw;
    height: 100vh;
    display: grid;
    grid-template-areas:
      "header header"
      "panel-left panel-right"
      "footer footer";
    grid-template-columns: 1fr 1fr;
    grid-template-rows: auto minmax(0, 1fr) auto;
  }
  .ui header {
    grid-area: header;
  }
  header {
    font-size: 24px;
    margin: 4px;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

src/Panel.svelte

Now this one needed almost a total rewrite.

Let's start with the template:

<div class="panel {position}" class:active={active}>
  <header>{directory.split("/").slice(-1)[0]}</header>
  <div class="file-list">
    {#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)}
      >{file.name}</div>
    {/each}
  </div>
</div>

<svelte:window on:keydown={handleKey}/>
Enter fullscreen mode Exit fullscreen mode

There's extra header with last part of directory name. Then the files are put in a scrollable list.

The API is a bit different - previously files were just a list of strings, and so focused / selected were just strings too. This isn't really going to work as we want to include a lot of extra information about each file. Files are now objects, and that means it's much easier to use integers for focused / selected.

The CSS changed only a bit:

<style>
  .left {
    grid-area: panel-left;
  }
  .right {
    grid-area: panel-right;
  }
  .panel {
    background: #338;
    margin: 4px;
    display: flex;
    flex-direction: column;
  }
  header {
    text-align: center;
    font-weight: bold;
  }
  .file-list {
    flex: 1;
    overflow-y: scroll;
  }
  .file {
    cursor: pointer;
  }
  .file.selected {
    color: #ff2;
    font-weight: bold;
  }
  .panel.active .file.focused {
    background-color: #66b;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

We how have a header, scrollable file list, and some small flexbox to make sure header is always displayed, even when file list is scrolled all the way down.

Let's get to the script part, in parts:

  let onclick = (idx) => {
    onActivate()
    focusedIdx = idx
  }
  let onrightclick = (idx) => {
    onActivate()
    focusedIdx = idx
    flipSelected(idx)
  }
  let flipSelected = (idx) => {
    if (selected.includes(idx)) {
      selected = selected.filter(f => f !== idx)
    } else {
      selected = [...selected, idx]
    }
  }
  let goUp = () => {
    if (focusedIdx > 0) {
      focusedIdx -= 1
    }
  }
  let goDown = () => {
    if (focusedIdx < filesCount - 1) {
      focusedIdx += 1
    }
  }
  let handleKey = (e) => {
    if (!active) {
      return
    }
    if (e.key === "ArrowDown") {
      e.preventDefault()
      goDown()
    }
    if (e.key === "ArrowUp") {
      e.preventDefault()
      goUp()
    }
    if (e.key === " ") {
      e.preventDefault()
      flipSelected(focusedIdx)
      goDown()
    }
  }
Enter fullscreen mode Exit fullscreen mode

The methods we use didn't change much, other than using indexes instead of file names.

We also haved filesCount here to save ourselves some promise troubles. Normally it's equal to files.length, but files is loaded from a promise, so we pre-initialize filesCount to 0 and don't need to worry about user pressing some keys before list of files is loaded and accessing null.length.

The properties we get from the parent are the same except it's now directory, not files:

  export let position
  export let directory
  export let active
  export let onActivate
Enter fullscreen mode Exit fullscreen mode

And finally the complicated part:

  let files = []
  let selected = []
  let focusedIdx = 0

  $: filesPromise = window.api.directoryContents(directory)
  $: filesPromise.then(x => {
    files = x
    focusedIdx = 0
    selected = []
  })
  $: filesCount = files.length
Enter fullscreen mode Exit fullscreen mode

Svelte has a bunch of different ways to deal with promises. For simple cases there's {#await promise} blocks, but they're a poor fit for what we do, as we also need to access this list in various methods, not just in the template.

For most complex cases we could use a store, and we might do this eventually, but for now a simple callback will do. If you're interested in some more discussion, check out this thread.

Result

Here's the results:

Episode 30 Screenshot

The app displays files, and we'd love to keep adding more functionality to it, unfortunately there's one small issue we need to address first.

The files are in a scrollable list, which can be scrolled with mouse wheel like all browser lists. The list can be navigated with arrow keys, but nothing ensures that the focused element remains scolled in view, so your focus can fall out of the screen.

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

Discussion (0)