Веб: окно правки вики с диффом поверх ленты (#140)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 13:15:26 +04:00
parent 93eff7eac0
commit 2f73f11170
13 changed files with 499 additions and 2 deletions

View File

@ -119,6 +119,10 @@
(`WikiPage`, `PageView` — `react-markdown` + `remark-gfm`, без `rehype-raw`; ссылки `page:` ведут на страницу вики, `attachment:` (`WikiPage`, `PageView` — `react-markdown` + `remark-gfm`, без `rehype-raw`; ссылки `page:` ведут на страницу вики, `attachment:`
скачивают файл через `download.ts`, `urlTransform.ts` пропускает только эти схемы и http(s)), форма корня без привязки (`RootForm`), скачивают файл через `download.ts`, `urlTransform.ts` пропускает только эти схемы и http(s)), форма корня без привязки (`RootForm`),
сводка для «Обзора» (`Summary`) и `status` для ядра; поле «Корень вики» в окне «Новый проект» — через `projectFields`. сводка для «Обзора» (`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` Модуль `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`; оболочка рисует перенаправляется на `/app/feed`), карточка `/app/jira/issues/{key}` окном поверх страницы (`issues/overlay.tsx`, `IssueModal.tsx`; оболочка рисует

View File

@ -33,3 +33,31 @@ test("вики: вложение скачивается через запрос
await req; // агента в тестовом хабе нет: скачивание остаётся «Загружается…», достаточно, что запрос ушёл await req; // агента в тестовом хабе нет: скачивание остаётся «Загружается…», достаточно, что запрос ушёл
await expect(page.getByRole("article").getByText("Загружается…")).toBeVisible(); 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);
});

View File

@ -3,7 +3,7 @@
Дополнения только для тестового хаба (описание, комментарии, команды) — из seed-extras.json рядом со скриптом.""" Дополнения только для тестового хаба (описание, комментарии, команды) — из seed-extras.json рядом со скриптом."""
import json import json
import sys import sys
from datetime import date, timedelta from datetime import date, datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from sdlc_hub.core import db, paths, settings from sdlc_hub.core import db, paths, settings
@ -109,6 +109,14 @@ for r in wiki_rows:
"VALUES ('AWPCA', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "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"])) (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]) 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") (paths.home() / "tokens.json").write_text(json.dumps(settings.tokens(conn)), encoding="utf-8")
conn.close() conn.close()
print("зеркало:", len(issues), "задач,", len(links), "связей; токены —", paths.home() / "tokens.json") print("зеркало:", len(issues), "задач,", len(links), "связей; токены —", paths.home() / "tokens.json")

View File

@ -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 { useEffect, useRef } from "react";
import { api } from "../../../api/client.ts"; 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[]; 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 POLL_MS = 3000; // опрос хода синхронизации, пока пакет не завершён
export const SEARCH_LIMIT = 20; export const SEARCH_LIMIT = 20;
@ -33,6 +40,8 @@ export const wikiQk = {
page: (p: string, code: string) => ["wiki", p, "page", code] as const, page: (p: string, code: string) => ["wiki", p, "page", code] as const,
search: (p: string, q: string) => ["wiki", p, "search", q] as const, search: (p: string, q: string) => ["wiki", p, "search", q] as const,
changes: (p: string, period: string) => ["wiki", p, "changes", period] 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"; 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<WikiChanges>("/wiki/changes", { project, since: since.toISOString(), until: until.toISOString(), limit: WIKI_CHANGES_LIMIT }); return api.get<WikiChanges>("/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<WikiDiff>(`/wiki/pages/${encodeURIComponent(code)}/diff`, { project, version, offset: pageParam }),
getNextPageParam: (last) => last.next_offset,
});

View File

@ -4,6 +4,7 @@ import type { HubModule } from "../types.ts";
import { wikiFeed } from "./feed/source.tsx"; import { wikiFeed } from "./feed/source.tsx";
import { wikiStatus } from "./status.ts"; import { wikiStatus } from "./status.ts";
import { Summary } from "./Summary.tsx"; import { Summary } from "./Summary.tsx";
import { useVersionOverlay } from "./versions/overlay.tsx";
import { WikiPage } from "./WikiPage.tsx"; import { WikiPage } from "./WikiPage.tsx";
export const wikiModule: HubModule = { export const wikiModule: HubModule = {
@ -14,5 +15,6 @@ export const wikiModule: HubModule = {
status: wikiStatus, status: wikiStatus,
Summary, Summary,
feed: wikiFeed, feed: wikiFeed,
useOverlay: useVersionOverlay,
projectFields: [{ name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text" }], projectFields: [{ name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text" }],
}; };

View File

@ -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(<App router={router} />);
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([]);
});
});

View File

@ -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 (
<Dialog size="lg" className="wiki-version-modal" label={`Правка ${props.version}`} onClose={onClose} aria-labelledby="wiki-version-title"
onCancel={(e) => { 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();
}}>
<ErrorBoundary key={props.version}><VersionBody {...props} /></ErrorBoundary>
</Dialog>
);
}
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 (
<div className="wiki-version" data-testid="wiki-version">
<header className="wiki-version__head">
<div className="wiki-version__row">
<h2 id="wiki-version-title" className="wiki-version__title">{title}</h2>
<IconButton label="Закрыть" icon={<IconClose />} onClick={onClose} />
</div>
{path && <p className="wiki-version__path">{path}</p>}
<p className="wiki-version__meta">
{index !== null && total !== null && <span>{`правка ${index} из ${total}`}</span>}
{first && <>
<span className="wiki-version__author">{authorName(first.author)}</span>
<span className="wiki-version__time">{eventTime(first.at, now)}</span>
<span className="wiki-version__delta">{deltaText(first)}</span>
</>}
</p>
<div className="wiki-version__nav">
<Button disabled={!older} onClick={() => older && onNavigate(older.version)}>Предыдущая правка</Button>
<Button disabled={!newer} onClick={() => newer && onNavigate(newer.version)}>Следующая правка</Button>
<Link className="wiki-version__page" to={wikiPageHref(project, code)}>Открыть страницу</Link>
</div>
</header>
{diff.isPending && <p className="wiki-version__hint">загрузка правки…</p>}
{diff.isError && !diff.data && (
<Notice tone="error" action={<Button onClick={() => void diff.refetch()}>Повторить</Button>}>{(diff.error as Error).message}</Notice>
)}
{first && (
<div className="wiki-version__diff" data-testid="wiki-version-diff">
{lines.length === 0 && <p className="wiki-version__hint">В правке нет изменений текста.</p>}
{lines.map((l, i) => (
<div key={i} className={`wiki-version-line wiki-version-line--${l.kind}`}>
{l.kind === "add" && <span className="visually-hidden">добавлено: </span>}
{l.kind === "del" && <span className="visually-hidden">удалено: </span>}
{l.text}
</div>
))}
{diff.hasNextPage && (
<Button loading={diff.isFetchingNextPage} onClick={() => void diff.fetchNextPage()}>Показать дальше</Button>
)}
</div>
)}
</div>
);
}

View File

@ -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([]);
});
});

View File

@ -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 };
});
}

View File

@ -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<string | null>(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 ? <VersionModal project={project} code={code} version={version} onClose={close} onNavigate={go} /> : null;
return { pageLocation, element };
}

View File

@ -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" });
});
});

View File

@ -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() };
}

View File

@ -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)}}