Electron Adventures: Episode 36: File Manager Event Bus
Tags: javascript, electron, svelte
Published: 2021-08-29 20:56:51

It's time to bring what we learned into our app. The first step will be adding event bus from episode 33 to file manager we last worked on in episode 32.

And while we're doing this, we'll also be refactoring the codebase.

### `src/EventBus.js`

We can setup event bus identical to what we already did.

I'm sort of considering adding some syntactic sugar support at some point so we can replace `eventBus.emit("app", "activatePanel", panelId)` by `eventBus.app.activatePanel(panelId)` using [`Proxy` objects](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy). That would be super easy in Ruby, but a bit complex with JS.

```javascript
export default class EventBus {
  constructor() {
    this.callbacks = {}
  }

  handle(target, map) {
    this.callbacks[target] = { ...(this.callbacks[target] || {}), ...map }
  }

  emit(target, event, ...details) {
    let handlers = this.callbacks[target]
    if (handlers) {
      if (handlers[event]) {
        handlers[event](...details)
      } else if (handlers["*"]) {
        handlers["*"](event, ...details)
      }
    }
  }
}
```

### `src/commands.js`

Previously we had the list of commands copied and pasted multiple times between keyboard handler, application menu, and command palette. We don't have application menu and command palette yet, but we can preempt this issue by extracting it to a separate file.

```javascript
export default [
  {key: "Tab", action: ["app", "switchPanel"]},
  {key: "F10", action: ["app", "quit"]},
  {key: "ArrowDown", action: ["activePanel", "nextItem"]},
  {key: "ArrowUp", action: ["activePanel", "previousItem"]},
  {key: "PageDown", action: ["activePanel", "pageDown"]},
  {key: "PageUp", action: ["activePanel", "pageUp"]},
  {key: "Home", action: ["activePanel", "firstItem"]},
  {key: "End", action: ["activePanel", "lastItem"]},
  {key: " ", action: ["activePanel", "flipItem"]},
  {key: "Enter", action: ["activePanel", "activateItem"]},
]
```

### `src/Keyboard.svelte`

With event bus and commands list extracted, `Keyboard` component is very simple. We'll need to change it to support modifier keys like Cmd, and maybe to disable shortcuts when modal panels are open, but even then it will be very simple component.

```html
<script>
  import commands from "./commands.js"
  import { getContext } from "svelte"
  let { eventBus } = getContext("app")

  function handleKey(e) {
    for (let command of commands) {
      if (command.key === e.key) {
        e.preventDefault()
        eventBus.emit(...command.action)
      }
    }
  }
</script>

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

### `src/Footer.svelte`

The only change is using `eventBus` to tell the app to quit instead of handling that locally. As we're adding functionality, we'll be adding similar handlers to other buttons. Of course at some point we can go fancy, and make the footer context-aware.

```html
<script>
  import { getContext } from "svelte"
  let { eventBus } = getContext("app")
</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={() => eventBus.emit("app", "quit")}>F10 Quit</button>
</footer>

<svelte:window />

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

  button {
    font-family: inherit;
    font-size: inherit;
    background-color: #66b;
    color: inherit;
  }
</style>
```

### `src/App.svelte`

And the main component. First template and styling, very little changed, we just added `Keyboard` and got rid of some `Panel` props:

```html
<div class="ui">
  <header>
    File Manager
  </header>
  <Panel initialDirectory={initialDirectoryLeft} id="left" />
  <Panel initialDirectory={initialDirectoryRight} id="right" />
  <Footer />
</div>

<Keyboard />

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

The script part does a bit more:

```html
<script>
  import { writable } from "svelte/store"
  import { setContext } from "svelte"
  import Panel from "./Panel.svelte"
  import Footer from "./Footer.svelte"
  import EventBus from "./EventBus.js"
  import Keyboard from "./Keyboard.svelte"

  let eventBus = new EventBus()
  let activePanel = writable("left")

  setContext("app", {eventBus, activePanel})

  let initialDirectoryLeft = window.api.currentDirectory()
  let initialDirectoryRight = window.api.currentDirectory() + "/node_modules"

  function switchPanel() {
    if ($activePanel === "left") {
      activePanel.set("right")
    } else {
      activePanel.set("left")
    }
  }
  function activatePanel(panel) {
    activePanel.set(panel)
  }
  function quit() {
    window.close()
  }
  function emitToActivePanel(...args) {
    eventBus.emit($activePanel, ...args)
  }
  eventBus.handle("app", {switchPanel, activatePanel, quit})
  eventBus.handle("activePanel", {"*": emitToActivePanel})
</script>
```

We register three commands - `switchPanel`, `activatePanel`, and `quit`. We also setup forwarding of `activePanel` events to either `left` or `right` panel.

For context we expose just two things - `activePanel` and `eventBus`. And I'm not even sure about exposing `activePanel`. Right now passing `true`/`false` to each `Panel` would work just as well. I might revisit this later.

### `src/File.svelte`

`Panel` was already getting very complicated, so I started by extracting `File` component out of it. It represents a single entry in the panel.

```html
<div
  class="file"
  class:focused={focused}
  class:selected={selected}
  on:click|preventDefault={() => onclick()}
  on:contextmenu|preventDefault={() => onrightclick()}
  on:dblclick|preventDefault={() => ondoubleclick()}
  bind:this={node}
>
  {filySymbol(file)}{file.name}
</div>

<style>
  .file {
    cursor: pointer;
  }
  .file.selected {
    color: #ff2;
    font-weight: bold;
  }
  :global(.panel.active) .file.focused {
    background-color: #66b;
  }
</style>
```

There are two new things here. First is `bind:this={node}`. We expose `node` as a bindable property, so parent can access to our DOM node. This is generally not the best pattern, so maybe we can figure out something less intrusive later.

The other new thing is `:global(.panel.active) .file.focused` selector. Svelte selectors are all automatically rewritten to only match elements created by the current component - there's an extra class automatically added by every component, and `.file.selected` is actually `.createdByFileComponent.file.selected` (except it's a hash not `createdByFileComponent`).

This is what we want 90% of the time, but in this case we want a special styling rule based on which context the element is in. `.panel.active .file.focused` won't ever work as the `panel` wasn't created here. There are two ways to do this - either pass some props to the component describing the context (`export let inActivePanel` etc.), so styling can be self contained. Or use `:global(selector)` to disable this rule for just this one selector. Everything else in the styling is still component-scoped.

And now the code:

```html
<script>
  import { getContext } from "svelte"

  export let file
  export let idx
  export let panelId
  export let focused
  export let selected
  export let node = undefined

  let {eventBus} = getContext("app")

  function onclick() {
    eventBus.emit("app", "activatePanel", panelId)
    eventBus.emit(panelId, "focusOn", idx)
  }
  function onrightclick() {
    eventBus.emit("app", "activatePanel", panelId)
    eventBus.emit(panelId, "focusOn", idx)
    eventBus.emit(panelId, "flipSelected", idx)
  }
  function ondoubleclick() {
    eventBus.emit("app", "activatePanel", panelId)
    eventBus.emit(panelId, "focusOn", idx)
    eventBus.emit(panelId, "activateItem")
  }
  function 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;
      }
    }
  }
</script>
```

We handle all events locally, by translating them into a series of `app` and `panelId` events. I'm sort of wondering about using some `Proxy` objects so I could instead write it like this:

```javascript
  function onclick() {
    eventBus.app.activatePanel(panelId)
    eventBus[panelId].focusOn(idx)
  }
  function onrightclick() {
    eventBus.app.activatePanel(panelId)
    eventBus[panelId].focusOn(idx)
    eventBus[panelId].flipSelected(idx)
  }
  function ondoubleclick() {
    eventBus.app.activatePanel(panelId)
    eventBus[panelId].focusOn(idx)
    eventBus[panelId].activateItem()
  }
```

Or even:

```javascript
  let app = eventBus.app
  let panel = eventBus[panelId]

  function onclick() {
    app.activatePanel(panelId)
    panel.focusOn(idx)
  }
  function onrightclick() {
    app.activatePanel(panelId)
    panel.focusOn(idx)
    panel.flipSelected(idx)
  }
  function ondoubleclick() {
    app.activatePanel(panelId)
    panel.focusOn(idx)
    panel.activateItem()
  }
```

That would be nicer, right?

A minor thing to note is `export let node = undefined`. As `node` is export-only property we explicitly mark it as such to avoid warning in development mode. Other than that, it works the same as not having `= undefined`.

### `src/Panel.svelte`

`Panel` svelte got slimmed down thanks to some code moving down to `File` component. Let's start with template and styling:

```html
<div class="panel {id}" class:active={active}>
  <header>{directory.split("/").slice(-1)[0]}</header>
  <div class="file-list" bind:this={fileListNode}>
    {#each files as file, idx}
      <File
        panelId={id}
        file={file}
        idx={idx}
        focused={idx === focusedIdx}
        selected={selected.includes(idx)}
        bind:node={fileNodes[idx]}
      />
    {/each}
  </div>
</div>

<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;
  }
</style>
```

The only unusual thing is `bind:node={fileNodes[idx]}`. `File` component exports its main DOM node in `node` instance variable, and we then store it in `fileNodes[idx]`.

The script is fairly long, but it's basically what we already had before, except now we register various functions with `eventBus`:

```html
<script>
  import File from "./File.svelte"
  import { getContext, tick } from "svelte"

  export let initialDirectory
  export let id

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

  let {eventBus, activePanel} = getContext("app")

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

  let flipSelected = (idx) => {
    if (selected.includes(idx)) {
      selected = selected.filter(f => f !== idx)
    } else {
      selected = [...selected, idx]
    }
  }
  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 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()
  }
  function pageSize() {
    if (!fileNodes[0] || !fileNodes[1] || !fileListNode) {
      return 16
    }
    let y0 = fileNodes[0].getBoundingClientRect().y
    let y1 = fileNodes[1].getBoundingClientRect().y
    let yh = fileListNode.getBoundingClientRect().height
    return Math.floor(yh / (y1 - y0))
  }
  function activateItem() {
    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
      }
    }
  }
  function nextItem() {
    focusOn(focusedIdx + 1)
  }
  function previousItem() {
    focusOn(focusedIdx - 1)
  }
  function pageDown() {
    focusOn(focusedIdx + pageSize())
  }
  function pageUp() {
    focusOn(focusedIdx - pageSize())
  }
  function firstItem() {
    focusOn(0)
  }
  function lastItem() {
    focusOn(filesCount - 1)
  }
  function flipItem() {
    flipSelected(focusedIdx)
    nextItem()
  }

  eventBus.handle(id, {nextItem, previousItem, pageDown, pageUp, firstItem, lastItem, flipItem, activateItem, focusOn, flipSelected, activateItem})
</script>
```

### Result

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

The next step is adding command palette, hopefully looking a bit better than what we had the last time.

As usual, [all the code for the episode is here](https://github.com/taw/electron-adventures/tree/master/episode-36-file-manager-event-bus).