Skip to content

Chapter 2 of 19

List the Notes ​

Read the .md files in the notes folder with Node's fs/promises and list them, newest first, in a GTK list view. Each note is a plain file, so any editor or sync tool can change it.

Find the notes folder ​

Notes keeps its files in a Notes folder inside the user's Documents folder:

src/notes/files.ts
ts
import * as GLib from "@gtkx/gi/glib";
import { mkdir, readdir, readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import { type Note, toNote } from "./note.js";

const EXTENSION = ".md";

const defaultFolder = (): string =>
    join(
        GLib.getUserSpecialDir(GLib.UserDirectory.DIRECTORY_DOCUMENTS) ??
            GLib.getHomeDir(),
        "Notes",
    );

const noteName = (fileName: string): string | null =>
    fileName.endsWith(EXTENSION) && !fileName.startsWith(".")
        ? fileName.slice(0, -EXTENSION.length)
        : null;

const readNote = async (folder: string, name: string): Promise<Note | null> => {
    const path = join(folder, `${name}${EXTENSION}`);
    const info = await stat(path);
    if (!info.isFile()) {
        return null;
    }

    return toNote(name, await readFile(path, "utf8"), info.mtimeMs);
};

const readNotes = async (folder: string): Promise<Note[]> => {
    await mkdir(folder, { recursive: true });
    const names = (await readdir(folder))
        .map(noteName)
        .filter((name) => name !== null);
    const notes = await Promise.all(
        names.map((name) => readNote(folder, name)),
    );

    return notes.filter((note) => note !== null);
};

export { defaultFolder, readNotes };

GLib.getUserSpecialDir returns the Documents folder set in user-dirs.dirs, which the desktop may have localized or moved, or null when there is none; Notes then uses the home folder. @gtkx/gi/glib holds the GLib bindings, generated like the elements in @gtkx/jsx.

The rest is ordinary Node.js code, since a GTKX app is a Node.js program (Why Node.js). readNotes creates the folder on first launch, keeps the visible .md files, and reads them in parallel. readNote calls stat before reading a file, which gives its modification time and skips anything that isn't a file, such as a folder named something.md.

Read the notes ​

A Note is the file's name without .md, its text, its modification time in milliseconds, and an excerpt for the list:

src/notes/note.ts
ts
type Note = {
    name: string;
    text: string;
    modified: number;
    excerpt: string;
};

const firstLine = (text: string): string =>
    text
        .split("\n")
        .map((line) => line.trim())
        .find((line) => line !== "" && !line.startsWith("#")) ?? "";

const toNote = (name: string, text: string, modified: number): Note => ({
    name,
    text,
    modified,
    excerpt: firstLine(text),
});

export { type Note, toNote };

The excerpt is the first line that is neither empty nor a heading, as written, so Markdown such as **bold** shows its asterisks. Render the Preview parses the Markdown and takes the excerpt from the parsed text instead.

Share the notes ​

The list, and later the editor and search, need the same notes. A context provider loads them once for a folder:

src/notes/notes-provider.tsx
tsx
import { createContext, type ReactNode, use, useEffect, useState } from "react";
import { readNotes } from "./files.js";
import type { Note } from "./note.js";

type NoteMap = ReadonlyMap<string, Note>;

type NotesValue = {
    folder: string;
    notes: NoteMap | null;
};

const NotesContext = createContext<NotesValue | null>(null);

const NotesProvider = ({
    folder,
    children,
}: {
    folder: string;
    children: ReactNode;
}) => {
    const [notes, setNotes] = useState<NoteMap | null>(null);

    useEffect(() => {
        const controller = new AbortController();

        const load = async (): Promise<void> => {
            const list = await readNotes(folder);
            if (!controller.signal.aborted) {
                setNotes(new Map(list.map((note) => [note.name, note])));
            }
        };

        void load();

        return () => controller.abort();
    }, [folder]);

    return <NotesContext value={{ folder, notes }}>{children}</NotesContext>;
};

const useNotes = (): NotesValue => {
    const value = use(NotesContext);
    if (value === null) {
        throw new Error("NotesProvider is required");
    }

    return value;
};

export { NotesProvider, useNotes };

notes is null until the folder has loaded, then a map from each note's name to the note. The effect aborts on cleanup, so a read that finishes after the provider unmounts, or after folder changes, sets no state. useNotes reads the context with React's use and throws outside the provider.

List the notes ​

Install @gtkx/components, which renders GTK's list widgets from data:

bash
npm install @gtkx/components@beta

The notes screen shows a spinner while the folder loads, the empty state from Create a Window when it has no notes, and otherwise a list of every note, newest first:

src/components/notes-screen.tsx
tsx
import { ListView } from "@gtkx/components";
import * as Gtk from "@gtkx/gi/gtk";
import * as Pango from "@gtkx/gi/pango";
import { useTranslation } from "@gtkx/i18n";
import { AdwSpinner, AdwStatusPage } from "@gtkx/jsx/adw";
import { GtkBox, GtkLabel, GtkScrolledWindow } from "@gtkx/jsx/gtk";
import type { Note } from "../notes/note.js";
import { useNotes } from "../notes/notes-provider.js";

const NoteRow = ({ note }: { note: Note }) => (
    <GtkBox
        orientation={Gtk.Orientation.VERTICAL}
        spacing={2}
        marginTop={8}
        marginBottom={8}
    >
        <GtkLabel
            label={note.name}
            xalign={0}
            ellipsize={Pango.EllipsizeMode.END}
            cssClasses={["heading"]}
        />
        <GtkLabel
            label={note.excerpt}
            xalign={0}
            ellipsize={Pango.EllipsizeMode.END}
            singleLineMode
            cssClasses={["dimmed", "caption"]}
        />
    </GtkBox>
);

const NotesScreen = () => {
    const { t } = useTranslation();
    const { notes } = useNotes();

    if (notes === null) {
        return <AdwSpinner />;
    }

    if (notes.size === 0) {
        return (
            <AdwStatusPage
                iconName="document-new-symbolic"
                title={t("No Notes")}
                description={t("Notes are Markdown files in your notes folder")}
            />
        );
    }

    const shown = [...notes.values()].sort((a, b) => b.modified - a.modified);

    return (
        <GtkScrolledWindow hscrollbarPolicy={Gtk.PolicyType.NEVER}>
            <ListView<Note>
                items={shown.map((note) => ({ id: note.name, value: note }))}
                isFlat
                cssClasses={["navigation-sidebar"]}
                rowProps={({ item }) => ({ accessibleLabel: item.name })}
                renderItem={({ item }) => <NoteRow note={item} />}
            />
        </GtkScrolledWindow>
    );
};

export { NotesScreen };
  • ListView takes items as { id, value } pairs and a renderItem for each row. The note's name is its ID, which is unique within a folder. List components covers selection and how IDs keep rows' state.
  • isFlat says no item has children, so the list skips looking for a tree.
  • GTK names a list row from its own accessible label, not from its content, so rowProps labels each row with the note's name. Screen readers announce it, and Test the App finds rows by it.
  • The navigation-sidebar style class gives the rows Adwaita's sidebar look, and the heading, dimmed and caption classes style the labels; see Style classes. Both labels end in an ellipsis when the window is too narrow, and the scrolled window never scrolls sideways.

The app wraps the toolbar view in the provider and shows the screen in place of the status page:

src/app.tsx
tsx
import { useTranslation } from "@gtkx/i18n";
import {
    AdwApplication,
    AdwApplicationWindow,
    AdwHeaderBar,
    AdwToolbarView,
} from "@gtkx/jsx/adw";
import { quit } from "@gtkx/react";
import { NotesScreen } from "./components/notes-screen.js";
import { defaultFolder } from "./notes/files.js";
import { NotesProvider } from "./notes/notes-provider.js";

function App() {
    const { t } = useTranslation();

    return (
        <AdwApplication>
            <AdwApplicationWindow
                title={t("Notes")}
                widthRequest={360}
                heightRequest={294}
                onCloseRequest={() => quit()}
            >
                <NotesProvider folder={defaultFolder()}>
                    <AdwToolbarView top={<AdwHeaderBar />}>
                        <NotesScreen />
                    </AdwToolbarView>
                </NotesProvider>
            </AdwApplicationWindow>
        </AdwApplication>
    );
}

export { App };

Run it ​

Save the files. The new imports restart the app, which creates the notes folder if it is missing and shows the empty state. Add a few .md files to the folder, ~/Documents/Notes on most desktops, then close the window and run npm run dev again: the app reads the folder only when it starts until Save and Watch Files watches it. The window lists the notes, newest first.

The Notes window listing five notes, newest first: Website relaunch, Meeting notes, Trip to Lisbon, Book club and Recipes, each name above its first line of text in dimmed type.The Notes window listing five notes, newest first: Website relaunch, Meeting notes, Trip to Lisbon, Book club and Recipes, each name above its first line of text in dimmed type.
Captured from src/app.tsx above.