From bd7e9a534d0a14db21fa61e7408fede761b1b508 Mon Sep 17 00:00:00 2001 From: Vasyl Kyrashchuk Date: Wed, 2 Sep 2026 17:21:27 +0300 Subject: [PATCH] fix(ui): open librarium abs paths in the Web UI --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 5 ++ tests/test_webui_selenium.py | 37 +++++++++++++ ui/eslint.config.js | 6 +++ ui/package.json | 1 + ui/src/App.tsx | 21 +++++++- ui/src/features/entries/entriesPath.test.ts | 37 +++++++++++++ ui/src/features/entries/entriesPath.ts | 57 +++++++++++++++++---- ui/tsconfig.app.json | 3 +- 9 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 ui/src/features/entries/entriesPath.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e63f8ea..1243803 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ jobs: node-version: 22 - name: Build UI run: cd ui && npm ci && npm run build + - name: UI unit tests + run: cd ui && npm test - name: cargo test run: cargo test --workspace --release fmt: diff --git a/CHANGELOG.md b/CHANGELOG.md index 04afe6a..8f53349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Web UI maps human librarium URLs (`/projects/…/doc.md`) to the entries + browser instead of sending unknown paths to the library root. + ## 0.1.7 - 2026-05-11 Authentication, concurrency, and operator tooling release. diff --git a/tests/test_webui_selenium.py b/tests/test_webui_selenium.py index ef0d591..d4060a0 100644 --- a/tests/test_webui_selenium.py +++ b/tests/test_webui_selenium.py @@ -373,6 +373,43 @@ def test_spa_deep_link_not_404(selenium_driver, tabularium_base_url): assert "404" not in selenium_driver.title.lower() +def test_spa_librarium_abs_path_opens_document(selenium_driver, tabularium_base_url): + slug = uuid.uuid4().hex[:8] + name = f"pretty_{slug}" + mkdir(tabularium_base_url, name) + base = tabularium_base_url.rstrip("/") + marker = f"pretty-open-{slug}" + requests.post( + f"{base}/api/doc/{name}", + json={"name": "tracker.md", "content": f"# {marker}\n"}, + timeout=15, + ).raise_for_status() + selenium_driver.get(f"{base}/{name}/tracker.md") + _wait_app_ready(selenium_driver) + _wait(selenium_driver).until(lambda d: "open=" in d.current_url) + assert f"/entries/{name}" in selenium_driver.current_url + _wait(selenium_driver).until( + EC.text_to_be_present_in_element( + (By.CSS_SELECTOR, "[data-testid='preview-pane']"), + marker, + ), + ) + + +def test_spa_librarium_abs_path_opens_directory( + selenium_driver, tabularium_base_url, seed_dir: str, +): + selenium_driver.get(f"{tabularium_base_url.rstrip('/')}/{seed_dir}") + _wait_app_ready(selenium_driver) + _wait(selenium_driver).until( + EC.text_to_be_present_in_element( + (By.CSS_SELECTOR, "[data-testid='entries-path']"), + f"/{seed_dir}", + ), + ) + assert "open=" not in selenium_driver.current_url + + # --- Group E — infrastructure --- diff --git a/ui/eslint.config.js b/ui/eslint.config.js index 5e6b472..094f19b 100644 --- a/ui/eslint.config.js +++ b/ui/eslint.config.js @@ -20,4 +20,10 @@ export default defineConfig([ globals: globals.browser, }, }, + { + files: ['**/*.test.ts'], + languageOptions: { + globals: globals.node, + }, + }, ]) diff --git a/ui/package.json b/ui/package.json index 1643f8e..574a323 100644 --- a/ui/package.json +++ b/ui/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "test": "node --experimental-strip-types --test src/features/entries/entriesPath.test.ts", "lint": "eslint .", "preview": "vite preview" }, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 09f3c84..126d5ef 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,11 +1,28 @@ import { lazy, Suspense } from "react"; -import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +import { + BrowserRouter, + Navigate, + Route, + Routes, + useLocation, +} from "react-router-dom"; import { AppShell } from "./app/AppShell"; import { AuthShell } from "./app/AuthShell"; +import { entriesUrlFromLibrariumPathname } from "./features/entries/entriesPath"; import { EntriesView } from "./features/entries/EntriesView"; const StatsView = lazy(() => import("./features/stats/StatsView")); +function LibrariumPathRedirect() { + const { pathname, hash } = useLocation(); + return ( + + ); +} + export default function App() { return ( @@ -22,7 +39,7 @@ export default function App() { } /> - } /> + } /> diff --git a/ui/src/features/entries/entriesPath.test.ts b/ui/src/features/entries/entriesPath.test.ts new file mode 100644 index 0000000..05f43b1 --- /dev/null +++ b/ui/src/features/entries/entriesPath.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { entriesUrlFromLibrariumPathname } from "./entriesPath.ts"; + +test("opens a nested markdown path as preview, not entries root", () => { + assert.equal( + entriesUrlFromLibrariumPathname( + "/projects/turret/design/sas-architecture-tracker.md", + ), + "/entries/projects/turret/design?open=%2Fprojects%2Fturret%2Fdesign%2Fsas-architecture-tracker.md", + ); +}); + +test("opens a root-level document", () => { + assert.equal( + entriesUrlFromLibrariumPathname("/notes.md"), + "/entries?open=%2Fnotes.md", + ); +}); + +test("treats a path without a file suffix as a directory", () => { + assert.equal( + entriesUrlFromLibrariumPathname("/projects/turret/design"), + "/entries/projects/turret/design", + ); +}); + +test("maps empty path to entries root", () => { + assert.equal(entriesUrlFromLibrariumPathname("/"), "/entries"); +}); + +test("opens a hidden file as preview", () => { + assert.equal( + entriesUrlFromLibrariumPathname("/secrets/.gitignore"), + "/entries/secrets?open=%2Fsecrets%2F.gitignore", + ); +}); diff --git a/ui/src/features/entries/entriesPath.ts b/ui/src/features/entries/entriesPath.ts index 6fd3986..79313dd 100644 --- a/ui/src/features/entries/entriesPath.ts +++ b/ui/src/features/entries/entriesPath.ts @@ -1,20 +1,14 @@ /** * Browser routes for the entries browser — `/entries` + optional path segments. * Optional `?open=` holds absolute document path for preview + refresh (Enginseer / Ferrum). + * Human links may omit `/entries` (`/projects/…/doc.md`); those map via + * `entriesUrlFromLibrariumPathname`. */ const PREFIX = "/entries"; -/** Librarium dir path from location (always a directory in the URL). */ -export function dirPathFromEntriesLocation(pathname: string): string { - if (pathname === PREFIX || pathname === `${PREFIX}/`) { - return "/"; - } - if (!pathname.startsWith(`${PREFIX}/`)) { - return "/"; - } - const rest = pathname.slice(PREFIX.length + 1); - const segments = rest +function decodePathSegments(pathname: string): string[] { + return pathname .split("/") .filter(Boolean) .map((s) => { @@ -24,6 +18,31 @@ export function dirPathFromEntriesLocation(pathname: string): string { return s; } }); +} + +function absFromPathname(pathname: string): string { + const segments = decodePathSegments(pathname); + return segments.length === 0 ? "/" : `/${segments.join("/")}`; +} + +/** Last segment looks like a file name (`notes.md`, `.gitignore`), not `.` / `..`. */ +function looksLikeDocumentName(name: string): boolean { + if (name === "." || name === "..") { + return false; + } + return name.includes("."); +} + +/** Librarium dir path from location (always a directory in the URL). */ +export function dirPathFromEntriesLocation(pathname: string): string { + if (pathname === PREFIX || pathname === `${PREFIX}/`) { + return "/"; + } + if (!pathname.startsWith(`${PREFIX}/`)) { + return "/"; + } + const rest = pathname.slice(PREFIX.length + 1); + const segments = decodePathSegments(rest); return `/${segments.join("/")}`; } @@ -51,3 +70,21 @@ export function withOpenDocQuery( q.set("open", docAbsolutePath); return `${path}?${q.toString()}`; } + +/** + * Maps a human librarium URL (`/projects/…/doc.md`) to the entries browser route. + * Last path segment containing `.` is treated as a document; otherwise a directory. + */ +export function entriesUrlFromLibrariumPathname(pathname: string): string { + const abs = absFromPathname(pathname); + if (abs === "/") { + return PREFIX; + } + const slash = abs.lastIndexOf("/"); + const name = abs.slice(slash + 1); + if (looksLikeDocumentName(name)) { + const dirPath = slash <= 0 ? "/" : abs.slice(0, slash); + return withOpenDocQuery(dirPath, abs); + } + return entriesPathForDir(abs); +} diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json index af516fc..160e659 100644 --- a/ui/tsconfig.app.json +++ b/ui/tsconfig.app.json @@ -24,5 +24,6 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts"] }