---
description: "Read Markdown files from a folder with Node's file APIs and list them in a GTK list view."
---

# 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:

```ts [src/notes/files.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`](https://docs.gtk.org/glib/func.get_user_special_dir.html) 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](/v2/guide/why-gtkx#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:

```ts [src/notes/note.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](/v2/tutorial/the-markdown-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:

```tsx [src/notes/notes-provider.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`](https://react.dev/reference/react/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](/v2/tutorial/your-first-window) when it has no notes, and otherwise a list of every note, newest first:

```tsx [src/components/notes-screen.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](/v2/guide/components#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](/v2/tutorial/testing) 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](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1-latest/style-classes.html). 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:

```tsx [src/app.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](/v2/tutorial/saving-and-watching) watches it. The window lists the notes, newest first.

<figure class="result">
    <img class="result-light" src="/figures/tutorial-the-notes-folder-light.png" width="360" height="294" loading="lazy" alt="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." />
    <img class="result-dark" src="/figures/tutorial-the-notes-folder-dark.png" width="360" height="294" loading="lazy" alt="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." />
    <figcaption>Captured from <a href="#list-the-notes"><code>src/app.tsx</code></a> above.</figcaption>
</figure>
