c2ced00ce4c7

show a change to a file's final newline as a changed last line with the no-newline marker, and record the phase 3 review fixes in continuity (v3.0.18)

time
2026-09-26 18:38 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
56d982f71079
3 files package.json+1 −1src/pages/save.ts+16 −2tests/pages.test.ts+28 −2
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.17",3+	"version": "3.0.18",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/pages/save.ts+16 −2
@@ -135,7 +135,7 @@135135 	}136136 	const oldText = new TextDecoder().decode(oldBytes);137137 	const newText = new TextDecoder().decode(newBytes);138-	const edits = diffLines(lines(oldText), lines(newText));138+	const edits = diffLines(compared(oldText), compared(newText));139139 	const added = edits.filter((e) => e.kind === "+").length;140140 	const removed = edits.filter((e) => e.kind === "-").length;141141 	return { ...change, kind: "text", oldText, newText, edits, added, removed };@@ -145,6 +145,14 @@145145 	return text === "" ? [] : text.replace(/\n$/, "").split("\n");146146 }147147 148+// A last line without its newline differs from the same line with one, as in GNU diff: a newline149+// can never occur inside a split line, so it marks the ending for the comparison alone.150+function compared(text: string): string[] {151+	const split = lines(text);152+	if (split.length > 0 && !text.endsWith("\n")) split[split.length - 1] += "\n";153+	return split;154+}155+148156 function stat(d: FileDiff): Html {149157 	if (d.kind !== "text") return html`<span class="diff-note">${d.kind}</span>`;150158 	return html`<span class="diff-add">+${d.added}</span> <span class="diff-del">−${d.removed}</span>`;@@ -194,10 +202,16 @@194202 		highlighted(d.oldText, d.path),195203 		highlighted(d.newText, d.path),196204 	]);205+	// The last line of a side that doesn't end in a newline, or -1 when it does.206+	const unterminated = (text: string, count: number) =>207+		text === "" || text.endsWith("\n") ? -1 : count - 1;208+	const oldEnd = unterminated(d.oldText, oldLines.length);209+	const newEnd = unterminated(d.newText, newLines.length);197210 	const row = (e: Edit) => {198211 		const code = e.kind === "-" ? oldLines[e.a] : newLines[e.b];199212 		const kind = e.kind === "+" ? "add" : e.kind === "-" ? "del" : "ctx";200-		return html`<span class="dl dl-${kind}"><span class="ln">${e.kind !== "+" && e.a + 1}</span><span class="ln">${e.kind !== "-" && e.b + 1}</span><span class="ds">${e.kind}</span><span class="lc">${code}</span></span>`;213+		const bare = (e.kind !== "+" && e.a === oldEnd) || (e.kind !== "-" && e.b === newEnd);214+		return html`<span class="dl dl-${kind}"><span class="ln">${e.kind !== "+" && e.a + 1}</span><span class="ln">${e.kind !== "-" && e.b + 1}</span><span class="ds">${e.kind}</span><span class="lc">${code}</span></span>${bare && html`<span class="dh">\\ No newline at end of file</span>`}`;201215 	};202216 	return html`<div class="file-view"><pre class="code diff"><code>${hunkList.map(203217 		(h) => html`<span class="dh">${h.header}</span>${h.edits.map(row)}`,

view file

tests/pages.test.ts+28 −2
@@ -14,6 +14,7 @@1414 let fx: Fixture;1515 let app: Hono;1616 let mirrorDir: string;17+let tailSaves: string[];1718 const get = (path: string) => app.request(path);1819 const body = async (path: string) => (await get(path)).text();1920 @@ -31,17 +32,31 @@3132 		cwd: large.repoDir,3233 		env: { PATH: process.env.PATH, HOME: join(large.repoDir, "../../home"), NO_COLOR: "1" },3334 	});35+	// A file that gains its final newline, then loses it again.36+	const tail = await makeKohBucket("tail");37+	const tailEnv = { PATH: process.env.PATH, HOME: join(tail.repoDir, "../../home"), NO_COLOR: "1" };38+	for (const [text, message] of [39+		["a\nb", "add tail"],40+		["a\nb\n", "end tail with a newline"],41+		["a\nb", "drop the newline"],42+	] as const) {43+		await writeFile(join(tail.repoDir, "tail.txt"), text);44+		Bun.spawnSync(["koh3", "save", message], { cwd: tail.repoDir, env: tailEnv });45+	}46+	tailSaves = JSON.parse(47+		Bun.spawnSync(["koh3", "log", "--json"], { cwd: tail.repoDir, env: tailEnv }).stdout.toString(),48+	).saves.map((save: { id: string }) => save.id);3449 	const dataDir = await mkdtemp(join(tmpdir(), "kepr-pages-"));3550 	mirrorDir = join(dataDir, "mirror");3651 	const kepr = openKeprDb(dataDir);37-	for (const bucketDir of [fx.bucketDir, secretRepo.bucketDir, large.bucketDir]) {52+	for (const bucketDir of [fx.bucketDir, secretRepo.bucketDir, large.bucketDir, tail.bucketDir]) {3853 		const bucket = openBucket({ kind: "folder", path: bucketDir });3954 		await syncAll({ bucket, kepr, mirrorDir, tarballDir: join(dataDir, "tarballs") });4055 	}4156 	kepr.run(4257 		"UPDATE repos SET public = 1, install1 = 'nix profile add x', homepage = 'https://demo.example' WHERE name = 'demo'",4358 	);44-	kepr.run("UPDATE repos SET public = 1 WHERE name = 'large'");59+	kepr.run("UPDATE repos SET public = 1 WHERE name IN ('large', 'tail')");4560 	app = createApp(4661 		{4762 			domain: "kepr.example",@@ -311,6 +326,17 @@311326 		const second_ = await body(`/demo/saves/${second()}`);312327 		expect(second_).toContain("<dt>agent</dt><dd>test-agent</dd><dt>session</dt><dd>fixture</dd>");313328 		expect(second_).not.toContain("save-flag");329+	});330+331+	test("a change to the final newline alone is a changed last line, marked as in GNU diff", async () => {332+		const marker = '<span class="dh">\\ No newline at end of file</span>';333+		const row = (kind: string, a: string, b: string, sign: string) =>334+			`<span class="dl dl-${kind}"><span class="ln">${a}</span><span class="ln">${b}</span><span class="ds">${sign}</span><span class="lc">b</span></span>`;335+		const gained = await body(`/tail/saves/${tailSaves[1]}`);336+		expect(gained).toContain('<span class="diff-add">+1</span> <span class="diff-del">−1</span>');337+		expect(gained).toContain(`${row("del", "2", "", "-")}${marker}${row("add", "", "2", "+")}<`);338+		const lost = await body(`/tail/saves/${tailSaves[0]}`);339+		expect(lost).toContain(`${row("del", "2", "", "-")}${row("add", "", "2", "+")}${marker}`);314340 	});315341 316342 	test("a prefix redirects, and a checkpoint, an unknown or a private save is a 404", async () => {

view file