Electron Adventures: Episode 36: File Manager Event Bus
Tomasz Wegrzanowski
Posted on August 29, 2021
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)
}
}
}
}
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"]},
]
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} />
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>
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>
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>
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>
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" //
}
}
}
</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:
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:
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:
<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
:
<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
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.
Posted on August 29, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.