---
description: "Save edits with debounced writes and show changes other programs make to the notes folder."
---

# Save and Watch Files

Write edits back to their files without blocking typing, and show notes that another editor or a sync tool adds, changes or removes while the app runs.

## Save edits

Writing the file on every keystroke would save once per character, and two writes of the same file could finish out of order. A `NoteSaver`, in a new file `src/notes/note-saver.ts`, waits until typing pauses, then writes the note's newest text:

```ts [src/notes/note-saver.ts]
import { writeNote } from "./files.js";

const SAVE_DELAY_MS = 400;

type PendingSave = { text: string; timer: NodeJS.Timeout };

class NoteSaver {
    readonly #folder: string;
    readonly #onError: (name: string, error: unknown) => void;
    readonly #failing = new Set<string>();
    readonly #pending = new Map<string, PendingSave>();
    readonly #written = new Map<string, string>();
    readonly #writes = new Map<string, Promise<void>>();

    constructor(
        folder: string,
        onError: (name: string, error: unknown) => void,
    ) {
        this.#folder = folder;
        this.#onError = onError;
    }

    schedule(name: string, text: string): void {
        clearTimeout(this.#pending.get(name)?.timer);
        this.#pending.set(name, {
            text,
            timer: setTimeout(() => this.#flush(name), SAVE_DELAY_MS),
        });
    }

    flushAll(): void {
        for (const name of [...this.#pending.keys()]) {
            this.#flush(name);
        }
    }

    isPending(name: string): boolean {
        return this.#pending.has(name);
    }

    isOwnWrite(name: string, text: string): boolean {
        return this.#written.get(name) === text;
    }

    #flush(name: string): void {
        const entry = this.#pending.get(name);
        if (entry === undefined) {
            return;
        }
        clearTimeout(entry.timer);
        this.#pending.delete(name);
        this.#written.set(name, entry.text);
        const previous = this.#writes.get(name) ?? Promise.resolve();
        const write = previous.then(() => this.#write(name, entry.text));
        this.#writes.set(name, write);
    }

    async #write(name: string, text: string): Promise<void> {
        try {
            await writeNote(this.#folder, name, text);
            this.#failing.delete(name);
        } catch (error) {
            this.#written.delete(name);
            if (!this.#failing.has(name)) {
                this.#failing.add(name);
                this.#onError(name, error);
            }
        }
    }
}

export { NoteSaver };
```

- `schedule` restarts the note's timer with the new text, so a burst of typing becomes one write, 400 milliseconds after the last change.
- `#flush` queues each write behind the note's previous one, so saves of one note never overlap and the last text wins. Different notes save independently.
- `#written` records the text of each note's latest write as soon as the write is queued, and `isPending` tells whether a note's text is still waiting for typing to pause. The folder watcher below uses both.
- A failed write calls `onError` once. The saver stays quiet about that note until one of its saves succeeds, so a full disk doesn't report every keystroke.
- `flushAll` starts every waiting write at once, for when the provider stops watching the folder.

`files.ts` gains `writeNote`, and `readNote` returns `null` for a file that no longer exists, which is how the watcher learns that a note was deleted:

```ts [src/notes/files.ts]
import * as GLib from "@gtkx/gi/glib";
import { mkdir, readdir, readFile, stat, writeFile } 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 notePath = (folder: string, name: string): string =>
    join(folder, `${name}${EXTENSION}`);

const isMissing = (error: unknown): boolean =>
    error instanceof Error && "code" in error && error.code === "ENOENT";

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

        return toNote(name, await readFile(path, "utf8"), info.mtimeMs);
    } catch (error) {
        if (isMissing(error)) {
            return null;
        }

        throw error;
    }
};

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);
};

const writeNote = (folder: string, name: string, text: string): Promise<void> =>
    writeFile(notePath(folder, name), text);

export { defaultFolder, noteName, readNote, readNotes, writeNote };
```

The editor reports each change to its buffer:

```tsx [src/editor/note-editor.tsx]
import * as Adw from "@gtkx/gi/adw";
import * as Gtk from "@gtkx/gi/gtk";
import * as GtkSource from "@gtkx/gi/gtksource";
import { useTranslation } from "@gtkx/i18n";
import { GtkScrolledWindow } from "@gtkx/jsx/gtk";
import { GtkSourceBuffer, GtkSourceView } from "@gtkx/jsx/gtksource";
import { useProperty } from "@gtkx/react";

const markdown = GtkSource.LanguageManager.getDefault().getLanguage("markdown");

type NoteEditorProps = { text: string; onChange: (text: string) => void };

const NoteEditor = ({ text, onChange }: NoteEditorProps) => {
    const { t } = useTranslation();
    const isDark = useProperty(Adw.StyleManager.getDefault(), "dark");
    const styleScheme = GtkSource.StyleSchemeManager.getDefault().getScheme(
        isDark ? "Adwaita-dark" : "Adwaita",
    );

    return (
        <GtkScrolledWindow vexpand hscrollbarPolicy={Gtk.PolicyType.NEVER}>
            <GtkSourceView
                accessibleLabel={t("Note text")}
                wrapMode={Gtk.WrapMode.WORD_CHAR}
                topMargin={18}
                bottomMargin={18}
                leftMargin={18}
                rightMargin={18}
                buffer={
                    <GtkSourceBuffer
                        text={text}
                        language={markdown}
                        styleScheme={styleScheme}
                        onChanged={(buffer) => {
                            onChange(
                                buffer.getText(
                                    buffer.getStartIter(),
                                    buffer.getEndIter(),
                                    false,
                                ),
                            );
                        }}
                    />
                }
            />
        </GtkScrolledWindow>
    );
};

export { NoteEditor };
```

`onChanged` handles the buffer's [`changed`](https://docs.gtk.org/gtk4/signal.TextBuffer.changed.html) signal, and `getText` reads the buffer from its start to its end. The `text` prop still fills the buffer: GTKX writes it only when it differs from the buffer's text, so typing never resets the cursor, and writing it doesn't call `onChanged`; see [The JSX prop model](/v2/guide/jsx-elements#the-jsx-prop-model).

The note screen passes each change to the provider's `edit`:

```tsx [src/components/note-screen.tsx]
import { useTranslation } from "@gtkx/i18n";
import { AdwStatusPage } from "@gtkx/jsx/adw";
import type { SplitViewScreenProps } from "@gtkx/navigation";
import { NoteEditor } from "../editor/note-editor.js";
import type { RootParamList } from "../navigation.js";
import { useNotes } from "../notes/notes-provider.js";

const NoteScreen = ({ route }: SplitViewScreenProps<RootParamList, "Note">) => {
    const { t } = useTranslation();
    const { notes, edit } = useNotes();
    const { name } = route.params;
    const note = notes?.get(name);

    if (note === undefined) {
        return (
            <AdwStatusPage
                iconName="edit-find-symbolic"
                title={t("Note Not Found")}
            />
        );
    }

    return (
        <NoteEditor
            key={name}
            text={note.text}
            onChange={(text) => edit(name, text)}
        />
    );
};

export { NoteScreen };
```

## Watch the folder

The provider owns the saver, edits the notes in memory, and watches the folder for changes made by other programs:

```tsx [src/notes/notes-provider.tsx]
import { useToast } from "@gtkx/components";
import { t } from "@gtkx/i18n";
import { watch } from "node:fs";
import { createContext, type ReactNode, use, useEffect, useState } from "react";
import { noteName, readNote, readNotes } from "./files.js";
import { type Note, toNote } from "./note.js";
import { NoteSaver } from "./note-saver.js";

type NoteMap = ReadonlyMap<string, Note>;

type NotesValue = {
    folder: string;
    notes: NoteMap | null;
    edit: (name: string, text: string) => void;
};

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

const withNote = (
    notes: NoteMap | null,
    name: string,
    note: Note | null,
): NoteMap => {
    const next = new Map(notes);
    if (note === null) {
        next.delete(name);
    } else {
        next.set(name, note);
    }

    return next;
};

const NotesProvider = ({
    folder,
    children,
}: {
    folder: string;
    children: ReactNode;
}) => {
    const [notes, setNotes] = useState<NoteMap | null>(null);
    const { show } = useToast();
    const [saver] = useState(
        () =>
            new NoteSaver(folder, (name) => {
                show({
                    useMarkup: false,
                    title: t("Couldn’t save “{{name}}”", { name }),
                    timeout: 0,
                });
            }),
    );

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

        const refresh = async (name: string): Promise<void> => {
            const note = await readNote(folder, name);
            const isOwnWrite =
                note !== null && saver.isOwnWrite(name, note.text);
            if (signal.aborted || saver.isPending(name) || isOwnWrite) {
                return;
            }
            setNotes((current) => withNote(current, name, note));
        };

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

        void load();

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

    const edit = (name: string, text: string): void => {
        saver.schedule(name, text);
        setNotes((current) =>
            withNote(current, name, toNote(name, text, Date.now())),
        );
    };

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

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

    return value;
};

export { NotesProvider, useNotes };
```

- `edit` schedules the save and updates the note at once, with the current time as its modification time. The list shows the new excerpt, and the edited note moves to the top.
- `useState` with an initializer creates one saver when the provider mounts and keeps it across renders.
- [`fs.watch`](https://nodejs.org/api/fs.html#fswatchfilename-options-listener) calls its listener with the name of each file that changes in the folder. The listener ignores the kind of event and rereads the file: a note that now exists is added or replaced, and one that doesn't is removed. `noteName` skips everything but visible `.md` files, such as the hidden temporary files some editors save through. Node doesn't promise a file name on every event ([Filename argument](https://nodejs.org/api/fs.html#filename-argument)), so the listener skips a `null` one.
- `refresh` leaves a note alone while it has a pending save, because the editor's text is newer than the file, and when the file holds the text the app last wrote, because the event reports the app's own save. Any other change came from another program, and the open editor shows it.
- The watcher takes the effect's abort signal, so the cleanup closes it, and a read that finishes afterwards sets no state. The cleanup also flushes pending saves. When the window closes, the app writes the last edits, and the Node process exits once they finish; an open watcher would keep it running. See [Ending work on unmount](/v2/guide/application-lifecycle#ending-work-on-unmount).

## Report a failed save

A toast from `@gtkx/components` names a note that couldn't be saved; [Toasts](/v2/guide/components#toasts) covers the provider and `useToast`. The window shares an `AdwToastOverlay` through `ToastProvider`, outside `NotesProvider` so the provider can call `useToast`. The overlay wraps the navigator, so the toast shows over both panes:

```tsx [src/app.tsx]
import { ToastProvider } from "@gtkx/components";
import type * as Adw from "@gtkx/gi/adw";
import { useTranslation } from "@gtkx/i18n";
import {
    AdwApplication,
    AdwApplicationWindow,
    AdwStatusPage,
    AdwToastOverlay,
} from "@gtkx/jsx/adw";
import { NavigationContainer } from "@gtkx/navigation";
import { quit } from "@gtkx/react";
import { useRef } from "react";
import { NoteScreen } from "./components/note-screen.js";
import { NotesScreen } from "./components/notes-screen.js";
import { Split } from "./navigation.js";
import { defaultFolder } from "./notes/files.js";
import { NotesProvider } from "./notes/notes-provider.js";

function App() {
    const { t } = useTranslation();
    const toastOverlayRef = useRef<Adw.ToastOverlay | null>(null);

    return (
        <AdwApplication>
            <AdwApplicationWindow
                title={t("Notes")}
                defaultWidth={720}
                defaultHeight={480}
                widthRequest={360}
                heightRequest={294}
                onCloseRequest={() => quit()}
            >
                <ToastProvider overlayRef={toastOverlayRef}>
                    <NotesProvider folder={defaultFolder()}>
                        <AdwToastOverlay ref={toastOverlayRef}>
                            <NavigationContainer>
                                <Split.Navigator
                                    contentPlaceholder={
                                        <AdwStatusPage
                                            iconName="document-edit-symbolic"
                                            title={t("No Note Selected")}
                                        />
                                    }
                                >
                                    <Split.Screen
                                        name="Notes"
                                        component={NotesScreen}
                                        options={{ title: t("Notes") }}
                                    />
                                    <Split.Screen
                                        name="Note"
                                        component={NoteScreen}
                                        options={({ route }) => ({
                                            title: route.params.name,
                                        })}
                                    />
                                </Split.Navigator>
                            </NavigationContainer>
                        </AdwToastOverlay>
                    </NotesProvider>
                </ToastProvider>
            </AdwApplicationWindow>
        </AdwApplication>
    );
}

export { App };
```

In the provider's `onError`:

- `timeout: 0` keeps the toast until the user dismisses it ([`timeout`](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1-latest/property.Toast.timeout.html)), so the message isn't missed while typing.
- `useMarkup: false` shows the title as written. Adwaita reads toast titles as Pango markup by default, and a note's name can contain `&` or `<`.
- The saver keeps the callback it was created with, so the message uses `t` imported from `@gtkx/i18n`, which translates into the current language whenever it runs, rather than the `t` of one render. <span v-pre>`{{name}}`</span> inserts the note's name; see [Internationalization](/v2/guide/internationalization).

## Run it

Save the files. The new imports restart the app. Open a note and type: about half a second after the last keystroke, its file in the notes folder holds the new text, and the note moves to the top of the list.

Add, change or delete a `.md` file in the folder from another program, and the list follows. Changing the open note's file updates the editor, and deleting it shows Note Not Found.

<figure class="result">
    <img class="result-light" src="/figures/tutorial-saving-and-watching-light.png" width="720" height="480" loading="lazy" alt="The Notes window with Website relaunch open in the editor and a new note, Groceries, at the top of the sidebar list above the five earlier notes." />
    <img class="result-dark" src="/figures/tutorial-saving-and-watching-dark.png" width="720" height="480" loading="lazy" alt="The Notes window with Website relaunch open in the editor and a new note, Groceries, at the top of the sidebar list above the five earlier notes." />
    <figcaption>Captured from <a href="#report-a-failed-save"><code>src/app.tsx</code></a> above, after opening Website relaunch and writing <code>Groceries.md</code> into the notes folder from outside the app.</figcaption>
</figure>
