Tutorial 47 of 54 · for anyone whose terminal tool keeps data between runs · Markdown

Save to a file

You'll read the task list from a JSON file under your home directory and write it back on every change. A missing file starts the list with a default. It starts from the app you built in 620.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

The machine comes from std#

A terminal app's page and loader read the machine through file and path from @snapfire/fsr/host and system from @snapfire/fsr/terminal:

MemberWhat it has
fileread, write, exists, list, makeDir and stat
pathjoin, resolve, normalize, dirname, basename, extname, relative and isAbsolute
systemenv(name), home(), platform(), tempDir(), lineEnd(), cwd() and memory()

They take the place of Node's fs, path and os, which a terminal app cannot import. Each one answers at once, so you call it without await. A web page cannot use them, since a browser has no files.

Read the file in the loader#

The loader from 620 runs once, before the first frame, so it is where the list is read. It works out the file's path from the name, reads the list and hands both to the page:

app/routes/page.loader.ts
import type { Ctx } from "@snapfire/fsr/host";
import { file, path } from "@snapfire/fsr/host";
import { system } from "@snapfire/fsr/terminal";

type Task = { title: string; done: boolean };

const STARTER: Task[] = [{ title: "Write the first task", done: false }];

function read(where: string): Task[] {
  try {
    return JSON.parse(file.read(where));
  } catch (error) {
    if (error instanceof Error && error.code === "ENOENT") return STARTER;
    throw error;
  }
}

export async function load({ params }: Ctx<"/">) {
  const name = params.file ?? "tasks.json";
  const where = path.join(system.home(), ".tasks", name);
  return { name, where, tasks: read(where), limit: Number(params.limit ?? "10") };
}

file.read of a path with nothing there throws an error whose code is ENOENT, as Node's does. The catch turns that one error into the starter list and throws any other, so a file you cannot read is not silently replaced.

Save from the page#

The START list from 610 goes away. The page starts from the tasks the loader read and writes the list back on every change. Add save above the component:

app/routes/page.tsx
import { useState } from "react";
import type { RootProps } from "@generated/client";
import { file, path } from "@snapfire/fsr/host";
import { Box, Newline, Spacer, Text, useInput } from "ink";

type Task = { title: string; done: boolean };

function save(where: string, tasks: Task[]) {
  file.makeDir(path.dirname(where), { recursive: true });
  file.write(where, JSON.stringify(tasks, null, 2));
}

file.makeDir without recursive fails when the directory is already there, as Node's mkdirSync does. With { recursive: true } it makes the parents and leaves an existing directory alone.

app/routes/page.tsx
export default function Tasks({ name, where, tasks: initial, limit }: RootProps) {
  const [tasks, setTasks] = useState(initial);
  const [selected, setSelected] = useState(0);

  useInput((input, key) => {
    if (key.upArrow && selected > 0) setSelected(selected - 1);
    if (key.downArrow && selected < shown.length - 1) setSelected(selected + 1);
    if (input === " ") {
      const next = tasks.map((task, index) => (index === selected ? { ...task, done: !task.done } : task));
      setTasks(next);
      save(where, next);
    }
  });

  const open = tasks.filter((task) => !task.done).length;
  const shown = tasks.slice(0, limit);

The rest of the component is the same as in 620.

The handler saves next, the list it just made. Saving tasks would write the list as it was before the key.

Run it#

$ fsr dev app --terminal

The first time there is no ~/.tasks/tasks.json, so the list starts with the starter task:

╭──────────────────────────────────────────╮
│ Tasks tasks.json                  1 open │
│                                          │
│ > Write the first task                   │
│                                          │
│ up and down move, space marks one done   │
│ Ctrl-C quits                             │
╰──────────────────────────────────────────╯

Press space and quit. The directory and the file are there now:

$ cat ~/.tasks/tasks.json
[
  {
    "title": "Write the first task",
    "done": true
  }
]

Run it again and the task opens as done, with 0 open in the header. --file work.json keeps a second list in ~/.tasks/work.json.

Next up: 640. Type into the app.