DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

Electron Adventures: Episode 37: File Manager Command Palette

And now it's time to add command palette to our file manager. It will be very simple at first, but we can keep adding features to it over the next few episodes.

I sort of wonder if I'm doing things backwards, as the file manager doesn't actually do anything yet, other than being a retro looking ls. We'll get to adding all the functionality eventually.

This episodes starts where we left over in episode 36, adding command pallete feature based on episode 35.

src/commands.js

This file is shared between keyboard handler and command palette. Once we add application menu, it should hopefully use it as well.

export default [
  {key: "F2", action: ["app", "openPalette"]},
  {name: "Close Palette", key: "Escape", action: ["app", "closePalette"] },
  {name: "Enter Directory", key: "Enter", action: ["activePanel", "activateItem"]},
  {name: "Flip Selection", key: " ", action: ["activePanel", "flipItem"]},
  {name: "Go to First File", key: "Home", action: ["activePanel", "firstItem"]},
  {name: "Go to Last File", key: "End", action: ["activePanel", "lastItem"]},
  {name: "Go to Next File", key: "ArrowDown", action: ["activePanel", "nextItem"]},
  {name: "Go to Previous File", key: "ArrowUp", action: ["activePanel", "previousItem"]},
  {name: "Page Down", key: "PageDown", action: ["activePanel", "pageDown"]},
  {name: "Page Up", key: "PageUp", action: ["activePanel", "pageUp"]},
  {name: "Quit", key: "F10", action: ["app", "quit"]},
  {name: "Switch Panel", key: "Tab", action: ["app", "switchPanel"]},
]
Enter fullscreen mode Exit fullscreen mode

The idea is that commands we don't want to have keyboard shortcuts for just won't have key (currently none, but there will be a lot of them). And commands we don't want in command palette just don't have name (currently Open Palette as it's meaningless to open it while it's already open).

So far the system only features commands that don't require any extra arguments. At some point we'll need to extend it to more complicated commands.

src/Keyboard.svelte

We just need to do two quick changes. The component will now get active prop, and if it's set to false, it will ignore all key events.

I also added e.stopPropagation() as now we have multiple keyboard handlers - this one for when palette is closed, and the one in palette when it is open. We don't need this line, but it will save us some debugging headaches as our app gets more complex.

The rest is as before.

<script>
  import commands from "./commands.js"
  import { getContext } from "svelte"

  export let active

  let { eventBus } = getContext("app")

  function handleKey(e) {
    if (!active) {
      return
    }
    for (let command of commands) {
      if (command.key === e.key) {
        e.preventDefault()
        e.stopPropagation()
        eventBus.emit(...command.action)
      }
    }
  }

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

src/CommandPaletteEntry.svelte

This component represents a single available command. I previously called it Command, but I don't think this is a great name.

It functions just like the one from episode 35, but styling is more in line with our app, and there's one hack to make space key be displayed as "Space", even though in JS it is just " ".

<script>
  import { getContext } from "svelte"
  let { eventBus } = getContext("app")

  export let name
  export let key
  export let action

  function handleClick() {
    eventBus.emit("app", "closePalette")
    eventBus.emit(...action)
  }
  function keyName(key) {
    if (key === " ") {
      return "Space"
    } else {
      return key
    }
  }
</script>

<li on:click={handleClick}>
  <span class="name">{name}</span>
  {#if key}
    <span class="key">{keyName(key)}</span>
  {/if}
</li>

<style>
  li {
    display: flex;
    padding: 0px 8px;
  }
  li:first-child {
    background-color: #66b;
  }
  .name {
    flex: 1;
  }
  .key {
    display: inline-block;
    background-color: hsl(180,100%,30%);
    padding: 2px;
    border: 1px solid  hsl(180,100%,20%);
    border-radius: 20%;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

src/CommandPalette.svelte

This component represents a simple command palette. Compared with what we had previously, styling is changed to match the app, and command list is imported from commands.js instead of being duplicated here.

We also need to do event.stopPropagation() here. Otherwise we'd press Enter to select command, but that Enter would also be sent to the regular keyboard handler - which would then try to run it as palette is closed at this point.
In general it's helpful to stop propagation of events even when it's not needed, just to save some debugging.

<script>
  import commands from "./commands.js"
  import { getContext } from "svelte"
  import CommandPaletteEntry from "./CommandPaletteEntry.svelte"

  let { eventBus } = getContext("app")
  let pattern = ""

  $: matchingCommands = commands.filter(({name}) => checkMatch(pattern, name))

  function handleKey(event) {
    let {key} = event;

    if (key === "Enter") {
      event.preventDefault()
      event.stopPropagation()
      eventBus.emit("app", "closePalette")
      if (matchingCommands[0]) {
        eventBus.emit(...matchingCommands[0].action)
      }
    }
    if (key === "Escape") {
      event.preventDefault()
      event.stopPropagation()
      eventBus.emit("app", "closePalette")
    }
  }
  function checkMatch(pattern, name) {
    if (!name) {
      return false
    }
    let parts = pattern.toLowerCase().replace(/[^a-z0-9]/, "")
    let rx = new RegExp(parts.split("").join(".*"))
    name = name.toLowerCase().replace(/[^a-z0-9]/, "")
    return rx.test(name)
  }
  function focus(el) {
    el.focus()
  }
</script>

<div class="palette">
  <input use:focus bind:value={pattern} placeholder="Search for command" on:keydown={handleKey}>
  <ul>
    {#each matchingCommands as command}
      <CommandPaletteEntry {...command} />
    {/each}
  </ul>
</div>

<style>
  .palette {
    position: fixed;
    left: 0;
    top: 0;
    right: 0;
    margin: auto;
    max-width: 50vw;
    background: #338;
    box-shadow: 0px 0px 24px #004;
  }

  input {
    font-family: inherit;
    background-color: inherit;
    font-size: inherit;
    font-weight: inherit;
    box-sizing: border-box;
    width: 100%;
    margin: 0;
    background: #66b;
    color: inherit;
  }

  input::placeholder {
    color: inherit;
    font-style: italic;
  }

  ul {
    list-style: none;
    padding: 0;
    margin: 0;
    margin-top: 8px;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

src/App.svelte

The main app component only changed slightly. The template now has CommandPalette and passes active flag to the Keyboard component.

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

<Keyboard active={!paletteOpen} />

{#if paletteOpen}
  <CommandPalette />
{/if}
Enter fullscreen mode Exit fullscreen mode

In the script we add small bit of logic to open and close the palette:

  import CommandPalette from "./CommandPalette.svelte"

  let paletteOpen = false

  function openPalette() {
    paletteOpen = true
  }
  function closePalette() {
    paletteOpen = false
  }

  eventBus.handle("app", {switchPanel, activatePanel, quit, openPalette, closePalette})
Enter fullscreen mode Exit fullscreen mode

The rest is as before.

Result

Here's the results:

Episode 37 Screenshot

The most recent few episodes were fairly heavy. The next few will be much lighter, focusing on a small function at a time. In the next episode, we'll add some highlighting feedback to command palette matches.

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

Discussion (0)