Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
37 changes: 37 additions & 0 deletions tests/test_webui_selenium.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---


Expand Down
6 changes: 6 additions & 0 deletions ui/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,10 @@ export default defineConfig([
globals: globals.browser,
},
},
{
files: ['**/*.test.ts'],
languageOptions: {
globals: globals.node,
},
},
])
1 change: 1 addition & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
21 changes: 19 additions & 2 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Navigate
to={`${entriesUrlFromLibrariumPathname(pathname)}${hash}`}
replace
/>
);
}

export default function App() {
return (
<BrowserRouter>
Expand All @@ -22,7 +39,7 @@ export default function App() {
</Suspense>
}
/>
<Route path="*" element={<Navigate to="/entries" replace />} />
<Route path="*" element={<LibrariumPathRedirect />} />
</Route>
</Route>
</Routes>
Expand Down
37 changes: 37 additions & 0 deletions ui/src/features/entries/entriesPath.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
57 changes: 47 additions & 10 deletions ui/src/features/entries/entriesPath.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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("/")}`;
}

Expand Down Expand Up @@ -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);
}
3 changes: 2 additions & 1 deletion ui/tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
Loading