DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

Electron Adventures: Episode 42: Marko File Manager

This episode was created in collaboration with the amazing Amanda Cavallaro.

In previous episode we wrote a Hello World in Marko. Let's try to write something more substantial - a very simple file manager. To keeps things manageable, we're not going to try to reach feature parity with Svelte version, in particular there will be no keyboard support.

window problem

And instantly we run into our first problem. We'd like to access the window object from our Marko code. Unfortunately Marko firmly believes that everything should be possible render server-side, so window is not available. Code like this will absolutely crash:

<file-list initial=(window.api.currentDirectory()) />
Enter fullscreen mode Exit fullscreen mode

That is sort of fine for the Web, but it's absolutely terrible idea for Electron, and it will make a lot of code awkward.

src/pages/index/index.marko

As I mentioned before, all components need - in their names. Other than that, it's very straigthforward.

<app-layout title="File Manager">
  <file-manager></file-manager>
</app-layout>
Enter fullscreen mode Exit fullscreen mode

src/components/buttons-footer.marko

Instead of starting from the top, let's start from the simplest component.

The footer buttons bar does only one thing, and disregarding labels on the buttons, by mouse click only.

$ function quit() {
  window.close()
}

<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(quit)>F10 Quit</button>
</footer>

<style>
  footer {
    text-align: center;
    grid-area: footer;
  }

  button {
    font-family: inherit;
    font-size: inherit;
    background-color: #66b;
    color: inherit;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

Contrary to what you might expect from Svelte, $ is not reactive statement, it's just inline Javascript not wrapped inside class { ... } or such.

There are many ways to handle events. on-click(quit) means to call quit function. Very similar looking on-click("quit") would mean to call this.quit() method.

src/components/file-manager.marko

Let's go through the main component one section at a time. This time it's more complicated, so we wrap it in a class.

We'd love to just set this.state.cwd = window.api.currentDirectory() - or even don't bother with state and put that in the template part - unfortunately Marko believes in server side rendering so we need to postpone setting that up to onMount.

We have one event - activate left or right panel.

class {
  onCreate() {
    this.state = {
      cwd: null,
      active: "left",
    }
  }
  onMount() {
    this.state.cwd = window.api.currentDirectory()
  }
  activate(panel) {
    this.state.active = panel
  }
}
Enter fullscreen mode Exit fullscreen mode

Template part should be understandable enough, but it has a few complications. First as state.cwd is null, and we really don't want to bother panels with null directory, we wrap the whole thing in state.cwd. Essentially we disable server-side rendering here, as server really has no way of knowing what files we have.

on-activate("activate", "left") means that when given component emits custom activate event, this.activate("left") will be called. Marko strongly believes in custom events over React-style callbacks - Svelte works both ways, but custom events are generally nicer.

<div class="ui">
  <header>
    File Manager
  </header>
  <if(state.cwd)>
    <file-list
      initial=(state.cwd)
      id="left"
      active=(state.active==="left")
      on-activate("activate", "left")
    />
    <file-list
      initial=(state.cwd + "/node_modules")
      id="right"
      active=(state.active==="right")
      on-activate("activate", "right")
    />
  </if>
  <buttons-footer />
</div>
Enter fullscreen mode Exit fullscreen mode

At least the style section is completely straightforward:

<style>
  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>
Enter fullscreen mode Exit fullscreen mode

src/components/file-list.marko

And finally, the most complex component. We'll go through it out of code order, to make understanding easier.

Styling is completely straighforward:

<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>
Enter fullscreen mode Exit fullscreen mode

Template has a few tricks:

<div class={panel: true, active: input.active}>
  <header>${state.directory.split("/").slice(-1)[0]}</header>
  <div class="file-list">
    <for|file,idx| of=state.files>
      <div
        class={
          file: "file",
          focused: (idx === state.focusedIdx),
          selected: state.selected.includes(idx),
        }
        on-click("click", idx)
        on-contextmenu("rightclick", idx)
        on-dblclick("dblclick", idx)
        >${file.name}
      </div>
    </for>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Marko has similar shortcut for setting multiple classes as Vue - class={class1: condition1, class2: condition2, ...}. I think Svelte's class:class1=condition1 is a bit more readable, but it's perfectly fine either way.

<for|file,idx| of=state.files> is Marko version of a loop. Every framework has some sort of loops, and some sort of ifs, with its unique syntax. All do basically the same thing.

Template refers to two objects - state and input. state is the state of the component (this.state).

input is component's props as they currently are, and this is strangely not available in the class, and there's no reactive way to do things based on props changing! We'd need to write onInput lifecycle method, and do all the logic there. I find this much more complicated than Svelte's or React's system.

Let's get to the class. It starts with onCreate setting up initial state:

class {
  onCreate(input) {
    this.state = {
      directory: input.initial,
      id: input.id,
      files: [],
      focusedIdx: 0,
      selected: [],
    }
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

It's important to know that this input is the props as they were when the component was created. It's not going to be called again when active prop changes. We can either use onInput to react to props changes, or we can use input.active in the template - where it always corresponds to the latest value. I find it very non-intuitive.

And as mentioned before, we don't have access to window in onCreate.

Once component mounts, we can ask Electron (more specifically our preload) for list of files in the directory:

  onMount() {
    this.fetchFiles()
  }
  fetchFiles() {
    let filesPromise = window.api.directoryContents(this.state.directory)
    filesPromise.then(x => {
      this.state.files = x
    })
  }
Enter fullscreen mode Exit fullscreen mode

We'd like to make this reactive like in Svelte $: (or like React would do with useEffect). Doesn't seem like we can, we need to call fetchFiles manually every time this.state.directory changes.

Now the event handlers. Various kinds of mouse clicks change this.state.focusedIdx to the clicked file's index, emit custom activate event to the parent, and then do some specific action based on left, right, or double click.

  click(idx) {
    this.emit("activate")
    this.state.focusedIdx = idx
  }
  rightclick(idx) {
    this.emit("activate")
    this.state.focusedIdx = idx
    this.flipSelected(idx)
  }
  dblclick(idx) {
    this.emit("activate")
    this.state.focusedIdx = idx
    this.enter()
  }
}
Enter fullscreen mode Exit fullscreen mode

Right click flips selection:

  flipSelected(idx) {
    if (this.state.selected.includes(idx)) {
      this.state.selected = this.state.selected.filter(f => f !== idx)
    } else {
      this.state.selected = [...this.state.selected, idx]
    }
  }
Enter fullscreen mode Exit fullscreen mode

And double click enters the clicked file if it's a directory. As we can't make this reactive, we need to call fetchFiles manually here.

  enter() {
    let focused = this.state.files[this.state.focusedIdx]
    if (focused?.type === "directory") {
      if (focused.name === "..") {
        this.state.directory = this.state.directory.split("/").slice(0, -1).join("/") || "/"
      } else {
        this.state.directory += "/" + focused.name
      }
      this.fetchFiles()
    }
  }
Enter fullscreen mode Exit fullscreen mode

First Impressions of Marko

Overall I haven't been very impressed. I despise boilerplate (and that's why there will be zero TypeScript in this series), so I can definitely appreciate Marko's concise syntax.

On the other hand, we ran into a lot of cases where we had to explicitly handle updates while Svelte's (or even React Hooks, just with more explicit dependency list) reactivity would do it for us.

There were also issues one might expect from a less popular framework. VSCode Marko plugin was fairly bad - it couldn't guess how to comment out code due to Marko's complex syntax, so it would put try <!-- --> in Javascript section, and getting syntax error. Error messages were very confusing, and often I had to reset npm run dev after fixing syntax error, as it would strangely not pick up that file changed when I reloaded the page. Documentation on the website was very poor, and googling answers was not very helpful.

Marko's website features Marko vs React section, which is fair enough, as React is the most popular framework of previous generation, but it compares it with fairly old style of React - hooks style React tends to cut on boilerplate a good deal with small components like that.

It also doesn't really try to compare with current generation frameworks like Svelte or Imba. I don't think comparison would go too well.

Result

Here's the results:

Episode 42 Screenshot

In the next episodes, we'll be back to improving our Svelte version.

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

Discussion (0)