diff --git a/CLAUDE.md b/CLAUDE.md index b809e4f4..a703a2be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,8 +55,15 @@ electron/ # Electron main process (compiled separately) historyMenu.ts # The Go menu (Back/Forward, Cmd-[ / Cmd-]) storage/ NodeFsStorageProvider.ts # StorageProvider on node:fs/promises - nodeFsGit.ts # isomorphic-git fs on node:fs/promises - bridge.ts # RPC dispatch on a MessagePortMain + bridge.ts # RPC dispatch on a MessagePortMain; owns + # the folder's GitSync + git/ # Version history and auto-sync, on the + # system git (see "Git and auto-sync") + runGit.ts # Spawns git: non-interactive, timed out + GitRepo.ts # Local operations: init, commit, log, diff + GitSync.ts # Worktree lock, pull/push, triggers, status + rebase.ts # Rebase with last-writer-wins resolution + classifyError.ts # offline / auth / rejected / other src/ main.tsx # App entry point @@ -73,6 +80,8 @@ src/ subwaytext/ # Subtext parser & serializer notes-worker/ # Dedicated worker hosting NotesProvider script-worker/ # Sandboxed worker for user script execution + git/ # Types, commit messages and the diff shared + # by electron/git/ and the history view electron/ # Renderer/worker side of the storage bridge FileSystemAccessAPIStorageProvider.tsx # File System Access API integration (OPFS mode) @@ -147,7 +156,7 @@ that is how a folder switch works. | `src/lib/electron/bridgeTypes.ts` | Message types shared by `electron/` and `src/` so the bridge cannot drift | | `src/lib/electron/BridgeClient.ts` | Request/response id bookkeeping for the storage bridge | | `src/lib/electron/StorageProviderProxy.ts` | `StorageProvider` over the bridge, with chunked streams | -| `src/lib/electron/GitFsProxy.ts` | isomorphic-git fs over the bridge | +| `src/lib/electron/GitClient.ts` | The worker's handle on the folder's repository: commit, log, diff over the bridge | **Gotcha — `MessagePort.start()`:** When listening on a `MessagePort` with `addEventListener` (as opposed to setting `onmessage`), the port @@ -192,7 +201,7 @@ RPC: main process renderer notes worker ┌───────────────────┐ ┌─────────────┐ ┌──────────────────┐ │ NodeFsStorage │◀═══════ MessagePortMain ══════▶│ StorageProxy │ - │ NodeFsGit │ │ (forwards │ │ GitFsProxy │ + │ GitSync (git CLI) │ │ (forwards │ │ GitClient │ │ dialogs, fs │ │ the port) │ │ NotesProvider │ └───────────────────┘ └─────────────┘ └──────────────────┘ ▲ contextBridge (window.neno) │ Worker RPC (unchanged) @@ -207,8 +216,11 @@ notes worker alongside the `initialize` action. The wire format is the `{id, method, args}` → `{id, result | error}` protocol `NotesProviderProxy` already used, with method names namespaced -`storage.…` / `git.…` so one port carries both. Both proxies share a -single `BridgeClient`, because they share the request id sequence. +`storage.…` / `git.…` so one port carries both. `StorageProviderProxy` +and `GitClient` share a single `BridgeClient`, because they share the +request id sequence. Main also speaks first on the same port: a +`BridgeEvent` without an id (`pathsChanged`, after a pull) tells the +worker which files to re-read. **Streams are chunked.** A `ReadableStream` is transferable between renderer contexts but not across the main-process boundary, so @@ -219,8 +231,59 @@ on the wire; there is no transfer list, since a `MessagePortMain` can only transfer `MessagePort`s and everything crossing the process boundary is serialized regardless. -Errors carry an `errorCode` field alongside the message, because -isomorphic-git branches on `err.code === "ENOENT"`. +Errors carry an `errorCode` field alongside the message, so callers can +branch on `err.code` (`ENOENT`, `EEXIST`, …) as they would on Node's. + +### Git and auto-sync + +Every flush is a commit, and auto-sync pushes it to the remote at once +and pulls in what other machines pushed. All of it runs on the **system +git in the main process** (`electron/git/`); the worker only reports what +a flush wrote (`git.commit` over the bridge). There is no git in OPFS +mode, and none without git installed — `findGit()` only accepts +`/usr/bin/git` once `xcode-select -p` succeeds, because running Apple's +stub opens the Command Line Tools installer. + +- **Saving never waits for the network.** `GitSync`'s worktree lock is + taken by every write through the bridge (`writeObject`, `removeObject`, + `renameObject`, and a streamed write from `openWrite` to `closeWrite`), + by commits and by the rebase. Fetch and push run in a separate queue + that never holds it. A rebase only starts on a worktree with no + uncommitted tracked changes; a flush caught halfway is committed a + moment later, and its rejected push pulls again. +- **git never prompts.** `runGit.ts` sets `GIT_TERMINAL_PROMPT=0`, ssh + `BatchMode=yes` with `ConnectTimeout=5`, an HTTPS stall limit and a + time limit per call, and kills the **whole process group**: ssh + outlives a killed git and holds the pipes open, so killing git alone + does not end the call. `GIT_CEILING_DIRECTORIES` stops a graph folder + without a `.git` of its own from being taken into an enclosing repo. +- **An unreachable remote is a state, not an error** (`offline`): a + LAN-only remote is unreachable whenever the laptop is elsewhere. + `classifyError.ts` decides; only auth failures, a missing remote and + unrelated histories are errors. Failures retry with backoff, and the + renderer's `online` event retries at once. +- **When:** push after every commit; pull on folder open, on window + focus (at most once a minute), every 5 minutes while focused, and when + a push is rejected. A pull waits while the window has unsaved changes + (`unsavedChanges.ts`), and the open note picks up pulled changes + through the worker's ordinary `mutation` broadcast. +- **Conflicts: last writer wins**, per file, by the author time of each + side's latest commit touching it (`rebase.ts`). Text goes through + `git merge-file --ours/--theirs`, so only overlapping hunks are lost; + binary files go whole to the newer side; an edit always beats a + deletion. When the local side loses, its commit is kept under + `refs/neno/overwritten/`, because the rebase drops it from the branch. + Untracked files the remote also has (`.pins.neno`, written on first + load) are committed first, or the rebase would refuse on every pull. +- The toggle lives in the repo's own config (`neno.autoSync` in + `.git/config`); the remote is the branch's upstream, else `origin`. + NENO never adds or changes remotes, refuses a repo whose history is + unrelated to its remote, and aborts a rebase a crash left behind when + the folder opens. +- **Quitting:** `will-quit` waits for `disposeStorageBridges()`, which + kills fetch and push but lets a commit or rebase that has started + finish, then calls `app.exit()` — a second `app.quit()` from a + prevented `will-quit` never completes. ### Non-obvious main-process requirements @@ -338,6 +401,12 @@ Unit tests live next to source files as `*.spec.ts` / `*.spec.tsx`, in both `src/` and `electron/` (see `vitest.config.ts`). Run a single test file: `npx vitest run src/lib/notes/noteUtils.spec.ts` +The specs in `electron/git/` run the real `/usr/bin/git` against +temporary repositories — a bare repo as the remote and two clones as two +machines — and take seconds each, hence the suite's 30 s timeout. +A hanging remote is simulated with `ext::sleep 30`, which is why +`GitRepo` takes `extraConfig` (`protocol.ext.allow=always`). + Integration tests are in `tests/integration/`. Visual regression tests are in `tests/visual-regression/`. Both drive `npm run dev` in a real browser at `localhost:5173` in **OPFS mode** — which is the main reason @@ -347,3 +416,5 @@ OPFS mode was worth keeping. - Node.js v24+ - macOS (the packaged app is macOS-only and unsigned) +- git (Apple's Command Line Tools) for version history and auto-sync, + and for the `electron/git/` specs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e3bf431..47180d35 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,7 +91,7 @@ need the right-click → Open detour described in the │ Electron main process │ │ │ │ neno:// protocol (serves dist/) native dialogs │ -│ NodeFsStorageProvider · NodeFsGit (node:fs/promises) │ +│ NodeFsStorageProvider (node:fs) · GitSync (git CLI) │ └───────▲──────────────────────────────────────▲───────────┘ │ contextBridge (window.neno) │ │ MessagePortMain RPC @@ -113,8 +113,8 @@ need the right-click → Open detour described in the │ ├── StorageProviderProxy ─────────┼──▶ main │ │ (or FileSystemAccessAPI- │ │ │ StorageProvider for OPFS) │ - │ └── isomorphic-git (optional) │ - │ over GitFsProxy ──────────────┼──▶ main + │ └── GitClient (optional, ──────┼──▶ main + │ desktop only) │ └─────────────────┬────────────────────┘ ▼ ┌──────────────────────────────────────┐ @@ -170,18 +170,28 @@ Hosts the single `NotesProvider` instance. The window talks to it through Registers and serves the `neno://` scheme, creates the window, sets the CSP and the macOS menu, and owns the Node file system: the `StorageProvider` (`/electron/storage/NodeFsStorageProvider.ts`), the -isomorphic-git adapter (`/electron/storage/nodeFsGit.ts`) and the RPC -dispatcher that exposes both to the notes worker -(`/electron/storage/bridge.ts`). `/electron/preload.ts` is the only -thing the renderer can see of it. +graph folder's git repository (`/electron/git`) and the RPC dispatcher +that exposes both to the notes worker (`/electron/storage/bridge.ts`). +`/electron/preload.ts` is the only thing the renderer can see of it. + +### Version history and auto-sync +- Entry point: `/electron/git` + +Every saved change is committed with the system `git`, and — once +switched on for a repository with a remote — pushed straight away, with +what other machines pushed pulled in on open, on focus and every few +minutes. Conflicts go to the newer side. Network calls never hold up a +save, and an unreachable remote is a normal "offline" state. See the +"Git and auto-sync" section of [CLAUDE.md](./CLAUDE.md) for the rules. ### Storage bridge (renderer side) - Entry point: `/src/lib/electron` -`StorageProviderProxy` and `GitFsProxy` implement the same contracts as -their in-renderer counterparts but forward every call to the main -process. Streams are chunked, because a `ReadableStream` cannot be -transferred across the process boundary. +`StorageProviderProxy` implements the `StorageProvider` contract but +forwards every call to the main process; `GitClient` does the same for +the handful of repository operations the notes worker needs. Streams are +chunked, because a `ReadableStream` cannot be transferred across the +process boundary. ### FileSystemAccessAPIStorageProvider - Entry point: `/src/lib/FileSystemAccessAPIStorageProvider.tsx` diff --git a/README.md b/README.md index 00ada3e7..28db21f9 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,10 @@ NENO is a powerful note-taking app that helps you manage your personal knowledge note to query, transform, or aggregate your graph; share helpers between notes via reusable `mod` modules * Versioning: NENO automatically manages a Git repository of your notes, so you -can go back to any point in time +can go back to any point in time (needs Git — Apple's Command Line Tools) +* Sync: optionally push every change to your repository's remote right away + and pull in what your other machines pushed — on your own server, even one + reachable only on your home network * Interoperability: NENO works with human-readable plain-text files and implemenents the free and open [Subtext Graph Specification](https://polyrainbow.github.io/neno/docs/subtext-graph-specification.html) * Powerful full-text search * Tap-to-link: One click is enough to link one note to another diff --git a/electron/git/GitRepo.spec.ts b/electron/git/GitRepo.spec.ts new file mode 100644 index 00000000..0fab0678 --- /dev/null +++ b/electron/git/GitRepo.spec.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs/promises"; +import GitRepo from "./GitRepo"; +import { + AUTHOR, + GIT, + git, + makeTempDir, + readFile, + writeFile, +} from "./test/testRepos"; +import { FlushChanges } from "../../src/lib/git/types"; + +describe("GitRepo", () => { + let dir: string; + let repo: GitRepo; + + beforeEach(async () => { + dir = await makeTempDir("repo"); + repo = new GitRepo({ dir, gitPath: GIT }); + }); + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + describe("init", () => { + it("creates a repo on main with an initial commit", async () => { + expect(await repo.exists()).toBe(false); + + await repo.init(AUTHOR); + + expect(await repo.exists()).toBe(true); + expect(git(dir, "symbolic-ref", "--short", "HEAD")).toBe("main"); + expect(git(dir, "log", "--format=%s|%an")).toBe("initial commit|Test"); + expect(await readFile(dir, ".gitignore")).toContain(".DS_Store"); + }); + + it("commits files that were already in the folder", async () => { + await writeFile(dir, "existing.subtext", "already here"); + + await repo.init(AUTHOR); + + expect(git(dir, "ls-files").split("\n")) + .toEqual([".gitignore", "existing.subtext"]); + }); + + it("does nothing to an existing repo", async () => { + await repo.init(AUTHOR); + await repo.init(AUTHOR); + + expect(git(dir, "rev-list", "--count", "HEAD")).toBe("1"); + }); + }); + + describe("commit", () => { + const changes = ( + notes: FlushChanges["canonicalNoteSlugs"], + extra: Partial = {}, + ): FlushChanges => ({ + canonicalNoteSlugs: notes, + aliases: [], + arbitraryFiles: [], + flushPins: false, + ...extra, + }); + + beforeEach(async () => { + await repo.init(AUTHOR); + }); + + it("names a single new note in the subject", async () => { + await writeFile(dir, "hello.subtext", "Hello"); + + await repo.commit(changes(["hello"]), AUTHOR); + + expect(git(dir, "log", "-1", "--format=%s|%an")) + .toBe("create: hello|Test"); + expect(git(dir, "status", "--porcelain")).toBe(""); + }); + + it("says update for a note that was committed before", async () => { + await writeFile(dir, "hello.subtext", "Hello"); + await repo.commit(changes(["hello"]), AUTHOR); + await writeFile(dir, "hello.subtext", "Hello again"); + + await repo.commit(changes(["hello"]), AUTHOR); + + expect(git(dir, "log", "-1", "--format=%s")).toBe("update: hello"); + }); + + it("commits a deletion when the file is gone", async () => { + await writeFile(dir, "hello.subtext", "Hello"); + await repo.commit(changes(["hello"]), AUTHOR); + await fs.rm(`${dir}/hello.subtext`); + + await repo.commit(changes(["hello"]), AUTHOR); + + expect(git(dir, "log", "-1", "--format=%s")).toBe("delete: hello"); + expect(git(dir, "ls-files")).toBe(".gitignore"); + }); + + it("ignores a slug that was never on disk or in git", async () => { + await writeFile(dir, "real.subtext", "Real"); + + await repo.commit(changes(["ghost", "real"]), AUTHOR); + + expect(git(dir, "log", "-1", "--format=%s")).toBe("create: real"); + }); + + it("makes no commit when nothing changed", async () => { + await writeFile(dir, "hello.subtext", "Hello"); + await repo.commit(changes(["hello"]), AUTHOR); + + const oid = await repo.commit(changes(["hello"]), AUTHOR); + + expect(oid).toBeNull(); + expect(git(dir, "rev-list", "--count", "HEAD")).toBe("2"); + }); + + it("commits an uploaded file with its graph file", async () => { + await writeFile(dir, "files/photo.png", new Uint8Array([0, 1, 2])); + await writeFile(dir, "files/photo.png.subtext", ":file:photo.png"); + + await repo.commit( + changes([], { arbitraryFiles: ["files/photo.png"] }), + AUTHOR, + ); + + expect(git(dir, "log", "-1", "--format=%B")).toBe( + "create: 1 note, 1 file\n\n" + + "+ files/photo.png\n" + + "+ files/photo.png.subtext", + ); + }); + + it("commits the pins file when pins changed", async () => { + await writeFile(dir, ".pins.neno", "hello"); + + await repo.commit(changes([], { flushPins: true }), AUTHOR); + + expect(git(dir, "log", "-1", "--format=%s")) + .toBe("create file: .pins.neno"); + }); + + it("commits every change when a set is 'all'", async () => { + await writeFile(dir, "a.subtext", "A"); + await writeFile(dir, "b.subtext", "B"); + + await repo.commit(changes("all"), AUTHOR); + + expect(git(dir, "log", "-1", "--format=%s")) + .toBe("create: 2 notes"); + expect(git(dir, "status", "--porcelain")).toBe(""); + }); + }); + describe("history", () => { + const save = async (slug: string, content: string | null) => { + if (content === null) { + await fs.rm(`${dir}/${slug}.subtext`); + } else { + await writeFile(dir, `${slug}.subtext`, content); + } + await repo.commit({ + canonicalNoteSlugs: [slug], + aliases: [], + arbitraryFiles: [], + flushPins: false, + }, AUTHOR); + }; + + beforeEach(async () => { + await writeFile(dir, "seed.subtext", "Seed"); + await repo.init(AUTHOR); + }); + + it("lists commits newest first with what each one changed", async () => { + await save("a", "A"); + await save("a", "A2"); + await save("a", null); + + const log = await repo.log({ limit: 10, offset: 0 }); + + expect(log.map((c) => [c.message.trim(), c.changes])).toEqual([ + ["delete: a", [{ path: "a.subtext", change: "delete" }]], + ["update: a", [{ path: "a.subtext", change: "modify" }]], + ["create: a", [{ path: "a.subtext", change: "add" }]], + ["initial commit", [ + { path: ".gitignore", change: "add" }, + { path: "seed.subtext", change: "add" }, + ]], + ]); + expect(log[0].author).toEqual(AUTHOR); + expect(Math.abs(log[0].timestamp - Date.now() / 1000)).toBeLessThan(60); + expect(log[0].timezoneOffset).toBe(new Date().getTimezoneOffset()); + }); + + it("pages through the history", async () => { + await save("a", "1"); + await save("a", "2"); + await save("a", "3"); + + const page = await repo.log({ limit: 2, offset: 1 }); + + expect(page.map((c) => c.message.trim())) + .toEqual(["update: a", "create: a"]); + }); + + it("diffs a new file as added lines", async () => { + await save("a", "one\ntwo"); + const [commit] = await repo.log({ limit: 1, offset: 0 }); + + expect(await repo.diff(commit.oid)).toEqual([{ + path: "a.subtext", + change: "add", + lines: [ + { type: "add", text: "one" }, + { type: "add", text: "two" }, + ], + }]); + }); + + it("emphasizes the changed words of a modified line", async () => { + await save("a", "the quick brown fox"); + await save("a", "the slow brown fox"); + const [commit] = await repo.log({ limit: 1, offset: 0 }); + + const [file] = await repo.diff(commit.oid); + + expect(file.change).toBe("modify"); + expect(file.lines?.find((l) => l.type === "remove")).toEqual({ + type: "remove", + text: "the quick brown fox", + segments: [ + { text: "the ", emphasized: false }, + { text: "quick", emphasized: true }, + { text: " brown fox", emphasized: false }, + ], + }); + expect(file.lines?.find((l) => l.type === "add")).toEqual({ + type: "add", + text: "the slow brown fox", + segments: [ + { text: "the ", emphasized: false }, + { text: "slow", emphasized: true }, + { text: " brown fox", emphasized: false }, + ], + }); + }); + + it("diffs a deleted file as removed lines", async () => { + await save("a", "gone"); + await save("a", null); + const [commit] = await repo.log({ limit: 1, offset: 0 }); + + expect(await repo.diff(commit.oid)).toEqual([{ + path: "a.subtext", + change: "delete", + lines: [{ type: "remove", text: "gone" }], + }]); + }); + + it("flags a binary file instead of diffing it", async () => { + await writeFile(dir, "files/x.bin", new Uint8Array([0, 159, 146])); + await writeFile(dir, "files/x.bin.subtext", ":file:x.bin"); + await repo.commit({ + canonicalNoteSlugs: [], + aliases: [], + arbitraryFiles: ["files/x.bin"], + flushPins: false, + }, AUTHOR); + const [commit] = await repo.log({ limit: 1, offset: 0 }); + + const [binary] = await repo.diff(commit.oid); + + expect(binary).toEqual({ + path: "files/x.bin", + change: "add", + binary: true, + }); + }); + + it("diffs the initial commit against nothing", async () => { + const [initial] = await repo.log({ limit: 1, offset: 0 }); + + const diff = await repo.diff(initial.oid); + + expect(diff.map((f) => [f.path, f.change])).toEqual([ + [".gitignore", "add"], + ["seed.subtext", "add"], + ]); + }); + }); +}); diff --git a/electron/git/GitRepo.ts b/electron/git/GitRepo.ts new file mode 100644 index 00000000..d9937fd1 --- /dev/null +++ b/electron/git/GitRepo.ts @@ -0,0 +1,426 @@ +/* + Local git operations on one graph folder, through the system git. + Nothing here touches the network, and nothing here locks: GitSync + decides what may run at the same time. +*/ + +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { createGitRunner, GitRunner, GitRunOptions } from "./runGit"; +import { + ChangedFile, + CommitHistoryOptions, + CommitInfo, + FileDiff, + FlushChanges, + GitAuthor, +} from "../../src/lib/git/types"; +import { + computeDiffLines, + decode, + isBinary, + MAX_DIFF_BYTES, +} from "../../src/lib/git/diff"; +import formatCommitMessage from "../../src/lib/git/commitMessage"; + +const GITIGNORE_DEFAULT = ".DS_Store\n"; +const GRAPH_FILE_EXTENSION = ".subtext"; +const PINS_FILENAME = ".pins.neno"; + +export interface RemoteTarget { + /** The remote's name, e.g. "origin". */ + name: string; + /** The branch on the remote. */ + branch: string; + localBranch: string; + /** Whether the local branch already tracks it. */ + hasUpstream: boolean; +} + +export function authorEnv(author: GitAuthor): Record { + return { + GIT_AUTHOR_NAME: author.name, + GIT_AUTHOR_EMAIL: author.email, + GIT_COMMITTER_NAME: author.name, + GIT_COMMITTER_EMAIL: author.email, + }; +} + +/* + git writes a zone as "+0100"; Date.prototype.getTimezoneOffset() — the + convention the history view already used — calls that -60. +*/ +function toTimezoneOffset(zone: string): number { + const match = /^([+-])(\d{2})(\d{2})$/.exec(zone ?? ""); + if (!match) return 0; + const minutes = Number(match[2]) * 60 + Number(match[3]); + return match[1] === "+" ? -minutes : minutes; +} + +/** Splits NUL-separated output into its non-empty fields. */ +export function splitNul(output: string): string[] { + return output.split("\0").filter((field) => field.length > 0); +} + +/* `--name-status -z` output: status and path, alternating. */ +function parseNameStatus(output: string): ChangedFile[] { + const fields = splitNul(output); + const changes: ChangedFile[] = []; + for (let i = 0; i + 1 < fields.length; i += 2) { + const status = fields[i]; + changes.push({ + path: fields[i + 1], + change: status === "A" ? "add" : status === "D" ? "delete" : "modify", + }); + } + return changes; +} + +export default class GitRepo { + readonly dir: string; + #run: GitRunner; + + constructor(options: { + dir: string; + gitPath: string; + extraConfig?: string[]; + }) { + this.dir = options.dir; + this.#run = createGitRunner(options); + } + + /** Runs git in the graph folder and returns stdout as text. */ + async git(args: string[], options?: GitRunOptions): Promise { + const result = await this.#run(args, options); + return result.stdout.toString("utf8"); + } + + /** Like git(), but keeps stdout as bytes. */ + async gitBytes( + args: string[], + options?: GitRunOptions, + ): Promise { + const result = await this.#run(args, options); + return result.stdout; + } + + /** Whether `args` exits 0. Any other exit code is a "no". */ + async succeeds(args: string[]): Promise { + try { + await this.#run(args); + return true; + } catch { + return false; + } + } + + async exists(): Promise { + try { + await fs.stat(path.join(this.dir, ".git")); + return true; + } catch { + return false; + } + } + + /** Creates the repository with an initial commit; a no-op if one exists. */ + async init(author: GitAuthor): Promise { + if (await this.exists()) return; + + await this.git(["init", "--quiet", "--initial-branch=main"]); + const gitignore = path.join(this.dir, ".gitignore"); + try { + await fs.writeFile(gitignore, GITIGNORE_DEFAULT, { flag: "wx" }); + } catch { + // The folder brought a .gitignore of its own; keep it. + } + await this.git(["add", "--all"]); + await this.git( + ["commit", "--quiet", "--no-verify", "-m", "initial commit"], + { env: authorEnv(author) }, + ); + } + + async #pathExists(relPath: string): Promise { + try { + await fs.lstat(path.join(this.dir, relPath)); + return true; + } catch { + return false; + } + } + + async #stagePaths(paths: string[]): Promise { + const present: string[] = []; + const missing: string[] = []; + for (const relPath of paths) { + (await this.#pathExists(relPath) ? present : missing).push(relPath); + } + /* + Paths go through stdin: a rename can touch hundreds of notes, and + argv has a size limit. + */ + if (present.length > 0) { + await this.git( + ["add", "--all", "--pathspec-from-file=-", "--pathspec-file-nul"], + { input: present.join("\0") }, + ); + } + /* + A path that is neither on disk nor tracked — a note created and + deleted between two commits — is nothing to commit, and + --ignore-unmatch keeps it from failing the call. + */ + if (missing.length > 0) { + await this.git( + [ + "rm", "--cached", "--quiet", "--ignore-unmatch", + "--pathspec-from-file=-", "--pathspec-file-nul", + ], + { input: missing.join("\0") }, + ); + } + } + + /** The staged changes, sorted into creates, modifies and removes. */ + async #stagedChanges(): Promise<{ + creates: string[]; + modifies: string[]; + removes: string[]; + }> { + const changes = parseNameStatus(await this.git([ + "diff", "--cached", "--name-status", "--no-renames", "-z", + ])); + const pathsOf = (change: ChangedFile["change"]) => changes + .filter((c) => c.change === change) + .map((c) => c.path); + return { + creates: pathsOf("add"), + modifies: pathsOf("modify"), + removes: pathsOf("delete"), + }; + } + + /* + Commits what one flush wrote. Returns the new commit's oid, or null + when the flush changed nothing git did not already have. + */ + async commit( + changes: FlushChanges, + author: GitAuthor, + ): Promise { + const sets = [ + changes.canonicalNoteSlugs, + changes.aliases, + changes.arbitraryFiles, + ]; + + if (sets.some((set) => set === "all")) { + await this.git(["add", "--all"]); + } else { + const paths = new Set(); + for (const slug of [ + ...changes.canonicalNoteSlugs as string[], + ...changes.aliases as string[], + ]) { + paths.add(`${slug}${GRAPH_FILE_EXTENSION}`); + } + // An uploaded file is its payload plus a graph file describing it. + for (const slug of changes.arbitraryFiles as string[]) { + paths.add(slug); + paths.add(`${slug}${GRAPH_FILE_EXTENSION}`); + } + if (changes.flushPins) { + paths.add(PINS_FILENAME); + } + await this.#stagePaths([...paths]); + } + + return this.#commitStaged(author); + } + + /** Commits these paths, relative to the graph folder, as they are now. */ + async commitPaths( + paths: string[], + author: GitAuthor, + ): Promise { + await this.#stagePaths(paths); + return this.#commitStaged(author); + } + + async #commitStaged(author: GitAuthor): Promise { + const { creates, modifies, removes } = await this.#stagedChanges(); + if (creates.length + modifies.length + removes.length === 0) { + return null; + } + + await this.git( + ["commit", "--quiet", "--no-verify", "--file=-"], + { + input: formatCommitMessage(creates, modifies, removes), + env: authorEnv(author), + }, + ); + return (await this.git(["rev-parse", "HEAD"])).trim(); + } + + /** What `oid` changed relative to its first parent, sorted by path. */ + async #changedFiles( + oid: string, + parent: string | undefined, + ): Promise { + const changes = parseNameStatus(await this.git( + parent + ? ["diff-tree", "-r", "-z", "--name-status", "--no-renames", + parent, oid] + : ["diff-tree", "-r", "-z", "--name-status", "--no-renames", + "--root", "--no-commit-id", oid], + )); + changes.sort((a, b) => a.path.localeCompare(b.path)); + return changes; + } + + async log(options: CommitHistoryOptions): Promise { + if (!await this.succeeds(["rev-parse", "--verify", "--quiet", "HEAD"])) { + return []; + } + // Unit and record separators cannot occur in any of the fields. + const output = await this.git([ + "log", + `--skip=${options.offset}`, + `--max-count=${options.limit}`, + "--date=raw", + "--format=%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%B%x1e", + ]); + const records = output.split("\x1e") + .map((record) => record.replace(/^\n/, "")) + .filter((record) => record.length > 0); + + const commits: CommitInfo[] = []; + for (const record of records) { + const [oid, parents, name, email, date, message] + = record.split("\x1f"); + const parent = parents.split(" ").find((p) => p.length > 0); + const [seconds, zone] = date.split(" "); + commits.push({ + oid, + message, + author: { name, email }, + timestamp: Number(seconds), + timezoneOffset: toTimezoneOffset(zone), + changes: await this.#changedFiles(oid, parent), + }); + } + return commits; + } + + /* + Where auto-sync goes: the current branch's upstream if it has one, + otherwise the same branch on `origin`. Null for a detached HEAD, or + when there is neither. + */ + async detectRemote(): Promise { + const localBranch = (await this.gitOrNull( + ["symbolic-ref", "--short", "--quiet", "HEAD"], + ))?.trim(); + if (!localBranch) return null; + + const configured = (await this.gitOrNull( + ["config", "--get", `branch.${localBranch}.remote`], + ))?.trim(); + const merge = (await this.gitOrNull( + ["config", "--get", `branch.${localBranch}.merge`], + ))?.trim(); + if (configured && configured !== "." && merge?.startsWith("refs/heads/")) { + return { + name: configured, + branch: merge.slice("refs/heads/".length), + localBranch, + hasUpstream: true, + }; + } + + const remotes = (await this.git(["remote"])).split("\n"); + if (!remotes.includes("origin")) return null; + return { + name: "origin", + branch: localBranch, + localBranch, + hasUpstream: false, + }; + } + + /** Like git(), but null instead of an error for a non-zero exit. */ + async gitOrNull(args: string[]): Promise { + try { + return await this.git(args); + } catch { + return null; + } + } + + async revParse(rev: string): Promise { + const oid = await this.gitOrNull( + ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`], + ); + return oid?.trim() || null; + } + + async #firstParent(oid: string): Promise { + const parents = await this.git( + ["rev-list", "--parents", "--max-count=1", oid], + ); + return parents.trim().split(" ")[1]; + } + + async #readBlob(rev: string, file: string): Promise { + return this.gitBytes(["cat-file", "blob", `${rev}:${file}`]); + } + + async diff(oid: string): Promise { + const parent = await this.#firstParent(oid); + const result: FileDiff[] = []; + + for (const changed of await this.#changedFiles(oid, parent)) { + const fileDiff: FileDiff = { path: changed.path, change: changed.change }; + let oldBytes: Buffer | null = null; + let newBytes: Buffer | null = null; + + try { + if (changed.change !== "add" && parent) { + oldBytes = await this.#readBlob(parent, changed.path); + } + if (changed.change !== "delete") { + newBytes = await this.#readBlob(oid, changed.path); + } + } catch { + result.push(fileDiff); + continue; + } + + if ( + (oldBytes && isBinary(oldBytes)) + || (newBytes && isBinary(newBytes)) + ) { + result.push({ ...fileDiff, binary: true }); + continue; + } + + if ( + (oldBytes?.length ?? 0) > MAX_DIFF_BYTES + || (newBytes?.length ?? 0) > MAX_DIFF_BYTES + ) { + result.push({ ...fileDiff, tooLarge: true }); + continue; + } + + fileDiff.lines = computeDiffLines( + oldBytes ? decode(oldBytes) : "", + newBytes ? decode(newBytes) : "", + ); + result.push(fileDiff); + } + + return result; + } +} diff --git a/electron/git/GitSync.spec.ts b/electron/git/GitSync.spec.ts new file mode 100644 index 00000000..3643ef20 --- /dev/null +++ b/electron/git/GitSync.spec.ts @@ -0,0 +1,474 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import GitRepo from "./GitRepo"; +import GitSync from "./GitSync"; +import { + AUTHOR, + exists, + GIT, + git, + makeTempDir, + readFile, + writeFile, +} from "./test/testRepos"; +import { FlushChanges, SyncStatus } from "../../src/lib/git/types"; + +/* + Two machines and a remote, all on the local disk: `remote` is a bare + repository, `a` is the machine under test (driven through GitSync, as + NENO drives it) and `b` is the other machine, driven with the git CLI. +*/ + +const notes = (...slugs: string[]): FlushChanges => ({ + canonicalNoteSlugs: slugs, + aliases: [], + arbitraryFiles: [], + flushPins: false, +}); + +/* + Each case spawns git a few dozen times across three repositories, which + takes seconds when the whole suite runs in parallel. +*/ +describe("GitSync", { timeout: 30_000 }, () => { + let root: string; + let remote: string; + let a: string; + let b: string; + let syncs: GitSync[]; + + const makeSync = ( + dir: string, + options: Partial[0]> = {}, + ): GitSync => { + const sync = new GitSync({ + repo: new GitRepo({ dir, gitPath: GIT }), + ...options, + }); + syncs.push(sync); + return sync; + }; + + /** A note saved on machine A, as NENO saves it. */ + const saveOnA = async ( + sync: GitSync, + slug: string, + content: string | null, + ) => { + await sync.withWorktreeLock(async () => { + if (content === null) { + await fs.rm(path.join(a, `${slug}.subtext`)); + } else { + await writeFile(a, `${slug}.subtext`, content); + } + }); + await sync.commit(notes(slug), AUTHOR); + }; + + beforeEach(async () => { + syncs = []; + root = await makeTempDir("sync"); + remote = path.join(root, "remote.git"); + a = path.join(root, "a"); + b = path.join(root, "b"); + + git(root, "init", "--quiet", "--bare", "--initial-branch=main", remote); + await fs.mkdir(a); + await writeFile(a, "shared.subtext", "line 1\nline 2\nline 3\n"); + await new GitRepo({ dir: a, gitPath: GIT }).init(AUTHOR); + git(a, "remote", "add", "origin", remote); + git(a, "push", "--quiet", "--set-upstream", "origin", "main"); + git(root, "clone", "--quiet", remote, b); + }); + + afterEach(async () => { + await Promise.all(syncs.map((sync) => sync.dispose())); + await fs.rm(root, { recursive: true, force: true }); + }); + + it("pushes a commit right after making it", async () => { + const sync = makeSync(a); + await sync.start(); + await sync.setAutoSync(true); + + await saveOnA(sync, "hello", "Hello"); + await sync.whenIdle(); + + expect(git(remote, "log", "-1", "--format=%s", "main")) + .toBe("create: hello"); + expect(sync.getStatus()).toMatchObject>({ + enabled: true, + state: "idle", + unpushedCommits: 0, + remote: { name: "origin", branch: "main" }, + error: null, + }); + }); + + it("keeps commits local while auto-sync is off", async () => { + const sync = makeSync(a); + await sync.start(); + + await saveOnA(sync, "hello", "Hello"); + await sync.whenIdle(); + + expect(git(remote, "log", "-1", "--format=%s", "main")) + .toBe("initial commit"); + expect(sync.getStatus().enabled).toBe(false); + }); + + it("remembers that auto-sync is on in the repo's own config", async () => { + await makeSync(a).setAutoSync(true); + + const later = makeSync(a); + await later.start(); + + expect(later.getStatus().enabled).toBe(true); + expect(git(a, "config", "--local", "neno.autoSync")).toBe("true"); + }); + + /** A change made and pushed on machine B, `ageSeconds` ago. */ + const pushFromB = async ( + relPath: string, + content: string | Uint8Array | null, + ageSeconds = 0, + ) => { + if (content === null) { + git(b, "rm", "--quiet", relPath); + } else { + await writeFile(b, relPath, content); + git(b, "add", relPath); + } + const date = `${Math.floor(Date.now() / 1000) - ageSeconds} +0000`; + git(b, "commit", "--quiet", "-m", `b: ${relPath}`, `--date=${date}`); + git(b, "push", "--quiet"); + }; + + describe("pulling", () => { + it("brings in what the other machine pushed", async () => { + const changed: string[][] = []; + const sync = makeSync(a, { + onPathsChanged: (paths) => { + changed.push(paths); + }, + }); + await sync.start(); + await sync.setAutoSync(true); + await pushFromB("from-b.subtext", "Hi from B"); + + await sync.syncNow(); + + expect(await readFile(a, "from-b.subtext")).toBe("Hi from B"); + expect(changed).toEqual([["from-b.subtext"]]); + expect(git(a, "status", "--porcelain")).toBe(""); + }); + + it("rebases and retries when a push is rejected", async () => { + const sync = makeSync(a); + await sync.start(); + await sync.setAutoSync(true); + await pushFromB("from-b.subtext", "B"); + + await saveOnA(sync, "from-a", "A"); + await sync.whenIdle(); + + expect(git(remote, "log", "--format=%s", "main").split("\n")) + .toEqual(["create: from-a", "b: from-b.subtext", "initial commit"]); + expect(await readFile(a, "from-b.subtext")).toBe("B"); + expect(sync.getStatus()).toMatchObject({ + state: "idle", + unpushedCommits: 0, + }); + }); + + it("waits while the editor has unsaved changes", async () => { + let dirty = true; + const sync = makeSync(a, { isEditorDirty: () => dirty }); + await sync.start(); + await sync.setAutoSync(true); + await pushFromB("from-b.subtext", "B"); + + await sync.syncNow(); + expect(await exists(a, "from-b.subtext")).toBe(false); + + dirty = false; + sync.editorBecameClean(); + await sync.whenIdle(); + + expect(await readFile(a, "from-b.subtext")).toBe("B"); + }); + }); + + describe("when both machines changed the same file", () => { + const HOUR = 3600; + let sync: GitSync; + + beforeEach(async () => { + sync = makeSync(a); + await sync.start(); + await sync.setAutoSync(true); + }); + + const pushedContent = () => git(remote, "show", "main:shared.subtext"); + + it("keeps the newer side of an overlapping edit: local", async () => { + await pushFromB("shared.subtext", "line 1\nB's line 2\nline 3\n", HOUR); + + await saveOnA(sync, "shared", "line 1\nA's line 2\nline 3\n"); + await sync.whenIdle(); + + expect(await readFile(a, "shared.subtext")) + .toBe("line 1\nA's line 2\nline 3\n"); + expect(pushedContent()).toBe("line 1\nA's line 2\nline 3"); + expect(git(a, "for-each-ref", "refs/neno/")).toBe(""); + }); + + it("keeps the newer side of an overlapping edit: remote", async () => { + await pushFromB( + "shared.subtext", + "line 1\nB's line 2\nline 3\n", + -HOUR, + ); + + await saveOnA(sync, "shared", "line 1\nA's line 2\nline 3\n"); + await sync.whenIdle(); + + expect(await readFile(a, "shared.subtext")) + .toBe("line 1\nB's line 2\nline 3\n"); + expect(sync.getStatus()).toMatchObject({ + state: "idle", + unpushedCommits: 0, + }); + }); + + it("keeps the overwritten local version under refs/neno/", async () => { + await pushFromB( + "shared.subtext", + "line 1\nB's line 2\nline 3\n", + -HOUR, + ); + + await saveOnA(sync, "shared", "line 1\nA's line 2\nline 3\n"); + await sync.whenIdle(); + + const [ref] = git(a, "for-each-ref", "--format=%(refname)", "refs/neno/") + .split("\n"); + expect(ref).toMatch(/^refs\/neno\/overwritten\//); + expect(git(a, "show", `${ref}:shared.subtext`)) + .toBe("line 1\nA's line 2\nline 3"); + }); + + it("keeps edits that do not overlap from both sides", async () => { + await pushFromB("shared.subtext", "B's line 1\nline 2\nline 3\n", -HOUR); + + await saveOnA(sync, "shared", "line 1\nline 2\nA's line 3\n"); + await sync.whenIdle(); + + expect(await readFile(a, "shared.subtext")) + .toBe("B's line 1\nline 2\nA's line 3\n"); + expect(pushedContent()).toBe("B's line 1\nline 2\nA's line 3"); + }); + + it("brings a note back that one side deleted and the other edited", + async () => { + await pushFromB("shared.subtext", "edited on B\n", HOUR); + + await saveOnA(sync, "shared", null); + await sync.whenIdle(); + + expect(await readFile(a, "shared.subtext")).toBe("edited on B\n"); + expect(pushedContent()).toBe("edited on B"); + }); + + it("keeps a local edit to a note the remote deleted later", async () => { + await pushFromB("shared.subtext", null, -HOUR); + + await saveOnA(sync, "shared", "edited on A\n"); + await sync.whenIdle(); + + expect(await readFile(a, "shared.subtext")).toBe("edited on A\n"); + expect(pushedContent()).toBe("edited on A"); + }); + + it("gives a binary file whole to the newer side", async () => { + await writeFile(b, "files/pic.bin", new Uint8Array([0, 1])); + git(b, "add", "."); + git(b, "commit", "--quiet", "-m", "pic"); + git(b, "push", "--quiet"); + await sync.syncNow(); + await pushFromB("files/pic.bin", new Uint8Array([0, 2]), HOUR); + + await sync.withWorktreeLock( + () => writeFile(a, "files/pic.bin", new Uint8Array([0, 3])), + ); + await sync.commit({ + canonicalNoteSlugs: [], + aliases: [], + arbitraryFiles: ["files/pic.bin"], + flushPins: false, + }, AUTHOR); + await sync.whenIdle(); + + expect([...await fs.readFile(path.join(a, "files/pic.bin"))]) + .toEqual([0, 3]); + expect(sync.getStatus().unpushedCommits).toBe(0); + }); + }); + + describe("setting up", () => { + it("refuses a repo that shares no history with the remote", async () => { + const c = path.join(root, "c"); + await fs.mkdir(c); + await new GitRepo({ dir: c, gitPath: GIT }).init(AUTHOR); + git(c, "remote", "add", "origin", remote); + const sync = makeSync(c); + await sync.start(); + + await expect(sync.setAutoSync(true)).rejects.toThrow(/share no history/); + + expect(sync.getStatus().enabled).toBe(false); + expect(git(remote, "log", "--format=%s", "main")).toBe("initial commit"); + }); + + it("cannot be switched on without a remote", async () => { + git(a, "remote", "remove", "origin"); + const sync = makeSync(a); + await sync.start(); + + await expect(sync.setAutoSync(true)).rejects.toThrow(/no remote/); + expect(sync.getStatus().remote).toBeNull(); + }); + + it("sets the upstream on the first push of a new branch", async () => { + git(a, "switch", "--quiet", "--create", "notes"); + const sync = makeSync(a); + await sync.start(); + await sync.setAutoSync(true); + + await saveOnA(sync, "hello", "Hello"); + await sync.whenIdle(); + + expect(git(remote, "log", "-1", "--format=%s", "notes")) + .toBe("create: hello"); + expect(git(a, "rev-parse", "--abbrev-ref", "notes@{upstream}")) + .toBe("origin/notes"); + }); + }); + + describe("when the remote cannot be reached", () => { + it("reports offline, not an error, and keeps the commits", async () => { + const sync = makeSync(a); + await sync.start(); + await sync.setAutoSync(true); + git(a, "remote", "set-url", "origin", "ssh://neno-test.invalid/x.git"); + + await saveOnA(sync, "hello", "Hello"); + await sync.whenIdle(); + + expect(sync.getStatus()).toMatchObject({ + enabled: true, + state: "offline", + unpushedCommits: 1, + error: null, + }); + }); + + it("never makes a save wait for the network", async () => { + const sync = makeSync(a, { + repo: new GitRepo({ + dir: a, + gitPath: GIT, + extraConfig: ["protocol.ext.allow=always"], + }), + networkTimeoutMs: 3000, + }); + await sync.start(); + await sync.setAutoSync(true); + // A remote that accepts the connection and then never answers. + git(a, "remote", "set-url", "origin", "ext::sleep 30"); + void sync.syncNow(); + + const started = Date.now(); + await saveOnA(sync, "hello", "Hello"); + await saveOnA(sync, "hello", "Hello again"); + + expect(Date.now() - started).toBeLessThan(1500); + await sync.whenIdle(); + expect(sync.getStatus()).toMatchObject({ + state: "offline", + unpushedCommits: 2, + }); + }); + + it("finishes a rebase an earlier run left behind", async () => { + await pushFromB("shared.subtext", "B\n"); + await writeFile(a, "shared.subtext", "A\n"); + git(a, "commit", "--quiet", "-am", "a"); + git(a, "fetch", "--quiet"); + try { + git(a, "rebase", "origin/main"); + } catch { + // Stopped at the conflict, as a crash mid-sync would leave it. + } + expect(await exists(a, ".git/rebase-merge")).toBe(true); + + const sync = makeSync(a); + await sync.start(); + + expect(await exists(a, ".git/rebase-merge")).toBe(false); + expect(git(a, "symbolic-ref", "--short", "HEAD")).toBe("main"); + expect(await readFile(a, "shared.subtext")).toBe("A\n"); + }); + }); + + describe("found in review", () => { + it("is not blocked by an untracked file the remote has too", async () => { + // NENO writes .pins.neno on first load and commits it with a pin. + await writeFile(a, ".pins.neno", ""); + const sync = makeSync(a); + await sync.start(); + await sync.setAutoSync(true); + await pushFromB(".pins.neno", "pinned-on-b", 3600); + + await sync.syncNow(); + + expect(await readFile(a, ".pins.neno")).toBe("pinned-on-b"); + expect(sync.getStatus()).toMatchObject({ + state: "idle", + error: null, + unpushedCommits: 0, + }); + }); + + it("reports a remote removed after sync was switched on", async () => { + const sync = makeSync(a); + await sync.start(); + await sync.setAutoSync(true); + git(a, "remote", "remove", "origin"); + + await sync.syncNow(); + + expect(sync.getStatus()).toMatchObject({ + enabled: true, + state: "error", + error: { kind: "no-remote" }, + }); + }); + + it("replays local commits as the configured author", async () => { + const sync = makeSync(a); + await sync.start(); + sync.setAuthor({ name: "Me", email: "me@example.com" }); + await pushFromB("from-b.subtext", "B"); + await writeFile(a, "local.subtext", "A"); + git(a, "add", "."); + git(a, "commit", "--quiet", "-m", "local"); + + await sync.setAutoSync(true); + + expect(git(a, "log", "-1", "--format=%s|%cn", "main")) + .toBe("local|Me"); + }); + }); +}); diff --git a/electron/git/GitSync.ts b/electron/git/GitSync.ts new file mode 100644 index 00000000..b69c2752 --- /dev/null +++ b/electron/git/GitSync.ts @@ -0,0 +1,526 @@ +/* + Keeps one graph folder's repository in step with its remote. + + Two rules shape everything here: + + - Network calls never hold the worktree lock. Saves take that lock for + their writes and their commit, so a fetch or push that hangs on an + unreachable remote would otherwise hang the save. Only local steps + hold it, and they take milliseconds. + - Failing to reach the remote is a state, not an error. A LAN-only + remote is unreachable whenever the laptop is somewhere else; the + commits wait and go out on a later attempt. +*/ + +import GitRepo, { RemoteTarget, splitNul } from "./GitRepo"; +import Mutex from "./Mutex"; +import classifyError from "./classifyError"; +import { GitError } from "./runGit"; +import { isRebaseInProgress, rebaseOnto } from "./rebase"; +import { + FlushChanges, + GitAuthor, + SyncErrorKind, + SyncStatus, +} from "../../src/lib/git/types"; + +const AUTO_SYNC_KEY = "neno.autoSync"; + +export interface GitSyncOptions { + repo: GitRepo; + /** Called with every status change. */ + onStatus?: (status: SyncStatus) => void; + /** Called with the paths a pull changed in the working tree. */ + onPathsChanged?: (paths: string[]) => void | Promise; + /* + A pull waits while the editor has unsaved changes; call + editorBecameClean() once it has none. + */ + isEditorDirty?: () => boolean; + /** Time limit for one fetch or push. */ + networkTimeoutMs?: number; + /** How often to pull while the window has focus. */ + pullIntervalMs?: number; + /** Focusing the window pulls at most this often. */ + focusThrottleMs?: number; + /** Waits between retries after a failure; the last one repeats. */ + retryDelaysMs?: number[]; +} + +/** A failure the user has to act on, with the kind the UI shows. */ +class SyncError extends Error { + readonly kind: SyncErrorKind; + + constructor(kind: SyncErrorKind, message: string) { + super(message); + this.kind = kind; + } +} + +export default class GitSync { + #repo: GitRepo; + #onStatus: (status: SyncStatus) => void; + #onPathsChanged: (paths: string[]) => void | Promise; + #isEditorDirty: () => boolean; + #networkTimeoutMs: number; + #pullIntervalMs: number; + #focusThrottleMs: number; + #retryDelaysMs: number[]; + #pullDeferred = false; + #lastFocusPullAt = 0; + #focused = false; + #intervalTimer: ReturnType | null = null; + #retryTimer: ReturnType | null = null; + #failures = 0; + + /* Writes to the working tree, commits and rebases. */ + #worktreeLock = new Mutex(); + /* Fetches and pushes, one at a time. */ + #network = Promise.resolve(); + #abort = new AbortController(); + #disposed = false; + + #author: GitAuthor = { name: "NENO", email: "noreply@neno.local" }; + #status: SyncStatus = { + repo: false, + remote: null, + enabled: false, + state: "idle", + unpushedCommits: 0, + lastSyncedAt: null, + error: null, + }; + + constructor(options: GitSyncOptions) { + this.#repo = options.repo; + this.#onStatus = options.onStatus ?? (() => {}); + this.#onPathsChanged = options.onPathsChanged ?? (() => {}); + this.#isEditorDirty = options.isEditorDirty ?? (() => false); + this.#networkTimeoutMs = options.networkTimeoutMs ?? 10 * 60_000; + this.#pullIntervalMs = options.pullIntervalMs ?? 5 * 60_000; + this.#focusThrottleMs = options.focusThrottleMs ?? 60_000; + this.#retryDelaysMs = options.retryDelaysMs + ?? [15_000, 30_000, 60_000, 120_000, 300_000]; + } + + get repo(): GitRepo { + return this.#repo; + } + + getStatus(): SyncStatus { + return { ...this.#status }; + } + + /** Called once when the folder opens. */ + async start(): Promise { + /* + A rebase is only ever left behind by a crash or a power cut in the + middle of one; the commits it was replaying are still on the + branch, so going back is safe and the next pull redoes it. + */ + await this.withWorktreeLock(async () => { + if (await this.#repo.exists() && await isRebaseInProgress(this.#repo)) { + await this.#repo.git(["rebase", "--abort"]); + } + }); + await this.#refreshStatus(); + if (this.#status.enabled) { + // The open counts as the focus pull: no second one right after. + this.#lastFocusPullAt = Date.now(); + void this.#requestNetwork({ pull: true }); + } + } + + /* + Who replays local commits in a rebase. Commits bring their own + author; this covers the pull on folder open, before the first save. + */ + setAuthor(author: GitAuthor): void { + this.#author = author; + } + + /* + Pulls, then pushes. Resolves when both are over, failed or not. Also + what the renderer calls when the network comes back, so it starts + the retries over. + */ + syncNow(): Promise { + this.#failures = 0; + return this.#requestNetwork({ pull: true }); + } + + windowFocused(): void { + this.#focused = true; + if (this.#disposed || !this.#status.enabled) return; + if (Date.now() - this.#lastFocusPullAt >= this.#focusThrottleMs) { + this.#lastFocusPullAt = Date.now(); + void this.#requestNetwork({ pull: true }); + } + this.#startInterval(); + } + + windowBlurred(): void { + this.#focused = false; + if (this.#intervalTimer) clearInterval(this.#intervalTimer); + this.#intervalTimer = null; + } + + #startInterval(): void { + this.#intervalTimer ??= setInterval(() => { + if (this.#status.enabled) void this.#requestNetwork({ pull: true }); + }, this.#pullIntervalMs); + } + + /** The pull that waited for unsaved changes can go ahead now. */ + editorBecameClean(): void { + if (!this.#pullDeferred) return; + this.#pullDeferred = false; + void this.#requestNetwork({ pull: true }); + } + + withWorktreeLock(fn: () => Promise): Promise { + return this.#worktreeLock.run(fn); + } + + async commit( + changes: FlushChanges, + author: GitAuthor, + ): Promise { + this.#author = author; + const oid = await this.withWorktreeLock( + () => this.#repo.commit(changes, author), + ); + if (oid !== null && this.#status.enabled) { + void this.#requestNetwork({ pull: false }); + } else if (oid !== null) { + await this.#refreshStatus(); + } + return oid; + } + + async setAutoSync(enabled: boolean): Promise { + await this.#refreshStatus(); + if (enabled) { + if (!this.#status.repo) { + throw new Error("This graph has no git repository"); + } + const target = await this.#repo.detectRemote(); + if (!target) { + throw new Error("The repository has no remote to sync with"); + } + await this.#refuseUnrelatedHistories(target); + await this.#repo.git(["config", "--local", AUTO_SYNC_KEY, "true"]); + } else { + await this.#repo.gitOrNull( + ["config", "--local", "--unset", AUTO_SYNC_KEY], + ); + } + await this.#refreshStatus(); + if (enabled) { + this.#lastFocusPullAt = Date.now(); + if (this.#focused) this.#startInterval(); + await this.#requestNetwork({ pull: true }); + } + return this.getStatus(); + } + + /** Resolves once no fetch or push is running or queued. */ + async whenIdle(): Promise { + let current: Promise; + do { + current = this.#network; + await current; + } while (current !== this.#network); + } + + async dispose(): Promise { + this.#disposed = true; + this.windowBlurred(); + if (this.#retryTimer) clearTimeout(this.#retryTimer); + this.#abort.abort(); + await this.#network.catch(() => {}); + await this.#worktreeLock.drain(); + } + + #emit(patch: Partial): void { + this.#status = { ...this.#status, ...patch }; + // A folder switch replaces this instance; its news is stale. + if (!this.#disposed) this.#onStatus(this.getStatus()); + } + + async #trackingRef(target: RemoteTarget): Promise { + const ref = `refs/remotes/${target.name}/${target.branch}`; + return (await this.#repo.revParse(ref)) ? ref : null; + } + + async #countUnpushed(target: RemoteTarget): Promise { + const tracking = await this.#trackingRef(target); + const range = tracking + ? `${tracking}..refs/heads/${target.localBranch}` + : `refs/heads/${target.localBranch}`; + const count = await this.#repo.gitOrNull(["rev-list", "--count", range]); + return Number(count?.trim() ?? 0); + } + + async #refreshStatus(): Promise { + const repo = await this.#repo.exists(); + const target = repo ? await this.#repo.detectRemote() : null; + const enabled = repo + && (await this.#repo.gitOrNull( + ["config", "--local", "--bool", AUTO_SYNC_KEY], + ))?.trim() === "true"; + this.#emit({ + repo, + remote: target ? { name: target.name, branch: target.branch } : null, + enabled, + unpushedCommits: target ? await this.#countUnpushed(target) : 0, + }); + } + + /* + Queues a fetch-and-push behind whatever is already running. The + returned promise settles when this run is over; it never rejects — + a failure is reported through the status. + */ + #requestNetwork(options: { pull: boolean }): Promise { + const run = this.#network.then(async () => { + try { + await this.#exchange(options.pull); + } catch (error) { + // Must not reject: a rejected link would skip every later run. + this.#reportFailure(error); + this.#scheduleRetry(); + } + }); + this.#network = run; + return run; + } + + async #exchange(pull: boolean): Promise { + if (this.#disposed) return; + await this.#refreshStatus(); + if (!this.#status.enabled) return; + const target = await this.#repo.detectRemote(); + if (!target) { + throw new SyncError( + "no-remote", + "The repository has no remote to sync with any more. Add one on " + + "the command line, or switch auto-sync off.", + ); + } + + this.#emit({ state: "syncing" }); + try { + if (pull) { + await this.#pull(target); + } + try { + await this.#push(target); + } catch (error) { + if (classifyError(error) !== "rejected") throw error; + // The remote moved on since the last fetch. + await this.#pull(target); + await this.#push(target); + } + this.#emit({ + state: "idle", + error: null, + lastSyncedAt: Date.now(), + }); + this.#failures = 0; + } catch (error) { + this.#reportFailure(error); + this.#scheduleRetry(); + } + await this.#refreshStatus(); + } + + #scheduleRetry(): void { + if (this.#disposed || this.#retryTimer) return; + const delays = this.#retryDelaysMs; + const delay = delays[Math.min(this.#failures, delays.length - 1)]; + this.#failures++; + this.#retryTimer = setTimeout(() => { + this.#retryTimer = null; + void this.#requestNetwork({ pull: true }); + }, delay); + } + + /* + Turning sync on for a repo that was initialized on its own would make + every pull a rebase of one history onto an unrelated one. If the + remote cannot be reached now, the check happens on the first pull. + */ + async #refuseUnrelatedHistories(target: RemoteTarget): Promise { + try { + await this.#fetch(target); + } catch (error) { + if (classifyError(error) === "offline") return; + throw error; + } + await this.#checkRelated(target); + } + + async #checkRelated(target: RemoteTarget): Promise { + const tracking = await this.#trackingRef(target); + if (!tracking) return; + const local = `refs/heads/${target.localBranch}`; + if (!await this.#repo.succeeds(["merge-base", local, tracking])) { + throw new SyncError( + "unrelated-histories", + `This repository and ${target.name}/${target.branch} share no ` + + "history. Clone the remote into a folder and open that folder " + + "in NENO instead.", + ); + } + } + + async #fetch(target: RemoteTarget): Promise { + try { + await this.#repo.git( + [ + "fetch", "--quiet", "--no-tags", target.name, + `+refs/heads/${target.branch}:` + + `refs/remotes/${target.name}/${target.branch}`, + ], + { timeoutMs: this.#networkTimeoutMs, signal: this.#abort.signal }, + ); + } catch (error) { + // An empty remote, or one that never had this branch. + if ( + error instanceof GitError + && /couldn't find remote ref/i.test(error.stderr) + ) { + return; + } + throw error; + } + } + + async #pull(target: RemoteTarget): Promise { + await this.#fetch(target); + const changed = await this.withWorktreeLock( + () => this.#integrate(target), + ); + if (changed.length > 0) { + await this.#onPathsChanged(changed); + } + } + + /* + Brings the fetched commits into the local branch. Returns the paths + that changed in the working tree. + */ + async #integrate(target: RemoteTarget): Promise { + if (this.#isEditorDirty()) { + this.#pullDeferred = true; + return []; + } + + const tracking = await this.#trackingRef(target); + if (!tracking) return []; + + const local = `refs/heads/${target.localBranch}`; + const current = (await this.#repo.gitOrNull( + ["symbolic-ref", "--quiet", "HEAD"], + ))?.trim(); + // Someone switched branches or detached HEAD on the command line. + if (current !== local) return []; + + if (await this.#repo.succeeds( + ["merge-base", "--is-ancestor", tracking, local], + )) { + return []; + } + await this.#checkRelated(target); + + /* + A save that has written files but not committed them yet. Its + commit comes in a moment, and its push — rejected — pulls again. + */ + const uncommitted = await this.#repo.git( + ["status", "--porcelain", "--untracked-files=no"], + ); + if (uncommitted.trim().length > 0) return []; + + /* + An untracked file the remote also has — .pins.neno, written on first + load and committed only with the first pin — would make the rebase + refuse to start, now and on every later pull. Committing it lets the + usual conflict rules decide between the two versions. + */ + const untracked = splitNul(await this.#repo.git( + ["ls-files", "--others", "--exclude-standard", "-z"], + )); + if (untracked.length > 0) { + const upstreamFiles = new Set(splitNul(await this.#repo.git( + ["ls-tree", "-r", "--name-only", "-z", tracking], + ))); + const colliding = untracked.filter((p) => upstreamFiles.has(p)); + if (colliding.length > 0) { + await this.#repo.commitPaths(colliding, this.#author); + } + } + + const before = await this.#repo.revParse(local); + await rebaseOnto(this.#repo, tracking, this.#author); + const after = await this.#repo.revParse(local); + if (!before || !after || before === after) return []; + + return splitNul(await this.#repo.git( + ["diff", "--name-only", "--no-renames", "-z", before, after], + )); + } + + async #push(target: RemoteTarget): Promise { + if (await this.#countUnpushed(target) === 0) return; + const local = `refs/heads/${target.localBranch}`; + const oid = await this.#repo.revParse(local); + await this.#repo.git( + [ + "push", "--quiet", "--porcelain", + ...target.hasUpstream ? [] : ["--set-upstream"], + target.name, + `${local}:refs/heads/${target.branch}`, + ], + { timeoutMs: this.#networkTimeoutMs, signal: this.#abort.signal }, + ); + // The tracking ref is what "unpushed" is counted against. + if (oid) { + await this.#repo.git([ + "update-ref", + `refs/remotes/${target.name}/${target.branch}`, + oid, + ]); + } + } + + #reportFailure(error: unknown): void { + if (this.#disposed) return; + if (error instanceof SyncError) { + this.#emit({ + state: "error", + error: { kind: error.kind, message: error.message }, + }); + return; + } + const failure = classifyError(error); + const message = error instanceof GitError + ? error.stderr.trim() || error.message + : error instanceof Error ? error.message : String(error); + if (failure === "offline") { + this.#emit({ state: "offline", error: null }); + } else if (failure === "rejected") { + /* + Still behind after a pull — the pull waited for unsaved changes, + or another machine pushed again in between. The retry sorts it. + */ + this.#emit({ state: "idle", error: null }); + } else { + this.#emit({ + state: "error", + error: { kind: failure === "auth" ? "auth" : "other", message }, + }); + } + } +} diff --git a/electron/git/Mutex.ts b/electron/git/Mutex.ts new file mode 100644 index 00000000..9d015ce5 --- /dev/null +++ b/electron/git/Mutex.ts @@ -0,0 +1,26 @@ +/* + First come, first served. A failing holder releases the lock like any + other. +*/ +export default class Mutex { + #tail: Promise = Promise.resolve(); + + async run(fn: () => Promise): Promise { + const previous = this.#tail; + let release!: () => void; + this.#tail = new Promise((resolve) => { + release = resolve; + }); + try { + await previous; + return await fn(); + } finally { + release(); + } + } + + /** Resolves once everything queued so far has run. */ + async drain(): Promise { + await this.run(async () => {}); + } +} diff --git a/electron/git/classifyError.ts b/electron/git/classifyError.ts new file mode 100644 index 00000000..e470f93f --- /dev/null +++ b/electron/git/classifyError.ts @@ -0,0 +1,50 @@ +/* + Sorts a failed network call by what NENO should do about it. Only the + "auth" kind is the user's problem; "offline" is the normal state of a + LAN-only remote seen from anywhere else, and is never an error. +*/ + +import { GitError } from "./runGit"; + +export type NetworkFailure = "offline" | "auth" | "rejected" | "other"; + +const AUTH = [ + /permission denied/i, + /authentication failed/i, + /host key verification failed/i, + /could not read (username|password)/i, + /terminal prompts disabled/i, + /invalid username or password/i, +]; + +const REJECTED = [ + /\[rejected\]/, + /non-fast-forward/, + /\(fetch first\)/, + /updates were rejected/i, +]; + +const OFFLINE = [ + /could not resolve (host|hostname)/i, + /connection timed out/i, + /operation timed out/i, + /connection refused/i, + /no route to host/i, + /network is unreachable/i, + /network is down/i, + /connection (closed|reset)/i, + /broken pipe/i, + /timeout, server .* not responding/i, + /unable to access/i, + /the remote end hung up unexpectedly/i, +]; + +export default function classifyError(error: unknown): NetworkFailure { + if (!(error instanceof GitError)) return "other"; + if (error.killed) return "offline"; + const text = error.stderr; + if (AUTH.some((pattern) => pattern.test(text))) return "auth"; + if (REJECTED.some((pattern) => pattern.test(text))) return "rejected"; + if (OFFLINE.some((pattern) => pattern.test(text))) return "offline"; + return "other"; +} diff --git a/electron/git/rebase.ts b/electron/git/rebase.ts new file mode 100644 index 00000000..76ed63ba --- /dev/null +++ b/electron/git/rebase.ts @@ -0,0 +1,227 @@ +/* + Replays the local commits onto what was fetched. Runs under the + worktree lock, and never leaves the repository mid-rebase: anything + it cannot finish is aborted. +*/ + +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import GitRepo, { splitNul } from "./GitRepo"; +import { GitError } from "./runGit"; +import { isBinary } from "../../src/lib/git/diff"; +import { GitAuthor } from "../../src/lib/git/types"; + +export async function isRebaseInProgress(repo: GitRepo): Promise { + for (const marker of ["rebase-merge", "rebase-apply"]) { + try { + await fs.stat(path.join(repo.dir, ".git", marker)); + return true; + } catch { + // Not this one. + } + } + return false; +} + +function committerEnv(author: GitAuthor): Record { + return { + GIT_COMMITTER_NAME: author.name, + GIT_COMMITTER_EMAIL: author.email, + }; +} + +async function conflictedPaths(repo: GitRepo): Promise { + return splitNul( + await repo.git(["diff", "--name-only", "--diff-filter=U", "-z"]), + ); +} + +/* + Runs one rebase command. True when the rebase is finished, false when + it stopped at conflicts; anything else is thrown. +*/ +async function step( + repo: GitRepo, + args: string[], + env: Record, +): Promise { + try { + await repo.git(args, { env }); + return !await isRebaseInProgress(repo); + } catch (error) { + if ( + error instanceof GitError + && !error.killed + && await isRebaseInProgress(repo) + && (await conflictedPaths(repo)).length > 0 + ) { + return false; + } + throw error; + } +} + +/** The index stages of a conflicted path: 1 base, 2 upstream, 3 local. */ +async function readStages( + repo: GitRepo, + file: string, +): Promise> { + const output = await repo.git(["ls-files", "--unmerged", "-z", "--", file]); + const stages = new Map(); + for (const entry of output.split("\0")) { + // " \t" + const match = /^\d+ ([0-9a-f]+) ([123])\t/.exec(entry); + if (match) stages.set(Number(match[2]), match[1]); + } + return stages; +} + +async function writeWorktreeFile( + repo: GitRepo, + file: string, + bytes: Uint8Array, +): Promise { + const target = path.join(repo.dir, file); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, bytes); +} + +/* + A three-way merge of text that settles every overlapping hunk for one + side and keeps everything that does not overlap from both. +*/ +async function mergeFavoring( + repo: GitRepo, + upstream: Uint8Array, + base: Uint8Array, + local: Uint8Array, + favor: "upstream" | "local", +): Promise { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "neno-merge-")); + try { + const files = ["upstream", "base", "local"].map((n) => path.join(tmp, n)); + await fs.writeFile(files[0], upstream); + await fs.writeFile(files[1], base); + await fs.writeFile(files[2], local); + return await repo.gitBytes( + [ + "merge-file", "--stdout", + favor === "upstream" ? "--ours" : "--theirs", + ...files, + ], + // The exit code counts conflicts; with a side favored there are none. + { okCodes: Array.from({ length: 128 }, (_, i) => i) }, + ); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } +} + +async function authorTime( + repo: GitRepo, + rev: string, + file?: string, +): Promise { + const output = await repo.git( + ["log", "-1", "--format=%at", rev, ...file ? ["--", file] : []], + ); + return Number(output.trim() || 0); +} + +/* + Settles every conflict of the commit being replayed, last writer wins: + per file, the side whose latest commit touching it is newer. An edit + beats a deletion whatever the times. Returns whether the local side + lost anything. +*/ +async function resolveConflicts(repo: GitRepo): Promise { + const localTime = await authorTime(repo, "REBASE_HEAD"); + let localLost = false; + + for (const file of await conflictedPaths(repo)) { + const stages = await readStages(repo, file); + const upstreamOid = stages.get(2); + const localOid = stages.get(3); + const blob = (oid: string) => repo.gitBytes(["cat-file", "blob", oid]); + + if (upstreamOid && localOid) { + const upstream = await blob(upstreamOid); + const local = await blob(localOid); + const baseOid = stages.get(1); + const base = baseOid ? await blob(baseOid) : Buffer.alloc(0); + const localWins = localTime >= await authorTime(repo, "HEAD", file); + + if (isBinary(upstream) || isBinary(local) || isBinary(base)) { + await writeWorktreeFile(repo, file, localWins ? local : upstream); + } else { + await writeWorktreeFile( + repo, + file, + await mergeFavoring( + repo, upstream, base, local, localWins ? "local" : "upstream", + ), + ); + } + localLost ||= !localWins; + } else if (upstreamOid || localOid) { + // Deleted on one side, edited on the other: the edit stays. + await writeWorktreeFile( + repo, + file, + await blob((upstreamOid ?? localOid) as string), + ); + } + await repo.git(["add", "--", file]); + } + + return localLost; +} + +export async function rebaseOnto( + repo: GitRepo, + upstream: string, + author: GitAuthor, +): Promise { + const env = committerEnv(author); + try { + let finished = await step( + repo, + [ + "rebase", "--quiet", "--no-autostash", + "--strategy-option=no-renames", + upstream, + ], + env, + ); + + while (!finished) { + const replayed = await repo.revParse("REBASE_HEAD"); + if (await resolveConflicts(repo) && replayed) { + /* + The rebase drops the local commit from the branch, and the + reflog only keeps it until gc. A ref keeps it for good. + */ + await repo.git([ + "update-ref", + `refs/neno/overwritten/${Date.now()}-${replayed.slice(0, 12)}`, + replayed, + ]); + } + // A commit the resolution emptied has nothing left to replay. + const empty = await repo.succeeds( + ["diff", "--cached", "--quiet", "HEAD"], + ); + finished = await step( + repo, + ["rebase", empty ? "--skip" : "--continue"], + env, + ); + } + } catch (error) { + if (await isRebaseInProgress(repo)) { + await repo.git(["rebase", "--abort"]); + } + throw error; + } +} diff --git a/electron/git/runGit.ts b/electron/git/runGit.ts new file mode 100644 index 00000000..6ce5570d --- /dev/null +++ b/electron/git/runGit.ts @@ -0,0 +1,253 @@ +/* + Runs the system git for one graph folder. + + NENO is a GUI app: there is no terminal for git or ssh to prompt on, + and a prompt nobody answers is a process that never exits. So every + call is non-interactive — a passphrase or password that is not already + in the agent, the keychain or a credential helper makes the call fail + at once instead of hanging — and has a time limit after which it is + killed. +*/ + +import { spawn } from "node:child_process"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +export interface GitRunOptions { + /** Written to the process's stdin. */ + input?: string | Uint8Array; + timeoutMs?: number; + signal?: AbortSignal; + /** Exit codes that count as success. Defaults to [0]. */ + okCodes?: number[]; + env?: Record; +} + +export interface GitResult { + code: number; + stdout: Buffer; + stderr: string; +} + +export type GitRunner = ( + args: string[], + options?: GitRunOptions, +) => Promise; + +export class GitError extends Error { + readonly args: string[]; + readonly exitCode: number | null; + readonly stderr: string; + /** Killed for running over its time limit or by an abort. */ + readonly killed: boolean; + + constructor( + args: string[], + exitCode: number | null, + stderr: string, + killed: boolean, + ) { + const summary = stderr.trim().split("\n").slice(-3).join("\n"); + super( + killed + ? `git ${args[0]} was stopped before it finished` + : `git ${args[0]} failed (${exitCode}): ${summary}`, + ); + this.args = args; + this.exitCode = exitCode; + this.stderr = stderr; + this.killed = killed; + } +} + +const DEFAULT_TIMEOUT_MS = 60_000; + +/* + ConnectTimeout bounds an unreachable host (a LAN-only remote seen from + a hotel); the keep-alives bound a connection that drops mid-transfer. +*/ +const SSH_COMMAND = [ + "ssh", + "-o BatchMode=yes", + "-o ConnectTimeout=5", + "-o ServerAliveInterval=5", + "-o ServerAliveCountMax=3", +].join(" "); + +/* + `-c` settings that hold for every call. NENO's commits never ran hooks + or signed anything under isomorphic-git, and an automatic commit per + save must not start doing either. +*/ +const BASE_CONFIG = [ + "core.quotePath=false", + "core.precomposeUnicode=true", + "commit.gpgSign=false", + "color.ui=false", + "advice.detachedHead=false", + /* + The HTTPS counterpart of ssh's ConnectTimeout: give up on a transfer + that moves less than 1 KB/s for 20 s, an unreachable host included. + */ + "http.lowSpeedLimit=1000", + "http.lowSpeedTime=20", +]; + +function buildEnv( + dir: string, + extra: Record | undefined, +): NodeJS.ProcessEnv { + return { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + GIT_EDITOR: "true", + GIT_SEQUENCE_EDITOR: "true", + GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? SSH_COMMAND, + // Slugs are names, not globs. + GIT_LITERAL_PATHSPECS: "1", + /* + A graph folder inside some other repository must not become part + of it: without a .git of its own, git would walk up and find the + outer one. + */ + GIT_CEILING_DIRECTORIES: path.dirname(dir), + // Read-only commands must not compete for index.lock. + GIT_OPTIONAL_LOCKS: "0", + // Error messages are matched on, so they must not be translated. + LC_ALL: "C", + LANG: "C", + ...extra, + }; +} + +export function createGitRunner(options: { + gitPath: string; + dir: string; + extraConfig?: string[]; +}): GitRunner { + const configArgs = [...BASE_CONFIG, ...(options.extraConfig ?? [])] + .flatMap((setting) => ["-c", setting]); + + return (args, runOptions = {}) => new Promise((resolve, reject) => { + if (runOptions.signal?.aborted) { + reject(new GitError(args, null, "", true)); + return; + } + + /* + Its own process group, so that a kill reaches the transport too: + ssh outlives a killed git and keeps the pipes open, and the call + would not end until ssh gave up on its own. + */ + const child = spawn(options.gitPath, [...configArgs, ...args], { + cwd: options.dir, + env: buildEnv(options.dir, runOptions.env), + stdio: ["pipe", "pipe", "pipe"], + detached: true, + }); + + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let killed = false; + + const signalGroup = (signal: NodeJS.Signals) => { + try { + if (child.pid !== undefined) process.kill(-child.pid, signal); + } catch { + // Already gone. + } + }; + const kill = () => { + killed = true; + signalGroup("SIGTERM"); + setTimeout(() => signalGroup("SIGKILL"), 2000).unref(); + }; + + const timer = setTimeout( + kill, + runOptions.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ); + runOptions.signal?.addEventListener("abort", kill, { once: true }); + + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + // A child that exits before reading its stdin is not an error here. + child.stdin.on("error", () => {}); + + child.on("error", (error) => { + clearTimeout(timer); + runOptions.signal?.removeEventListener("abort", kill); + reject(error); + }); + + child.on("close", (code) => { + clearTimeout(timer); + runOptions.signal?.removeEventListener("abort", kill); + const stderrText = Buffer.concat(stderr).toString("utf8"); + const okCodes = runOptions.okCodes ?? [0]; + if (killed || code === null || !okCodes.includes(code)) { + reject(new GitError(args, code, stderrText, killed)); + return; + } + resolve({ code, stdout: Buffer.concat(stdout), stderr: stderrText }); + }); + + if (runOptions.input !== undefined) { + child.stdin.end(runOptions.input); + } else { + child.stdin.end(); + } + }); +} + +async function isExecutable(file: string): Promise { + try { + await fs.access(file, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function succeeds(command: string, args: string[]): Promise { + return new Promise((resolve) => { + const child = spawn(command, args, { stdio: "ignore" }); + child.on("error", () => resolve(false)); + child.on("close", (code) => resolve(code === 0)); + }); +} + +/* + On macOS /usr/bin/git is a stub until the Command Line Tools are + installed, and running the stub opens Apple's install dialog — so it + only counts once xcode-select reports a developer directory. +*/ +async function locateGit(): Promise { + const candidates = [ + "/opt/homebrew/bin/git", + "/usr/local/bin/git", + "/usr/bin/git", + ]; + for (const candidate of candidates) { + if (!await isExecutable(candidate)) continue; + if ( + candidate === "/usr/bin/git" + && process.platform === "darwin" + && !await succeeds("/usr/bin/xcode-select", ["-p"]) + ) { + continue; + } + if (await succeeds(candidate, ["--version"])) { + return candidate; + } + } + return null; +} + +let located: Promise | null = null; + +/** Absolute path of a usable git binary, or null if there is none. */ +export function findGit(): Promise { + located ??= locateGit(); + return located; +} diff --git a/electron/git/test/testRepos.ts b/electron/git/test/testRepos.ts new file mode 100644 index 00000000..67de06e8 --- /dev/null +++ b/electron/git/test/testRepos.ts @@ -0,0 +1,63 @@ +/* + Throwaway repositories for the git specs: real directories, driven by + the real git CLI, so what the specs check is what a user's terminal + would see. +*/ + +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +export const GIT = "/usr/bin/git"; + +export const AUTHOR = { name: "Test", email: "test@example.com" }; + +export async function makeTempDir(prefix: string): Promise { + // realpath: macOS hands out /var/…, which is a symlink to /private/var/…. + return fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), `neno-${prefix}-`)), + ); +} + +/** Runs git in `cwd` and returns its trimmed stdout. */ +export function git(cwd: string, ...args: string[]): string { + return execFileSync(GIT, args, { + cwd, + encoding: "utf8", + env: { + ...process.env, + GIT_AUTHOR_NAME: "CLI", + GIT_AUTHOR_EMAIL: "cli@example.com", + GIT_COMMITTER_NAME: "CLI", + GIT_COMMITTER_EMAIL: "cli@example.com", + GIT_CONFIG_NOSYSTEM: "1", + }, + }).trim(); +} + +export async function writeFile( + dir: string, + relPath: string, + content: string | Uint8Array, +): Promise { + const file = path.join(dir, relPath); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, content); +} + +export async function readFile( + dir: string, + relPath: string, +): Promise { + return fs.readFile(path.join(dir, relPath), "utf8"); +} + +export async function exists(dir: string, relPath: string): Promise { + try { + await fs.stat(path.join(dir, relPath)); + return true; + } catch { + return false; + } +} diff --git a/electron/main.ts b/electron/main.ts index 9dd41343..36a45626 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -396,8 +396,27 @@ app.whenReady().then(async () => { }); }); +/* + Quitting must not wait for the network: disposing the bridges kills + any fetch or push. It does wait for a commit or rebase that has + already started — milliseconds — so the repository is never left in + the middle of one. +*/ +let quitting = false; +app.on("will-quit", (event) => { + if (quitting) return; + quitting = true; + event.preventDefault(); + /* + exit(), not quit(): a second quit() from a prevented will-quit never + finishes. The windows are closed by now and the bridges disposed, so + there is nothing left for the quit sequence to do. + */ + void disposeStorageBridges().finally(() => app.exit(0)); +}); + app.on("window-all-closed", () => { - disposeStorageBridges(); + void disposeStorageBridges(); /* macOS convention: the app stays in the dock with no window open, and "activate" (above) opens a fresh one. diff --git a/electron/preload.ts b/electron/preload.ts index e050813c..4381b5b3 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -20,6 +20,8 @@ const STORAGE_PORT_MESSAGE = "neno:storage-port"; const FIND_COMMAND_MESSAGE = "neno:find-command"; // Likewise HISTORY_COMMAND_MESSAGE. const HISTORY_COMMAND_MESSAGE = "neno:history-command"; +// Likewise SYNC_STATUS_MESSAGE. +const SYNC_STATUS_MESSAGE = "neno:sync-status"; type FindCommand = "open" | "next" | "previous"; type HistoryCommand = "back" | "forward"; @@ -86,6 +88,22 @@ const neno = { listener: (command: HistoryCommand) => void, ): () => void => subscribe(HISTORY_COMMAND_MESSAGE, listener), + + /* + The status is structured-cloned across the bridge; its type lives in + src/lib/git/types.ts, which this file must not import. + */ + getSyncStatus: (): Promise => + ipcRenderer.invoke("git:getSyncStatus"), + + setAutoSync: (enabled: boolean): Promise => + ipcRenderer.invoke("git:setAutoSync", enabled), + + syncNow: (): Promise => + ipcRenderer.invoke("git:syncNow"), + + onSyncStatus: (listener: (status: unknown) => void): () => void => + subscribe(SYNC_STATUS_MESSAGE, listener), }; contextBridge.exposeInMainWorld("neno", neno); diff --git a/electron/storage/bridge.ts b/electron/storage/bridge.ts index d5aab907..77e05493 100644 --- a/electron/storage/bridge.ts +++ b/electron/storage/bridge.ts @@ -5,22 +5,43 @@ renderer, which forwards it into the notes worker. Method names are namespaced: "storage.…" hits the StorageProvider, - "git.…" hits the isomorphic-git file system. Streams are chunked - because a ReadableStream cannot be transferred across the - main-process boundary. + "git.…" the folder's repository. Streams are chunked because a + ReadableStream cannot be transferred across the main-process boundary. + + Every write to the folder takes the repository's worktree lock, which + a pull's rebase holds while it rewrites the working tree. */ -import { ipcMain, MessageChannelMain, MessagePortMain } from "electron"; -import type { WebContents } from "electron"; +import { + BrowserWindow, + ipcMain, + MessageChannelMain, + MessagePortMain, +} from "electron"; +import type { IpcMainInvokeEvent, WebContents } from "electron"; import NodeFsStorageProvider from "./NodeFsStorageProvider"; -import NodeFsGit, { isFsStat, serializeStat } from "./nodeFsGit"; +import GitRepo from "../git/GitRepo"; +import GitSync from "../git/GitSync"; +import { findGit } from "../git/runGit"; import { + hasUnsavedChanges, + onUnsavedChangesCleared, +} from "../unsavedChanges"; +import { + BridgeEvent, BridgeRequest, BridgeResponse, OpenReadResult, OpenWriteResult, STORAGE_PORT_MESSAGE, + SYNC_STATUS_MESSAGE, } from "../../src/lib/electron/bridgeTypes"; +import { + CommitHistoryOptions, + FlushChanges, + GitAuthor, + SyncStatus, +} from "../../src/lib/git/types"; type ReadSession = { reader: ReadableStreamDefaultReader; @@ -30,8 +51,20 @@ type WriteSession = { writer: WritableStreamDefaultWriter; /** Resolves with the number of bytes that landed on disk. */ done: Promise; + /** Gives the worktree lock back; a streamed write holds it throughout. */ + release: () => void; }; +/* StorageProvider methods that change the folder. */ +const WRITING_METHODS = new Set([ + "writeObject", + "removeObject", + "renameObject", +]); + +const GIT_MISSING_MESSAGE = "Git is not installed. Install Apple's " + + "Command Line Tools (xcode-select --install) and restart NENO."; + type ByteRangeArg = { start: number; end: number } | undefined; function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { @@ -43,17 +76,46 @@ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { class StorageBridge { + #folderPath: string; #storage: NodeFsStorageProvider; - #git: NodeFsGit; + /* Null when no usable git is installed. */ + #sync: Promise; #readSessions = new Map(); #writeSessions = new Map(); #nextStreamId = 1; #streamMethods: Record Promise>; + #gitMethods: Record Promise>; #port: MessagePortMain | null = null; + #sender: WebContents; + #cleanups: (() => void)[] = []; + #disposal: Promise | null = null; - constructor(folderPath: string) { + constructor(folderPath: string, sender: WebContents) { + this.#folderPath = folderPath; + this.#sender = sender; this.#storage = new NodeFsStorageProvider(folderPath); - this.#git = new NodeFsGit(folderPath); + this.#sync = this.#createSync(); + // Replaces whatever the renderer still shows for the previous folder. + void this.#sync.then((sync) => { + if (!sender.isDestroyed()) { + sender.send(SYNC_STATUS_MESSAGE, sync?.getStatus() ?? null); + } + }, () => {}); + this.#gitMethods = { + hasRepo: () => this.#hasRepo(), + setAuthor: async (args) => { + (await this.#sync)?.setAuthor(args[0] as GitAuthor); + }, + init: (args) => this.#initRepo(args[0] as GitAuthor), + commit: (args) => this.#commit( + args[0] as FlushChanges, + args[1] as GitAuthor, + ), + log: async (args) => + (await this.#requireSync()).repo.log(args[0] as CommitHistoryOptions), + diff: async (args) => + (await this.#requireSync()).repo.diff(args[0] as string), + }; this.#streamMethods = { openRead: (args) => this.#openRead(args[0] as string, args[1] as ByteRangeArg), @@ -67,6 +129,89 @@ class StorageBridge { }; } + async #createSync(): Promise { + const gitPath = await findGit(); + if (!gitPath) return null; + + const sender = this.#sender; + const window = BrowserWindow.fromWebContents(sender); + const sync = new GitSync({ + repo: new GitRepo({ dir: this.#folderPath, gitPath }), + onStatus: (status) => { + if (!sender.isDestroyed()) { + sender.send(SYNC_STATUS_MESSAGE, status); + } + }, + onPathsChanged: (paths) => { + const event: BridgeEvent = { event: "pathsChanged", paths }; + this.#port?.postMessage(event); + }, + isEditorDirty: () => window !== null && hasUnsavedChanges(window.id), + }); + + if (window) { + const onFocus = () => sync.windowFocused(); + const onBlur = () => sync.windowBlurred(); + window.on("focus", onFocus); + window.on("blur", onBlur); + this.#cleanups.push(() => { + window.off("focus", onFocus); + window.off("blur", onBlur); + }); + } + this.#cleanups.push(onUnsavedChangesCleared((windowId) => { + if (windowId === window?.id) sync.editorBecameClean(); + })); + + await sync.start(); + if (window?.isFocused()) sync.windowFocused(); + return sync; + } + + async #requireSync(): Promise { + const sync = await this.#sync; + if (!sync) throw new Error(GIT_MISSING_MESSAGE); + return sync; + } + + /** Runs a write to the folder under the worktree lock. */ + async #locked(fn: () => Promise): Promise { + const sync = await this.#sync; + return sync ? sync.withWorktreeLock(fn) : fn(); + } + + async #hasRepo(): Promise { + const sync = await this.#sync; + return sync !== null && await sync.repo.exists(); + } + + async #initRepo(author: GitAuthor): Promise { + const sync = await this.#requireSync(); + await sync.withWorktreeLock(() => sync.repo.init(author)); + } + + async #commit( + changes: FlushChanges, + author: GitAuthor, + ): Promise { + const sync = await this.#requireSync(); + return sync.commit(changes, author); + } + + async getSyncStatus(): Promise { + const sync = await this.#sync; + return sync ? sync.getStatus() : null; + } + + async setAutoSync(enabled: boolean): Promise { + return (await this.#requireSync()).setAutoSync(enabled); + } + + async syncNow(): Promise { + const sync = await this.#sync; + await sync?.syncNow(); + } + async #openRead( requestPath: string, range: ByteRangeArg, @@ -98,7 +243,26 @@ class StorageBridge { }); } + /* + Takes the worktree lock and hands back the function that releases + it, for a write that spans several bridge calls. + */ + async #holdLock(): Promise<() => void> { + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + await new Promise((acquired) => { + void this.#locked(async () => { + acquired(); + await released; + }); + }); + return release; + } + async #openWrite(requestPath: string): Promise { + const release = await this.#holdLock(); const streamId = this.#nextStreamId++; /* writeObjectFromReadable consumes a ReadableStream, so we hand it @@ -118,6 +282,7 @@ class StorageBridge { this.#writeSessions.set(streamId, { writer: writable.getWriter(), done, + release, }); return { streamId }; } @@ -133,8 +298,12 @@ class StorageBridge { const session = this.#writeSessions.get(streamId); if (!session) throw new Error("Unknown write stream: " + streamId); this.#writeSessions.delete(streamId); - await session.writer.close(); - return await session.done; + try { + await session.writer.close(); + return await session.done; + } finally { + session.release(); + } } async #abortWrite(streamId: number): Promise { @@ -147,6 +316,7 @@ class StorageBridge { await session.done.catch(() => { // Expected after an abort. }); + session.release(); } #callOn( @@ -168,21 +338,20 @@ class StorageBridge { if (streamMethod) { return await streamMethod(args); } + if (WRITING_METHODS.has(name)) { + return await this.#locked( + () => this.#callOn(this.#storage, name, args), + ); + } return await this.#callOn(this.#storage, name, args); } if (method.startsWith("git.")) { - const name = method.slice("git.".length); - const result = await this.#callOn(this.#git, name, args); - // readFile hands back bytes; the proxy expects an ArrayBuffer. - if (result instanceof Uint8Array) { - return toArrayBuffer(result); - } - // A stat's predicates cannot survive a structured clone. - if (isFsStat(result)) { - return serializeStat(result); + const gitMethod = this.#gitMethods[method.slice("git.".length)]; + if (!gitMethod) { + throw new Error("Unknown bridge method: " + method); } - return result; + return await gitMethod(args); } throw new Error("Unknown bridge method: " + method); @@ -192,15 +361,26 @@ class StorageBridge { this.#port = port; } - dispose(): void { - for (const streamId of [...this.#readSessions.keys()]) { - void this.#closeRead(streamId); - } - for (const streamId of [...this.#writeSessions.keys()]) { - void this.#abortWrite(streamId); - } - this.#port?.close(); - this.#port = null; + /* + Kills any fetch or push, and resolves once a local step that had + already started — a commit, a rebase — is over, so the repository is + never left halfway through one. + */ + dispose(): Promise { + this.#disposal ??= (async () => { + for (const cleanup of this.#cleanups) cleanup(); + for (const streamId of [...this.#readSessions.keys()]) { + void this.#closeRead(streamId); + } + for (const streamId of [...this.#writeSessions.keys()]) { + void this.#abortWrite(streamId); + } + this.#port?.close(); + this.#port = null; + const sync = await this.#sync.catch(() => null); + await sync?.dispose(); + })(); + return this.#disposal; } } @@ -235,9 +415,34 @@ function attachDispatch(port: MessagePortMain, bridge: StorageBridge): void { const bridgesByWebContents = new Map(); +const disposals = new Set>(); + +function dispose(bridge: StorageBridge): void { + const disposal = bridge.dispose(); + disposals.add(disposal); + void disposal.finally(() => disposals.delete(disposal)); +} + +function getBridgeFor(event: IpcMainInvokeEvent): StorageBridge | null { + return bridgesByWebContents.get(event.sender.id) ?? null; +} export function registerStorageBridge(): void { + ipcMain.handle("git:getSyncStatus", async (event) => { + return await getBridgeFor(event)?.getSyncStatus() ?? null; + }); + + ipcMain.handle("git:setAutoSync", async (event, enabled: unknown) => { + const bridge = getBridgeFor(event); + if (!bridge) throw new Error("No folder is open"); + return bridge.setAutoSync(enabled === true); + }); + + ipcMain.handle("git:syncNow", async (event) => { + await getBridgeFor(event)?.syncNow(); + }); + ipcMain.handle( "storage:connect", async (event, folderPath: unknown): Promise => { @@ -246,12 +451,19 @@ export function registerStorageBridge(): void { } const sender = event.sender as WebContents; + /* + Waits for the old folder's sync to stop: reopening the same folder + must not leave two instances, with two locks, on one repository. + It never waits on the network — dispose() kills fetch and push. + */ const previous = bridgesByWebContents.get(sender.id); if (previous) { - previous.dispose(); + bridgesByWebContents.delete(sender.id); + dispose(previous); + await previous.dispose(); } - const bridge = new StorageBridge(folderPath); + const bridge = new StorageBridge(folderPath, sender); bridgesByWebContents.set(sender.id, bridge); const { port1, port2 } = new MessageChannelMain(); @@ -263,9 +475,11 @@ export function registerStorageBridge(): void { } -export function disposeStorageBridges(): void { +/* Resolves once every bridge, including ones disposed earlier, is done. */ +export async function disposeStorageBridges(): Promise { for (const bridge of bridgesByWebContents.values()) { - bridge.dispose(); + dispose(bridge); } bridgesByWebContents.clear(); + await Promise.all(disposals); } diff --git a/electron/storage/nodeFsGit.git-integration.spec.ts b/electron/storage/nodeFsGit.git-integration.spec.ts deleted file mode 100644 index 3850c6e0..00000000 --- a/electron/storage/nodeFsGit.git-integration.spec.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import git from "isomorphic-git"; -import { execFileSync } from "node:child_process"; -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; -import NodeFsGit from "./nodeFsGit"; -import { - commitChanged, - ensureRepo, - getCommitDiff, - getCommitHistory, - hasExistingRepo, -} from "../../src/lib/notes-worker/git"; - -/* - The same git helpers src/lib/notes-worker/git.spec.ts exercises against - FileSystemAccessFs, but driven through the Node adapter against a real - directory — so a repository written by NENO is a repository the git CLI - would recognize. -*/ - -const AUTHOR = { name: "Test", email: "test@example.com" }; - -describe("git helpers via NodeFsGit", () => { - let root: string; - let gitFs: NodeFsGit; - - beforeEach(async () => { - root = await fs.mkdtemp(path.join(os.tmpdir(), "neno-git-int-")); - gitFs = new NodeFsGit(root); - }); - - afterEach(async () => { - await fs.rm(root, { recursive: true, force: true }); - }); - - it("initializes a fresh repo with an initial commit", async () => { - expect(await hasExistingRepo(gitFs, "/")).toBe(false); - await ensureRepo(gitFs, "/", AUTHOR); - expect(await hasExistingRepo(gitFs, "/")).toBe(true); - - const head = await fs.readFile(path.join(root, ".git", "HEAD"), "utf8"); - expect(head).toContain("refs/heads/main"); - - const commits = await git.log({ fs: gitFs, dir: "/" }); - expect(commits.length).toBe(1); - expect(commits[0].commit.message).toContain("initial"); - expect(commits[0].commit.author.name).toBe("Test"); - }); - - it("commits pre-existing files in the initial commit", async () => { - await fs.writeFile(path.join(root, "existing.subtext"), "already here"); - await ensureRepo(gitFs, "/", AUTHOR); - - const tree = await git.listFiles({ fs: gitFs, dir: "/", ref: "HEAD" }); - expect(tree).toContain("existing.subtext"); - expect(tree).toContain(".gitignore"); - }); - - it("commits a flush and reports it in the history and diff", async () => { - await ensureRepo(gitFs, "/", AUTHOR); - - await fs.writeFile(path.join(root, "my-note.subtext"), "# My note\nbody"); - await commitChanged( - gitFs, - "/", - { - canonicalNoteSlugs: new Set(["my-note"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - const history = await getCommitHistory(gitFs, "/", { - limit: 10, - offset: 0, - }); - expect(history.length).toBe(2); - expect(history[0].message.trim()).toBe("create: my-note"); - expect(history[0].changes).toEqual([ - { path: "my-note.subtext", change: "add" }, - ]); - - const diff = await getCommitDiff(gitFs, "/", history[0].oid); - expect(diff.map((entry) => entry.path)).toEqual(["my-note.subtext"]); - expect(diff[0].lines?.some((line) => line.text.includes("My note"))) - .toBe(true); - }); - - it("records a deletion when the file is gone", async () => { - await fs.writeFile(path.join(root, "doomed.subtext"), "bye"); - await ensureRepo(gitFs, "/", AUTHOR); - - await fs.rm(path.join(root, "doomed.subtext")); - await commitChanged( - gitFs, - "/", - { - canonicalNoteSlugs: new Set(["doomed"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - const history = await getCommitHistory(gitFs, "/", { - limit: 10, - offset: 0, - }); - expect(history[0].message.trim()).toBe("delete: doomed"); - const tree = await git.listFiles({ fs: gitFs, dir: "/", ref: "HEAD" }); - expect(tree).not.toContain("doomed.subtext"); - }); - - it("leaves a clean working tree after a commit", async () => { - await ensureRepo(gitFs, "/", AUTHOR); - await fs.writeFile(path.join(root, "a.subtext"), "a"); - await commitChanged( - gitFs, - "/", - { - canonicalNoteSlugs: new Set(["a"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - const matrix = await git.statusMatrix({ fs: gitFs, dir: "/" }); - const dirty = matrix.filter( - ([, head, workdir, stage]) => head !== workdir || head !== stage, - ); - expect(dirty).toEqual([]); - }); - - /* - A repo the user also works on with command-line git ends up packed by - its automatic gc. isomorphic-git loads a whole packfile to read one - object from it, and without a cache it did that again on every save. - */ - it("does not re-read the packfile on every save", async () => { - const slugs = ["a", "b", "c"]; - for (const slug of slugs) { - await fs.writeFile(path.join(root, `${slug}.subtext`), "v1"); - } - await ensureRepo(gitFs, "/", AUTHOR); - execFileSync("git", ["repack", "-adq"], { cwd: root }); - execFileSync("git", ["prune-packed"], { cwd: root }); - - const save = async (slug: string): Promise => { - await fs.writeFile(path.join(root, `${slug}.subtext`), "v2"); - await commitChanged( - gitFs, - "/", - { - canonicalNoteSlugs: new Set([slug]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - }; - - const readFile = gitFs.readFile.bind(gitFs); - let packReads = 0; - /* - Must be async: isomorphic-git calls readFile() without arguments to - tell a promise fs from a callback fs, and a synchronous throw makes - it wait for a callback that never comes. - */ - gitFs.readFile = async (requestPath, options) => { - if (requestPath.endsWith(".pack")) packReads++; - return readFile(requestPath, options); - }; - - /* - The first save loads the pack, and loads it a second time while - HEAD is still packed: git.commit reads the parent commit with a - fresh cache of its own, whatever cache it is given. - */ - await save("a"); - packReads = 0; - - await save("b"); - await save("c"); - - expect(packReads).toBe(0); - const history = await getCommitHistory(gitFs, "/", { - limit: 10, - offset: 0, - }); - expect(history[0].message.trim()).toBe("update: c"); - }); -}); diff --git a/electron/storage/nodeFsGit.spec.ts b/electron/storage/nodeFsGit.spec.ts deleted file mode 100644 index f3400a34..00000000 --- a/electron/storage/nodeFsGit.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; -import NodeFsGit from "./nodeFsGit"; - -/* - The isomorphic-git file system against a real temporary directory. The - paths isomorphic-git passes are absolute relative to the graph root - (it is initialized with dir "/"), so they have to land inside the - rooted folder and nowhere else. -*/ - -describe("NodeFsGit", () => { - let root: string; - let gitFs: NodeFsGit; - - beforeEach(async () => { - root = await fs.mkdtemp(path.join(os.tmpdir(), "neno-git-test-")); - gitFs = new NodeFsGit(root); - }); - - afterEach(async () => { - await fs.rm(root, { recursive: true, force: true }); - }); - - it("should alias promises to itself, as isomorphic-git expects", () => { - expect(gitFs.promises).toBe(gitFs); - }); - - it("should resolve isomorphic-git's absolute paths inside the root", - async () => { - await gitFs.writeFile("/.git/HEAD", "ref: refs/heads/main\n"); - const onDisk = await fs.readFile( - path.join(root, ".git", "HEAD"), - "utf8", - ); - expect(onDisk).toBe("ref: refs/heads/main\n"); - }, - ); - - it("should create missing parent directories on writeFile", async () => { - await gitFs.writeFile("/.git/refs/heads/main", "abc\n"); - expect(await gitFs.readFile("/.git/refs/heads/main", "utf8")).toBe("abc\n"); - }); - - it("should round-trip binary data", async () => { - const bytes = new Uint8Array([0, 1, 250, 255]); - await gitFs.writeFile("/.git/objects/ab/cdef", bytes); - const back = await gitFs.readFile("/.git/objects/ab/cdef"); - expect(back).toBeInstanceOf(Uint8Array); - expect([...(back as Uint8Array)]).toEqual([0, 1, 250, 255]); - }); - - it("should accept an ArrayBuffer, which is what the proxy sends", - async () => { - const buffer = new Uint8Array([7, 8, 9]).slice().buffer; - await gitFs.writeFile("/.git/objects/x", buffer); - const back = await gitFs.readFile("/.git/objects/x"); - expect([...(back as Uint8Array)]).toEqual([7, 8, 9]); - }, - ); - - it("should reject a missing file with code ENOENT", async () => { - await expect(gitFs.readFile("/.git/HEAD")) - .rejects.toMatchObject({ code: "ENOENT" }); - await expect(gitFs.stat("/.git/HEAD")) - .rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("should stat files and directories", async () => { - await gitFs.writeFile("/a.txt", "12345"); - const fileStat = await gitFs.stat("/a.txt"); - expect(fileStat.type).toBe("file"); - expect(fileStat.size).toBe(5); - - await gitFs.mkdir("/sub"); - expect((await gitFs.stat("/sub")).type).toBe("dir"); - }); - - it("should list a directory in NFC", async () => { - await fs.writeFile( - path.join(root, "über.subtext".normalize("NFD")), - "x", - ); - expect(await gitFs.readdir("/")) - .toEqual(["über.subtext".normalize("NFC")]); - }); - - it("should unlink files and rmdir directories", async () => { - await gitFs.writeFile("/a.txt", "x"); - await gitFs.unlink("/a.txt"); - expect(await gitFs.readdir("/")).toEqual([]); - - await gitFs.mkdir("/sub"); - await gitFs.rmdir("/sub"); - expect(await gitFs.readdir("/")).toEqual([]); - }); - - it("should read and write symlinks", async () => { - await gitFs.writeFile("/target.txt", "x"); - await gitFs.symlink("target.txt", "/link.txt"); - expect(await gitFs.readlink("/link.txt")).toBe("target.txt"); - expect((await gitFs.lstat("/link.txt")).type).toBe("symlink"); - expect((await gitFs.stat("/link.txt")).type).toBe("file"); - }); - - it("should chmod", async () => { - await gitFs.writeFile("/a.sh", "#!/bin/sh\n"); - await gitFs.chmod("/a.sh", 0o755); - expect((await gitFs.stat("/a.sh")).mode & 0o777).toBe(0o755); - }); - - it("should refuse paths that escape the root", async () => { - await expect(gitFs.readFile("/../outside.txt")) - .rejects.toThrow("Invalid path"); - await expect(gitFs.writeFile("/../outside.txt", "x")) - .rejects.toThrow("Invalid path"); - }); -}); diff --git a/electron/storage/nodeFsGit.ts b/electron/storage/nodeFsGit.ts deleted file mode 100644 index 52ad33ab..00000000 --- a/electron/storage/nodeFsGit.ts +++ /dev/null @@ -1,160 +0,0 @@ -/* - nodeFsGit — the subset of the node `fs` module that isomorphic-git - uses, delegated to node:fs/promises and rooted at an absolute path. - Essentially the identity mapping that - src/lib/notes-worker/FileSystemAccessFs.ts had to fake on top of the - File System Access API. - - isomorphic-git probes for a `promises` namespace and uses that - exclusively if present, so the instance carries a `promises` alias - pointing back at itself. -*/ - -import * as fs from "node:fs/promises"; -import type { Stats } from "node:fs"; -import * as path from "node:path"; -import { SerializedStat } from "../../src/lib/electron/bridgeTypes"; -import { - FsStat, - GitFs, - makeStatFromType, -} from "../../src/lib/notes-worker/GitFs"; - -const TEXT_DECODER = new TextDecoder(); - -function toFsStat(stats: Stats): FsStat { - return makeStatFromType({ - type: stats.isSymbolicLink() - ? "symlink" - : stats.isDirectory() - ? "dir" - : "file", - mode: stats.mode, - size: stats.size, - ino: Number(stats.ino), - mtimeMs: stats.mtimeMs, - ctimeMs: stats.ctimeMs, - uid: stats.uid, - gid: stats.gid, - dev: Number(stats.dev), - }); -} - -export function isFsStat(value: unknown): value is FsStat { - return Boolean(value) - && typeof value === "object" - && typeof (value as FsStat).isFile === "function"; -} - -/** - * Drops the predicates, which a structured clone cannot carry. The proxy - * rebuilds them on the far side. - */ -export function serializeStat(stat: FsStat): SerializedStat { - const { - type, mode, size, ino, mtimeMs, ctimeMs, uid, gid, dev, - } = stat; - return { type, mode, size, ino, mtimeMs, ctimeMs, uid, gid, dev }; -} - - -export default class NodeFsGit implements GitFs { - #rootPath: string; - promises: NodeFsGit; - - constructor(rootPath: string) { - this.#rootPath = rootPath; - this.promises = this; - } - - /* - isomorphic-git is initialized with dir "/", so every path it passes - is absolute relative to the graph root. Strip the leading slash and - resolve inside the root, refusing anything that would escape it. - */ - #resolve(requestPath: string): string { - const segments = requestPath - .replace(/\\/g, "/") - .split("/") - .filter((segment) => segment.length > 0 && segment !== "."); - - if (segments.includes("..")) { - throw new Error("Invalid path: " + requestPath); - } - - return path.join(this.#rootPath, ...segments); - } - - async readFile( - requestPath: string, - options?: { encoding?: string } | string, - ): Promise { - const encoding = typeof options === "string" - ? options - : options?.encoding; - const bytes = await fs.readFile(this.#resolve(requestPath)); - if (encoding === "utf8" || encoding === "utf-8") { - return TEXT_DECODER.decode(bytes); - } - return new Uint8Array(bytes); - } - - async writeFile( - requestPath: string, - // Bytes arrive from the proxy as an ArrayBuffer, which node's - // writeFile does not accept. - data: ArrayBuffer | Uint8Array | string, - options?: { encoding?: string; mode?: number } | string, - ): Promise { - const absolutePath = this.#resolve(requestPath); - await fs.mkdir(path.dirname(absolutePath), { recursive: true }); - const mode = typeof options === "object" ? options?.mode : undefined; - const payload = data instanceof ArrayBuffer - ? new Uint8Array(data) - : data; - await fs.writeFile(absolutePath, payload, mode ? { mode } : undefined); - } - - async unlink(requestPath: string): Promise { - await fs.unlink(this.#resolve(requestPath)); - } - - async readdir(requestPath: string): Promise { - const names = await fs.readdir(this.#resolve(requestPath)); - return names.map((name) => name.normalize("NFC")); - } - - async mkdir( - requestPath: string, - options?: { recursive?: boolean; mode?: number }, - ): Promise { - await fs.mkdir(this.#resolve(requestPath), { - recursive: options?.recursive ?? false, - mode: options?.mode, - }); - } - - async rmdir(requestPath: string): Promise { - await fs.rmdir(this.#resolve(requestPath)); - } - - async stat(requestPath: string): Promise { - return toFsStat(await fs.stat(this.#resolve(requestPath))); - } - - async lstat(requestPath: string): Promise { - return toFsStat(await fs.lstat(this.#resolve(requestPath))); - } - - async readlink(requestPath: string): Promise { - return await fs.readlink(this.#resolve(requestPath)); - } - - async symlink(target: string, requestPath: string): Promise { - await fs.symlink(target, this.#resolve(requestPath)); - } - - async chmod(requestPath: string, mode: number): Promise { - await fs.chmod(this.#resolve(requestPath), mode); - } -} diff --git a/electron/tsconfig.json b/electron/tsconfig.json index 30ccf1ac..088f7d69 100644 --- a/electron/tsconfig.json +++ b/electron/tsconfig.json @@ -25,7 +25,7 @@ "include": [ "**/*.ts", "../src/lib/electron/bridgeTypes.ts", - "../src/lib/notes-worker/GitFs.ts", + "../src/lib/git/*.ts", "../src/lib/notes/types/StorageProvider.ts", "../src/lib/notes/types/ByteRange.ts" ] diff --git a/electron/unsavedChanges.ts b/electron/unsavedChanges.ts index c850b54a..0f14ddae 100644 --- a/electron/unsavedChanges.ts +++ b/electron/unsavedChanges.ts @@ -7,6 +7,25 @@ import { BrowserWindow, dialog, ipcMain } from "electron"; const dirtyWindows = new Set(); +const cleanListeners = new Set<(windowId: number) => void>(); + +/* + Read by auto-sync, which must not pull a newer version of a note from + under unsaved edits to it. +*/ +export function hasUnsavedChanges(windowId: number): boolean { + return dirtyWindows.has(windowId); +} + +/** Called whenever a window goes from unsaved changes to none. */ +export function onUnsavedChangesCleared( + listener: (windowId: number) => void, +): () => void { + cleanListeners.add(listener); + return () => { + cleanListeners.delete(listener); + }; +} export function registerUnsavedChangesHandler(window: BrowserWindow): void { let forceClose = false; @@ -46,8 +65,10 @@ export function registerUnsavedChangesIpc(): void { if (!window) return; if (hasChanges) { dirtyWindows.add(window.id); - } else { - dirtyWindows.delete(window.id); + } else if (dirtyWindows.delete(window.id)) { + for (const listener of cleanListeners) { + listener(window.id); + } } }, ); diff --git a/package-lock.json b/package-lock.json index 0eea3f78..910af4aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,7 @@ "dependencies": { "@lexical/link": "0.52.0", "@lexical/react": "0.52.0", - "buffer": "^6.0.3", "idb-keyval": "^6.2.5", - "isomorphic-git": "^1.38.5", "lexical": "0.52.0", "monaco-editor": "^0.55.1", "prismjs": "^1.30.0", @@ -2594,18 +2592,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -2962,12 +2948,6 @@ "node": ">=0.12.0" } }, - "node_modules/async-lock": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", - "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", - "license": "MIT" - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2985,21 +2965,6 @@ "node": ">= 4.0.0" } }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/aws4": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", @@ -3021,6 +2986,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, "funding": [ { "type": "github", @@ -3089,30 +3055,6 @@ "node": ">=8" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3238,28 +3180,11 @@ "@keyv/serialize": "^1.1.1" } }, - "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3269,22 +3194,6 @@ "node": ">= 0.4" } }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3378,12 +3287,6 @@ "node": ">=8" } }, - "node_modules/clean-git-ref": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz", - "integrity": "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==", - "license": "Apache-2.0" - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3558,18 +3461,6 @@ } } }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "license": "Apache-2.0", - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -3681,6 +3572,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -3713,7 +3605,9 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, "license": "MIT", + "optional": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -3773,12 +3667,6 @@ "license": "MIT", "optional": true }, - "node_modules/diff3": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/diff3/-/diff3-0.0.3.tgz", - "integrity": "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==", - "license": "MIT" - }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -3891,6 +3779,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -4211,6 +4100,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4220,6 +4110,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4236,6 +4127,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4487,24 +4379,6 @@ "node": ">=0.10.0" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -4732,21 +4606,6 @@ "dev": true, "license": "ISC" }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -4806,6 +4665,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4838,6 +4698,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4862,6 +4723,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -5020,6 +4882,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5078,7 +4941,9 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, "license": "MIT", + "optional": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -5090,6 +4955,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5102,6 +4968,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -5130,6 +4997,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5259,30 +5127,11 @@ "integrity": "sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q==", "license": "Apache-2.0" }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -5342,6 +5191,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -5358,18 +5208,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5433,27 +5271,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "license": "MIT" - }, "node_modules/isbinaryfile": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", @@ -5474,31 +5291,6 @@ "dev": true, "license": "ISC" }, - "node_modules/isomorphic-git": { - "version": "1.38.5", - "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.38.5.tgz", - "integrity": "sha512-4HBmx1UB+SdRaQh7+zN7lvFQxc4zl7s0oIM/1+5xveu6+QrOeFfx5QDeRNpqCzFHICISEdbK7GilsTsrgE006Q==", - "license": "MIT", - "dependencies": { - "async-lock": "^1.4.1", - "clean-git-ref": "^2.0.1", - "crc-32": "^1.2.0", - "diff3": "0.0.3", - "ignore": "^5.1.4", - "minimisted": "^2.0.0", - "pako": "^1.0.10", - "pify": "^4.0.1", - "readable-stream": "^4.0.0", - "sha.js": "^2.4.12", - "simple-get": "^4.0.1" - }, - "bin": { - "isogit": "cli.cjs" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/isomorphic.js": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", @@ -6121,6 +5913,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6234,6 +6027,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -6262,20 +6056,12 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minimisted": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minimisted/-/minimisted-2.0.1.tgz", - "integrity": "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5" - } - }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -6515,6 +6301,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -6580,12 +6367,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6703,15 +6484,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/pkijs": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", @@ -6790,15 +6562,6 @@ "node": ">=10.4.0" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/postcss": { "version": "8.5.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", @@ -6935,15 +6698,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -7123,22 +6877,6 @@ "read-binary-file-arch": "cli.js" } }, - "node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7384,26 +7122,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/sanitize-filename": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", @@ -7495,43 +7213,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/sha.js": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", - "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", - "license": "(MIT AND BSD-3-Clause)", - "dependencies": { - "inherits": "^2.0.4", - "safe-buffer": "^5.2.1", - "to-buffer": "^1.2.0" - }, - "bin": { - "sha.js": "bin.js" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7575,51 +7256,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -7727,15 +7363,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-width": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", @@ -8223,20 +7850,6 @@ "tmp": "^0.2.0" } }, - "node_modules/to-buffer": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", - "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", - "license": "MIT", - "dependencies": { - "isarray": "^2.0.5", - "safe-buffer": "^5.2.1", - "typed-array-buffer": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8319,20 +7932,6 @@ "node": ">= 0.8.0" } }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -8725,27 +8324,6 @@ "node": ">= 8" } }, - "node_modules/which-typed-array": { - "version": "1.1.22", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", - "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -8833,6 +8411,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { diff --git a/package.json b/package.json index d2b7f52b..186dc62f 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,7 @@ "dependencies": { "@lexical/link": "0.52.0", "@lexical/react": "0.52.0", - "buffer": "^6.0.3", "idb-keyval": "^6.2.5", - "isomorphic-git": "^1.38.5", "lexical": "0.52.0", "monaco-editor": "^0.55.1", "prismjs": "^1.30.0", diff --git a/public/assets/css/navigation-rail.css b/public/assets/css/navigation-rail.css index 82a14774..9af34f3c 100644 --- a/public/assets/css/navigation-rail.css +++ b/public/assets/css/navigation-rail.css @@ -49,3 +49,13 @@ background-color: var(--accent-color-1-light); } } + +/* Sits at the foot of the rail, apart from the views above it. */ +.navigation-rail .sync-status-indicator { + margin-top: auto; + margin-bottom: var(--default-gap); +} + +.navigation-rail .sync-state-error img.svg-icon { + filter: drop-shadow(0 0 0.15rem var(--neno-red-2)); +} diff --git a/src/components/AutoSyncSetting.tsx b/src/components/AutoSyncSetting.tsx new file mode 100644 index 00000000..faa4f94a --- /dev/null +++ b/src/components/AutoSyncSetting.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { l } from "../lib/intl"; +import useSyncStatus from "../hooks/useSyncStatus"; +import { setAutoSync, syncNow } from "../lib/syncStatus"; +import { describeSyncStatus } from "../lib/describeSyncStatus"; + +const AutoSyncSetting = () => { + const status = useSyncStatus(); + const [busy, setBusy] = useState(false); + const [refusal, setRefusal] = useState(null); + + if (!status) { + return
+

{l("sync.heading")}

+

{l("sync.git-missing")}

+
; + } + + const toggle = async (enabled: boolean) => { + setBusy(true); + setRefusal(null); + try { + await setAutoSync(enabled); + } catch (e) { + setRefusal(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const remote = status.remote + ? `${status.remote.name}/${status.remote.branch}` + : null; + + return
+

{l("sync.heading")}

+

{l("sync.description")}

+ { + remote + ?

+ +

+ :

{l("sync.no-remote")}

+ } + {status.enabled &&

+ {describeSyncStatus(status).label} + {status.lastSyncedAt !== null && status.state !== "error" + ? " · " + l("sync.last-synced", { + time: new Date(status.lastSyncedAt).toLocaleString(), + }) + : null} +

} + {status.enabled && status.error &&

+ {status.error.message} +

} + {refusal &&

+ {l("sync.enable-failed", { message: refusal })} +

} + {status.enabled &&

+ +

} +
; +}; + +export default AutoSyncSetting; diff --git a/src/components/NavigationRail.tsx b/src/components/NavigationRail.tsx index 86f86530..fe2c2643 100644 --- a/src/components/NavigationRail.tsx +++ b/src/components/NavigationRail.tsx @@ -11,6 +11,7 @@ import { isInitialized } from "../lib/LocalDataStorage"; import { LOCAL_GRAPH_ID } from "../config"; import useGitEnabled from "../hooks/useGitEnabled"; import { navigateTo } from "../lib/navigation"; +import SyncStatusIndicator from "./SyncStatusIndicator"; interface NavigationRailProps { activeView: string, @@ -28,6 +29,21 @@ const NavigationRail = ({ = useConfirmDiscardingUnsavedChangesDialog(); const gitEnabled = useGitEnabled(); + const openSettings = async () => { + const target = getAppPath( + PathTemplate.SETTINGS, + new Map([["GRAPH_ID", LOCAL_GRAPH_ID]]), + ); + if (pathname === target) return; + + if (unsavedChanges) { + await confirmDiscardingUnsavedChanges(); + setUnsavedChanges(false); + } + + navigateTo(target); + }; + return
{ - const target = getAppPath( - PathTemplate.SETTINGS, - new Map([["GRAPH_ID", LOCAL_GRAPH_ID]]), - ); - if (pathname === target) return; - - if (unsavedChanges) { - await confirmDiscardingUnsavedChanges(); - setUnsavedChanges(false); - } - - navigateTo(target); - }} + onClick={openSettings} /> + {gitEnabled && }
; }; diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 00ae66ce..2c5d14a1 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -3,7 +3,9 @@ import ChangeLanguageSetting from "./ChangeLanguageSetting"; import GitConfigSetting from "./GitConfigSetting"; import EnableGitSetting from "./EnableGitSetting"; import NavigationRail from "./NavigationRail"; +import AutoSyncSetting from "./AutoSyncSetting"; import useGitEnabled from "../hooks/useGitEnabled"; +import { isElectron } from "../lib/electron/bridge"; const SettingsView = () => { @@ -13,7 +15,10 @@ const SettingsView = () => {
- {gitEnabled ? : } + {/* git runs in the desktop app's main process; OPFS mode has none. */} + {isElectron() && !gitEnabled && } + {isElectron() && gitEnabled && } + {isElectron() && gitEnabled && }
; }; diff --git a/src/components/SyncStatusIndicator.tsx b/src/components/SyncStatusIndicator.tsx new file mode 100644 index 00000000..94aad16a --- /dev/null +++ b/src/components/SyncStatusIndicator.tsx @@ -0,0 +1,34 @@ +import IconButton from "./IconButton"; +import useSyncStatus from "../hooks/useSyncStatus"; +import { describeSyncStatus } from "../lib/describeSyncStatus"; + +interface SyncStatusIndicatorProps { + /** Opens where the details are: the auto-sync setting. */ + onClick: () => void, +} + +/* + Where auto-sync stands, at the foot of the navigation rail. Renders + nothing while auto-sync is off. The tooltip is set as HTML, so it only + ever carries our own strings — git's messages are shown, as text, in + the settings. +*/ +const SyncStatusIndicator = ({ onClick }: SyncStatusIndicatorProps) => { + const status = useSyncStatus(); + if (!status?.enabled) return null; + const { icon, label } = describeSyncStatus(status); + + return
+ +
; +}; + +export default SyncStatusIndicator; diff --git a/src/hooks/useSyncStatus.ts b/src/hooks/useSyncStatus.ts new file mode 100644 index 00000000..519f3e08 --- /dev/null +++ b/src/hooks/useSyncStatus.ts @@ -0,0 +1,9 @@ +import { useSyncExternalStore } from "react"; +import { getSyncStatus, subscribeSyncStatus } from "../lib/syncStatus"; +import { SyncStatus } from "../lib/git/types"; + +/** Null outside the desktop app, and when git is not installed. */ +const useSyncStatus = (): SyncStatus | null => + useSyncExternalStore(subscribeSyncStatus, getSyncStatus); + +export default useSyncStatus; diff --git a/src/intl/de-DE.json b/src/intl/de-DE.json index 5f644339..87d302a1 100644 --- a/src/intl/de-DE.json +++ b/src/intl/de-DE.json @@ -198,5 +198,18 @@ "stats.metadata.total-storage-size": "Gesamte Speicherplatzbelegung", "stats.metadata.updated-at": "Letzte Aktualisierung", "stats.number-of-notes": "Notizen", - "stats.unlinked-notes": "Unverbundene Notizen" + "stats.unlinked-notes": "Unverbundene Notizen", + "sync.description": "Jeden Commit sofort an das Remote des Repositorys senden und übernehmen, was andere Rechner gesendet haben. Haben beide Seiten dieselben Zeilen geändert, gewinnt die neuere Änderung; die ältere lässt sich mit Git wiederherstellen.", + "sync.enable": "Automatisch mit {remote} synchronisieren", + "sync.enable-failed": "Auto-Sync konnte nicht aktiviert werden: {message}", + "sync.git-missing": "Git ist nicht installiert. Installiere Apples Command Line Tools (xcode-select --install) und starte NENO neu.", + "sync.heading": "Auto-Sync", + "sync.last-synced": "zuletzt synchronisiert {time}", + "sync.no-remote": "Dieses Repository hat kein Remote. Füge eines in der Kommandozeile hinzu (git remote add origin …), um es zu synchronisieren.", + "sync.state.error": "Synchronisierung braucht Aufmerksamkeit", + "sync.state.offline": "Offline · wartende Commits: {count}", + "sync.state.synced": "Synchronisiert", + "sync.state.syncing": "Synchronisiere …", + "sync.state.waiting": "Wartende Commits: {count}", + "sync.sync-now": "Jetzt synchronisieren" } diff --git a/src/intl/en-US.json b/src/intl/en-US.json index c522d1b5..0d1bd092 100644 --- a/src/intl/en-US.json +++ b/src/intl/en-US.json @@ -198,5 +198,18 @@ "stats.metadata.total-storage-size": "Total storage size", "stats.metadata.updated-at": "Updated at", "stats.number-of-notes": "Number of notes", - "stats.unlinked-notes": "Unlinked notes" + "stats.unlinked-notes": "Unlinked notes", + "sync.description": "Push every commit to the repository's remote as soon as it is made, and bring in what other machines pushed. If both sides changed the same lines, the newer change wins; the older one can still be recovered with git.", + "sync.enable": "Sync automatically with {remote}", + "sync.enable-failed": "Could not switch on auto-sync: {message}", + "sync.git-missing": "Git is not installed. Install Apple's Command Line Tools (xcode-select --install) and restart NENO.", + "sync.heading": "Auto-sync", + "sync.last-synced": "last synced {time}", + "sync.no-remote": "This repository has no remote. Add one on the command line (git remote add origin …) to sync it.", + "sync.state.error": "Sync needs attention", + "sync.state.offline": "Offline · commits waiting: {count}", + "sync.state.synced": "Synced", + "sync.state.syncing": "Syncing…", + "sync.state.waiting": "Commits waiting: {count}", + "sync.sync-now": "Sync now" } diff --git a/src/lib/describeSyncStatus.ts b/src/lib/describeSyncStatus.ts new file mode 100644 index 00000000..9e70d031 --- /dev/null +++ b/src/lib/describeSyncStatus.ts @@ -0,0 +1,26 @@ +import { SyncStatus } from "./git/types"; +import { l } from "./intl"; + +/* + Where sync stands, as an icon and one line. An unreachable remote reads + as a plain fact: it is the normal state of a LAN-only remote on the + road. +*/ +export const describeSyncStatus = ( + status: SyncStatus, +): { icon: string; label: string } => { + const count = String(status.unpushedCommits); + if (status.state === "error") { + return { icon: "warning", label: l("sync.state.error") }; + } + if (status.state === "syncing") { + return { icon: "cached", label: l("sync.state.syncing") }; + } + if (status.state === "offline") { + return { icon: "link_off", label: l("sync.state.offline", { count }) }; + } + if (status.unpushedCommits > 0) { + return { icon: "file_upload", label: l("sync.state.waiting", { count }) }; + } + return { icon: "done", label: l("sync.state.synced") }; +}; diff --git a/src/lib/dialogHelpers.spec.ts b/src/lib/dialogHelpers.spec.ts index 46929ad9..fb8e612c 100644 --- a/src/lib/dialogHelpers.spec.ts +++ b/src/lib/dialogHelpers.spec.ts @@ -37,6 +37,12 @@ const makeBridge = (overrides: Partial = {}) => { setUnsavedChanges: async () => undefined, onFindCommand: () => () => undefined, onHistoryCommand: () => () => undefined, + getSyncStatus: async () => null, + setAutoSync: async () => { + throw new Error("not in this test"); + }, + syncNow: async () => undefined, + onSyncStatus: () => () => undefined, ...overrides, }; diff --git a/src/lib/electron/BridgeClient.ts b/src/lib/electron/BridgeClient.ts index 6afa9422..f87d3a1a 100644 --- a/src/lib/electron/BridgeClient.ts +++ b/src/lib/electron/BridgeClient.ts @@ -7,7 +7,7 @@ crossing the process boundary is serialized regardless. */ -import { BridgeRequest, BridgeResponse } from "./bridgeTypes"; +import { BridgeEvent, BridgeRequest, BridgeResponse } from "./bridgeTypes"; interface PendingCall { resolve: (value: unknown) => void; @@ -18,13 +18,18 @@ export default class BridgeClient { #port: MessagePort; #pendingCalls = new Map(); #nextId = 0; + #eventListeners = new Set<(event: BridgeEvent) => void>(); constructor(port: MessagePort) { this.#port = port; port.addEventListener("message", (event: MessageEvent) => { - const data = event.data as BridgeResponse | undefined; + const data = event.data as BridgeResponse | BridgeEvent | undefined; if (!data || typeof data !== "object") return; + if ("event" in data) { + for (const listener of this.#eventListeners) listener(data); + return; + } const { id, result, error, errorCode } = data; if (typeof id !== "number") return; const pending = this.#pendingCalls.get(id); @@ -33,7 +38,7 @@ export default class BridgeClient { if (typeof error === "string") { const err = new Error(error); if (errorCode) { - // isomorphic-git branches on err.code (ENOENT, EEXIST, …). + // Callers branch on err.code (ENOENT, EEXIST, …). (err as Error & { code?: string }).code = errorCode; } pending.reject(err); @@ -48,6 +53,11 @@ export default class BridgeClient { port.start(); } + /** Messages main sends unprompted. */ + onEvent(listener: (event: BridgeEvent) => void): void { + this.#eventListeners.add(listener); + } + call(method: string, args: unknown[]): Promise { const id = this.#nextId++; const request: BridgeRequest = { id, method, args }; diff --git a/src/lib/electron/GitClient.ts b/src/lib/electron/GitClient.ts new file mode 100644 index 00000000..55167a34 --- /dev/null +++ b/src/lib/electron/GitClient.ts @@ -0,0 +1,47 @@ +/* + The folder's repository, as seen from the notes worker. git itself + runs in the Electron main process (electron/git/); this forwards the + handful of operations the worker needs over the storage bridge. +*/ + +import BridgeClient from "./BridgeClient"; +import { + CommitHistoryOptions, + CommitInfo, + FileDiff, + FlushChanges, + GitAuthor, +} from "../git/types"; + +export default class GitClient { + #client: BridgeClient; + + constructor(client: BridgeClient) { + this.#client = client; + } + + async hasRepo(): Promise { + return await this.#client.call("git.hasRepo", []) as boolean; + } + + /** Who rebases replay local commits as, until the next commit says. */ + async setAuthor(author: GitAuthor): Promise { + await this.#client.call("git.setAuthor", [author]); + } + + async init(author: GitAuthor): Promise { + await this.#client.call("git.init", [author]); + } + + async commit(changes: FlushChanges, author: GitAuthor): Promise { + await this.#client.call("git.commit", [changes, author]); + } + + async log(options: CommitHistoryOptions): Promise { + return await this.#client.call("git.log", [options]) as CommitInfo[]; + } + + async diff(oid: string): Promise { + return await this.#client.call("git.diff", [oid]) as FileDiff[]; + } +} diff --git a/src/lib/electron/GitFsProxy.spec.ts b/src/lib/electron/GitFsProxy.spec.ts deleted file mode 100644 index 827ee680..00000000 --- a/src/lib/electron/GitFsProxy.spec.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import GitFsProxy from "./GitFsProxy"; -import { BridgeRequest, SerializedStat } from "./bridgeTypes"; - -/* - The git file system over the bridge. Two things here are contractual - rather than incidental: isomorphic-git branches on `err.code`, and it - calls the predicates on a stat object — neither survives a plain - structured clone, so both are reconstructed on this side. -*/ - -type Handler = (method: string, args: unknown[]) => unknown; - -const setupHandler = (port: MessagePort, handler: Handler): void => { - port.onmessage = async (event: MessageEvent) => { - const { id, method, args } = event.data as BridgeRequest; - try { - port.postMessage({ id, result: await handler(method, args) }); - } catch (e: unknown) { - port.postMessage({ - id, - error: e instanceof Error ? e.message : String(e), - errorCode: (e as { code?: string })?.code, - }); - } - }; -}; - -const fileStat: SerializedStat = { - type: "file", - mode: 0o100644, - size: 12, - ino: 7, - mtimeMs: 1, - ctimeMs: 1, - uid: 501, - gid: 20, - dev: 1, -}; - - -describe("GitFsProxy over MessagePort", () => { - let proxy: GitFsProxy; - let serverPort: MessagePort; - - beforeEach(() => { - const channel = new MessageChannel(); - serverPort = channel.port1; - proxy = new GitFsProxy(channel.port2); - }); - - it("should alias promises to itself, as isomorphic-git expects", () => { - expect(proxy.promises).toBe(proxy); - }); - - it("should namespace method names with \"git.\"", async () => { - const seen: string[] = []; - setupHandler(serverPort, (method) => { - seen.push(method); - return []; - }); - - await proxy.readdir("/"); - expect(seen).toEqual(["git.readdir"]); - }); - - it("should return raw bytes when no encoding is asked for", async () => { - setupHandler(serverPort, () => - new Uint8Array([104, 105]).slice().buffer, - ); - - const result = await proxy.readFile("/a"); - expect(result).toBeInstanceOf(Uint8Array); - expect([...(result as Uint8Array)]).toEqual([104, 105]); - }); - - it("should decode as UTF-8 when the encoding is utf8", async () => { - setupHandler(serverPort, () => - new TextEncoder().encode("ref: refs/heads/main").slice().buffer, - ); - - expect(await proxy.readFile("/.git/HEAD", "utf8")) - .toBe("ref: refs/heads/main"); - expect(await proxy.readFile("/.git/HEAD", { encoding: "utf8" })) - .toBe("ref: refs/heads/main"); - }); - - it("should send string data through unchanged", async () => { - let received: unknown = "not called"; - setupHandler(serverPort, (_method, args) => { - received = args[1]; - return undefined; - }); - - await proxy.writeFile("/.gitignore", ".DS_Store\n"); - expect(received).toBe(".DS_Store\n"); - }); - - it("should send binary data as an ArrayBuffer copy", async () => { - let received: unknown = "not called"; - setupHandler(serverPort, (_method, args) => { - received = args[1]; - return undefined; - }); - - const source = new Uint8Array([1, 2, 3]); - await proxy.writeFile("/.git/objects/x", source); - - expect(received).toBeInstanceOf(ArrayBuffer); - expect([...new Uint8Array(received as ArrayBuffer)]).toEqual([1, 2, 3]); - // The source must not have been detached by the send. - expect(source.byteLength).toBe(3); - }); - - /* - isomorphic-git calls stats.isFile() / isDirectory(); those methods - cannot cross a structured clone, so the proxy rebuilds them from the - serialized `type`. - */ - it("should rebuild the stat predicates", async () => { - setupHandler(serverPort, (method) => { - if (method === "git.stat") return fileStat; - if (method === "git.lstat") return { ...fileStat, type: "dir" }; - throw new Error("Unknown method: " + method); - }); - - const stats = await proxy.stat("/a"); - expect(stats.isFile()).toBe(true); - expect(stats.isDirectory()).toBe(false); - expect(stats.isSymbolicLink()).toBe(false); - expect(stats.size).toBe(12); - - const lstats = await proxy.lstat("/a"); - expect(lstats.isDirectory()).toBe(true); - expect(lstats.isFile()).toBe(false); - }); - - it("should surface ENOENT as err.code, which isomorphic-git needs", - async () => { - setupHandler(serverPort, () => { - const err = new Error( - "ENOENT: no such file or directory, '/.git/HEAD'", - ) as Error & { code: string }; - err.code = "ENOENT"; - throw err; - }); - - await expect(proxy.stat("/.git/HEAD")).rejects.toMatchObject({ - code: "ENOENT", - }); - }, - ); - - it("should forward mkdir options", async () => { - let received: unknown = "not called"; - setupHandler(serverPort, (_method, args) => { - received = args[1]; - return undefined; - }); - - await proxy.mkdir("/.git/refs", { recursive: true }); - expect(received).toEqual({ recursive: true }); - }); -}); diff --git a/src/lib/electron/GitFsProxy.ts b/src/lib/electron/GitFsProxy.ts deleted file mode 100644 index 02daa53f..00000000 --- a/src/lib/electron/GitFsProxy.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - isomorphic-git file system that forwards to node:fs/promises in the - Electron main process. Shares the storage bridge's MessagePort; - methods are namespaced with "git.". -*/ - -import { FsStat, GitFs, makeStatFromType } from "../notes-worker/GitFs"; -import BridgeClient from "./BridgeClient"; -import { SerializedStat } from "./bridgeTypes"; - -const TEXT_DECODER = new TextDecoder(); - -export default class GitFsProxy implements GitFs { - #client: BridgeClient; - promises: GitFsProxy; - - constructor(portOrClient: MessagePort | BridgeClient) { - this.#client = portOrClient instanceof BridgeClient - ? portOrClient - : new BridgeClient(portOrClient); - this.promises = this; - } - - #call(method: string, args: unknown[] = []): Promise { - return this.#client.call("git." + method, args); - } - - async readFile( - path: string, - options?: { encoding?: string } | string, - ): Promise { - const encoding = typeof options === "string" - ? options - : options?.encoding; - const buffer = await this.#call("readFile", [path]) as ArrayBuffer; - const bytes = new Uint8Array(buffer); - if (encoding === "utf8" || encoding === "utf-8") { - return TEXT_DECODER.decode(bytes); - } - return bytes; - } - - async writeFile( - path: string, - data: Uint8Array | string, - options?: { encoding?: string; mode?: number } | string, - ): Promise { - if (typeof data === "string") { - await this.#call("writeFile", [path, data, options]); - return; - } - // Copy before sending: isomorphic-git reuses its buffers. - const buffer = data.slice().buffer as ArrayBuffer; - await this.#call("writeFile", [path, buffer, options]); - } - - async unlink(path: string): Promise { - await this.#call("unlink", [path]); - } - - async readdir(path: string): Promise { - return await this.#call("readdir", [path]) as string[]; - } - - async mkdir( - path: string, - options?: { recursive?: boolean; mode?: number }, - ): Promise { - await this.#call("mkdir", [path, options]); - } - - async rmdir(path: string): Promise { - await this.#call("rmdir", [path]); - } - - async stat(path: string): Promise { - const stat = await this.#call("stat", [path]) as SerializedStat; - return makeStatFromType(stat); - } - - async lstat(path: string): Promise { - const stat = await this.#call("lstat", [path]) as SerializedStat; - return makeStatFromType(stat); - } - - async readlink(path: string): Promise { - return await this.#call("readlink", [path]) as string; - } - - async symlink(target: string, path: string): Promise { - await this.#call("symlink", [target, path]); - } - - async chmod(path: string, mode: number): Promise { - await this.#call("chmod", [path, mode]); - } -} diff --git a/src/lib/electron/bridgeTypes.ts b/src/lib/electron/bridgeTypes.ts index de52e250..21a1f6f9 100644 --- a/src/lib/electron/bridgeTypes.ts +++ b/src/lib/electron/bridgeTypes.ts @@ -7,10 +7,15 @@ `src/lib/notes-worker/NotesProviderProxy.ts` uses to talk to the notes worker: `{ id, method, args }` in, `{ id, result | error }` out. Method names are namespaced (`storage.…`, `git.…`) because a single - MessagePort carries both the StorageProvider and the isomorphic-git - file system. + MessagePort carries both the StorageProvider and the repository + operations, which run the system git in the main process. + + Main also speaks first on the same port, with `BridgeEvent`s that + carry no id. */ +import { SyncStatus } from "../git/types"; + export type BridgeRequest = { id: number; method: string; @@ -22,10 +27,19 @@ export type BridgeResponse = { result?: unknown; /** Human-readable message; presence of this field marks a failure. */ error?: string; - /** POSIX error code (ENOENT, EEXIST, …). isomorphic-git branches on it. */ + /** POSIX error code (ENOENT, EEXIST, …). */ errorCode?: string; }; +/* + Main → notes worker, unprompted: a pull changed these paths in the + working tree, so the in-memory graph has to re-read them. +*/ +export type BridgeEvent = { + event: "pathsChanged"; + paths: string[]; +}; + /* ReadableStream is transferable between renderer contexts but not across the main-process boundary, so streams are chunked: the reader asks for @@ -103,6 +117,14 @@ export interface NenoBridge { onFindCommand(listener: (command: FindCommand) => void): () => void; /** Subscribes to the Go menu's back/forward items. Returns an unsubscribe. */ onHistoryCommand(listener: (command: HistoryCommand) => void): () => void; + /** Auto-sync of the open folder's repository with its remote. */ + getSyncStatus(): Promise; + /** Rejects with the reason when sync cannot be switched on. */ + setAutoSync(enabled: boolean): Promise; + /** Pull and push now — e.g. because the network just came back. */ + syncNow(): Promise; + /** Null when the open folder has no usable git (or no folder is open). */ + onSyncStatus(listener: (status: SyncStatus | null) => void): () => void; } /** Message the preload uses to hand the storage MessagePort to the page. */ @@ -127,3 +149,6 @@ export type HistoryCommand = "back" | "forward"; /** Main → renderer: a back/forward command from the Go menu. */ export const HISTORY_COMMAND_MESSAGE = "neno:history-command"; + +/** Main → renderer: the open folder's sync status changed. */ +export const SYNC_STATUS_MESSAGE = "neno:sync-status"; diff --git a/src/lib/git/commitMessage.ts b/src/lib/git/commitMessage.ts new file mode 100644 index 00000000..20dca666 --- /dev/null +++ b/src/lib/git/commitMessage.ts @@ -0,0 +1,67 @@ +/* + The message of a per-flush commit: a one-line subject for a single + file, and a summary with one line per path for anything larger. +*/ + +const GRAPH_FILE_EXTENSION = ".subtext"; + +function pathToSlug(path: string): string { + return path.endsWith(GRAPH_FILE_EXTENSION) + ? path.slice(0, -GRAPH_FILE_EXTENSION.length) + : path; +} + +function formatSingle(verb: string, path: string): string { + return path.endsWith(GRAPH_FILE_EXTENSION) + ? `${verb}: ${pathToSlug(path)}` + : `${verb} file: ${path}`; +} + +export default function formatCommitMessage( + creates: string[], + modifies: string[], + removes: string[], +): string { + const total = creates.length + modifies.length + removes.length; + + if (total === 0) { + return "update"; + } + + if (total === 1) { + if (creates.length === 1) return formatSingle("create", creates[0]); + if (modifies.length === 1) return formatSingle("update", modifies[0]); + return formatSingle("delete", removes[0]); + } + + const all = [...creates, ...modifies, ...removes]; + const noteCount = all.filter( + (p) => p.endsWith(GRAPH_FILE_EXTENSION), + ).length; + const fileCount = total - noteCount; + + const header = [ + noteCount > 0 + ? `${noteCount} note${noteCount === 1 ? "" : "s"}` + : null, + fileCount > 0 + ? `${fileCount} file${fileCount === 1 ? "" : "s"}` + : null, + ] + .filter((s): s is string => s !== null) + .join(", "); + + const verb = modifies.length === 0 && removes.length === 0 + ? "create" + : creates.length === 0 && modifies.length === 0 + ? "delete" + : "update"; + + const body = [ + ...creates.map((p) => `+ ${p}`), + ...modifies.map((p) => `~ ${p}`), + ...removes.map((p) => `- ${p}`), + ].join("\n"); + + return `${verb}: ${header}\n\n${body}`; +} diff --git a/src/lib/git/diff.ts b/src/lib/git/diff.ts new file mode 100644 index 00000000..5a5c8eda --- /dev/null +++ b/src/lib/git/diff.ts @@ -0,0 +1,191 @@ +/* + Line diff with word-level emphasis for the history view. Pure: it + takes the two versions of a file and knows nothing about git. +*/ + +import { DiffLine, DiffSegment } from "./types"; + +export const MAX_DIFF_BYTES = 200_000; + +export function isBinary(bytes: Uint8Array): boolean { + const limit = Math.min(bytes.length, 8000); + for (let i = 0; i < limit; i++) { + if (bytes[i] === 0) return true; + } + return false; +} + +export function decode(bytes: Uint8Array): string { + return new TextDecoder("utf-8").decode(bytes); +} + +function tokenize(text: string): string[] { + return text.match(/\w+|\s+|[^\s\w]/gu) ?? []; +} + +function pushSegment( + arr: DiffSegment[], + text: string, + emphasized: boolean, +): void { + const last = arr[arr.length - 1]; + if (last && last.emphasized === emphasized) { + last.text += text; + } else { + arr.push({ text, emphasized }); + } +} + +function diffTokens( + oldText: string, + newText: string, +): { removeSegments: DiffSegment[]; addSegments: DiffSegment[] } { + const a = tokenize(oldText); + const b = tokenize(newText); + const m = a.length; + const n = b.length; + + const lcs: number[][] = Array.from( + { length: m + 1 }, + () => new Array(n + 1).fill(0), + ); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (a[i - 1] === b[j - 1]) { + lcs[i][j] = lcs[i - 1][j - 1] + 1; + } else { + lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]); + } + } + } + + const ops: Array<{ type: "context" | "add" | "remove"; text: string }> = []; + let i = m; + let j = n; + while (i > 0 && j > 0) { + if (a[i - 1] === b[j - 1]) { + ops.unshift({ type: "context", text: a[i - 1] }); + i--; + j--; + } else if (lcs[i - 1][j] >= lcs[i][j - 1]) { + ops.unshift({ type: "remove", text: a[i - 1] }); + i--; + } else { + ops.unshift({ type: "add", text: b[j - 1] }); + j--; + } + } + while (i > 0) { + i--; + ops.unshift({ type: "remove", text: a[i] }); + } + while (j > 0) { + j--; + ops.unshift({ type: "add", text: b[j] }); + } + + const removeSegments: DiffSegment[] = []; + const addSegments: DiffSegment[] = []; + for (const op of ops) { + if (op.type === "context") { + pushSegment(removeSegments, op.text, false); + pushSegment(addSegments, op.text, false); + } else if (op.type === "remove") { + pushSegment(removeSegments, op.text, true); + } else { + pushSegment(addSegments, op.text, true); + } + } + return { removeSegments, addSegments }; +} + +function pairLines(lines: DiffLine[]): DiffLine[] { + const result = [...lines]; + let i = 0; + while (i < result.length) { + if (result[i].type === "context") { + i++; + continue; + } + const runStart = i; + while (i < result.length && result[i].type !== "context") i++; + const runEnd = i; + + const removeIndexes: number[] = []; + const addIndexes: number[] = []; + for (let k = runStart; k < runEnd; k++) { + if (result[k].type === "remove") { + removeIndexes.push(k); + } else { + addIndexes.push(k); + } + } + + const pairCount = Math.min(removeIndexes.length, addIndexes.length); + for (let p = 0; p < pairCount; p++) { + const removeIdx = removeIndexes[p]; + const addIdx = addIndexes[p]; + const { removeSegments, addSegments } = diffTokens( + result[removeIdx].text, + result[addIdx].text, + ); + result[removeIdx] = { ...result[removeIdx], segments: removeSegments }; + result[addIdx] = { ...result[addIdx], segments: addSegments }; + } + } + return result; +} + +function diffLines(oldText: string, newText: string): DiffLine[] { + const a = oldText.length === 0 ? [] : oldText.split("\n"); + const b = newText.length === 0 ? [] : newText.split("\n"); + const m = a.length; + const n = b.length; + + const lcs: number[][] = Array.from( + { length: m + 1 }, + () => new Array(n + 1).fill(0), + ); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (a[i - 1] === b[j - 1]) { + lcs[i][j] = lcs[i - 1][j - 1] + 1; + } else { + lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]); + } + } + } + + const result: DiffLine[] = []; + let i = m; + let j = n; + while (i > 0 && j > 0) { + if (a[i - 1] === b[j - 1]) { + result.unshift({ type: "context", text: a[i - 1] }); + i--; + j--; + } else if (lcs[i - 1][j] >= lcs[i][j - 1]) { + result.unshift({ type: "remove", text: a[i - 1] }); + i--; + } else { + result.unshift({ type: "add", text: b[j - 1] }); + j--; + } + } + while (i > 0) { + i--; + result.unshift({ type: "remove", text: a[i] }); + } + while (j > 0) { + j--; + result.unshift({ type: "add", text: b[j] }); + } + return result; +} + +export function computeDiffLines( + oldText: string, + newText: string, +): DiffLine[] { + return pairLines(diffLines(oldText, newText)); +} diff --git a/src/lib/git/types.ts b/src/lib/git/types.ts new file mode 100644 index 00000000..a155c8c7 --- /dev/null +++ b/src/lib/git/types.ts @@ -0,0 +1,85 @@ +/* + Shapes shared by the main process, which runs git, and the renderer, + which shows its history. Imported by both `electron/` and `src/`. +*/ + +export interface GitAuthor { + name: string; + email: string; +} + +/* + What one flush touched, as the slugs DatabaseIO flushed. Arrays rather + than Sets so the value crosses the storage bridge as plain data. +*/ +export interface FlushChanges { + canonicalNoteSlugs: string[] | "all"; + aliases: string[] | "all"; + arbitraryFiles: string[] | "all"; + flushPins: boolean; +} + +export interface ChangedFile { + path: string; + change: "add" | "modify" | "delete"; +} + +export interface CommitInfo { + oid: string; + message: string; + author: GitAuthor; + /** Seconds since the epoch. */ + timestamp: number; + /** Minutes, signed like Date.prototype.getTimezoneOffset(). */ + timezoneOffset: number; + changes: ChangedFile[]; +} + +export interface CommitHistoryOptions { + limit: number; + offset: number; +} + +export interface DiffSegment { + text: string; + emphasized: boolean; +} + +export interface DiffLine { + type: "context" | "add" | "remove"; + text: string; + segments?: DiffSegment[]; +} + +export interface FileDiff { + path: string; + change: "add" | "modify" | "delete"; + lines?: DiffLine[]; + binary?: boolean; + tooLarge?: boolean; +} + +/* + Problems the user has to fix. Everything else — an unreachable remote + above all — is a state, not an error, and is retried quietly. +*/ +export type SyncErrorKind + = "auth" + | "no-remote" + | "unrelated-histories" + | "other"; + +export interface SyncStatus { + /** A git repository exists in the graph folder. */ + repo: boolean; + /** The remote and branch auto-sync pushes to, if the repo has one. */ + remote: { name: string; branch: string } | null; + /** Switched on for this repo — even if its remote has since gone. */ + enabled: boolean; + state: "idle" | "syncing" | "offline" | "error"; + /** Local commits the remote does not have yet. */ + unpushedCommits: number; + /** Milliseconds since the epoch of the last successful exchange. */ + lastSyncedAt: number | null; + error: { kind: SyncErrorKind; message: string } | null; +} diff --git a/src/lib/notes-worker/FileSystemAccessFs.spec.ts b/src/lib/notes-worker/FileSystemAccessFs.spec.ts deleted file mode 100644 index 79d0d49b..00000000 --- a/src/lib/notes-worker/FileSystemAccessFs.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, it, expect } from "vitest"; -import FakeDirectoryHandle from "./test/FakeDirectoryHandle"; -import FileSystemAccessFs from "./FileSystemAccessFs"; - -const makeFs = () => { - const handle = new FakeDirectoryHandle(); - const fs = new FileSystemAccessFs( - handle as unknown as FileSystemDirectoryHandle, - ); - return { handle, fs }; -}; - -describe("FileSystemAccessFs", () => { - it("round-trips a utf8 string file", async () => { - const { fs } = makeFs(); - await fs.writeFile("/hello.txt", "hello world"); - const result = await fs.readFile("/hello.txt", { encoding: "utf8" }); - expect(result).toBe("hello world"); - }); - - it("round-trips a binary file", async () => { - const { fs } = makeFs(); - const bytes = new Uint8Array([1, 2, 3, 4]); - await fs.writeFile("/blob.bin", bytes); - const read = await fs.readFile("/blob.bin") as Uint8Array; - expect(Array.from(read)).toEqual([1, 2, 3, 4]); - }); - - it("creates nested directories implicitly on writeFile", async () => { - const { fs } = makeFs(); - await fs.writeFile("/.git/objects/ab/cd", "x"); - const result = await fs.readFile("/.git/objects/ab/cd", "utf8"); - expect(result).toBe("x"); - }); - - it("lists readdir entries", async () => { - const { fs } = makeFs(); - await fs.writeFile("/a.txt", "a"); - await fs.writeFile("/b.txt", "b"); - await fs.mkdir("/sub"); - const names = await fs.readdir("/"); - expect(names.sort()).toEqual(["a.txt", "b.txt", "sub"]); - }); - - it("throws ENOENT for missing readFile", async () => { - const { fs } = makeFs(); - await expect(fs.readFile("/nope.txt", "utf8")) - .rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("throws ENOENT for missing readdir", async () => { - const { fs } = makeFs(); - await expect(fs.readdir("/nope")) - .rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("unlink removes files", async () => { - const { fs } = makeFs(); - await fs.writeFile("/x.txt", "x"); - await fs.unlink("/x.txt"); - await expect(fs.readFile("/x.txt", "utf8")) - .rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("mkdir + rmdir work", async () => { - const { fs } = makeFs(); - await fs.mkdir("/a"); - await fs.mkdir("/a/b"); - const stat = await fs.stat("/a/b"); - expect(stat.isDirectory()).toBe(true); - await fs.rmdir("/a/b"); - await expect(fs.stat("/a/b")) - .rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("stat reports file vs dir correctly", async () => { - const { fs } = makeFs(); - await fs.writeFile("/f.txt", "hi"); - await fs.mkdir("/d"); - - const fileStat = await fs.stat("/f.txt"); - expect(fileStat.isFile()).toBe(true); - expect(fileStat.isDirectory()).toBe(false); - expect(fileStat.size).toBe(2); - - const dirStat = await fs.stat("/d"); - expect(dirStat.isDirectory()).toBe(true); - expect(dirStat.isFile()).toBe(false); - }); - - it("lstat matches stat (no symlinks)", async () => { - const { fs } = makeFs(); - await fs.writeFile("/y.txt", "y"); - const s = await fs.lstat("/y.txt"); - expect(s.isFile()).toBe(true); - }); - - it("exposes promises namespace pointing at itself", () => { - const { fs } = makeFs(); - expect(fs.promises).toBe(fs); - }); - - it("handles paths without leading slash", async () => { - const { fs } = makeFs(); - await fs.writeFile("a/b.txt", "ab"); - expect(await fs.readFile("a/b.txt", "utf8")).toBe("ab"); - }); -}); diff --git a/src/lib/notes-worker/FileSystemAccessFs.ts b/src/lib/notes-worker/FileSystemAccessFs.ts deleted file mode 100644 index f3674d30..00000000 --- a/src/lib/notes-worker/FileSystemAccessFs.ts +++ /dev/null @@ -1,318 +0,0 @@ -/* - FileSystemAccessFs — node-fs-compatible adapter on top of a - FileSystemDirectoryHandle. Provides the subset of the node `fs` - module used by isomorphic-git. - - isomorphic-git probes for a `promises` namespace and uses that - exclusively if present, so the top-level default export is itself - the promise API with a `promises` alias pointing back at itself. -*/ - -import { FsStat, GitFs, makeStatFromType } from "./GitFs"; - -export type { FsStat }; - -const TEXT_DECODER = new TextDecoder(); - -const FILE_MODE = 0o100644; -const DIR_MODE = 0o040755; - -type PathSegments = { - segments: string[]; - basename: string; -}; - -type AnyHandle = FileSystemDirectoryHandle | FileSystemFileHandle; - -function normalizePath(path: string): string { - return path.replace(/\\/g, "/"); -} - -function splitPath(path: string): PathSegments { - const normalized = normalizePath(path); - const allSegments = normalized - .split("/") - .filter((s) => s.length > 0 && s !== "."); - const basename = allSegments.length > 0 - ? allSegments[allSegments.length - 1] - : ""; - return { - segments: allSegments, - basename, - }; -} - -function hashPath(path: string): number { - let hash = 0; - for (let i = 0; i < path.length; i++) { - hash = ((hash << 5) - hash + path.charCodeAt(i)) | 0; - } - return Math.abs(hash) || 1; -} - -function isNotFoundError(e: unknown): boolean { - if (e instanceof DOMException) { - return e.name === "NotFoundError" || e.name === "TypeMismatchError"; - } - return false; -} - -function makeEnoent(path: string): Error & { code: string } { - const err = new Error(`ENOENT: no such file or directory, '${path}'`); - (err as Error & { code: string }).code = "ENOENT"; - return err as Error & { code: string }; -} - -function makeEexist(path: string): Error & { code: string } { - const err = new Error(`EEXIST: file already exists, '${path}'`); - (err as Error & { code: string }).code = "EEXIST"; - return err as Error & { code: string }; -} - -async function getDirectoryHandleByPath( - root: FileSystemDirectoryHandle, - segments: string[], - create: boolean, -): Promise { - let handle: FileSystemDirectoryHandle = root; - for (const segment of segments) { - handle = await handle.getDirectoryHandle(segment, { create }); - } - return handle; -} - -async function getParentAndName( - root: FileSystemDirectoryHandle, - path: string, - createParents: boolean, -): Promise<{ parent: FileSystemDirectoryHandle; name: string }> { - const { segments, basename } = splitPath(path); - if (segments.length === 0) { - throw makeEnoent(path); - } - const parentSegments = segments.slice(0, segments.length - 1); - const parent = await getDirectoryHandleByPath( - root, - parentSegments, - createParents, - ); - return { parent, name: basename }; -} - -function makeStat(args: { - type: "file" | "dir" | "symlink"; - size: number; - mtimeMs: number; - path: string; -}): FsStat { - const { type, size, mtimeMs, path } = args; - const mode = type === "dir" ? DIR_MODE : FILE_MODE; - return makeStatFromType({ - type, - mode, - size, - ino: hashPath(path), - mtimeMs, - ctimeMs: mtimeMs, - uid: 1, - gid: 1, - dev: 1, - }); -} - -async function getEntry( - root: FileSystemDirectoryHandle, - path: string, -): Promise { - const { segments } = splitPath(path); - if (segments.length === 0) { - return root; - } - const { parent, name } = await getParentAndName(root, path, false); - // Try as file first, then as directory. - try { - return await parent.getFileHandle(name); - } catch (e) { - if (!isNotFoundError(e) && !(e instanceof TypeError)) { - throw e; - } - } - return await parent.getDirectoryHandle(name); -} - -export default class FileSystemAccessFs implements GitFs { - #root: FileSystemDirectoryHandle; - promises: FileSystemAccessFs; - - constructor(root: FileSystemDirectoryHandle) { - this.#root = root; - this.promises = this; - } - - async readFile( - path: string, - options?: { encoding?: string } | string, - ): Promise { - const encoding = typeof options === "string" - ? options - : options?.encoding; - let fileHandle: FileSystemFileHandle; - try { - const { parent, name } = await getParentAndName( - this.#root, - path, - false, - ); - fileHandle = await parent.getFileHandle(name); - } catch (e) { - if (isNotFoundError(e)) throw makeEnoent(path); - throw e; - } - const file = await fileHandle.getFile(); - const buffer = await file.arrayBuffer(); - const bytes = new Uint8Array(buffer); - if (encoding === "utf8" || encoding === "utf-8") { - return TEXT_DECODER.decode(bytes); - } - return bytes; - } - - async writeFile( - path: string, - data: Uint8Array | string, - _options?: { encoding?: string; mode?: number } | string, - ): Promise { - const { parent, name } = await getParentAndName( - this.#root, - path, - true, - ); - const fileHandle = await parent.getFileHandle(name, { create: true }); - const writable = await fileHandle.createWritable(); - if (typeof data === "string") { - await writable.write(data); - } else { - const exact = data.buffer.slice( - data.byteOffset, - data.byteOffset + data.byteLength, - ) as ArrayBuffer; - await writable.write(exact); - } - await writable.close(); - } - - async unlink(path: string): Promise { - try { - const { parent, name } = await getParentAndName( - this.#root, - path, - false, - ); - await parent.removeEntry(name); - } catch (e) { - if (isNotFoundError(e)) throw makeEnoent(path); - throw e; - } - } - - async readdir(path: string): Promise { - let dir: FileSystemDirectoryHandle; - try { - const { segments } = splitPath(path); - dir = await getDirectoryHandleByPath(this.#root, segments, false); - } catch (e) { - if (isNotFoundError(e)) throw makeEnoent(path); - throw e; - } - const names: string[] = []; - // @ts-ignore — async iterator for FileSystemDirectoryHandle - for await (const entry of dir.values()) { - names.push((entry as AnyHandle).name); - } - return names; - } - - async mkdir( - path: string, - _options?: { recursive?: boolean; mode?: number }, - ): Promise { - const { segments, basename } = splitPath(path); - if (segments.length === 0) return; - const parentSegments = segments.slice(0, segments.length - 1); - const parent = await getDirectoryHandleByPath( - this.#root, - parentSegments, - true, - ); - try { - await parent.getDirectoryHandle(basename, { create: false }); - throw makeEexist(path); - } catch (e) { - if (!isNotFoundError(e)) { - // Attempt to proceed — may already be a file, which we let bubble. - if ((e as { code?: string }).code === "EEXIST") throw e; - } - } - await parent.getDirectoryHandle(basename, { create: true }); - } - - async rmdir(path: string): Promise { - try { - const { parent, name } = await getParentAndName( - this.#root, - path, - false, - ); - await parent.removeEntry(name); - } catch (e) { - if (isNotFoundError(e)) throw makeEnoent(path); - throw e; - } - } - - async stat(path: string): Promise { - let entry: AnyHandle; - try { - entry = await getEntry(this.#root, path); - } catch (e) { - if (isNotFoundError(e)) throw makeEnoent(path); - throw e; - } - - if (entry.kind === "file") { - const file = await (entry as FileSystemFileHandle).getFile(); - return makeStat({ - type: "file", - size: file.size, - mtimeMs: file.lastModified, - path, - }); - } - return makeStat({ - type: "dir", - size: 0, - mtimeMs: 0, - path, - }); - } - - async lstat(path: string): Promise { - return this.stat(path); - } - - async readlink(_path: string): Promise { - const err = new Error("ENOSYS: symlinks not supported"); - (err as Error & { code: string }).code = "ENOSYS"; - throw err; - } - - async symlink(_target: string, _path: string): Promise { - const err = new Error("ENOSYS: symlinks not supported"); - (err as Error & { code: string }).code = "ENOSYS"; - throw err; - } - - async chmod(_path: string, _mode: number): Promise { - // No-op: FSA API has no POSIX modes. - } -} diff --git a/src/lib/notes-worker/GitFs.ts b/src/lib/notes-worker/GitFs.ts deleted file mode 100644 index bc6e9ff5..00000000 --- a/src/lib/notes-worker/GitFs.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - The subset of the node `fs` module that isomorphic-git uses. Two - implementations exist: FileSystemAccessFs (File System Access API / - OPFS) and GitFsProxy (Node fs in the Electron main process, over RPC). -*/ - -export interface FsStat { - type: "file" | "dir" | "symlink"; - mode: number; - size: number; - ino: number; - mtimeMs: number; - ctimeMs: number; - uid: number; - gid: number; - dev: number; - isFile(): boolean; - isDirectory(): boolean; - isSymbolicLink(): boolean; -} - -export interface GitFs { - readFile( - path: string, - options?: { encoding?: string } | string, - ): Promise; - writeFile( - path: string, - data: Uint8Array | string, - options?: { encoding?: string; mode?: number } | string, - ): Promise; - unlink(path: string): Promise; - readdir(path: string): Promise; - mkdir( - path: string, - options?: { recursive?: boolean; mode?: number }, - ): Promise; - rmdir(path: string): Promise; - stat(path: string): Promise; - lstat(path: string): Promise; - readlink(path: string): Promise; - symlink(target: string, path: string): Promise; - chmod(path: string, mode: number): Promise; - /* - isomorphic-git probes for a `promises` namespace and uses it - exclusively if present, so implementations alias it to themselves. - */ - promises: GitFs; -} - -/** - * Rebuilds the isFile/isDirectory/isSymbolicLink predicates that cannot - * survive a structured clone. - */ -export function makeStatFromType(args: { - type: "file" | "dir" | "symlink"; - mode: number; - size: number; - ino: number; - mtimeMs: number; - ctimeMs: number; - uid: number; - gid: number; - dev: number; -}): FsStat { - const { type } = args; - return { - ...args, - isFile: () => type === "file", - isDirectory: () => type === "dir", - isSymbolicLink: () => type === "symlink", - }; -} diff --git a/src/lib/notes-worker/NotesProviderProxy.ts b/src/lib/notes-worker/NotesProviderProxy.ts index 24c717a1..9bbb9bca 100644 --- a/src/lib/notes-worker/NotesProviderProxy.ts +++ b/src/lib/notes-worker/NotesProviderProxy.ts @@ -28,7 +28,7 @@ import { DiffLine, DiffSegment, FileDiff, -} from "./git"; +} from "../git/types"; export type { ChangedFile, diff --git a/src/lib/notes-worker/git.spec.ts b/src/lib/notes-worker/git.spec.ts deleted file mode 100644 index 1fb71802..00000000 --- a/src/lib/notes-worker/git.spec.ts +++ /dev/null @@ -1,552 +0,0 @@ -import { describe, it, expect } from "vitest"; -import git from "isomorphic-git"; -import FakeDirectoryHandle from "./test/FakeDirectoryHandle"; -import FileSystemAccessFs from "./FileSystemAccessFs"; -import { - ensureRepo, - commitChanged, - getCommitDiff, - getCommitHistory, - hasExistingRepo, -} from "./git"; - -const AUTHOR = { name: "Test", email: "test@example.com" }; - -const makeFs = () => { - const handle = new FakeDirectoryHandle(); - const fs = new FileSystemAccessFs( - handle as unknown as FileSystemDirectoryHandle, - ); - return { handle, fs }; -}; - -describe("git helpers via FileSystemAccessFs", () => { - it("ensureRepo initializes a fresh repo with initial commit", async () => { - const { fs } = makeFs(); - await ensureRepo(fs, "/", AUTHOR); - - const head = await fs.readFile("/.git/HEAD", "utf8") as string; - expect(head).toContain("refs/heads/main"); - - const gitignore = await fs.readFile("/.gitignore", "utf8") as string; - expect(gitignore).toContain(".DS_Store"); - - const commits = await git.log({ fs, dir: "/" }); - expect(commits.length).toBe(1); - expect(commits[0].commit.message).toContain("initial"); - expect(commits[0].commit.author.name).toBe("Test"); - }); - - it("ensureRepo commits pre-existing files in initial commit", async () => { - const { fs } = makeFs(); - await fs.writeFile("/existing.subtext", "already here"); - await fs.writeFile("/other.subtext", "and me"); - await ensureRepo(fs, "/", AUTHOR); - - const tree = await git.listFiles({ fs, dir: "/", ref: "HEAD" }); - expect(tree).toContain("existing.subtext"); - expect(tree).toContain("other.subtext"); - expect(tree).toContain(".gitignore"); - }); - - it("hasExistingRepo returns false before init and true after", async () => { - const { fs } = makeFs(); - expect(await hasExistingRepo(fs, "/")).toBe(false); - - await ensureRepo(fs, "/", AUTHOR); - expect(await hasExistingRepo(fs, "/")).toBe(true); - }); - - it("ensureRepo is a no-op on an existing repo", async () => { - const { fs } = makeFs(); - await ensureRepo(fs, "/", AUTHOR); - const logBefore = await git.log({ fs, dir: "/" }); - - await ensureRepo(fs, "/", { name: "Other", email: "other@x.com" }); - const logAfter = await git.log({ fs, dir: "/" }); - - expect(logAfter.length).toBe(logBefore.length); - expect(logAfter[0].oid).toBe(logBefore[0].oid); - }); - - it("commitChanged commits a new note", async () => { - const { fs } = makeFs(); - await ensureRepo(fs, "/", AUTHOR); - - await fs.writeFile("/my-note.subtext", "hello"); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set(["my-note"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - const log = await git.log({ fs, dir: "/" }); - expect(log.length).toBe(2); - expect(log[0].commit.message).toContain("my-note"); - expect(log[0].commit.author.email).toBe("test@example.com"); - }); - - it("commitChanged uses 'create' for a new note", async () => { - const { fs } = makeFs(); - await ensureRepo(fs, "/", AUTHOR); - - await fs.writeFile("/brand-new.subtext", "hi"); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set(["brand-new"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - const log = await git.log({ fs, dir: "/" }); - expect(log[0].commit.message.trim()).toBe("create: brand-new"); - }); - - it("commitChanged uses 'update' when modifying existing note", async () => { - const { fs } = makeFs(); - await ensureRepo(fs, "/", AUTHOR); - - await fs.writeFile("/existing.subtext", "v1"); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set(["existing"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - await fs.writeFile("/existing.subtext", "v2"); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set(["existing"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - const log = await git.log({ fs, dir: "/" }); - expect(log[0].commit.message.trim()).toBe("update: existing"); - }); - - it("commitChanged stages a delete when file is missing", async () => { - const { fs } = makeFs(); - await ensureRepo(fs, "/", AUTHOR); - - await fs.writeFile("/doomed.subtext", "x"); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set(["doomed"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - await fs.unlink("/doomed.subtext"); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set(["doomed"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - const log = await git.log({ fs, dir: "/" }); - expect(log[0].commit.message).toContain("delete"); - }); - - it("commitChanged is a no-op when nothing changed", async () => { - const { fs } = makeFs(); - await ensureRepo(fs, "/", AUTHOR); - const before = await git.log({ fs, dir: "/" }); - - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set(), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - const after = await git.log({ fs, dir: "/" }); - expect(after.length).toBe(before.length); - }); - - it("commitChanged uses statusMatrix when a set is 'all'", async () => { - const { fs } = makeFs(); - await ensureRepo(fs, "/", AUTHOR); - - await fs.writeFile("/a.subtext", "a"); - await fs.writeFile("/b.subtext", "b"); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: "all", - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - const log = await git.log({ fs, dir: "/" }); - expect(log.length).toBe(2); - }); -}); - -describe("getCommitHistory", () => { - it("returns the initial commit with seeded files marked as add", async () => { - const { fs } = makeFs(); - await fs.writeFile("/seeded.subtext", "hello"); - await ensureRepo(fs, "/", AUTHOR); - - const history = await getCommitHistory( - fs, - "/", - { limit: 10, offset: 0 }, - ); - - expect(history.length).toBe(1); - expect(history[0].message).toContain("initial"); - expect(history[0].author.name).toBe("Test"); - const paths = history[0].changes.map((c) => c.path).sort(); - expect(paths).toContain("seeded.subtext"); - expect(paths).toContain(".gitignore"); - for (const change of history[0].changes) { - expect(change.change).toBe("add"); - } - }); - - it("classifies add, modify, and delete across commits", async () => { - const { fs } = makeFs(); - await ensureRepo(fs, "/", AUTHOR); - - await fs.writeFile("/note.subtext", "v1"); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set(["note"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - await fs.writeFile("/note.subtext", "v2"); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set(["note"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - await fs.unlink("/note.subtext"); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set(["note"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - const history = await getCommitHistory( - fs, - "/", - { limit: 10, offset: 0 }, - ); - - expect(history.length).toBe(4); - - const newest = history[0].changes.find((c) => c.path === "note.subtext"); - expect(newest?.change).toBe("delete"); - - const middle = history[1].changes.find((c) => c.path === "note.subtext"); - expect(middle?.change).toBe("modify"); - - const earliest = history[2].changes.find((c) => c.path === "note.subtext"); - expect(earliest?.change).toBe("add"); - }); - -}); - -describe("getCommitDiff", () => { - it("returns added lines for a brand-new file", async () => { - const { fs } = makeFs(); - await ensureRepo(fs, "/", AUTHOR); - - await fs.writeFile("/note.subtext", "line1\nline2\n"); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set(["note"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - const log = await git.log({ fs, dir: "/" }); - const diff = await getCommitDiff(fs, "/", log[0].oid); - - expect(diff.length).toBe(1); - expect(diff[0].path).toBe("note.subtext"); - expect(diff[0].change).toBe("add"); - const types = diff[0].lines!.map((l) => l.type); - expect(types.every((t) => t === "add")).toBe(true); - const texts = diff[0].lines!.map((l) => l.text); - expect(texts).toContain("line1"); - expect(texts).toContain("line2"); - }); - - it("returns add+remove lines for a modified file", async () => { - const { fs } = makeFs(); - await ensureRepo(fs, "/", AUTHOR); - - await fs.writeFile("/note.subtext", "alpha\nbeta\ngamma\n"); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set(["note"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - await fs.writeFile("/note.subtext", "alpha\nBETA\ngamma\n"); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set(["note"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - const log = await git.log({ fs, dir: "/" }); - const diff = await getCommitDiff(fs, "/", log[0].oid); - - expect(diff.length).toBe(1); - expect(diff[0].change).toBe("modify"); - const removed = diff[0].lines!.filter((l) => l.type === "remove"); - const added = diff[0].lines!.filter((l) => l.type === "add"); - expect(removed.map((l) => l.text)).toContain("beta"); - expect(added.map((l) => l.text)).toContain("BETA"); - const context = diff[0].lines!.filter((l) => l.type === "context"); - expect(context.map((l) => l.text)).toContain("alpha"); - expect(context.map((l) => l.text)).toContain("gamma"); - }); - - it("marks emphasized segments on modified lines", async () => { - const { fs } = makeFs(); - await ensureRepo(fs, "/", AUTHOR); - - await fs.writeFile("/note.subtext", "the quick brown fox\n"); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set(["note"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - await fs.writeFile("/note.subtext", "the slow brown fox\n"); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set(["note"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - const log = await git.log({ fs, dir: "/" }); - const diff = await getCommitDiff(fs, "/", log[0].oid); - - const removed = diff[0].lines!.find((l) => l.type === "remove"); - const added = diff[0].lines!.find((l) => l.type === "add"); - - expect(removed?.segments).toBeDefined(); - expect(added?.segments).toBeDefined(); - - const removedEmphasized = removed!.segments! - .filter((s) => s.emphasized) - .map((s) => s.text) - .join(""); - const addedEmphasized = added!.segments! - .filter((s) => s.emphasized) - .map((s) => s.text) - .join(""); - - expect(removedEmphasized).toContain("quick"); - expect(addedEmphasized).toContain("slow"); - - const removedContext = removed!.segments! - .filter((s) => !s.emphasized) - .map((s) => s.text) - .join(""); - expect(removedContext).toContain("the "); - expect(removedContext).toContain(" brown fox"); - }); - - it("returns removed lines for a deleted file", async () => { - const { fs } = makeFs(); - await ensureRepo(fs, "/", AUTHOR); - - await fs.writeFile("/gone.subtext", "x\ny\n"); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set(["gone"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - await fs.unlink("/gone.subtext"); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set(["gone"]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - - const log = await git.log({ fs, dir: "/" }); - const diff = await getCommitDiff(fs, "/", log[0].oid); - - expect(diff.length).toBe(1); - expect(diff[0].change).toBe("delete"); - expect(diff[0].lines!.every((l) => l.type === "remove")).toBe(true); - }); - - it("flags binary files instead of computing a diff", async () => { - const { fs } = makeFs(); - await ensureRepo(fs, "/", AUTHOR); - - const binary = new Uint8Array([1, 2, 0, 3, 4, 0, 5]); - await fs.writeFile("/blob.bin", binary); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set(), - aliases: new Set(), - arbitraryFiles: new Set(["blob.bin"]), - flushPins: false, - }, - AUTHOR, - ); - - const log = await git.log({ fs, dir: "/" }); - const diff = await getCommitDiff(fs, "/", log[0].oid); - - const binaryEntry = diff.find((d) => d.path === "blob.bin"); - expect(binaryEntry).toBeDefined(); - expect(binaryEntry!.binary).toBe(true); - expect(binaryEntry!.lines).toBeUndefined(); - }); -}); - -describe("getCommitHistory pagination", () => { - it("respects limit and offset", async () => { - const { fs } = makeFs(); - await ensureRepo(fs, "/", AUTHOR); - - for (let i = 0; i < 3; i++) { - await fs.writeFile(`/n${i}.subtext`, `v${i}`); - await commitChanged( - fs, - "/", - { - canonicalNoteSlugs: new Set([`n${i}`]), - aliases: new Set(), - arbitraryFiles: new Set(), - flushPins: false, - }, - AUTHOR, - ); - } - - const firstPage = await getCommitHistory( - fs, - "/", - { limit: 2, offset: 0 }, - ); - expect(firstPage.length).toBe(2); - expect(firstPage[0].message).toContain("n2"); - expect(firstPage[1].message).toContain("n1"); - - const secondPage = await getCommitHistory( - fs, - "/", - { limit: 2, offset: 2 }, - ); - expect(secondPage.length).toBe(2); - expect(secondPage[0].message).toContain("n0"); - expect(secondPage[1].message).toContain("initial"); - }); -}); diff --git a/src/lib/notes-worker/git.ts b/src/lib/notes-worker/git.ts deleted file mode 100644 index dded67b0..00000000 --- a/src/lib/notes-worker/git.ts +++ /dev/null @@ -1,712 +0,0 @@ -/* - Helpers for per-flush commits against the user's notes directory. - - - `ensureRepo` — initializes a fresh repo if `.git/HEAD` is missing; - otherwise adopts the existing one. - - `commitChanged` — stages the paths named by the three flush sets - (plus `.pins.neno` when pins changed) and creates a single commit - with a generated message. -*/ - -import git from "isomorphic-git"; -import { GitFs } from "./GitFs"; -import { Slug } from "../notes/types/Slug"; - -const GITIGNORE_DEFAULT = ".DS_Store\n"; -const GRAPH_FILE_EXTENSION = ".subtext"; -const PINS_FILENAME = ".pins.neno"; - -export interface GitAuthor { - name: string; - email: string; -} - -export interface FlushChangeSets { - canonicalNoteSlugs: Set | "all"; - aliases: Set | "all"; - arbitraryFiles: Set | "all"; - flushPins: boolean; -} - -export interface ChangedFile { - path: string; - change: "add" | "modify" | "delete"; -} - -export interface CommitInfo { - oid: string; - message: string; - author: { name: string; email: string }; - timestamp: number; - timezoneOffset: number; - changes: ChangedFile[]; -} - -export interface CommitHistoryOptions { - limit: number; - offset: number; -} - -export interface DiffSegment { - text: string; - emphasized: boolean; -} - -export interface DiffLine { - type: "context" | "add" | "remove"; - text: string; - segments?: DiffSegment[]; -} - -export interface FileDiff { - path: string; - change: "add" | "modify" | "delete"; - lines?: DiffLine[]; - binary?: boolean; - tooLarge?: boolean; -} - -const MAX_DIFF_BYTES = 200_000; - -function getFilenameForSlug(slug: Slug): string { - return `${slug}${GRAPH_FILE_EXTENSION}`; -} - -/* - Without a cache, isomorphic-git reads a whole packfile into memory for - every object it looks up in one, and throws it away again. In a packed - repo with committed attachments that is hundreds of MB per save, and in - Electron every byte of it crosses the storage bridge. - - The cache holds the git index, which isomorphic-git re-validates with an - lstat on every use, and packfiles keyed by their content-hashed names, - with the pack directory listed afresh on every lookup — so it stays - correct when the repo is changed outside NENO. It is keyed by the fs, - because the worker creates a new one for every folder it opens. The - price is that a loaded pack stays in memory until the folder is closed. - - git.commit reads the parent commit with a fresh cache of its own, so a - save still loads the pack once more while HEAD is packed — after a - commit or gc on the command line, until NENO's next commit. -*/ -const caches = new WeakMap(); - -function getCache(fs: GitFs): object { - let cache = caches.get(fs); - if (!cache) { - cache = {}; - caches.set(fs, cache); - } - return cache; -} - -async function fileExists( - fs: GitFs, - path: string, -): Promise { - try { - await fs.stat(path); - return true; - } catch { - return false; - } -} - -function getHeadPath(dir: string): string { - return dir.endsWith("/") - ? `${dir}.git/HEAD` - : `${dir}/.git/HEAD`; -} - -export async function hasExistingRepo( - fs: GitFs, - dir: string, -): Promise { - return fileExists(fs, getHeadPath(dir)); -} - -export async function ensureRepo( - fs: GitFs, - dir: string, - author: GitAuthor, -): Promise { - if (await hasExistingRepo(fs, dir)) { - return; - } - - await git.init({ fs, dir, defaultBranch: "main" }); - await fs.writeFile( - dir.endsWith("/") ? `${dir}.gitignore` : `${dir}/.gitignore`, - GITIGNORE_DEFAULT, - ); - const cache = getCache(fs); - const matrix = await git.statusMatrix({ fs, dir, cache }); - for (const [filepath, , workdir] of matrix) { - if (workdir === 0) continue; - await git.add({ fs, dir, filepath, cache }); - } - await git.commit({ - fs, - dir, - cache, - author, - message: "initial commit", - }); -} - -function pathToSlug(path: string): string { - return path.endsWith(GRAPH_FILE_EXTENSION) - ? path.slice(0, -GRAPH_FILE_EXTENSION.length) - : path; -} - -function formatMessage( - creates: string[], - modifies: string[], - removes: string[], -): string { - const total = creates.length + modifies.length + removes.length; - - if (total === 0) { - return "update"; - } - - if (total === 1) { - if (creates.length === 1) { - const path = creates[0]; - if (path.endsWith(GRAPH_FILE_EXTENSION)) { - return `create: ${pathToSlug(path)}`; - } - return `create file: ${path}`; - } - if (modifies.length === 1) { - const path = modifies[0]; - if (path.endsWith(GRAPH_FILE_EXTENSION)) { - return `update: ${pathToSlug(path)}`; - } - return `update file: ${path}`; - } - const path = removes[0]; - if (path.endsWith(GRAPH_FILE_EXTENSION)) { - return `delete: ${pathToSlug(path)}`; - } - return `delete file: ${path}`; - } - - const all = [...creates, ...modifies, ...removes]; - const noteCount = all.filter( - (p) => p.endsWith(GRAPH_FILE_EXTENSION), - ).length; - const fileCount = total - noteCount; - - const header = [ - noteCount > 0 - ? `${noteCount} note${noteCount === 1 ? "" : "s"}` - : null, - fileCount > 0 - ? `${fileCount} file${fileCount === 1 ? "" : "s"}` - : null, - ] - .filter((s): s is string => s !== null) - .join(", "); - - const verb = modifies.length === 0 && removes.length === 0 - ? "create" - : creates.length === 0 && modifies.length === 0 - ? "delete" - : "update"; - - const body = [ - ...creates.map((p) => `+ ${p}`), - ...modifies.map((p) => `~ ${p}`), - ...removes.map((p) => `- ${p}`), - ].join("\n"); - - return `${verb}: ${header}\n\n${body}`; -} - -async function stagePath( - fs: GitFs, - dir: string, - relPath: string, - headOid: string | null, - creates: string[], - modifies: string[], - removes: string[], -): Promise { - const absPath = dir.endsWith("/") - ? `${dir}${relPath}` - : `${dir}/${relPath}`; - - const cache = getCache(fs); - - if (await fileExists(fs, absPath)) { - let inHead = false; - if (headOid) { - try { - await git.readBlob({ - fs, dir, cache, oid: headOid, filepath: relPath, - }); - inHead = true; - } catch { - inHead = false; - } - } - await git.add({ fs, dir, cache, filepath: relPath }); - if (inHead) { - modifies.push(relPath); - } else { - creates.push(relPath); - } - } else { - try { - await git.remove({ fs, dir, cache, filepath: relPath }); - removes.push(relPath); - } catch { - // Not tracked and no longer on disk — nothing to do. - } - } -} - -async function stageAll( - fs: GitFs, - dir: string, - creates: string[], - modifies: string[], - removes: string[], -): Promise { - const cache = getCache(fs); - const matrix = await git.statusMatrix({ fs, dir, cache }); - for (const row of matrix) { - const [filepath, head, workdir, stage] = row; - if (workdir === head && stage === head) { - continue; - } - if (workdir === 0) { - try { - await git.remove({ fs, dir, cache, filepath }); - removes.push(filepath); - } catch { - // ignore - } - } else { - await git.add({ fs, dir, cache, filepath }); - if (head === 0) { - creates.push(filepath); - } else { - modifies.push(filepath); - } - } - } -} - -export async function commitChanged( - fs: GitFs, - dir: string, - changes: FlushChangeSets, - author: GitAuthor, -): Promise { - const creates: string[] = []; - const modifies: string[] = []; - const removes: string[] = []; - - let headOid: string | null; - try { - headOid = await git.resolveRef({ fs, dir, ref: "HEAD" }); - } catch { - headOid = null; - } - - const allSetsAreSets - = changes.canonicalNoteSlugs instanceof Set - && changes.aliases instanceof Set - && changes.arbitraryFiles instanceof Set; - - if (!allSetsAreSets) { - await stageAll(fs, dir, creates, modifies, removes); - } else { - const noteSlugs = changes.canonicalNoteSlugs as Set; - const aliasSlugs = changes.aliases as Set; - const fileSlugs = changes.arbitraryFiles as Set; - - for (const slug of noteSlugs) { - await stagePath( - fs, dir, getFilenameForSlug(slug), headOid, - creates, modifies, removes, - ); - } - for (const slug of aliasSlugs) { - await stagePath( - fs, dir, getFilenameForSlug(slug), headOid, - creates, modifies, removes, - ); - } - for (const slug of fileSlugs) { - await stagePath( - fs, dir, getFilenameForSlug(slug), headOid, - creates, modifies, removes, - ); - await stagePath( - fs, dir, slug, headOid, - creates, modifies, removes, - ); - } - } - - if (changes.flushPins) { - await stagePath( - fs, dir, PINS_FILENAME, headOid, - creates, modifies, removes, - ); - } - - if ( - creates.length === 0 - && modifies.length === 0 - && removes.length === 0 - ) { - return; - } - - await git.commit({ - fs, - dir, - cache: getCache(fs), - author, - message: formatMessage(creates, modifies, removes), - }); -} - -async function getChangedPaths( - fs: GitFs, - dir: string, - oid: string, - parentOid: string | undefined, -): Promise { - const trees = parentOid - ? [git.TREE({ ref: oid }), git.TREE({ ref: parentOid })] - : [git.TREE({ ref: oid })]; - - const changes: ChangedFile[] = []; - - await git.walk({ - fs, - dir, - cache: getCache(fs), - trees, - map: async (filepath, entries) => { - if (filepath === ".") return; - if (!entries) return; - - const A = entries[0]; - const B = entries.length > 1 ? entries[1] : null; - - const aType = A ? await A.type() : null; - const bType = B ? await B.type() : null; - - if (aType === "tree" || bType === "tree") return; - - const aOid = A ? await A.oid() : null; - const bOid = B ? await B.oid() : null; - - if (parentOid) { - if (aOid === bOid) return; - if (!A) { - changes.push({ path: filepath, change: "delete" }); - } else if (!B) { - changes.push({ path: filepath, change: "add" }); - } else { - changes.push({ path: filepath, change: "modify" }); - } - } else if (A) { - changes.push({ path: filepath, change: "add" }); - } - }, - }); - - changes.sort((a, b) => a.path.localeCompare(b.path)); - return changes; -} - -export async function getCommitHistory( - fs: GitFs, - dir: string, - options: CommitHistoryOptions, -): Promise { - const { limit, offset } = options; - - const allCommits = await git.log({ - fs, - dir, - cache: getCache(fs), - depth: offset + limit, - }); - const page = allCommits.slice(offset, offset + limit); - - const result: CommitInfo[] = []; - - for (const entry of page) { - const parentOid: string | undefined = entry.commit.parent[0]; - const changes = await getChangedPaths(fs, dir, entry.oid, parentOid); - - result.push({ - oid: entry.oid, - message: entry.commit.message, - author: { - name: entry.commit.author.name, - email: entry.commit.author.email, - }, - timestamp: entry.commit.author.timestamp, - timezoneOffset: entry.commit.author.timezoneOffset, - changes, - }); - } - - return result; -} - -function isBinary(bytes: Uint8Array): boolean { - const limit = Math.min(bytes.length, 8000); - for (let i = 0; i < limit; i++) { - if (bytes[i] === 0) return true; - } - return false; -} - -function decode(bytes: Uint8Array): string { - return new TextDecoder("utf-8").decode(bytes); -} - -function tokenize(text: string): string[] { - return text.match(/\w+|\s+|[^\s\w]/gu) ?? []; -} - -function pushSegment( - arr: DiffSegment[], - text: string, - emphasized: boolean, -): void { - const last = arr[arr.length - 1]; - if (last && last.emphasized === emphasized) { - last.text += text; - } else { - arr.push({ text, emphasized }); - } -} - -function diffTokens( - oldText: string, - newText: string, -): { removeSegments: DiffSegment[]; addSegments: DiffSegment[] } { - const a = tokenize(oldText); - const b = tokenize(newText); - const m = a.length; - const n = b.length; - - const lcs: number[][] = Array.from( - { length: m + 1 }, - () => new Array(n + 1).fill(0), - ); - for (let i = 1; i <= m; i++) { - for (let j = 1; j <= n; j++) { - if (a[i - 1] === b[j - 1]) { - lcs[i][j] = lcs[i - 1][j - 1] + 1; - } else { - lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]); - } - } - } - - const ops: Array<{ type: "context" | "add" | "remove"; text: string }> = []; - let i = m; - let j = n; - while (i > 0 && j > 0) { - if (a[i - 1] === b[j - 1]) { - ops.unshift({ type: "context", text: a[i - 1] }); - i--; - j--; - } else if (lcs[i - 1][j] >= lcs[i][j - 1]) { - ops.unshift({ type: "remove", text: a[i - 1] }); - i--; - } else { - ops.unshift({ type: "add", text: b[j - 1] }); - j--; - } - } - while (i > 0) { - i--; - ops.unshift({ type: "remove", text: a[i] }); - } - while (j > 0) { - j--; - ops.unshift({ type: "add", text: b[j] }); - } - - const removeSegments: DiffSegment[] = []; - const addSegments: DiffSegment[] = []; - for (const op of ops) { - if (op.type === "context") { - pushSegment(removeSegments, op.text, false); - pushSegment(addSegments, op.text, false); - } else if (op.type === "remove") { - pushSegment(removeSegments, op.text, true); - } else { - pushSegment(addSegments, op.text, true); - } - } - return { removeSegments, addSegments }; -} - -function pairLines(lines: DiffLine[]): DiffLine[] { - const result = [...lines]; - let i = 0; - while (i < result.length) { - if (result[i].type === "context") { - i++; - continue; - } - const runStart = i; - while (i < result.length && result[i].type !== "context") i++; - const runEnd = i; - - const removeIndexes: number[] = []; - const addIndexes: number[] = []; - for (let k = runStart; k < runEnd; k++) { - if (result[k].type === "remove") { - removeIndexes.push(k); - } else { - addIndexes.push(k); - } - } - - const pairCount = Math.min(removeIndexes.length, addIndexes.length); - for (let p = 0; p < pairCount; p++) { - const removeIdx = removeIndexes[p]; - const addIdx = addIndexes[p]; - const { removeSegments, addSegments } = diffTokens( - result[removeIdx].text, - result[addIdx].text, - ); - result[removeIdx] = { ...result[removeIdx], segments: removeSegments }; - result[addIdx] = { ...result[addIdx], segments: addSegments }; - } - } - return result; -} - -function diffLines(oldText: string, newText: string): DiffLine[] { - const a = oldText.length === 0 ? [] : oldText.split("\n"); - const b = newText.length === 0 ? [] : newText.split("\n"); - const m = a.length; - const n = b.length; - - const lcs: number[][] = Array.from( - { length: m + 1 }, - () => new Array(n + 1).fill(0), - ); - for (let i = 1; i <= m; i++) { - for (let j = 1; j <= n; j++) { - if (a[i - 1] === b[j - 1]) { - lcs[i][j] = lcs[i - 1][j - 1] + 1; - } else { - lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]); - } - } - } - - const result: DiffLine[] = []; - let i = m; - let j = n; - while (i > 0 && j > 0) { - if (a[i - 1] === b[j - 1]) { - result.unshift({ type: "context", text: a[i - 1] }); - i--; - j--; - } else if (lcs[i - 1][j] >= lcs[i][j - 1]) { - result.unshift({ type: "remove", text: a[i - 1] }); - i--; - } else { - result.unshift({ type: "add", text: b[j - 1] }); - j--; - } - } - while (i > 0) { - i--; - result.unshift({ type: "remove", text: a[i] }); - } - while (j > 0) { - j--; - result.unshift({ type: "add", text: b[j] }); - } - return result; -} - -export async function getCommitDiff( - fs: GitFs, - dir: string, - oid: string, -): Promise { - const cache = getCache(fs); - const { commit } = await git.readCommit({ fs, dir, cache, oid }); - const parentOid: string | undefined = commit.parent[0]; - const changedPaths = await getChangedPaths(fs, dir, oid, parentOid); - - const result: FileDiff[] = []; - - for (const cp of changedPaths) { - const fileDiff: FileDiff = { path: cp.path, change: cp.change }; - - let oldBytes: Uint8Array | null = null; - let newBytes: Uint8Array | null = null; - - try { - if (cp.change !== "add" && parentOid) { - const { blob } = await git.readBlob({ - fs, - dir, - cache, - oid: parentOid, - filepath: cp.path, - }); - oldBytes = blob; - } - if (cp.change !== "delete") { - const { blob } = await git.readBlob({ - fs, - dir, - cache, - oid, - filepath: cp.path, - }); - newBytes = blob; - } - } catch { - result.push(fileDiff); - continue; - } - - if ( - (oldBytes && isBinary(oldBytes)) - || (newBytes && isBinary(newBytes)) - ) { - fileDiff.binary = true; - result.push(fileDiff); - continue; - } - - const oldSize = oldBytes ? oldBytes.length : 0; - const newSize = newBytes ? newBytes.length : 0; - if (oldSize > MAX_DIFF_BYTES || newSize > MAX_DIFF_BYTES) { - fileDiff.tooLarge = true; - result.push(fileDiff); - continue; - } - - const oldText = oldBytes ? decode(oldBytes) : ""; - const newText = newBytes ? decode(newBytes) : ""; - fileDiff.lines = pairLines(diffLines(oldText, newText)); - result.push(fileDiff); - } - - return result; -} diff --git a/src/lib/notes-worker/index.spec.ts b/src/lib/notes-worker/index.spec.ts index cac31bf6..2e34c287 100644 --- a/src/lib/notes-worker/index.spec.ts +++ b/src/lib/notes-worker/index.spec.ts @@ -8,10 +8,10 @@ import { SharedWorker. Script workers still attach extra MessagePorts via the `addPort` action, and those are exercised here too. - Heavy dependencies (NotesProvider, FileSystemAccessAPIStorageProvider, - FileSystemAccessFs, git) are mocked because they need a real - FileSystemDirectoryHandle and isomorphic-git's node-fs adapter, - neither of which is available in jsdom. + Heavy dependencies (NotesProvider, FileSystemAccessAPIStorageProvider) + are mocked because they need a real FileSystemDirectoryHandle, which + jsdom does not have. GitClient is mocked because it would ask the + Electron main process — absent here — whether the folder has a repo. */ vi.mock("../FileSystemAccessAPIStorageProvider", () => ({ @@ -21,25 +21,19 @@ vi.mock("../FileSystemAccessAPIStorageProvider", () => ({ }, })); -vi.mock("../notes", () => ({ +vi.mock("../electron/GitClient", () => ({ default: class { - async put() { return { meta: { slug: "x" } }; } + async hasRepo() { return false; } + async setAuthor() { /* noop */ } }, })); -vi.mock("./FileSystemAccessFs", () => ({ +vi.mock("../notes", () => ({ default: class { - constructor() { /* noop */ } + async put() { return { meta: { slug: "x" } }; } }, })); -vi.mock("./git", () => ({ - hasExistingRepo: async () => false, - ensureRepo: async () => { /* noop */ }, - commitChanged: async () => { /* noop */ }, - getCommitHistory: async () => [], - getCommitDiff: async () => [], -})); type WorkerHarness = { diff --git a/src/lib/notes-worker/index.ts b/src/lib/notes-worker/index.ts index 63131006..4140862e 100644 --- a/src/lib/notes-worker/index.ts +++ b/src/lib/notes-worker/index.ts @@ -1,29 +1,24 @@ -import { Buffer } from "buffer"; import FileSystemAccessAPIStorageProvider from "../FileSystemAccessAPIStorageProvider"; import NotesProvider from "../notes"; import StorageProvider from "../notes/types/StorageProvider"; -import FileSystemAccessFs from "./FileSystemAccessFs"; -import { GitFs } from "./GitFs"; +import { Slug } from "../notes/types/Slug"; import BridgeClient from "../electron/BridgeClient"; import StorageProviderProxy from "../electron/StorageProviderProxy"; -import GitFsProxy from "../electron/GitFsProxy"; +import GitClient from "../electron/GitClient"; import { - commitChanged, - ensureRepo, - getCommitDiff, - getCommitHistory, + CommitHistoryOptions, + FlushChanges, GitAuthor, - hasExistingRepo, -} from "./git"; - -// isomorphic-git uses Node's Buffer internally; make it available -// to the worker's global scope. -(globalThis as { Buffer?: typeof Buffer }).Buffer = Buffer; +} from "../git/types"; let notesProvider: NotesProvider | null = null; -let gitFs: GitFs | null = null; -let createGitFs: (() => GitFs) | null = null; +/* + git runs in the Electron main process, so it exists only in the + desktop app: the OPFS "try it out" mode has no repository. +*/ +let gitClient: GitClient | null = null; +let gitEnabled = false; let folderName: string | null = null; let folderPath: string | null = null; let usingOPFS = false; @@ -120,6 +115,21 @@ function broadcast( } } +function toFlushChanges(change: { + canonicalNoteSlugs: Set | "all"; + aliases: Set | "all"; + arbitraryFiles: Set | "all"; + flushPins: boolean; +}): FlushChanges { + const list = (set: Set | "all") => set === "all" ? "all" : [...set]; + return { + canonicalNoteSlugs: list(change.canonicalNoteSlugs), + aliases: list(change.aliases), + arbitraryFiles: list(change.arbitraryFiles), + flushPins: change.flushPins, + }; +} + function getBasename(folderPath: string): string { const segments = folderPath .split("/") @@ -134,7 +144,7 @@ async function runInitialize( let storageProvider: StorageProvider; /* - Two provider pairs: the Node fs implementations in the Electron main + Two providers: the Node fs implementation in the Electron main process, reached over a MessagePort, and the File System Access API provider used for the OPFS "try it out" mode. */ @@ -143,12 +153,18 @@ async function runInitialize( throw new Error("No storage port transferred with folderPath"); } /* - One client for both proxies: they share a single port, so they must - also share the request id sequence. + One client for storage and git: they share a single port, so they + must also share the request id sequence. */ const bridgeClient = new BridgeClient(storagePort); storageProvider = new StorageProviderProxy(bridgeClient); - createGitFs = () => new GitFsProxy(bridgeClient); + gitClient = new GitClient(bridgeClient); + bridgeClient.onEvent(async (event) => { + if (event.event !== "pathsChanged" || !notesProvider) return; + await notesProvider.reloadPaths(event.paths); + // The open note reloads itself, or offers to, on this. + broadcast({ event: "mutation" }); + }); folderPath = opts.folderPath; folderName = getBasename(opts.folderPath); usingOPFS = false; @@ -166,7 +182,6 @@ async function runInitialize( } storageProvider = new FileSystemAccessAPIStorageProvider(dirHandle); - createGitFs = () => new FileSystemAccessFs(dirHandle); folderName = dirHandle.name; } @@ -183,16 +198,13 @@ async function runInitialize( gitAuthor = opts.gitAuthor; } - const candidateGitFs = createGitFs(); - if (await hasExistingRepo(candidateGitFs, "/")) { - await ensureRepo(candidateGitFs, "/", gitAuthor); - gitFs = candidateGitFs; - } + gitEnabled = gitClient !== null && await gitClient.hasRepo(); + await gitClient?.setAuthor(gitAuthor); notesProvider = new NotesProvider(storageProvider, { onFlush: async (change) => { - if (!gitFs) return; - await commitChanged(gitFs, "/", change, gitAuthor); + if (!gitEnabled || !gitClient) return; + await gitClient.commit(toFlushChanges(change), gitAuthor); }, }); } @@ -213,8 +225,8 @@ async function ensureInitialized( function tearDown(): void { notesProvider = null; - gitFs = null; - createGitFs = null; + gitClient = null; + gitEnabled = false; folderName = null; folderPath = null; usingOPFS = false; @@ -233,30 +245,15 @@ async function handleRPCCall( port.postMessage(data, transfer ?? []); }; - if (method === "getCommitHistory") { - if (!gitFs) { - respond({ id, error: "Git not initialized" }); - return; - } - try { - const [options] = args as [{ limit: number; offset: number }]; - const result = await getCommitHistory(gitFs, "/", options); - respond({ id, result }); - } catch (e: unknown) { - const message = e instanceof Error ? e.message : String(e); - respond({ id, error: message }); - } - return; - } - - if (method === "getCommitDiff") { - if (!gitFs) { + if (method === "getCommitHistory" || method === "getCommitDiff") { + if (!gitEnabled || !gitClient) { respond({ id, error: "Git not initialized" }); return; } try { - const [oid] = args as [string]; - const result = await getCommitDiff(gitFs, "/", oid); + const result = method === "getCommitHistory" + ? await gitClient.log(args[0] as CommitHistoryOptions) + : await gitClient.diff(args[0] as string); respond({ id, result }); } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); @@ -329,7 +326,7 @@ async function handleAction( port.postMessage({ action: "helloAck", initialized: notesProvider !== null, - gitEnabled: gitFs !== null, + gitEnabled, folderName, folderPath, usingOPFS, @@ -351,7 +348,7 @@ async function handleAction( ); port.postMessage({ action: "initialized", - gitEnabled: gitFs !== null, + gitEnabled, folderName, folderPath, usingOPFS, @@ -373,25 +370,25 @@ async function handleAction( if (action.action === "setGitAuthor") { gitAuthor = action.author; + await gitClient?.setAuthor(gitAuthor); return; } if (action.action === "enableGit") { - if (gitFs) { + if (gitEnabled) { port.postMessage({ action: "gitEnabled" }); return; } - if (!createGitFs) { + if (!gitClient) { port.postMessage({ action: "gitEnableFailed", - error: "Worker not initialized", + error: "Version history is only available in the desktop app", }); return; } - const candidateGitFs = createGitFs(); try { - await ensureRepo(candidateGitFs, "/", gitAuthor); - gitFs = candidateGitFs; + await gitClient.init(gitAuthor); + gitEnabled = true; port.postMessage({ action: "gitEnabled" }); broadcast({ event: "gitEnabled" }, port); } catch (e) { diff --git a/src/lib/notes-worker/test/FakeDirectoryHandle.ts b/src/lib/notes-worker/test/FakeDirectoryHandle.ts deleted file mode 100644 index 00b866ab..00000000 --- a/src/lib/notes-worker/test/FakeDirectoryHandle.ts +++ /dev/null @@ -1,227 +0,0 @@ -/* - Minimal in-memory fake of FileSystemDirectoryHandle / - FileSystemFileHandle / FileSystemWritableFileStream covering the - subset used by FileSystemAccessFs and - FileSystemAccessAPIStorageProvider. - - Nodes are stored in a flat Map keyed by posix path so tree - traversal stays trivial. -*/ - -interface FileNode { - kind: "file"; - name: string; - data: Uint8Array; - lastModified: number; -} - -interface DirNode { - kind: "directory"; - name: string; - children: Map; -} - -type Node = FileNode | DirNode; - -function makeDomException(name: string, message: string): DOMException { - try { - return new DOMException(message, name); - } catch { - const e = new Error(message) as Error & { name: string }; - e.name = name; - return e as unknown as DOMException; - } -} - -class FakeFileSystemWritableFileStream { - #chunks: Uint8Array[] = []; - #target: FileNode; - - constructor(target: FileNode) { - this.#target = target; - } - - async write(data: unknown): Promise { - if (data === null || data === undefined) return; - - if (typeof data === "string") { - this.#chunks.push(new TextEncoder().encode(data)); - return; - } - if (data instanceof ArrayBuffer) { - this.#chunks.push(new Uint8Array(data)); - return; - } - if (ArrayBuffer.isView(data)) { - const view = data as ArrayBufferView; - this.#chunks.push(new Uint8Array( - view.buffer.slice( - view.byteOffset, - view.byteOffset + view.byteLength, - ), - )); - return; - } - if (data instanceof Blob) { - const ab = await data.arrayBuffer(); - this.#chunks.push(new Uint8Array(ab)); - return; - } - throw new Error("Unsupported write data type"); - } - - async close(): Promise { - const total = this.#chunks.reduce((sum, c) => sum + c.length, 0); - const out = new Uint8Array(total); - let offset = 0; - for (const c of this.#chunks) { - out.set(c, offset); - offset += c.length; - } - this.#target.data = out; - this.#target.lastModified = Date.now(); - } - - getWriter(): never { - throw new Error("Not implemented"); - } - - get locked(): boolean { - return false; - } -} - -export class FakeFileHandle { - kind = "file" as const; - name: string; - #node: FileNode; - - constructor(node: FileNode) { - this.name = node.name; - this.#node = node; - } - - async getFile(): Promise { - const copy = this.#node.data.slice(); - const file = new File([copy], this.#node.name, { - lastModified: this.#node.lastModified, - }); - return file; - } - - async createWritable(): Promise { - return new FakeFileSystemWritableFileStream(this.#node); - } -} - -export default class FakeDirectoryHandle { - kind = "directory" as const; - name: string; - #node: DirNode; - - constructor(node?: DirNode) { - if (node) { - this.#node = node; - this.name = node.name; - } else { - this.#node = { kind: "directory", name: "", children: new Map() }; - this.name = ""; - } - } - - async getDirectoryHandle( - name: string, - options?: { create?: boolean }, - ): Promise { - const existing = this.#node.children.get(name); - if (existing && existing.kind === "directory") { - return new FakeDirectoryHandle(existing); - } - if (existing && existing.kind === "file") { - throw makeDomException( - "TypeMismatchError", - `'${name}' is a file, not a directory`, - ); - } - if (options?.create) { - const newDir: DirNode = { - kind: "directory", - name, - children: new Map(), - }; - this.#node.children.set(name, newDir); - return new FakeDirectoryHandle(newDir); - } - throw makeDomException( - "NotFoundError", - `Directory '${name}' not found`, - ); - } - - async getFileHandle( - name: string, - options?: { create?: boolean }, - ): Promise { - const existing = this.#node.children.get(name); - if (existing && existing.kind === "file") { - return new FakeFileHandle(existing); - } - if (existing && existing.kind === "directory") { - throw makeDomException( - "TypeMismatchError", - `'${name}' is a directory, not a file`, - ); - } - if (options?.create) { - const newFile: FileNode = { - kind: "file", - name, - data: new Uint8Array(0), - lastModified: Date.now(), - }; - this.#node.children.set(name, newFile); - return new FakeFileHandle(newFile); - } - throw makeDomException( - "NotFoundError", - `File '${name}' not found`, - ); - } - - async removeEntry( - name: string, - _options?: { recursive?: boolean }, - ): Promise { - if (!this.#node.children.has(name)) { - throw makeDomException( - "NotFoundError", - `Entry '${name}' not found`, - ); - } - this.#node.children.delete(name); - } - - async *values(): AsyncIterableIterator< - FakeDirectoryHandle | FakeFileHandle - > { - for (const child of this.#node.children.values()) { - if (child.kind === "file") { - yield new FakeFileHandle(child); - } else { - yield new FakeDirectoryHandle(child); - } - } - } - - async *entries(): AsyncIterableIterator< - [string, FakeDirectoryHandle | FakeFileHandle] - > { - for (const [name, child] of this.#node.children.entries()) { - if (child.kind === "file") { - yield [name, new FakeFileHandle(child)]; - } else { - yield [name, new FakeDirectoryHandle(child)]; - } - } - } -} diff --git a/src/lib/notes/DatabaseIO.ts b/src/lib/notes/DatabaseIO.ts index a28f0f51..eb565c34 100644 --- a/src/lib/notes/DatabaseIO.ts +++ b/src/lib/notes/DatabaseIO.ts @@ -31,6 +31,7 @@ import { } from "./slugUtils.js"; import { FileInfo } from "./types/FileInfo.js"; import { CanonicalNoteHeader } from "./types/CanonicalNoteHeader.js"; +import { removeSlugFromIndexes, updateIndexes } from "./indexUtils.js"; export type FlushCallback = (change: { canonicalNoteSlugs: Set | "all"; @@ -39,6 +40,11 @@ export type FlushCallback = (change: { flushPins: boolean; }) => Promise; +type ParsedGraphFile + = { kind: "alias"; target: Slug } + | { kind: "file"; fileInfo: FileInfo } + | { kind: "note"; note: ExistingNote }; + export default class DatabaseIO { #storageProvider: StorageProvider; #loadedGraph: Graph | null = null; @@ -47,6 +53,11 @@ export default class DatabaseIO { #onFlush: FlushCallback | null = null; static #PINS_FILENAME = ".pins.neno"; + /* + Past this many changed files, re-reading the whole graph is cheaper + than updating the indexes one note at a time. + */ + static #MAX_INCREMENTAL_RELOAD = 300; static #GRAPH_FILE_EXTENSION = ".subtext"; static #ALIAS_HEADER_KEY = "alias-of"; static #ARBITRARY_FILE_HEADER_KEY = "file"; @@ -110,6 +121,52 @@ export default class DatabaseIO { ); } + /* + What one graph file describes: an alias, an uploaded file, or a note. + Null for a file that does not parse. + */ + static #parseGraphFile( + slug: Slug, + serializedGraphFile: string, + ): ParsedGraphFile | null { + try { + const headers = parseGraphFileHeaders( + cleanSerializedNote(serializedGraphFile), + ); + + if (headers.has(DatabaseIO.#ALIAS_HEADER_KEY)) { + return { + kind: "alias", + target: headers.get(DatabaseIO.#ALIAS_HEADER_KEY)!, + }; + } + + if ( + headers.has(DatabaseIO.#ARBITRARY_FILE_HEADER_KEY) + && headers.has(DatabaseIO.#ARBITRARY_FILE_SIZE_HEADER_KEY) + ) { + return { + kind: "file", + fileInfo: { + slug, + size: parseInt( + headers.get(DatabaseIO.#ARBITRARY_FILE_SIZE_HEADER_KEY)!, + ), + filename: headers.get(DatabaseIO.#ARBITRARY_FILE_HEADER_KEY)!, + createdAt: headers.get("created-at"), + }, + }; + } + + return { + kind: "note", + note: parseSerializedExistingGraphFile(serializedGraphFile, slug), + }; + } catch (_e) { + return null; + } + } + private async parseGraph( serializedGraphFiles: Map, pinsSerialized?: string, @@ -119,37 +176,13 @@ export default class DatabaseIO { const files = new Map(); for (const [slug, serializedGraphFile] of serializedGraphFiles) { - try { - const serializedGraphFileCleaned = cleanSerializedNote( - serializedGraphFile, - ); - const headers = parseGraphFileHeaders(serializedGraphFileCleaned); - - if (headers.has(DatabaseIO.#ALIAS_HEADER_KEY)) { - const targetSlug = headers.get(DatabaseIO.#ALIAS_HEADER_KEY)!; - aliases.set(slug, targetSlug); - } else if ( - headers.has(DatabaseIO.#ARBITRARY_FILE_HEADER_KEY) - && headers.has(DatabaseIO.#ARBITRARY_FILE_SIZE_HEADER_KEY) - ) { - const fileInfo: FileInfo = { - slug, - size: parseInt( - headers.get(DatabaseIO.#ARBITRARY_FILE_SIZE_HEADER_KEY)!, - ), - filename: headers.get(DatabaseIO.#ARBITRARY_FILE_HEADER_KEY)!, - createdAt: headers.get("created-at"), - }; - files.set(slug, fileInfo); - } else { - const parsedNote = parseSerializedExistingGraphFile( - serializedGraphFile, - slug, - ); - parsedNotes.set(slug, parsedNote); - } - } catch (_e) { - continue; + const parsed = DatabaseIO.#parseGraphFile(slug, serializedGraphFile); + if (parsed?.kind === "alias") { + aliases.set(slug, parsed.target); + } else if (parsed?.kind === "file") { + files.set(slug, parsed.fileInfo); + } else if (parsed?.kind === "note") { + parsedNotes.set(slug, parsed.note); } } @@ -438,6 +471,85 @@ export default class DatabaseIO { } + /* + Brings the in-memory graph up to date after something outside NENO — + a pull — changed these files on disk. Paths are relative to the graph + folder, as git reports them. + */ + async reloadPaths(paths: string[]): Promise { + // Nothing cached yet: the next getGraph() reads everything anyway. + if (!this.#loadedGraph) return; + + if (paths.length > DatabaseIO.#MAX_INCREMENTAL_RELOAD) { + await this.getGraph(true); + return; + } + + const graph = await this.getGraph(); + const read = async (filename: string): Promise => { + try { + return await this.#storageProvider.readObjectAsString(filename); + } catch (_e) { + return null; + } + }; + + // Read everything first, so the graph changes in one synchronous go. + const changedGraphFiles = new Map(); + let pins: string | null | undefined; + for (const filePath of paths) { + const normalized = filePath.normalize("NFC"); + if (normalized === DatabaseIO.#PINS_FILENAME) { + pins = await read(normalized); + continue; + } + if (!normalized.endsWith(DatabaseIO.#GRAPH_FILE_EXTENSION)) { + // An uploaded file's payload. Its graph file carries the metadata. + continue; + } + const slug = DatabaseIO.getSlugFromGraphFilename(normalized); + // The payload of an uploaded foo.subtext, not a graph file. + if (!isValidSlug(slug) || graph.files.has(normalized)) continue; + changedGraphFiles.set(slug, await read(normalized)); + } + + const parsedGraphFiles = new Map( + Array.from(changedGraphFiles, ([slug, content]) => [ + slug, + content === null ? null : DatabaseIO.#parseGraphFile(slug, content), + ]), + ); + + /* + An alias changes the backlinks of every note linking to it, which + the per-note index update cannot express. + */ + for (const [slug, parsed] of parsedGraphFiles) { + if (graph.aliases.has(slug) || parsed?.kind === "alias") { + await this.getGraph(true); + return; + } + } + + for (const [slug, parsed] of parsedGraphFiles) { + graph.notes.delete(slug); + graph.files.delete(slug); + removeSlugFromIndexes(graph, slug); + + if (parsed?.kind === "file") { + graph.files.set(slug, parsed.fileInfo); + } else if (parsed?.kind === "note") { + graph.notes.set(slug, parsed.note); + updateIndexes(graph, parsed.note); + } + } + + if (pins !== undefined) { + graph.pinnedNotes = pins === null ? [] : DatabaseIO.parsePinsFile(pins); + } + } + + // flushChanges makes sure that the changes applied to the graph object are // written to the disk and thus are persistent. It should always be called // after any operation on the internal graph representation diff --git a/src/lib/notes/index.ts b/src/lib/notes/index.ts index c5a1e355..938c8392 100644 --- a/src/lib/notes/index.ts +++ b/src/lib/notes/index.ts @@ -90,6 +90,15 @@ export default class NotesProvider { } + /* + Re-reads these files — paths relative to the graph folder — after + they changed on disk without NENO, e.g. in a pull. + */ + async reloadPaths(paths: string[]): Promise { + await this.#io.reloadPaths(paths); + } + + async get( slug: Slug, options?: { diff --git a/src/lib/syncStatus.ts b/src/lib/syncStatus.ts new file mode 100644 index 00000000..6ae47e09 --- /dev/null +++ b/src/lib/syncStatus.ts @@ -0,0 +1,50 @@ +/* + The open folder's auto-sync status, mirrored from the Electron main + process. One subscription to main for the whole app, however many + components read it. +*/ + +import { SyncStatus } from "./git/types"; +import { getBridge, isElectron } from "./electron/bridge"; + +let status: SyncStatus | null = null; +let started = false; +const listeners = new Set<() => void>(); + +const notify = (): void => { + for (const listener of listeners) listener(); +}; + +const adopt = (next: SyncStatus | null): void => { + status = next; + notify(); +}; + +function start(): void { + if (started || !isElectron()) return; + started = true; + const bridge = getBridge(); + bridge.onSyncStatus(adopt); + void bridge.getSyncStatus().then(adopt); + // A remote that was out of reach may be back: try now, not on schedule. + window.addEventListener("online", () => { + void bridge.syncNow(); + }); +} + +export const subscribeSyncStatus = (listener: () => void): () => void => { + start(); + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +export const getSyncStatus = (): SyncStatus | null => status; + +/** Rejects with the reason when sync cannot be switched on. */ +export const setAutoSync = async (enabled: boolean): Promise => { + adopt(await getBridge().setAutoSync(enabled)); +}; + +export const syncNow = (): Promise => getBridge().syncNow();