Electron Adventures: Episode 36: File Manager Event Bus

taw

Tomasz Wegrzanowski

Posted on August 29, 2021

Electron Adventures: Episode 36: File Manager Event Bus

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. That would be super easy in Ruby, but a bit complex with JS.

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

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.

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

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.

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

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.

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

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:

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

The script part does a bit more:

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

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.

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

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:

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

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:

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

Or even:

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

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:

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

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:

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

Result

(image)
Episode 36 Screenshot

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.

💖 💪 🙅 🚩
taw
Tomasz Wegrzanowski

Posted on August 29, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related