diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d019734..a5f9809 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -119,6 +119,10 @@ (`WikiPage`, `PageView` — `react-markdown` + `remark-gfm`, без `rehype-raw`; ссылки `page:` ведут на страницу вики, `attachment:` скачивают файл через `download.ts`, `urlTransform.ts` пропускает только эти схемы и http(s)), форма корня без привязки (`RootForm`), сводка для «Обзора» (`Summary`) и `status` для ядра; поле «Корень вики» в окне «Новый проект» — через `projectFields`. +Источник ленты (`feed/`): правки страниц группами по странице. Окно правки `/app/wiki/pages/{code}/versions/{version}` — поверх +страницы под ним (`versions/overlay.tsx`, `useOverlay`; фон — `location.state.background`, прямой адрес — поверх «Вики» с этой +страницей), «Предыдущая/Следующая правка» заменяют запись истории, дифф (`versions/`: `VersionModal`, `diffLines` — склейка частей +`GET …/diff` и разбор на строки) без горизонтальной прокрутки страницы. Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Связи» (`/app/jira/links`, заголовок страницы — «Доска связей»), «Задачи» (`/app/jira/issues`) (страницы «Лента» у модуля нет — она в ядре; `/app/jira/feed` перенаправляется на `/app/feed`), карточка `/app/jira/issues/{key}` окном поверх страницы (`issues/overlay.tsx`, `IssueModal.tsx`; оболочка рисует diff --git a/web/e2e/wiki.spec.ts b/web/e2e/wiki.spec.ts index 604cdc3..790c611 100644 --- a/web/e2e/wiki.spec.ts +++ b/web/e2e/wiki.spec.ts @@ -33,3 +33,31 @@ test("вики: вложение скачивается через запрос await req; // агента в тестовом хабе нет: скачивание остаётся «Загружается…», достаточно, что запрос ушёл await expect(page.getByRole("article").getByText("Загружается…")).toBeVisible(); }); + +// две правки DOC-9003 засеяны seed-hub.py (сегодня, автор Дмитрий Соснов): новая «+1 / −1», старая «+5 / −0» +test("правка из ленты: окно с диффом, «Предыдущая правка», назад — лента", async ({ page }) => { + await login(page, "/app/feed?project=AWPCA&period=7d&sources=wiki"); + const feed = page.getByTestId("page-feed"); + await feed.getByRole("link", { name: /\+1 \/ −1/ }).click(); + const d = page.getByRole("dialog"); + await expect(d.getByRole("heading", { name: "Регламент согласования" })).toBeVisible(); + await expect(d.getByText("правка 2 из 2")).toBeVisible(); + await expect(d.locator(".wiki-version-line--add").first()).toBeVisible(); + await expect(d.locator(".wiki-version-line--del").first()).toBeVisible(); + await expect(feed).toBeVisible(); // лента под окном + await d.getByRole("button", { name: "Предыдущая правка" }).click(); + await expect(d.getByText("правка 1 из 2")).toBeVisible(); + await expect(d.getByRole("button", { name: "Предыдущая правка" })).toBeDisabled(); + await page.goBack(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page).toHaveURL(/\/app\/feed\?project=AWPCA&period=7d&sources=wiki/); +}); + +test("окно правки на 390 px: горизонтальной прокрутки страницы нет", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }); + await login(page, "/app/feed?project=AWPCA&period=7d&sources=wiki"); + await page.getByTestId("page-feed").getByRole("link", { name: /\+5 \/ −0/ }).click(); + await expect(page.getByRole("dialog").locator(".wiki-version-line--add").first()).toBeVisible(); + const fits = await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth); + expect(fits).toBe(true); +}); diff --git a/web/scripts/seed-hub.py b/web/scripts/seed-hub.py index 37d529e..604a13a 100644 --- a/web/scripts/seed-hub.py +++ b/web/scripts/seed-hub.py @@ -3,7 +3,7 @@ Дополнения только для тестового хаба (описание, комментарии, команды) — из seed-extras.json рядом со скриптом.""" import json import sys -from datetime import date, timedelta +from datetime import date, datetime, timedelta, timezone from pathlib import Path from sdlc_hub.core import db, paths, settings @@ -109,6 +109,14 @@ for r in wiki_rows: "VALUES ('AWPCA', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (r["code"], r["eva_id"], r["title"], r["parent_code"], r["orderno"], r["depth"], r["path"], r["url"], r["size"], r["synced_at"])) wiki_search.replace(conn, "AWPCA", [(r["code"], r["title"], r["markdown"]) for r in wiki_rows]) +# история правок DOC-9003 (две версии сегодня) и отметка загрузки истории — для ленты и окна правки +_md = WIKI_PAGES[2]["markdown"] +_v1 = _md.replace("Порядок согласования описан в файле.", "Черновик порядка согласования.") +for _code, _ago, _md_v, _added, _removed in (("DOCH-9001", 50, _v1, 5, 0), ("DOCH-9002", 20, _md, 1, 1)): + _at = (datetime.now(timezone.utc) - timedelta(minutes=_ago)).replace(microsecond=0).isoformat() + conn.execute("INSERT OR REPLACE INTO wiki_version(label, code, page_code, created_at, author_login, author_name, markdown, added, removed) " + "VALUES ('AWPCA', ?, 'DOC-9003', ?, 'd.sosnov', 'Дмитрий Соснов', ?, ?, ?)", (_code, _at, _md_v, _added, _removed)) +conn.execute("INSERT OR REPLACE INTO wiki_history(label, history_at) VALUES ('AWPCA', ?)", (db.now(),)) (paths.home() / "tokens.json").write_text(json.dumps(settings.tokens(conn)), encoding="utf-8") conn.close() print("зеркало:", len(issues), "задач,", len(links), "связей; токены —", paths.home() / "tokens.json") diff --git a/web/src/modules/wiki/api/queries.ts b/web/src/modules/wiki/api/queries.ts index 771db73..79e10aa 100644 --- a/web/src/modules/wiki/api/queries.ts +++ b/web/src/modules/wiki/api/queries.ts @@ -1,4 +1,4 @@ -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; import { api } from "../../../api/client.ts"; @@ -22,6 +22,13 @@ export interface WikiChanges { since: string; until: string; synced_at: string | null; history_at: string | null; truncated: boolean; changes: WikiChange[]; } +export interface WikiAuthor { login: string | null; name: string | null } +export interface WikiVersion { version: string; at: string; author: WikiAuthor; added: number; removed: number } +export interface WikiDiff { + code: string; title: string | null; path: string | null; version: string; prev_version: string | null; at: string; author: WikiAuthor; + added: number; removed: number; index: number; total: number; offset: number; next_offset: number | null; diff: string; +} + export const POLL_MS = 3000; // опрос хода синхронизации, пока пакет не завершён export const SEARCH_LIMIT = 20; @@ -33,6 +40,8 @@ export const wikiQk = { page: (p: string, code: string) => ["wiki", p, "page", code] as const, search: (p: string, q: string) => ["wiki", p, "search", q] as const, changes: (p: string, period: string) => ["wiki", p, "changes", period] as const, + versions: (p: string, code: string) => ["wiki", p, "versions", code] as const, + diff: (p: string, code: string, version: string) => ["wiki", p, "diff", code, version] as const, }; const terminal = (b: WikiBatch | null) => b === null || b.status === "done" || b.status === "cancelled"; @@ -82,3 +91,18 @@ export const useWikiChanges = (project: string, period: string, bounds: () => { return api.get("/wiki/changes", { project, since: since.toISOString(), until: until.toISOString(), limit: WIKI_CHANGES_LIMIT }); }, }); + +// версии страницы, новые первыми — для «Предыдущая/Следующая правка» и «правка N из M» +export const useWikiVersions = (project: string, code: string) => + useQuery({ + queryKey: wikiQk.versions(project, code), + queryFn: () => api.get<{ code: string; title: string | null; versions: WikiVersion[] }>(`/wiki/pages/${encodeURIComponent(code)}/versions`, { project }).then((r) => r.versions), + }); + +// дифф правки частями: pageParam — offset (0, затем next_offset прежней части) +export const useWikiDiff = (project: string, code: string, version: string) => + useInfiniteQuery({ + queryKey: wikiQk.diff(project, code, version), initialPageParam: 0, + queryFn: ({ pageParam }) => api.get(`/wiki/pages/${encodeURIComponent(code)}/diff`, { project, version, offset: pageParam }), + getNextPageParam: (last) => last.next_offset, + }); diff --git a/web/src/modules/wiki/index.tsx b/web/src/modules/wiki/index.tsx index 086d358..14e31f1 100644 --- a/web/src/modules/wiki/index.tsx +++ b/web/src/modules/wiki/index.tsx @@ -4,6 +4,7 @@ import type { HubModule } from "../types.ts"; import { wikiFeed } from "./feed/source.tsx"; import { wikiStatus } from "./status.ts"; import { Summary } from "./Summary.tsx"; +import { useVersionOverlay } from "./versions/overlay.tsx"; import { WikiPage } from "./WikiPage.tsx"; export const wikiModule: HubModule = { @@ -14,5 +15,6 @@ export const wikiModule: HubModule = { status: wikiStatus, Summary, feed: wikiFeed, + useOverlay: useVersionOverlay, projectFields: [{ name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text" }], }; diff --git a/web/src/modules/wiki/versions/VersionModal.test.tsx b/web/src/modules/wiki/versions/VersionModal.test.tsx new file mode 100644 index 0000000..44f6c9c --- /dev/null +++ b/web/src/modules/wiki/versions/VersionModal.test.tsx @@ -0,0 +1,184 @@ +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createMemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it } from "vitest"; +import { App } from "../../../App.tsx"; +import { setToken } from "../../../auth/token.ts"; +import settings from "../../jira/board/__fixtures__/settings.json"; +import { routes } from "../../../routes.tsx"; +import { json, mockFetch } from "../../../test/fetchMock.ts"; +import type { WikiChange } from "../api/queries.ts"; + +const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9, wiki_root: "DOC-1" }; +const SOSNOV = { login: "d.sosnov", name: "Дмитрий Соснов" }; +const at = (hhmm: string) => { const d = new Date(); d.setHours(+hhmm.slice(0, 2), +hhmm.slice(3), 0, 0); return d.toISOString(); }; +const TIMES = ["10:00", "11:00", "12:00", "12:40"]; +const VERSIONS = [4, 3, 2, 1].map((n) => ({ version: `DOCH-${n}`, at: at(TIMES[n - 1]), author: SOSNOV, added: n === 4 ? 40 : n, removed: n === 4 ? 12 : 0 })); +const change = (n: number): WikiChange => ({ + at: at(TIMES[n - 1]), page_code: "DOC-101252", title: "Методика расчёта", path: "АРМ / Методика", version: `DOCH-${n}`, + author: SOSNOV, added: n === 4 ? 40 : n, removed: n === 4 ? 12 : 0, +}); +type Part = { diff: string; next_offset: number | null }; +const diffBody = (version: string, offset: number, part: Part) => { + const n = Number(version.slice(5)); + return { + code: "DOC-101252", title: "Методика расчёта", path: "АРМ / Методика", version, prev_version: n > 1 ? `DOCH-${n - 1}` : null, + at: at(TIMES[n - 1]), author: SOSNOV, added: n === 4 ? 40 : n, removed: n === 4 ? 12 : 0, index: n, total: 4, offset, ...part, + }; +}; +const DEFAULT_DIFF = (v: string): Part => ({ diff: `--- DOCH-prev\n+++ ${v}\n@@ -1 +1 @@\n-старая строка\n+новая строка\n общая строка\n`, next_offset: null }); + +function hub(parts: (version: string, offset: number) => Part = (v) => DEFAULT_DIFF(v)) { + const calls: { method: string; url: URL }[] = []; + mockFetch((method, url) => { + calls.push({ method, url }); + const k = `${method} ${url.pathname}`; + if (k === "GET /projects") return json(200, { projects: [A] }); + if (k === "GET /settings") return json(200, settings); + if (k === "GET /batches") return json(200, { batches: [] }); + if (k === "GET /agents") return json(200, { agents: [] }); + if (k === "GET /changes") return json(200, { since: "", until: "", truncated: false, changes: [] }); + if (k === "GET /wiki/changes") { + return json(200, { since: "", until: "", synced_at: null, history_at: "2026-10-28T09:00:00+00:00", truncated: false, changes: [change(4), change(3)] }); + } + if (k === "GET /wiki/status") return json(200, { root: "DOC-1", root_title: "Корень", synced_at: null, pages: 1, error: null, batch: null }); + if (k === "GET /wiki/tree") return json(200, { pages: [{ code: "DOC-101252", title: "Методика расчёта", depth: 0, parent_code: null, size: 1 }] }); + if (k === "GET /wiki/pages/DOC-101252") { + return json(200, { code: "DOC-101252", title: "Методика расчёта", path: "АРМ / Методика", url: null, synced_at: null, markdown: "Текст страницы", attachments: [] }); + } + if (k === "GET /wiki/pages/DOC-101252/versions") return json(200, { code: "DOC-101252", title: "Методика расчёта", versions: VERSIONS }); + if (k === "GET /wiki/pages/DOC-101252/diff") { + const version = url.searchParams.get("version") ?? "DOCH-4"; + const offset = Number(url.searchParams.get("offset") ?? 0); + return json(200, diffBody(version, offset, parts(version, offset))); + } + return json(404, { error: "not_found" }); + }); + return calls; +} +const open = (entries: string[], index = entries.length - 1) => { + const router = createMemoryRouter(routes, { initialEntries: entries, initialIndex: index, basename: "/app" }); + render(); + return router; +}; +const FEED = "/app/feed?project=AWPCA&period=7d&sources=wiki"; +const dialog = () => screen.getByRole("dialog"); +async function openFromFeed(entries = [FEED]) { + const router = open(entries); + await userEvent.click((await screen.findAllByRole("link", { name: /\+40 \/ −12/ }))[0]); + await screen.findByRole("dialog"); + return router; +} + +describe("окно правки вики", () => { + beforeEach(() => setToken("tok")); + + it("«Правка из ленты»: шапка, строки диффа и лента под окном", async () => { + const calls = hub(); + await openFromFeed(); + const d = dialog(); + expect(await within(d).findByRole("heading", { name: "Методика расчёта" })).toBeInTheDocument(); + expect(within(d).getByText("правка 4 из 4")).toBeInTheDocument(); + expect(within(d).getByText("Дмитрий Соснов")).toBeInTheDocument(); + expect(within(d).getByText("12:40")).toBeInTheDocument(); + expect(within(d).getByText("+40 / −12")).toBeInTheDocument(); + const add = await within(d).findByText("+новая строка"); + expect(add).toHaveClass("wiki-version-line--add"); + expect(within(add).getByText("добавлено:")).toHaveClass("visually-hidden"); + const del = within(d).getByText("-старая строка"); + expect(del).toHaveClass("wiki-version-line--del"); + expect(within(del).getByText("удалено:")).toHaveClass("visually-hidden"); + expect(screen.getByTestId("page-feed")).toBeInTheDocument(); + const dc = calls.find((c) => c.url.pathname === "/wiki/pages/DOC-101252/diff")!; + expect(dc.url.searchParams.get("version")).toBe("DOCH-4"); + }); + + it("«Предыдущая правка»: 3 из 4, у первой недоступна; назад закрывает окно, лента прежняя", async () => { + hub(); + const router = await openFromFeed(); + const d = dialog(); + await within(d).findByText("правка 4 из 4"); + expect(within(d).getByRole("button", { name: "Следующая правка" })).toBeDisabled(); + await userEvent.click(within(d).getByRole("button", { name: "Предыдущая правка" })); + expect(await within(d).findByText("правка 3 из 4")).toBeInTheDocument(); + expect(router.state.location.pathname).toBe("/app/wiki/pages/DOC-101252/versions/DOCH-3"); + expect(within(d).getByRole("button", { name: "Следующая правка" })).toBeEnabled(); + await userEvent.click(within(d).getByRole("button", { name: "Предыдущая правка" })); + await userEvent.click(within(d).getByRole("button", { name: "Предыдущая правка" })); + await within(d).findByText("правка 1 из 4"); + expect(within(d).getByRole("button", { name: "Предыдущая правка" })).toBeDisabled(); + act(() => { void router.navigate(-1); }); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname + router.state.location.search).toBe(FEED); + }); + + it("Escape и «Закрыть» закрывают окно — адрес ленты прежний", async () => { + hub(); + const router = await openFromFeed(); + await within(dialog()).findByText("правка 4 из 4"); + await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname + router.state.location.search).toBe(FEED); + await userEvent.click((await screen.findAllByRole("link", { name: /\+40 \/ −12/ }))[0]); + await screen.findByRole("dialog"); + fireEvent(dialog(), new Event("cancel", { cancelable: true })); // Esc в jsdom: событие cancel окна + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname + router.state.location.search).toBe(FEED); + }); + + it("«Окно по ссылке»: поверх «Вики» с этой страницей, закрытие — на страницу", async () => { + hub(); + const router = open(["/app/wiki/pages/DOC-101252/versions/DOCH-3?project=AWPCA"]); + const d = await screen.findByRole("dialog"); + expect(await within(d).findByText("правка 3 из 4")).toBeInTheDocument(); + expect(await screen.findByRole("heading", { level: 1, name: "Вики" })).toBeInTheDocument(); + await userEvent.click(within(d).getByRole("button", { name: "Закрыть" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname + router.state.location.search).toBe("/app/wiki?project=AWPCA&page=DOC-101252"); + }); + + it("«Длинный дифф»: «Показать дальше» грузит со следующего offset; без next_offset кнопки нет", async () => { + const calls = hub((_v, offset) => offset === 0 + ? { diff: "--- a\n+++ b\n+первая часть\n", next_offset: 20000 } + : { diff: "+вторая часть\n", next_offset: null }); + await openFromFeed(); + const d = dialog(); + await within(d).findByText("+первая часть"); + expect(within(d).queryByText("+вторая часть")).toBeNull(); + await userEvent.click(within(d).getByRole("button", { name: "Показать дальше" })); + const second = await within(d).findByText("+вторая часть"); + expect(calls.some((c) => c.url.searchParams.get("offset") === "20000")).toBe(true); + expect(within(d).getByText("+первая часть").compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(within(d).queryByRole("button", { name: "Показать дальше" })).toBeNull(); + }); + + it("«Показать дальше»: часть рвёт строку — строка целая и одна", async () => { + hub((_v, offset) => offset === 0 + ? { diff: "--- a\n+++ b\n+строка дл", next_offset: 20000 } + : { diff: "инная\n+другая\n", next_offset: null }); + await openFromFeed(); + const d = dialog(); + await within(d).findByText("+строка дл"); + await userEvent.click(within(d).getByRole("button", { name: "Показать дальше" })); + expect(await within(d).findByText("+строка длинная")).toHaveClass("wiki-version-line--add"); + expect(d.querySelectorAll(".wiki-version-line--add")).toHaveLength(2); + expect(within(d).queryByText("+строка дл")).toBeNull(); + }); + + it("«Открыть страницу» ведёт на страницу вики", async () => { + hub(); + await openFromFeed(); + const link = await within(dialog()).findByRole("link", { name: "Открыть страницу" }); + expect(link).toHaveAttribute("href", "/app/wiki?project=AWPCA&page=DOC-101252"); + }); + + it("окно ничего не пишет: только GET", async () => { + const calls = hub(); + await openFromFeed(); + await within(dialog()).findByText("+новая строка"); + await userEvent.click(within(dialog()).getByRole("button", { name: "Предыдущая правка" })); + await within(dialog()).findByText("правка 3 из 4"); + await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); + expect(calls.filter((c) => c.method !== "GET")).toEqual([]); + }); +}); diff --git a/web/src/modules/wiki/versions/VersionModal.tsx b/web/src/modules/wiki/versions/VersionModal.tsx new file mode 100644 index 0000000..ada6e91 --- /dev/null +++ b/web/src/modules/wiki/versions/VersionModal.tsx @@ -0,0 +1,98 @@ +import { useEffect, useMemo, useRef } from "react"; +import { Link } from "react-router"; +import { ErrorBoundary } from "../../../shell/ErrorBoundary.tsx"; +import { Button, Dialog, IconButton, IconClose, Notice } from "../../../ui/index.ts"; +import { eventTime } from "../../../ui/format.ts"; +import { useWikiDiff, useWikiVersions } from "../api/queries.ts"; +import { authorName, deltaText } from "../feed/group.ts"; +import { wikiPageHref } from "../paths.ts"; +import { diffLines } from "./diffLines.ts"; +import "./versions.css"; + +type Props = { project: string; code: string; version: string; onClose: () => void; onNavigate: (version: string) => void }; + +// окно правки (spec web-app «Правки вики в ленте»): Esc, «Закрыть» и щелчок по фону — onClose; окно исчезает вместе с адресом +export function VersionModal(props: Props) { + const downOnBackdrop = useRef(false); + const { onClose } = props; + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; // страница под окном не прокручивается + return () => { document.body.style.overflow = prev; }; + }, []); + return ( + { if (e.cancelable) { e.preventDefault(); onClose(); } }} + onNativeClose={(e) => { const d = e.currentTarget; if (d.isConnected) { d.showModal(); onClose(); } }} + onPointerDown={(e) => { downOnBackdrop.current = e.target === e.currentTarget; }} + onClick={(e) => { + const fromBackdrop = downOnBackdrop.current; + downOnBackdrop.current = false; + if (e.target === e.currentTarget && fromBackdrop) onClose(); + }}> + + + ); +} + +function VersionBody({ project, code, version, onClose, onNavigate }: Props) { + const diff = useWikiDiff(project, code, version); + const versions = useWikiVersions(project, code); + const first = diff.data?.pages[0]; + // части склеиваются до разбора на строки: часть может оборваться посреди строки + const text = diff.data?.pages.map((p) => p.diff).join("") ?? ""; + const lines = useMemo(() => diffLines(text), [text]); + const list = versions.data; + const pos = list ? list.findIndex((v) => v.version === version) : -1; + const older = list && pos >= 0 ? list[pos + 1] : undefined; + const newer = list && pos > 0 ? list[pos - 1] : undefined; + const index = first?.index ?? (list && pos >= 0 ? list.length - pos : null); + const total = first?.total ?? list?.length ?? null; + const title = first?.title || code; + const path = first?.path; + const now = new Date(); + + return ( +
+
+
+

{title}

+ } onClick={onClose} /> +
+ {path &&

{path}

} +

+ {index !== null && total !== null && {`правка ${index} из ${total}`}} + {first && <> + {authorName(first.author)} + {eventTime(first.at, now)} + {deltaText(first)} + } +

+
+ + + Открыть страницу +
+
+ {diff.isPending &&

загрузка правки…

} + {diff.isError && !diff.data && ( + void diff.refetch()}>Повторить}>{(diff.error as Error).message} + )} + {first && ( +
+ {lines.length === 0 &&

В правке нет изменений текста.

} + {lines.map((l, i) => ( +
+ {l.kind === "add" && добавлено: } + {l.kind === "del" && удалено: } + {l.text} +
+ ))} + {diff.hasNextPage && ( + + )} +
+ )} +
+ ); +} diff --git a/web/src/modules/wiki/versions/diffLines.test.ts b/web/src/modules/wiki/versions/diffLines.test.ts new file mode 100644 index 0000000..9766f8e --- /dev/null +++ b/web/src/modules/wiki/versions/diffLines.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { diffLines } from "./diffLines.ts"; + +describe("diffLines", () => { + it("разбирает unified diff по видам строк", () => { + const lines = diffLines("--- A\n+++ B\n@@ -1 +1 @@\n-x\n+y\n z\n"); + expect(lines.map((l) => l.kind)).toEqual(["head", "head", "hunk", "del", "add", "ctx"]); + expect(lines[3]).toEqual({ kind: "del", text: "-x" }); + }); + + it("после первого @@ строка «--- …» — удаление, а не заголовок", () => { + expect(diffLines("--- A\n+++ B\n@@ -1 +1 @@\n--- x\n").map((l) => l.kind)).toEqual(["head", "head", "hunk", "del"]); + }); + + it("хвостовой пустой строки нет, пустой текст — пустой список", () => { + expect(diffLines("+a\n")).toHaveLength(1); + expect(diffLines("")).toEqual([]); + }); +}); diff --git a/web/src/modules/wiki/versions/diffLines.ts b/web/src/modules/wiki/versions/diffLines.ts new file mode 100644 index 0000000..047bff3 --- /dev/null +++ b/web/src/modules/wiki/versions/diffLines.ts @@ -0,0 +1,16 @@ +export type DiffLine = { kind: "head" | "hunk" | "add" | "del" | "ctx"; text: string }; + +// unified diff — в строки с видом; хвостовой пустой строки нет. `---`/`+++` — заголовок только до первого `@@` (дальше это удалённая/добавленная строка) +export function diffLines(text: string): DiffLine[] { + if (text === "") return []; + const rows = text.split("\n"); + if (rows[rows.length - 1] === "") rows.pop(); + let inHunk = false; + return rows.map((t): DiffLine => { + if (!inHunk && (t.startsWith("---") || t.startsWith("+++"))) return { kind: "head", text: t }; + if (t.startsWith("@@")) { inHunk = true; return { kind: "hunk", text: t }; } + if (t.startsWith("+")) return { kind: "add", text: t }; + if (t.startsWith("-")) return { kind: "del", text: t }; + return { kind: "ctx", text: t }; + }); +} diff --git a/web/src/modules/wiki/versions/overlay.tsx b/web/src/modules/wiki/versions/overlay.tsx new file mode 100644 index 0000000..226774e --- /dev/null +++ b/web/src/modules/wiki/versions/overlay.tsx @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { matchPath, useLocation, useNavigate } from "react-router"; +import type { Overlay } from "../../types.ts"; +import { VERSION_PATH, versionHref } from "../paths.ts"; +import { pageBackground, versionState } from "./versionState.ts"; +import { VersionModal } from "./VersionModal.tsx"; + +// окно правки /wiki/pages/{code}/versions/{version} поверх страницы под ним (spec web-app «Правки вики в ленте») +export function useVersionOverlay(): Overlay { + const location = useLocation(); + const navigate = useNavigate(); + const params = matchPath(VERSION_PATH, location.pathname)?.params; + const code = params?.code ?? null; + const version = params?.version ?? null; + const open = code !== null && version !== null; + const st = open ? versionState(location.state) : null; + const direct = code ? pageBackground(code, location.search) : null; + const bg = open ? (st?.background ?? direct) : null; + const bgPath = bg?.pathname ?? null; + const bgSearch = bg?.search ?? ""; + const pageLocation = useMemo(() => bgPath === null ? null : { pathname: bgPath, search: bgSearch, hash: "", state: null, key: "version-background" }, + [bgPath, bgSearch]); + + // прямой адрес: запись истории получает state окна поверх «Вики» с этой страницей + useEffect(() => { + if (open && !st && direct) { + void navigate(location.pathname + location.search, { replace: true, state: { background: direct, depth: 0, direct: true } }); + } + }, [open, st, direct, location.pathname, location.search, navigate]); + + // повторное закрытие на той же записи истории игнорируется + const closingKey = useRef(null); + useEffect(() => { closingKey.current = null; }, [location.key]); + const close = useCallback(() => { + if (closingKey.current === location.key || !direct) return; + closingKey.current = location.key; + if (!st || st.depth === 0) { void navigate(st?.background ?? direct, { replace: true }); return; } + void navigate(-st.depth); + }, [st, direct, navigate, location.key]); + + const project = new URLSearchParams(location.search).get("project") ?? ""; + // соседняя правка заменяет запись: то же state, «назад» закрывает окно, а не листает правки + const go = useCallback((v: string) => { + if (!code) return; + void navigate(versionHref(project, code, v), { replace: true, state: st ?? undefined }); + }, [navigate, project, code, st]); + + const element = open ? : null; + return { pageLocation, element }; +} diff --git a/web/src/modules/wiki/versions/versionState.test.ts b/web/src/modules/wiki/versions/versionState.test.ts new file mode 100644 index 0000000..2f3443b --- /dev/null +++ b/web/src/modules/wiki/versions/versionState.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { pageBackground, versionState } from "./versionState.ts"; + +describe("versionState", () => { + it("мусорный state — null", () => { + expect(versionState(null)).toBeNull(); + expect(versionState("x")).toBeNull(); + expect(versionState({ background: 1, depth: 1 })).toBeNull(); + expect(versionState({ background: { pathname: "/feed", search: "" }, depth: "1" })).toBeNull(); + }); + + it("разбирает фон, глубину и direct; глубина нормализуется", () => { + expect(versionState({ background: { pathname: "/feed", search: "?a=1" }, depth: 1 })) + .toEqual({ background: { pathname: "/feed", search: "?a=1" }, depth: 1, direct: false }); + expect(versionState({ background: { pathname: "/wiki", search: "" }, depth: -3, direct: true })?.depth).toBe(0); + }); + + it("фон по прямому адресу — «Вики» с этой страницей", () => { + expect(pageBackground("DOC-1", "?project=AWPCA")).toEqual({ pathname: "/wiki", search: "?project=AWPCA&page=DOC-1" }); + expect(pageBackground("DOC-1", "")).toEqual({ pathname: "/wiki", search: "?page=DOC-1" }); + }); +}); diff --git a/web/src/modules/wiki/versions/versionState.ts b/web/src/modules/wiki/versions/versionState.ts new file mode 100644 index 0000000..b82cb3d --- /dev/null +++ b/web/src/modules/wiki/versions/versionState.ts @@ -0,0 +1,22 @@ +// состояние окна правки в записи истории: страница под окном, сколько записей заняли окна поверх неё, прямой ли адрес +// (своя копия логики окна карточки: модуль не импортирует другой модуль) +export interface Background { pathname: string; search: string } +export interface VersionState { background: Background; depth: number; direct: boolean } + +export function versionState(s: unknown): VersionState | null { + const c = s as { background?: { pathname?: unknown; search?: unknown }; depth?: unknown; direct?: unknown } | null; + if (!c || typeof c !== "object" || !c.background || typeof c.background !== "object") return null; + const { pathname, search } = c.background; + if (typeof pathname !== "string" || typeof search !== "string" || typeof c.depth !== "number") return null; + const depth = Number.isInteger(c.depth) && c.depth >= 0 ? c.depth : 0; + return { background: { pathname, search }, depth, direct: c.direct === true }; +} + +// фон по прямому адресу: «Вики» с этой страницей, проект — из адреса окна +export function pageBackground(code: string, search: string): Background { + const params = new URLSearchParams(); + const project = new URLSearchParams(search).get("project"); + if (project) params.set("project", project); + params.set("page", code); + return { pathname: "/wiki", search: "?" + params.toString() }; +} diff --git a/web/src/modules/wiki/versions/versions.css b/web/src/modules/wiki/versions/versions.css new file mode 100644 index 0000000..2614a3c --- /dev/null +++ b/web/src/modules/wiki/versions/versions.css @@ -0,0 +1,20 @@ +/* окно правки вики (spec web-app «Правки вики в ленте»): только токены дизайн-системы */ +.ui-dialog.wiki-version-modal{padding:0} +.wiki-version{display:flex;flex-direction:column;gap:var(--space-4);padding:0 var(--space-6) var(--space-6);min-width:0} +.wiki-version__head{display:flex;flex-direction:column;gap:var(--space-2);position:sticky;top:0;z-index:1;background:var(--color-bg-surface);padding:var(--space-4) 0 var(--space-3);border-bottom:1px solid var(--color-border-default)} +.wiki-version__row{display:flex;align-items:flex-start;gap:var(--space-3);justify-content:space-between} +.wiki-version__title{margin:0;min-width:0;overflow-wrap:anywhere;font-size:var(--fs-section)} +.wiki-version__path{margin:0;font-size:13px;color:var(--color-text-secondary);overflow-wrap:anywhere} +.wiki-version__meta{margin:0;display:flex;flex-wrap:wrap;gap:var(--space-1) var(--space-3);color:var(--color-text-secondary)} +.wiki-version__author{font-weight:600;color:var(--color-text-primary);overflow-wrap:anywhere} +.wiki-version__time,.wiki-version__delta{font-family:var(--font-mono);font-size:13px;white-space:nowrap} +.wiki-version__nav{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2) var(--space-3)} +.wiki-version__page{color:var(--color-action-primary)} +.wiki-version__hint{margin:0;color:var(--color-text-secondary)} +.wiki-version__diff{display:flex;flex-direction:column;gap:var(--space-3);align-items:flex-start;min-width:0} +.wiki-version-line{font-family:var(--font-mono);font-size:13px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;min-width:0;max-width:100%;align-self:stretch} +.wiki-version-line--add{color:var(--color-status-success);background:var(--color-status-success-bg)} +.wiki-version-line--del{color:var(--color-status-error);background:var(--color-status-error-bg)} +.wiki-version-line--head,.wiki-version-line--hunk{color:var(--color-text-secondary)} +.wiki-version-line--ctx{color:var(--color-text-primary)} +@media (max-width:640px){.wiki-version{padding:0 var(--space-4) var(--space-4)}}