Electron Adventures: Episode 30: File Manager: Now With Actual Files
Tags: javascript, electron, svelte
Published: 2021-08-25 18:11:37

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):

```javascript
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()
})
```

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.

```javascript
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 }
)
```

### 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`:

```html
<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>
```

`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](https://stackoverflow.com/questions/52861086/why-does-minmax0-1fr-work-for-long-elements-while-1fr-doesnt). It's honestly not the best part of `display: grid`, but we have a workaround.

The rest of the code is unchanged:

```html
<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>
```

### `src/Panel.svelte`

Now this one needed almost a total rewrite.

Let's start with the template:

```html
<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}/>
```

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:

```html
<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>
```

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:

```javascript
  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()
    }
  }
```

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`:

```javascript
  export let position
  export let directory
  export let active
  export let onActivate
```

And finally the complicated part:

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

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

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](https://github.com/sveltejs/svelte/issues/2118).

### Result

Here's the results:

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

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](https://github.com/taw/electron-adventures/tree/master/episode-30-file-manager-now-with-actual-files).