Now that we have basical functionality running, let's make navigating between directories work.
preload.js
First, we need a bit more information about the files. I took preload.js
from episode 23, and added logic for handling of ..
and root directory here, as unnecessary complicated the frontend.
let path = require("path")
let { readdir, stat, readlink } = require("fs/promises")
let { contextBridge } = require("electron")
let fileInfo = async (basePath, entry) => {
let { name } = entry
let fullPath = path.join(basePath, name)
let linkTarget = null
let fileStat
if (entry.isSymbolicLink()) {
linkTarget = await readlink(fullPath)
}
// This most commonly happens with broken symlinks
// but could also happen if the file is deleted
// while we're checking it as race condition
try {
fileStat = await stat(fullPath)
} catch {
return {
name,
type: "broken",
linkTarget,
}
}
let { size, mtime } = fileStat
if (fileStat.isDirectory()) {
return {
name,
type: "directory",
mtime,
linkTarget,
}
} else if (fileStat.isFile()) {
return {
name,
type: "file",
size,
mtime,
linkTarget,
}
} else {
return {
name,
type: "special",
}
}
}
let directoryContents = async (path) => {
let entries = await readdir(path, { withFileTypes: true })
let fileInfos = await Promise.all(entries.map(entry => fileInfo(path, entry)))
if (path !== "/") {
fileInfos.unshift({
name: "..",
type: "directory",
})
}
return fileInfos;
}
let currentDirectory = () => {
return process.cwd()
}
contextBridge.exposeInMainWorld(
"api", { directoryContents, currentDirectory }
)
Panel
API changes
Panel
component had directory
property, but we now want it to be able to change its directory. To make it clearer, I renamed it to initialDirectory
, so in App.svelte
template is changed by just renaming one property:
<div class="ui">
<header>
File Manager
</header>
<Panel
initialDirectory={directoryLeft}
position="left"
active={activePanel === "left"}
onActivate={() => activePanel = "left"}
/>
<Panel
initialDirectory={directoryRight}
position="right"
active={activePanel === "right"}
onActivate={() => activePanel = "right"}
/>
<Footer />
</div>
<svelte:window on:keydown={handleKey}/>
File Symbols
There's a lot of changes to src/Panel.svelte
, so let's start with the simple one. Here's the updated template:
<div class="panel {position}" class:active={active}>
<header>{directory.split("/").slice(-1)[0]}</header>
<div class="file-list" bind:this={fileListNode}>
{#each files as file, idx}
<div
class="file"
class:focused={idx === focusedIdx}
class:selected={selected.includes(idx)}
on:click|preventDefault={() => onclick(idx)}
on:contextmenu|preventDefault={() => onrightclick(idx)}
on:dblclick|preventDefault={() => ondoubleclick(idx)}
bind:this={fileNodes[idx]}
>
{filySymbol(file)}{file.name}
</div>
{/each}
</div>
</div>
<svelte:window on:keydown={handleKey}/>
There are two changes here. There's now a double click handler, and every file now has a file symbol in front of it. It terminal most file managers use a symbol like /
for directories, @
or ~
for symbolic links, and space for files. We probably should use some Unicode character, or some proper icon, but this will do for now.
File symbol function is simple enough:
let 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" //
}
}
}
We cannot return
as that would be converted to those 6 characters by Svelte, which handles XSS for us. Instead we need to use its Unicode value which is 00A0
.
New event handlers
There are two event handlers - Enter key and double click, and they both do the same thing - if it's a directory they enter it. Otherwise they do nothing. The relevant code is in enterCommand
, which assumes we're trying to enter focused element.
let ondoubleclick = (idx) => {
onActivate()
focusOn(idx)
enterCommand()
}
let handleKey = (e) => {
if (!active) {
return
}
if (e.key === "ArrowDown") {
focusOn(focusedIdx + 1)
} else if (e.key === "ArrowUp") {
focusOn(focusedIdx - 1)
} else if (e.key === "PageDown") {
focusOn(focusedIdx + pageSize())
} else if (e.key === "PageUp") {
focusOn(focusedIdx - pageSize())
} else if (e.key === "Home") {
focusOn(0)
} else if (e.key === "End") {
focusOn(filesCount - 1)
} else if (e.key === " ") {
flipSelected(focusedIdx)
focusOn(focusedIdx + 1)
} else if (e.key === "Enter") {
enterCommand()
} else {
return
}
e.preventDefault()
}
Setting focus
As we'll be needing the second part, I split the function to focus on new element and scroll to it.
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()
}
Changing directories
I'll show to the code soon, but first let's talk about how navigation works.
- when component starts, it received
initialDirectory
- it should get files from that directory, and focus on first one - when you navigate to a new directory, it received name of a new
directory
- it should get files from that directory, and focus on first one - when navigating up, it receives name of new
directory
- however in this case it should focus on the directory we just came out of!
So for that reason we have initialFocus
variable, which is either null
or name of directory we came out of. And a bit of logic for handling it.
Because everything is async we need to do this in multiple steps:
- first we set
directory
and possiblyinitialFocus
- this makes Svelte run
filesPromise = window.api.directoryContents(directory)
reactively, asdirectory
changed - once this promise is resolved, we set
files
to what it returned andselected
to[]
as noting is selected. Then we callsetInitialFocus()
to handle focus. To avoid issues with Svelte reactivity possibly causing a loop, we have a separate function for that instead of trying to do all this inside promise callback. - in
setInitialFocus
we find ifinitialFocus
is set, and if yes, if we actually have such a file. If yes, we setfocusedIdx
to its index, otherwise we setfocusedIdx
to 0. - now we want to scroll to it - unfortunately we only just set this, and it's not rendered yet
- so we use an async lifecycle method,
await tick()
, which will resolve when DOM has been updated - after that we can finally call
scrollFocusedIntoView()
So here's the rest of src/Panel.svelte
, skipping functions which did not change for clarity:
import { tick } from "svelte"
export let initialDirectory
export let position
export let active
export let onActivate
let directory = initialDirectory
let initialFocus
let files = []
let selected = []
let focusedIdx = 0
let fileNodes = []
let fileListNode
$: filesPromise = window.api.directoryContents(directory)
$: filesPromise.then(x => {
files = x
selected = []
setInitialFocus()
})
$: filesCount = files.length
$: focused = files[focusedIdx]
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 enterCommand = () => {
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
}
}
}
Our component is getting quite complicated, and we're just getting started.
Perhaps we should split this component into child component that just displays the data, and its parent component that handles navigation.
Result
Here's the results:
In the next episode we'll refactor how we handle events, as we need a lot of extra functionality like modals, command palette, configurable shortcuts, and commands that need information from multiple components, and the current system will not get us there.
As usual, all the code for the episode is here.
Top comments (0)