Веб: окно правки вики с диффом поверх ленты (#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:`
скачивают файл через `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`; оболочка рисует

View File

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

View File

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

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 { 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<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 { 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" }],
};

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