Electron Adventures: Episode 37: File Manager Command Palette
Tags: javascript, electron, svelte
Published: 2021-08-30 02:42:49

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.

```javascript
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"]},
]
```

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.

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

### `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 `" "`.

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

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

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

### `src/App.svelte`

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

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

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

```javascript
  import CommandPalette from "./CommandPalette.svelte"

  let paletteOpen = false

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

  eventBus.handle("app", {switchPanel, activatePanel, quit, openPalette, closePalette})
```

The rest is as before.

### Result

Here's the results:

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

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](https://github.com/taw/electron-adventures/tree/master/episode-37-file-manager-command-palette).