From 3f35f80de87ee447bd4f04e00d59af01dea7dd6e Mon Sep 17 00:00:00 2001 From: DilsonsPickles <111350316+DilsonsPickles@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:21:45 +0700 Subject: [PATCH] edit via manual editor --- manual-editor/netlify/functions/page.mts | 5 - manual-editor/src/app/App.tsx | 22 +- manual-editor/src/app/Editor.tsx | 190 ++++-------------- manual-editor/src/app/NewPageDialog.tsx | 16 +- manual-editor/src/app/PageList.tsx | 3 +- manual-editor/src/app/ReadOnlyDoc.tsx | 28 --- manual-editor/src/app/api.ts | 4 - manual-editor/src/app/autosave.test.tsx | 16 +- manual-editor/src/app/editor.css | 84 -------- manual-editor/src/app/editorHeader.test.tsx | 4 +- manual-editor/src/app/insertCommands.test.tsx | 1 - manual-editor/src/app/insertCommands.ts | 7 +- .../src/app/slash/SlashCommand.test.ts | 1 - manual-editor/src/app/slash/SlashMenu.tsx | 24 +-- manual-editor/src/backend/inMemoryBackend.ts | 5 - .../src/backend/octokitBackend.test.ts | 46 ++--- manual-editor/src/backend/octokitBackend.ts | 95 ++++----- manual-editor/src/backend/types.ts | 2 - .../manual/audacity-basics/anton-s-draft.mdx | 2 - .../manual/audacity-basics/test-page.mdx | 19 +- src/content/manual/manual-index/page-1.mdx | 9 +- 21 files changed, 131 insertions(+), 452 deletions(-) delete mode 100644 manual-editor/src/app/ReadOnlyDoc.tsx diff --git a/manual-editor/netlify/functions/page.mts b/manual-editor/netlify/functions/page.mts index 5bb773bb..6fda9338 100644 --- a/manual-editor/netlify/functions/page.mts +++ b/manual-editor/netlify/functions/page.mts @@ -13,12 +13,7 @@ export default async (request: Request): Promise => { return json({ error: "not found" }, 404); } } - const base = url.searchParams.get("base") === "1"; try { - if (base) { - const content = await backend.readBasePage(path); - return json(content); - } return json(await backend.readPage(path)); } catch { return json({ error: "not found" }, 404); diff --git a/manual-editor/src/app/App.tsx b/manual-editor/src/app/App.tsx index 6701983f..93b6ef53 100644 --- a/manual-editor/src/app/App.tsx +++ b/manual-editor/src/app/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { api as defaultApi, type makeApi, type Me } from "./api"; import { Editor } from "./Editor"; import { PageList } from "./PageList"; @@ -71,8 +71,6 @@ export function App({ | null >(null); - const editorFlushRef = useRef<(() => Promise) | null>(null); - function openNewPage(parent: ManualPageMeta | null) { setNewPageIntent(parent ? { kind: "child", parent } : { kind: "top" }); } @@ -100,7 +98,6 @@ export function App({ setPublishError(null); setPublishResult(null); try { - await editorFlushRef.current?.(); const result = await api.publish(); setPublishResult(result); api.listPages().then(setPages); @@ -190,17 +187,9 @@ export function App({ // (same "always re-fetch rather than patch locally" reasoning as // `handleDraftSaved` above — the backend's listing already reflects the // deletion by the time `onDeleted` fires). - function handleDeleted(deletedPath: string) { + function handleDeleted() { setSource(null); setActivePath(null); - // Optimistically drop the page so the sidebar updates instantly — the - // GitHub tree API returns a cached ref for a moment after the delete - // commit lands, so a bare re-fetch here would briefly show the page as - // still present. The background re-fetch corrects any other side effects - // (hasDraft dots etc.) once GitHub catches up. - setPages((prev) => - prev ? prev.filter((p) => p.path !== deletedPath) : prev, - ); api.listPages().then(setPages); } @@ -209,9 +198,6 @@ export function App({ // `Editor` to gate the header's delete action (deleting a parent would // orphan its children in the tree). const activeSlug = activeSlugFromPath(activePath); - const activePageMeta = activePath - ? pages?.find((p) => p.path === activePath) - : undefined; const hasChildren = activeSlug !== null && (pages?.some( @@ -318,15 +304,13 @@ export function App({ sections={sections} pages={pages ?? []} api={api} - flushRef={editorFlushRef} onDraftSaved={handleDraftSaved} onAddSubpage={() => { const meta = pages?.find((p) => p.path === activePath); if (meta) openNewPage(meta); }} hasChildren={hasChildren} - hasDraft={activePageMeta?.hasDraft ?? false} - onDeleted={(p) => handleDeleted(p)} + onDeleted={handleDeleted} /> ) : (

diff --git a/manual-editor/src/app/Editor.tsx b/manual-editor/src/app/Editor.tsx index 1a27adcf..a5b08e49 100644 --- a/manual-editor/src/app/Editor.tsx +++ b/manual-editor/src/app/Editor.tsx @@ -34,7 +34,6 @@ import { getBlockActions, type BlockAction } from "./blockActions"; import { getSelectedBlocks } from "./blockSelection"; import { HandleMenu } from "./HandleMenu"; import { SelectionBar } from "./SelectionBar"; -import { ReadOnlyDoc } from "./ReadOnlyDoc"; /** Matches the manual content collection schema's `sectionOrder`/`order` default. */ const DEFAULT_ORDER = 99; @@ -235,8 +234,6 @@ export function Editor({ hasChildren, onDeleted, enableDragHandle = true, - flushRef, - hasDraft = false, }: { source: string; path: string; @@ -287,7 +284,7 @@ export function Editor({ * on unmount (see its cleanup), so no extra guard is needed here for the * delete-then-unmount sequence. */ - onDeleted: (path: string) => void; + onDeleted: () => void; /** * Renders the Notion-style block drag handle. Defaults on for the real * app; existing suites that mount `Editor` under happy-dom leave it on @@ -298,18 +295,6 @@ export function Editor({ * case can still opt out per-test without changing the default. */ enableDragHandle?: boolean; - /** - * When provided, Editor populates this ref with an async function that - * flushes any pending autosave and resolves once the save lands. App uses - * it to ensure unsaved changes are committed before calling publish. - */ - flushRef?: { current: (() => Promise) | null }; - /** - * True when this page has unpublished changes on the drafts branch. Used - * to show/hide the "Compare" toggle in the header — comparing only makes - * sense when there's a published version to compare against. - */ - hasDraft?: boolean; }) { const [frontmatterData, setFrontmatterData] = useState(() => toFrontmatterData(parseFrontmatter(source).data), @@ -332,27 +317,6 @@ export function Editor({ // starts collapsed. const [detailsExpanded, setDetailsExpanded] = useState(false); - // Compare mode: show published (base branch) alongside the draft side by - // side. `baseSource` is null when the page is draft-only (no published - // version), "loading" while the fetch is in flight, or the fetched source - // string once resolved. Reset to off on `path` change via `useEditor`'s - // recreation — but `path` is already the mount key so a fresh Editor - // instance always starts with compare off. - const [compareMode, setCompareMode] = useState(false); - const [baseSource, setBaseSource] = useState(null); - - async function handleToggleCompare() { - if (compareMode) { - setCompareMode(false); - setBaseSource(null); - return; - } - setCompareMode(true); - setBaseSource("loading"); - const content = await api.getBasePage(path); - setBaseSource(content ? content.source : null); - } - const doc = useMemo(() => { const { doc } = mdastToDoc(parseMdx(source)); return doc; @@ -561,19 +525,7 @@ export function Editor({ // fire-and-forget (the component may already be gone — no setState), and // is nulled the moment the debounce fires normally so a flush never // double-saves. - const pendingSaveRef = useRef<{ flush: () => Promise } | null>(null); - - // Populate flushRef once so App can await any pending save before publishing. - useEffect(() => { - if (!flushRef) return; - flushRef.current = async () => { - if (pendingSaveRef.current) await pendingSaveRef.current.flush(); - }; - return () => { - if (flushRef) flushRef.current = null; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const pendingSaveRef = useRef<{ flush: () => void } | null>(null); // The debounced autosave itself. `saveVersion` is the trigger: it only // increments once an actual edit has happened (content or frontmatter), @@ -586,15 +538,13 @@ export function Editor({ pendingSaveRef.current = { flush: () => { pendingSaveRef.current = null; - return api + void api .saveDraftDoc( savingPath, editor.getJSON(), serializeFrontmatter(frontmatterData), ) - .then(() => { - onDraftSaved?.(savingPath); - }) + .then(() => onDraftSaved?.(savingPath)) .catch(() => {}); }, }; @@ -636,7 +586,7 @@ export function Editor({ // not flush per keystroke; only leaving the page does. useEffect(() => { return () => { - void pendingSaveRef.current?.flush(); + pendingSaveRef.current?.flush(); }; }, [path]); @@ -675,7 +625,7 @@ export function Editor({ setDeleting(true); try { await api.deletePage(path); - onDeleted(path); + onDeleted(); } catch { setDeleting(false); setConfirmingDelete(false); @@ -693,11 +643,11 @@ export function Editor({ const saveStatusLabel = saveStatus === "saving" - ? "Saving changes…" + ? "Saving…" : saveStatus === "saved" - ? "Changes saved ●" + ? "Saved draft ●" : saveStatus === "dirty" - ? "Unsaved changes" + ? "Edited" : saveStatus === "error" ? "Save failed" : saveStatus === "delete-error" @@ -732,17 +682,6 @@ export function Editor({

- {hasDraft ? ( - - ) : null}
- {compareMode ? ( -
-
-
Published
-
- {baseSource === "loading" ? ( -

Loading…

- ) : baseSource === null ? ( -

- No published version — this page has not been merged yet. -

- ) : ( - - )} -
-
-
-
Your changes
-
setHandleMenu(null)}> - {enableDragHandle && editor ? ( - - - - ) : null} - -
-
-
- ) : ( -
setHandleMenu(null)} - > - {enableDragHandle && editor ? ( - setHandleMenu(null)} + > + {enableDragHandle && editor ? ( + + - - ) : null} - -
- )} + ⠿ + + + ) : null} + + {handleMenu && editor ? ( void; onCancel: () => void; }) { const [title, setTitle] = useState(""); - const [section, setSection] = useState(() => - newSection ? "" : (parent?.section ?? sectionPrefill ?? ""), + const [section, setSection] = useState( + () => parent?.section ?? sectionPrefill ?? "", ); const [location, setLocation] = useState( () => @@ -124,9 +117,7 @@ export function NewPageDialog({ ? `New sub-page of ${parent.title}` : sectionPrefill ? `New page in ${sectionPrefill}` - : newSection - ? "New section" - : "New page"} + : "New page"}
@@ -148,7 +139,6 @@ export function NewPageDialog({ required list={SECTION_LIST_ID} value={section} - autoFocus={newSection} onChange={(e) => setSection(e.target.value)} /> diff --git a/manual-editor/src/app/PageList.tsx b/manual-editor/src/app/PageList.tsx index c0aa7257..63b0fb54 100644 --- a/manual-editor/src/app/PageList.tsx +++ b/manual-editor/src/app/PageList.tsx @@ -136,8 +136,7 @@ function TreeNodeRow({ {node.page.hasDraft ? ( {" "} ● diff --git a/manual-editor/src/app/ReadOnlyDoc.tsx b/manual-editor/src/app/ReadOnlyDoc.tsx deleted file mode 100644 index cece3c08..00000000 --- a/manual-editor/src/app/ReadOnlyDoc.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { EditorContent, useEditor } from "@tiptap/react"; -import { useMemo } from "react"; -import { mdastToDoc } from "../adapter/mdastToDoc"; -import { parseMdx } from "../mdx/pipeline"; -import { buildAppExtensions } from "./editorExtensions"; - -/** - * Renders a page source as a non-editable TipTap view. Used by the compare - * pane to show the published (base-branch) version alongside the live draft. - * Keyed on `source` so the editor is recreated when the source changes. - */ -export function ReadOnlyDoc({ source }: { source: string }) { - const doc = useMemo(() => { - const { doc } = mdastToDoc(parseMdx(source)); - return doc; - }, [source]); - - const editor = useEditor( - { - extensions: buildAppExtensions(), - content: doc, - editable: false, - }, - [source], - ); - - return ; -} diff --git a/manual-editor/src/app/api.ts b/manual-editor/src/app/api.ts index dce5a148..7b81b620 100644 --- a/manual-editor/src/app/api.ts +++ b/manual-editor/src/app/api.ts @@ -76,10 +76,6 @@ export function makeApi(f: typeof fetch = fetch) { f(`/api/page?path=${encodeURIComponent(path)}`).then((r) => jsonOrThrow(r), ), - getBasePage: (path: string) => - f(`/api/page?path=${encodeURIComponent(path)}&base=1`).then((r) => - jsonOrThrow(r), - ), saveDraft: (path: string, source: string) => f("/api/draft", { method: "POST", diff --git a/manual-editor/src/app/autosave.test.tsx b/manual-editor/src/app/autosave.test.tsx index b345863a..800438c3 100644 --- a/manual-editor/src/app/autosave.test.tsx +++ b/manual-editor/src/app/autosave.test.tsx @@ -98,9 +98,7 @@ test("editing content triggers a debounced saveDraftDoc(path, doc, frontmatter) }); await waitFor(() => - expect(screen.getByTestId("save-status").textContent).toBe( - "Changes saved ●", - ), + expect(screen.getByTestId("save-status").textContent).toBe("Saved draft ●"), ); expect(calls.length).toBe(1); @@ -114,7 +112,7 @@ test("editing content triggers a debounced saveDraftDoc(path, doc, frontmatter) expect(containsText(calls[0]!.doc, "EDITED-TEXT")).toBe(true); }); -test("save status shows Saving changes… while the request is in flight, then Changes saved ● after it resolves", async () => { +test("save status shows Saving… while the request is in flight, then Saved draft ● after it resolves", async () => { const { getEditor } = await mountEditor(); const editor = getEditor(); @@ -124,19 +122,15 @@ test("save status shows Saving changes… while the request is in flight, then C }); await waitFor(() => - expect(screen.getByTestId("save-status").textContent).toBe( - "Unsaved changes", - ), + expect(screen.getByTestId("save-status").textContent).toBe("Edited"), ); await waitFor(() => - expect(["Saving changes…", "Changes saved ●"]).toContain( + expect(["Saving…", "Saved draft ●"]).toContain( screen.getByTestId("save-status").textContent, ), ); await waitFor(() => - expect(screen.getByTestId("save-status").textContent).toBe( - "Changes saved ●", - ), + expect(screen.getByTestId("save-status").textContent).toBe("Saved draft ●"), ); }); diff --git a/manual-editor/src/app/editor.css b/manual-editor/src/app/editor.css index 6a436814..4db9e71f 100644 --- a/manual-editor/src/app/editor.css +++ b/manual-editor/src/app/editor.css @@ -582,31 +582,6 @@ body { gap: 0.6rem; } -/* Header "Compare" toggle: same secondary button chrome as "Add sub-page", - with an active/pressed state matching the app's indigo accent. */ -.editor-header__compare { - flex: 0 0 auto; - padding: 0.3rem 0.7rem; - border: 1px solid #cbd5e1; - border-radius: 5px; - background: #ffffff; - color: #1f2937; - font-size: 0.8rem; - font-weight: 600; - white-space: nowrap; - cursor: pointer; -} - -.editor-header__compare:hover { - background: #f1f5f9; -} - -.editor-header__compare[aria-pressed="true"] { - border-color: #4f46e5; - background: #e0e7ff; - color: #4f46e5; -} - /* Header "Add sub-page" action: small secondary button. */ .editor-header__add-subpage { flex: 0 0 auto; @@ -822,65 +797,6 @@ body { background: #fee2e2; } -/* Compare view: two scrolling panes side by side. */ -.editor-compare { - display: flex; - flex: 1; - min-height: 0; - overflow: hidden; -} - -.compare-pane { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.compare-pane + .compare-pane { - border-left: 1px solid #dbe2ea; -} - -.compare-pane__label { - flex: 0 0 auto; - padding: 0.3rem 1.5rem; - background: #f8fafc; - border-bottom: 1px solid #e2e8f0; - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: #64748b; -} - -.compare-pane--published .compare-pane__label { - color: #059669; -} - -.compare-pane--draft .compare-pane__label { - color: #4f46e5; -} - -.compare-pane__scroll { - flex: 1; - min-height: 0; - overflow-y: auto; - padding: 2rem 0.5rem; -} - -.compare-pane__loading, -.compare-pane__empty { - padding: 2rem 1.5rem; - color: #64748b; - font-size: 0.9rem; -} - -.compare-pane--draft .editor-scroll { - flex: 1; - min-height: 0; -} - /* Dropcursor color/width is set via StarterKit's `dropcursor` option (`editorExtensions.ts`); this class-based fallback only kicks in if that option is ever unset, so the indicator never regresses to invisible. */ diff --git a/manual-editor/src/app/editorHeader.test.tsx b/manual-editor/src/app/editorHeader.test.tsx index 06add564..470f0918 100644 --- a/manual-editor/src/app/editorHeader.test.tsx +++ b/manual-editor/src/app/editorHeader.test.tsx @@ -117,9 +117,7 @@ test("the save-status pill hides while idle and exposes the save-status testid o }); await waitFor(() => - expect(screen.getByTestId("save-status").textContent).toBe( - "Unsaved changes", - ), + expect(screen.getByTestId("save-status").textContent).toBe("Edited"), ); expect(screen.getByTestId("save-status-pill").className).not.toContain( "editor-save-pill--hidden", diff --git a/manual-editor/src/app/insertCommands.test.tsx b/manual-editor/src/app/insertCommands.test.tsx index 851601fe..24e9bf7d 100644 --- a/manual-editor/src/app/insertCommands.test.tsx +++ b/manual-editor/src/app/insertCommands.test.tsx @@ -129,7 +129,6 @@ test("insertTabs inserts a tabs node with two starter tab children (Windows/macO expect(tabs?.content?.map((t) => t.attrs?.label)).toEqual([ "Windows", "macOS", - "Linux", ]); for (const tab of tabs?.content ?? []) { expect(tab.content?.[0]?.type).toBe("paragraph"); diff --git a/manual-editor/src/app/insertCommands.ts b/manual-editor/src/app/insertCommands.ts index e2e61db0..6ef659bd 100644 --- a/manual-editor/src/app/insertCommands.ts +++ b/manual-editor/src/app/insertCommands.ts @@ -48,7 +48,7 @@ export function insertAdmonition( .run(); } -/** Inserts a `tabs` node with three starter tabs (Windows/macOS/Linux), each with an empty paragraph. */ +/** Inserts a `tabs` node with two starter tabs (Windows/macOS), each with an empty paragraph. */ export function insertTabs(editor: Editor) { editor .chain() @@ -66,11 +66,6 @@ export function insertTabs(editor: Editor) { attrs: { label: "macOS" }, content: [{ type: "paragraph" }], }, - { - type: "tab", - attrs: { label: "Linux" }, - content: [{ type: "paragraph" }], - }, ], }) .run(); diff --git a/manual-editor/src/app/slash/SlashCommand.test.ts b/manual-editor/src/app/slash/SlashCommand.test.ts index c23d772f..047b55cd 100644 --- a/manual-editor/src/app/slash/SlashCommand.test.ts +++ b/manual-editor/src/app/slash/SlashCommand.test.ts @@ -86,7 +86,6 @@ test("suggestion.command deletes the trigger range and runs the selected item", expect(tabs?.content?.map((t) => t.attrs?.label)).toEqual([ "Windows", "macOS", - "Linux", ]); editor.destroy(); diff --git a/manual-editor/src/app/slash/SlashMenu.tsx b/manual-editor/src/app/slash/SlashMenu.tsx index 28fd4fa6..0e09ffca 100644 --- a/manual-editor/src/app/slash/SlashMenu.tsx +++ b/manual-editor/src/app/slash/SlashMenu.tsx @@ -1,10 +1,4 @@ -import { - forwardRef, - useEffect, - useImperativeHandle, - useRef, - useState, -} from "react"; +import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; import { ReactRenderer } from "@tiptap/react"; import type { SuggestionKeyDownProps, @@ -43,7 +37,6 @@ export const SlashMenuList = forwardRef< SlashMenuListProps >(function SlashMenuList({ items, command }, ref) { const [activeIndex, setActiveIndex] = useState(0); - const listRef = useRef(null); // A fresh filtered `items` array arrives on every keystroke; reset the // selection to the top row so it never points past the new list's end @@ -52,14 +45,6 @@ export const SlashMenuList = forwardRef< setActiveIndex(0); }, [items]); - // Scroll the active item into view when keyboard navigation moves the - // selection outside the visible portion of the menu. - useEffect(() => { - listRef.current - ?.querySelector(".slash-menu__item.is-active") - ?.scrollIntoView({ block: "nearest" }); - }, [activeIndex]); - function selectIndex(index: number) { const item = items[index]; if (!item) return; @@ -105,12 +90,7 @@ export const SlashMenuList = forwardRef< let flatIndex = 0; return ( -
+
{items.length === 0 ? (
No results diff --git a/manual-editor/src/backend/inMemoryBackend.ts b/manual-editor/src/backend/inMemoryBackend.ts index 65a4c870..025a9075 100644 --- a/manual-editor/src/backend/inMemoryBackend.ts +++ b/manual-editor/src/backend/inMemoryBackend.ts @@ -82,11 +82,6 @@ export class InMemoryBackend implements GitHubBackend { if (source === undefined) throw new Error(`No such page: ${path}`); return { path, source }; } - async readBasePage(path: string): Promise { - const source = this.base.get(path); - if (source === undefined) return null; - return { path, source }; - } async saveDraft(changes: FileChange[], _message: string): Promise { for (const c of changes) { this.deleted.delete(c.path); diff --git a/manual-editor/src/backend/octokitBackend.test.ts b/manual-editor/src/backend/octokitBackend.test.ts index cefbe87f..4e23f131 100644 --- a/manual-editor/src/backend/octokitBackend.test.ts +++ b/manual-editor/src/backend/octokitBackend.test.ts @@ -803,18 +803,22 @@ test("saveDraft: missing drafts branch is created off base head, then committed "docs: edit a", ); - // Squash strategy: get BASE head first (parent), then check DRAFTS for - // accumulated tree (404 here), then createTree/createCommit, then - // createRef (not updateRef — branch didn't exist). expect(calls.map((c) => c.op)).toEqual([ + "getRef", // heads/DRAFTS -> 404 "getRef", // heads/BASE - "getCommit", // base commit -> base tree sha - "getRef", // heads/DRAFTS -> 404 (no branch yet) + "createRef", // refs/heads/DRAFTS off base head + "getCommit", // drafts head commit -> tree sha "createTree", "createCommit", - "createRef", // refs/heads/DRAFTS with new squashed commit sha + "updateRef", ]); + const createRefCall = calls.find((c) => c.op === "createRef")!; + expect(createRefCall.args).toEqual({ + ref: `refs/heads/${DRAFTS}`, + sha: `commit-${BASE}-seed`, + }); + const createTreeCall = calls.find((c) => c.op === "createTree")!; expect((createTreeCall.args as any).base_tree).toBe(`tree-${BASE}-seed`); expect((createTreeCall.args as any).tree).toEqual([ @@ -827,16 +831,16 @@ test("saveDraft: missing drafts branch is created off base head, then committed `commit-${BASE}-seed`, ]); - // createRef points at the newly created commit (not the old base HEAD). - const createRefCall = calls.find((c) => c.op === "createRef")!; - expect(createRefCall.args).toMatchObject({ ref: `refs/heads/${DRAFTS}` }); - expect((createRefCall.args as any).sha).toMatch(/^commit-gen-\d+$/); - expect((createRefCall.args as any).sha).not.toBe(`commit-${BASE}-seed`); - - expect(calls.some((c) => c.op === "updateRef")).toBe(false); + const updateRefCall = calls.find((c) => c.op === "updateRef")!; + expect((updateRefCall.args as any).ref).toBe(`heads/${DRAFTS}`); + expect((updateRefCall.args as any).force).toBe(false); + // A freshly generated commit sha (the fake threads real state through: + // it's whatever `createCommit` returned), and distinct from the old head. + expect((updateRefCall.args as any).sha).toMatch(/^commit-gen-\d+$/); + expect((updateRefCall.args as any).sha).not.toBe(`commit-${BASE}-seed`); }); -test("saveDraft: existing drafts branch — squashes onto base head, carries drafts tree, force-updates ref", async () => { +test("saveDraft: existing drafts branch — no createRef, correct parents from drafts head", async () => { const PATH = "src/content/manual/basics/a.mdx"; const { backend, calls } = writeBackendFor({ login: "u", @@ -853,28 +857,20 @@ test("saveDraft: existing drafts branch — squashes onto base head, carries dra expect(calls.some((c) => c.op === "createRef")).toBe(false); expect(calls.map((c) => c.op)).toEqual([ - "getRef", // heads/BASE - "getCommit", // base commit -> base tree sha "getRef", // heads/DRAFTS -> found - "getCommit", // drafts commit -> drafts tree sha (carries accumulated state) + "getCommit", "createTree", "createCommit", - "updateRef", // force: true (squash replaces previous commit) + "updateRef", ]); - // Accumulated drafts tree is used as base_tree so prior unsaved changes - // from other pages are preserved. const createTreeCall = calls.find((c) => c.op === "createTree")!; expect((createTreeCall.args as any).base_tree).toBe(`tree-${DRAFTS}-seed`); - // Parent is BASE head (not drafts head) — the squash. const createCommitCall = calls.find((c) => c.op === "createCommit")!; expect((createCommitCall.args as any).parents).toEqual([ - `commit-${BASE}-seed`, + `commit-${DRAFTS}-seed`, ]); - - const updateRefCall = calls.find((c) => c.op === "updateRef")!; - expect((updateRefCall.args as any).force).toBe(true); }); test("saveImage: createBlob(base64), tree item uses the returned sha, returns repo-relative path", async () => { diff --git a/manual-editor/src/backend/octokitBackend.ts b/manual-editor/src/backend/octokitBackend.ts index 98419bab..9a8a19f0 100644 --- a/manual-editor/src/backend/octokitBackend.ts +++ b/manual-editor/src/backend/octokitBackend.ts @@ -355,12 +355,6 @@ export class OctokitBackend implements GitHubBackend { return { path, source: baseResult }; } - async readBasePage(path: string): Promise { - const result = await this.tryGetContent(path, this.baseBranch); - if (result === "not-found") return null; - return { path, source: result }; - } - private async tryGetContent( path: string, ref: string, @@ -395,63 +389,53 @@ export class OctokitBackend implements GitHubBackend { /** * Commits `tree` (a diff, not a full tree — entries merge onto the - * accumulated drafts tree via `base_tree`) as a single squashed commit - * on top of the base branch's current HEAD, replacing any previous - * drafts commit rather than appending to it. The drafts branch therefore - * always has exactly one commit ahead of base, no matter how many - * autosave cycles have fired — clean history, no per-keystroke noise. - * - * The accumulated drafts tree is still carried forward (used as - * `base_tree`) so incremental saves don't lose earlier unsaved changes - * from other pages. The commit's parent is always base HEAD so the diff - * presented by any PR viewer covers the full changeset in one commit. + * drafts branch's current tree via `base_tree`) as a single atomic + * commit on the drafts branch, creating that branch off the base + * branch's head first if it doesn't exist yet. * * Single-writer assumption (one QA editing at a time): no retry-on- - * conflict loop. Errors propagate to the caller. + * conflict loop. If `updateRef` fails (e.g. drafts moved concurrently), + * the error just propagates to the caller. */ private async commitToDrafts( tree: DraftTreeItem[], message: string, ): Promise { - // Squash parent: always base HEAD, so the drafts branch has one commit. - const baseRef = await this.octokit.git.getRef({ - owner: this.owner, - repo: this.repo, - ref: `heads/${this.baseBranch}`, - }); - const baseHeadSha = baseRef.data.object.sha; - const baseCommit = await this.octokit.git.getCommit({ - owner: this.owner, - repo: this.repo, - commit_sha: baseHeadSha, - }); - - // Carry forward the accumulated drafts tree so this incremental diff - // doesn't overwrite earlier unsaved changes. Falls back to base tree - // on first save (no drafts branch yet). - let draftsTreeSha = baseCommit.data.tree.sha; - let draftsBranchExists = false; + let draftsHeadSha: string; try { - const draftsRef = await this.octokit.git.getRef({ + const ref = await this.octokit.git.getRef({ owner: this.owner, repo: this.repo, ref: `heads/${this.draftsBranch}`, }); - const draftsCommit = await this.octokit.git.getCommit({ + draftsHeadSha = ref.data.object.sha; + } catch (err) { + if (!isNotFound(err)) throw err; + const baseRef = await this.octokit.git.getRef({ owner: this.owner, repo: this.repo, - commit_sha: draftsRef.data.object.sha, + ref: `heads/${this.baseBranch}`, }); - draftsTreeSha = draftsCommit.data.tree.sha; - draftsBranchExists = true; - } catch (err) { - if (!isNotFound(err)) throw err; + const baseHeadSha = baseRef.data.object.sha; + await this.octokit.git.createRef({ + owner: this.owner, + repo: this.repo, + ref: `refs/heads/${this.draftsBranch}`, + sha: baseHeadSha, + }); + draftsHeadSha = baseHeadSha; } + const headCommit = await this.octokit.git.getCommit({ + owner: this.owner, + repo: this.repo, + commit_sha: draftsHeadSha, + }); + const newTree = await this.octokit.git.createTree({ owner: this.owner, repo: this.repo, - base_tree: draftsTreeSha, + base_tree: headCommit.data.tree.sha, tree, }); @@ -460,25 +444,16 @@ export class OctokitBackend implements GitHubBackend { repo: this.repo, message, tree: newTree.data.sha, - parents: [baseHeadSha], + parents: [draftsHeadSha], }); - if (draftsBranchExists) { - await this.octokit.git.updateRef({ - owner: this.owner, - repo: this.repo, - ref: `heads/${this.draftsBranch}`, - sha: newCommit.data.sha, - force: true, - }); - } else { - await this.octokit.git.createRef({ - owner: this.owner, - repo: this.repo, - ref: `refs/heads/${this.draftsBranch}`, - sha: newCommit.data.sha, - }); - } + await this.octokit.git.updateRef({ + owner: this.owner, + repo: this.repo, + ref: `heads/${this.draftsBranch}`, + sha: newCommit.data.sha, + force: false, + }); } async saveDraft(changes: FileChange[], message: string): Promise { diff --git a/manual-editor/src/backend/types.ts b/manual-editor/src/backend/types.ts index 51dfb2eb..c875da21 100644 --- a/manual-editor/src/backend/types.ts +++ b/manual-editor/src/backend/types.ts @@ -49,8 +49,6 @@ export interface GitHubBackend { listPages(): Promise; /** Reads the drafts-branch version if present, else the base branch. */ readPage(path: string): Promise; - /** Reads the base-branch version only. Returns null if the page is draft-only (not yet published). */ - readBasePage(path: string): Promise; /** Commits text changes to the drafts branch (creating it off base if needed). */ saveDraft(changes: FileChange[], message: string): Promise; /** Commits an optimized image to the drafts branch; returns repo-relative path. */ diff --git a/src/content/manual/audacity-basics/anton-s-draft.mdx b/src/content/manual/audacity-basics/anton-s-draft.mdx index 34affc04..5574d1fa 100644 --- a/src/content/manual/audacity-basics/anton-s-draft.mdx +++ b/src/content/manual/audacity-basics/anton-s-draft.mdx @@ -23,5 +23,3 @@ paragraphswordsbyteslists Start with 'Lorem\ ipsum dolor sit amet...' - -\ diff --git a/src/content/manual/audacity-basics/test-page.mdx b/src/content/manual/audacity-basics/test-page.mdx index d55fc277..cd66a6d3 100644 --- a/src/content/manual/audacity-basics/test-page.mdx +++ b/src/content/manual/audacity-basics/test-page.mdx @@ -3,6 +3,7 @@ title: Test page section: Audacity Basics sectionOrder: 3 order: 1 +draft: true --- import Callout from "../../../components/manual/Callout.astro"; @@ -23,13 +24,9 @@ Oui oui oui - + - - - - - + / @@ -39,6 +36,16 @@ And here is another update, but with a code block now ### Hello! + + + + + + + + + + ## Hello Hello diff --git a/src/content/manual/manual-index/page-1.mdx b/src/content/manual/manual-index/page-1.mdx index e89e57f3..904dbc79 100644 --- a/src/content/manual/manual-index/page-1.mdx +++ b/src/content/manual/manual-index/page-1.mdx @@ -1,9 +1,10 @@ --- -title: "Page 1" -section: "Page 1" +title: Page 1 +section: Page 1 sectionOrder: 150 order: 1 -draft: false --- -_This page is a stub. Content coming soon._ +### _This page is not a stub anymore, it's a loose end._ + +_Why has this page still got a blue dot?_