So after a brief Vue detour, let's go back to our Svelte file manager. Right now it's displaying mock data, so we'd like to give it some actual functionality such as:
- displaying actual files
- displaying basic information about files
- displaying which directory each panel shows
- moving to a different directory
- F10 or footer button to quit the app
We'll start where we left of it episode 27.
API functions
We already added functionality for listing contents of a directory in episode 17, so let's just copy those two files from there.
Here's updated index.js
(just added preload line):
let { app, BrowserWindow } = require("electron")
function createWindow() {
let win = new BrowserWindow({
webPreferences: {
preload: `${__dirname}/preload.js`,
},
})
win.maximize()
win.loadURL("http://localhost:5000/")
}
app.on("ready", createWindow)
app.on("window-all-closed", () => {
app.quit()
})
And here's preload.js
we already did before. It's the simplest version without any such fancy things like support for symlinks, file sizes, last modified dates and so on. We'll bring it all together soon, but we have a lot to do here already.
let { readdir } = require("fs/promises")
let { contextBridge } = require("electron")
let directoryContents = async (path) => {
let results = await readdir(path, { withFileTypes: true })
return results.map(entry => ({
name: entry.name,
type: entry.isDirectory() ? "directory" : "file",
}))
}
let currentDirectory = () => {
return process.cwd()
}
contextBridge.exposeInMainWorld(
"api", { directoryContents, currentDirectory }
)
F10 to quit
This isn't even related to rest of the changes, but I really wanted at least F10 shortcut and button to work, so here's the updated src/Footer.svelte
:
<script>
let quitCommand = (e) => {
window.close()
}
let handleKey = (e) => {
if (e.key === "F10") {
e.preventDefault()
quitCommand()
}
}
</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={quitCommand}>F10 Quit</button>
</footer>
<svelte:window on:keydown={handleKey}/>
<style>
footer {
text-align: center;
grid-area: footer;
}
button {
font-family: inherit;
font-size: inherit;
background-color: #66b;
color: inherit;
}
</style>
window.close()
is an old browser function, nothing Electron specific, but in actual browsers there are some security limitations of when you're allowed to call it, as a lot of that window management was abused by popup ads. Remember those?
Anyway, there's important thing to note here. A lot of Electron tutorial have logic in index.js
like this:
- if last window is closed, then quit the app (so far so good)
- except on OSX, then keep the app active, and just relaunch a window if app reactivates
This is how many OSX apps behave, but it's a horrendous default, and we absolutely should not be doing this unless we have a good reason to. Most apps should simply quit when you close their last window, on any operating system.
Also if we wanted to support this OSX behavior, we'd need to add extra functionality to tell the app to quit - browser APIs can close windows, but it's some extra code to make apps quit. As it's extra code to do something we don't even want, we're not going to do this.
src/App.svelte
We need to adjust it in a few ways.
- instead of passing files to each panel, we just pass directory we want it to display
- for left panel we start it with
window.api.currentDirectory()
- source code of our app - for right panel we start it with
window.api.currentDirectory() + "/node_modules"
-node_modules
for our app - list of files might be bigger than the screen, and we don't want to scroll the whole, just each panel separately, so we adjust grid css from
grid-template-rows: auto 1fr auto
togrid-template-rows: auto minmax(0, 1fr) auto
. You can check this for some discussion on this. It's honestly not the best part ofdisplay: grid
, but we have a workaround.
The rest of the code is unchanged:
<script>
import Panel from "./Panel.svelte"
import Footer from "./Footer.svelte"
let activePanel = "left"
let directoryLeft = window.api.currentDirectory()
let directoryRight = window.api.currentDirectory() + "/node_modules"
let handleKey = (e) => {
if (e.key === "Tab") {
if (activePanel === "left") {
activePanel = "right"
} else {
activePanel = "left"
}
e.preventDefault()
}
}
</script>
<div class="ui">
<header>
File Manager
</header>
<Panel
directory={directoryLeft}
position="left"
active={activePanel === "left"}
onActivate={() => activePanel = "left"}
/>
<Panel
directory={directoryRight}
position="right"
active={activePanel === "right"}
onActivate={() => activePanel = "right"}
/>
<Footer />
</div>
<svelte:window on:keydown={handleKey}/>
<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>
src/Panel.svelte
Now this one needed almost a total rewrite.
Let's start with the template:
<div class="panel {position}" class:active={active}>
<header>{directory.split("/").slice(-1)[0]}</header>
<div class="file-list">
{#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)}
>{file.name}</div>
{/each}
</div>
</div>
<svelte:window on:keydown={handleKey}/>
There's extra header with last part of directory name. Then the files are put in a scrollable list.
The API is a bit different - previously files were just a list of strings, and so focused
/ selected
were just strings too. This isn't really going to work as we want to include a lot of extra information about each file. Files are now objects, and that means it's much easier to use integers for focused
/ selected
.
The CSS changed only a bit:
<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;
}
.file {
cursor: pointer;
}
.file.selected {
color: #ff2;
font-weight: bold;
}
.panel.active .file.focused {
background-color: #66b;
}
</style>
We how have a header, scrollable file list, and some small flexbox to make sure header is always displayed, even when file list is scrolled all the way down.
Let's get to the script part, in parts:
let onclick = (idx) => {
onActivate()
focusedIdx = idx
}
let onrightclick = (idx) => {
onActivate()
focusedIdx = idx
flipSelected(idx)
}
let flipSelected = (idx) => {
if (selected.includes(idx)) {
selected = selected.filter(f => f !== idx)
} else {
selected = [...selected, idx]
}
}
let goUp = () => {
if (focusedIdx > 0) {
focusedIdx -= 1
}
}
let goDown = () => {
if (focusedIdx < filesCount - 1) {
focusedIdx += 1
}
}
let handleKey = (e) => {
if (!active) {
return
}
if (e.key === "ArrowDown") {
e.preventDefault()
goDown()
}
if (e.key === "ArrowUp") {
e.preventDefault()
goUp()
}
if (e.key === " ") {
e.preventDefault()
flipSelected(focusedIdx)
goDown()
}
}
The methods we use didn't change much, other than using indexes instead of file names.
We also haved filesCount
here to save ourselves some promise troubles. Normally it's equal to files.length
, but files
is loaded from a promise, so we pre-initialize filesCount
to 0
and don't need to worry about user pressing some keys before list of files is loaded and accessing null.length
.
The properties we get from the parent are the same except it's now directory
, not files
:
export let position
export let directory
export let active
export let onActivate
And finally the complicated part:
let files = []
let selected = []
let focusedIdx = 0
$: filesPromise = window.api.directoryContents(directory)
$: filesPromise.then(x => {
files = x
focusedIdx = 0
selected = []
})
$: filesCount = files.length
Svelte has a bunch of different ways to deal with promises. For simple cases there's {#await promise}
blocks, but they're a poor fit for what we do, as we also need to access this list in various methods, not just in the template.
For most complex cases we could use a store, and we might do this eventually, but for now a simple callback will do. If you're interested in some more discussion, check out this thread.
Result
Here's the results:
The app displays files, and we'd love to keep adding more functionality to it, unfortunately there's one small issue we need to address first.
The files are in a scrollable list, which can be scrolled with mouse wheel like all browser lists. The list can be navigated with arrow keys, but nothing ensures that the focused element remains scolled in view, so your focus can fall out of the screen.
As usual, all the code for the episode is here.
Top comments (0)