From 172dc0b56342d0284eb6e4a11a9e451d9196764a Mon Sep 17 00:00:00 2001 From: callumalpass Date: Wed, 28 Oct 2026 16:59:10 +1100 Subject: [PATCH] Stabilize recently modified manuscript ordering by path --- apps/writer/src/backend/native.test.ts | 40 ++++++++++++++++++++++++++ apps/writer/src/ui/Home.test.tsx | 26 ++++++++++++++++- apps/writer/src/ui/names.test.ts | 23 +++++++++++---- apps/writer/src/ui/names.ts | 9 +++--- 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/apps/writer/src/backend/native.test.ts b/apps/writer/src/backend/native.test.ts index 158cf00b..d18f3a40 100644 --- a/apps/writer/src/backend/native.test.ts +++ b/apps/writer/src/backend/native.test.ts @@ -4,6 +4,7 @@ import { MemoryReplica, type MemoryContract } from "@mdbase-dev/sdk/testing"; import manifest from "../generated/mdbase-app.json"; import { NativeWriterBackend } from "./native.js"; import { nativeObject, nativeWriterSchema } from "./native-schema.js"; +import { byRecentlyEdited } from "../ui/names.js"; // Native catalog/type metadata are explicit stand-ins. All record/file/session // operations use actual public SDK over MemoryReplica, not native acceptance. @@ -72,6 +73,45 @@ describe("native Writer domain backend", () => { expect(get).not.toHaveBeenCalled(); expect(h.metadata).not.toHaveBeenCalled(); expect("writeFile" in h.backend).toBe(false); }); + it("keeps per-record SDK modification times through projection and refreshes after local and synced edits", async () => { + const h = await fixture(); + const second = h.replica.seed({path: "manuscripts/second.md", types: ["native-manuscript"], frontmatter: {type: "native-manuscript", meta: new Map([["heading", "A second paper"]])}, body: "Second\n"}); + // MemoryReplica does not supply mtime. Stand in only for that public query + // metadata; edits, sessions, projections and watch delivery use the SDK. + const times = new Map([[h.manuscript.id, Date.parse("2026-01-01T00:00:00Z")], [second.id, Date.parse("2026-02-01T00:00:00Z")]]); + const query = h.client.query.bind(h.client); + vi.spyOn(h.client, "query").mockImplementation(async (...args) => { + const page = await query(...args); + return {...page, records: page.records.map(record => ({...record, ...(times.has(record.id) ? {mtime: times.get(record.id)!} : {})}))}; + }); + const listed = async () => { + const result = await h.backend.listManuscripts(); + if (!result.ok) throw new Error(result.message); + return byRecentlyEdited(result.value); + }; + expect((await listed()).map(record => [record.path, record.modified])).toEqual([ + [second.path, "2026-02-01T00:00:00.000Z"], [h.manuscript.path, "2026-01-01T00:00:00.000Z"], + ]); + const changed = vi.fn(); h.backend.onCollectionChange(changed); + const opened = await h.backend.records.open(h.manuscript.path, {autosave: false}); + if (!opened.ok) throw new Error("Missing native lease"); + times.set(h.manuscript.id, Date.parse("2026-03-01T00:00:00Z")); + opened.value.session.setBody("Locally edited\n"); + expect((await opened.value.session.flush()).ok).toBe(true); + await vi.waitFor(() => expect(changed).toHaveBeenCalled()); + await vi.waitFor(async () => expect((await listed())[0]).toMatchObject({path: h.manuscript.path, modified: "2026-03-01T00:00:00.000Z"})); + changed.mockClear(); + times.set(second.id, Date.parse("2026-04-01T00:00:00Z")); + h.replica.editElsewhere(second.id, {body: "Synced edit\n"}); + await vi.waitFor(() => expect(changed).toHaveBeenCalled()); + await vi.waitFor(async () => expect((await listed())[0]).toMatchObject({path: second.path, modified: "2026-04-01T00:00:00.000Z"})); + // A newer collection snapshot from an unrelated edit must not retime papers. + changed.mockClear(); + h.replica.editElsewhere("notes/chapter.md", {body: "Unrelated sync\n"}); + await vi.waitFor(() => expect(changed).toHaveBeenCalled()); + await vi.waitFor(async () => expect((await listed()).map(record => record.modified)).toEqual(["2026-04-01T00:00:00.000Z", "2026-03-01T00:00:00.000Z"])); + opened.value.release(); + }); it("unrelated invalid definitions do not hide Writer records or untyped notes", async () => { const h = await fixture(); h.description.issues.push({code: "type_invalid", severity: "error", message: "An unrelated custom type is invalid", details: new Map([["path", "_types/unrelated.md"]])}); diff --git a/apps/writer/src/ui/Home.test.tsx b/apps/writer/src/ui/Home.test.tsx index a6822c17..b9a3276e 100644 --- a/apps/writer/src/ui/Home.test.tsx +++ b/apps/writer/src/ui/Home.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, screen, waitFor } from "@testing-library/react"; import { render } from "../test/render.js"; import { afterEach, beforeAll, expect, it, vi } from "vitest"; import { fail, ok, type CollectionIndex, type ManuscriptSummary, type Result, type WriterBackend } from "../backend/types.js"; @@ -42,6 +42,30 @@ it("lists untyped records with a calm No type label and opens them", async () => expect(onOpen).toHaveBeenCalledWith("notes/loose.md"); }); +it("orders by record modification time and reorders on collection notifications", async () => { + let manuscripts: ManuscriptSummary[] = [ + {path: "z.md", title: "A title", modified: "2026-02-01T00:00:00Z"}, + {path: "a.md", title: "Z title", modified: "2026-02-01T00:00:00Z"}, + {path: "old.md", title: "Older", modified: "2026-01-01T00:00:00Z"}, + ]; + let notify: Parameters>[0] = () => {}; + const fixture: WriterBackend = { + ...backend({recordPaths: manuscripts.map(record => record.path), notePaths: [], filePaths: []}), + listManuscripts: async () => ok(manuscripts), + onCollectionChange: listener => {notify = listener; return () => {};}, + }; + const view = render(); + const paths = () => [...view.container.querySelectorAll(".manuscript-row")].map(row => row.getAttribute("data-path")); + await waitFor(() => expect(paths()).toEqual(["a.md", "z.md", "old.md"])); + // A local edit and then a sync/reset both take the new per-record metadata. + manuscripts = manuscripts.map(record => record.path === "old.md" ? {...record, modified: "2026-03-01T00:00:00Z"} : record); + act(() => notify({paths: ["old.md"]})); + await waitFor(() => expect(paths()).toEqual(["old.md", "a.md", "z.md"])); + manuscripts = manuscripts.map(record => record.path === "z.md" ? {...record, modified: "2026-04-01T00:00:00Z"} : record).reverse(); + act(() => notify({paths: ["z.md"], reset: true})); + await waitFor(() => expect(paths()).toEqual(["z.md", "old.md", "a.md"])); +}); + it("names an unavailable type plainly, once, without blocking the rest", async () => { render(); expect(await screen.findByText(message)).toBeTruthy(); diff --git a/apps/writer/src/ui/names.test.ts b/apps/writer/src/ui/names.test.ts index 7ba6ee47..1ab3c761 100644 --- a/apps/writer/src/ui/names.test.ts +++ b/apps/writer/src/ui/names.test.ts @@ -10,13 +10,24 @@ describe("noteName", () => { }); describe("byRecentlyEdited", () => { - it("lists the newest first, then those without a time by title", () => { + it("lists the newest first, then unknown times by path without mutating the input", () => { const list = [ - { title: "B", modified: "2026-01-01T00:00:00Z" }, - { title: "Z" }, - { title: "A" }, - { title: "C", modified: "2026-03-01T00:00:00Z" }, + { path: "b.md", modified: "2026-01-01T00:00:00Z" }, + { path: "z.md" }, + { path: "a.md", modified: "invalid" }, + { path: "c.md", modified: "2026-03-01T00:00:00Z" }, ]; - expect(byRecentlyEdited(list).map((m) => m.title)).toEqual(["C", "B", "A", "Z"]); + expect(byRecentlyEdited(list).map((m) => m.path)).toEqual(["c.md", "b.md", "a.md", "z.md"]); + expect(list.map((m) => m.path)).toEqual(["b.md", "z.md", "a.md", "c.md"]); + }); + + it("breaks equal instants by path, not title or arrival order", () => { + const list = [ + { path: "z.md", title: "A", modified: "2026-03-01T01:00:00+01:00" }, + { path: "a.md", title: "Z", modified: "2026-03-01T00:00:00Z" }, + ]; + expect(byRecentlyEdited(list).map((m) => m.path)).toEqual(["a.md", "z.md"]); + expect(byRecentlyEdited([...list].reverse())).toEqual(byRecentlyEdited(list)); + expect(byRecentlyEdited(list.map((m) => ({...m, title: "Renamed"}))).map((m) => m.path)).toEqual(["a.md", "z.md"]); }); }); diff --git a/apps/writer/src/ui/names.ts b/apps/writer/src/ui/names.ts index bbbef32e..61d3db52 100644 --- a/apps/writer/src/ui/names.ts +++ b/apps/writer/src/ui/names.ts @@ -1,7 +1,7 @@ import { t } from "@lingui/core/macro"; // Names people read for the settings' stored values. import { STYLES } from "@mdbase-writer/core/styles"; -import { formatDate, formatRelative, intlLocale } from "../i18n/presentation.js"; +import { formatDate, formatRelative } from "../i18n/presentation.js"; /** A bundled layout's name, or a collection template's file name. */ export function templateName(template: string): string { @@ -41,13 +41,14 @@ export function noteName(path: string): string { return base ? base.charAt(0).toUpperCase() + base.slice(1) : path; } -/** Manuscripts by when they were last edited, newest first; those without a time after, by title. */ -export function byRecentlyEdited(list: readonly T[]): T[] { +/** Record modification time, newest first; unknown times last. Paths break ties + * independently of titles, locale and discovery/sync arrival order. */ +export function byRecentlyEdited(list: readonly T[]): T[] { const time = (m: T) => (m.modified ? Date.parse(m.modified) : Number.NaN); return [...list].sort((a, b) => { const ta = time(a); const tb = time(b); if (Number.isNaN(ta) !== Number.isNaN(tb)) return Number.isNaN(ta) ? 1 : -1; - return (Number.isNaN(ta) ? 0 : tb - ta) || a.title.localeCompare(b.title, intlLocale()); + return (Number.isNaN(ta) ? 0 : tb - ta) || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0); }); }