Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/writer/src/backend/native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"]])});
Expand Down
26 changes: 25 additions & 1 deletion apps/writer/src/ui/Home.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<NonNullable<WriterBackend["onCollectionChange"]>>[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(<Home backend={fixture} onOpen={vi.fn()} />);
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(<Home backend={backend({recordPaths: [], filePaths: [], notePaths: [], unavailableTypes: [{name: "native-comment", message}]}, fail(message))} onOpen={vi.fn()} />);
expect(await screen.findByText(message)).toBeTruthy();
Expand Down
23 changes: 17 additions & 6 deletions apps/writer/src/ui/names.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
});
9 changes: 5 additions & 4 deletions apps/writer/src/ui/names.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<T extends { readonly title: string; readonly modified?: string }>(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<T extends { readonly path: string; readonly modified?: string }>(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);
});
}