diff --git a/packages/app/package.json b/packages/app/package.json index a3bc355..b3c900d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -16,6 +16,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@nibling/sdk": "workspace:*", "@nibling/web": "workspace:*" }, "devDependencies": { diff --git a/packages/app/src/platform/files.ts b/packages/app/src/platform/files.ts new file mode 100644 index 0000000..e486737 --- /dev/null +++ b/packages/app/src/platform/files.ts @@ -0,0 +1,55 @@ +import type { FileTree } from "@nibling/sdk"; +import type { Files } from "../files.ts"; + +/** A project's files as the views see them, plus what a save needs: which paths changed and a copy to write. */ +export interface ProjectFiles extends Files { + /** Paths whose bytes differ from the last saved tree, sorted. */ + dirty(): string[]; + /** A copy of the current tree. */ + snapshot(): FileTree; + /** + * Records `tree` as what the PDS holds. Pass the snapshot a save wrote, so edits made while the save + * was in flight stay dirty. + */ + markSaved(tree: FileTree): void; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export function projectFiles(tree: FileTree): ProjectFiles { + const current = new Map(tree); + let saved: FileTree = new Map(tree); + const touched = new Set(); + const listeners = new Set<(path: string) => void>(); + const changed = (path: string) => !same(current.get(path), saved.get(path)); + + return { + list: () => [...current.keys()].sort(), + read(path) { + const bytes = current.get(path); + return bytes && decoder.decode(bytes); + }, + write(path, text) { + current.set(path, encoder.encode(text)); + touched.add(path); + for (const listener of listeners) listener(path); + }, + watch(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + dirty: () => [...touched].filter(changed).sort(), + snapshot: () => new Map(current), + markSaved(next) { + saved = new Map(next); + for (const path of touched) if (!changed(path)) touched.delete(path); + }, + }; +} + +function same(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean { + if (a === b) return true; + if (!a || !b || a.length !== b.length) return false; + return a.every((byte, i) => byte === b[i]); +} diff --git a/packages/app/tests/project-files.test.ts b/packages/app/tests/project-files.test.ts new file mode 100644 index 0000000..05bc77d --- /dev/null +++ b/packages/app/tests/project-files.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { projectFiles } from "../src/platform/files.ts"; + +const bytes = (text: string) => new TextEncoder().encode(text); +const texts = (tree: Map) => Object.fromEntries([...tree].map(([path, b]) => [path, new TextDecoder().decode(b)])); + +test("reads and lists the tree as text", () => { + const files = projectFiles(new Map([["src/a.ts", bytes("a")], ["index.html", bytes("

hi

")]])); + assert.deepEqual(files.list(), ["index.html", "src/a.ts"]); + assert.equal(files.read("index.html"), "

hi

"); + assert.equal(files.read("missing.txt"), undefined); + assert.deepEqual(files.dirty(), []); +}); + +test("a write is dirty until it matches the saved bytes again", () => { + const files = projectFiles(new Map([["a.txt", bytes("one")]])); + files.write("a.txt", "two"); + files.write("b.txt", "new"); + assert.deepEqual(files.dirty(), ["a.txt", "b.txt"]); + files.write("a.txt", "one"); + assert.deepEqual(files.dirty(), ["b.txt"]); +}); + +test("watch reports each write's path until unsubscribed", () => { + const files = projectFiles(new Map()); + const seen: string[] = []; + const stop = files.watch((path) => seen.push(path)); + files.write("a.txt", "1"); + files.write("b.txt", "2"); + stop(); + files.write("c.txt", "3"); + assert.deepEqual(seen, ["a.txt", "b.txt"]); +}); + +test("snapshot is a copy, and markSaved keeps edits made after it dirty", () => { + const files = projectFiles(new Map([["a.txt", bytes("one")]])); + files.write("a.txt", "two"); + const snapshot = files.snapshot(); + files.write("b.txt", "during save"); + assert.deepEqual(texts(snapshot), { "a.txt": "two" }); + files.markSaved(snapshot); + assert.deepEqual(files.dirty(), ["b.txt"]); + assert.deepEqual(texts(files.snapshot()), { "a.txt": "two", "b.txt": "during save" }); +}); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 4650dc4..58bba0c 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -19,6 +19,7 @@ "@atcute/lexicons": "^2.1.1", "@atcute/oauth-browser-client": "^5.1.0", "@nibling/lexicons": "workspace:*", + "buffer": "^6.0.3", "isomorphic-git": "^1.44.11" }, "devDependencies": { diff --git a/packages/sdk/src/tangled/git.ts b/packages/sdk/src/tangled/git.ts index b84c99b..150249b 100644 --- a/packages/sdk/src/tangled/git.ts +++ b/packages/sdk/src/tangled/git.ts @@ -1,9 +1,13 @@ +import { Buffer } from 'buffer'; import * as git from 'isomorphic-git'; import type { PromiseFsClient, TreeEntry } from 'isomorphic-git'; import http from 'isomorphic-git/http/web'; import { TangledError } from './errors.ts'; import { createMemoryFs } from './memfs.ts'; +// isomorphic-git 1.44 reads a global Buffer and throws "Missing Buffer dependency" in browsers, which have none. +globalThis.Buffer ??= Buffer; + const dir = '/repo'; const gitdir = '/repo/.git'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fb9142..73abee1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: packages/app: dependencies: + '@nibling/sdk': + specifier: workspace:* + version: link:../sdk '@nibling/web': specifier: workspace:* version: link:../web @@ -155,6 +158,9 @@ importers: '@nibling/lexicons': specifier: workspace:* version: link:../lexicons + buffer: + specifier: ^6.0.3 + version: 6.0.3 isomorphic-git: specifier: ^1.44.11 version: 1.44.11 @@ -1050,9 +1056,15 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1178,6 +1190,9 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2076,8 +2091,15 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + base64-js@1.5.1: {} + blake3-wasm@2.1.5: {} + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -2242,6 +2264,8 @@ snapshots: dependencies: function-bind: 1.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} inherits@2.0.4: {}