refactor(web): consolidate workspace onto root route with URL state machine
Move the workspace shell from /workspace to / and introduce a typed URL state codec (parseUrlState/serializeUrlState) for deep-linkable workspace params. Legacy /workspace URLs are still recognized for backward compatibility.
This commit is contained in:
parent
f9d454f5c7
commit
1c21b039fc
@ -216,7 +216,7 @@ function WorkspaceSection({ tree, onRefresh }: { tree: TreeNode[]; onRefresh: ()
|
||||
const handleSelect = useCallback((node: TreeNode) => {
|
||||
// Navigate to workspace page for actionable items
|
||||
if (node.type === "object" || node.type === "document" || node.type === "file" || node.type === "database" || node.type === "report") {
|
||||
window.location.href = `/workspace?path=${encodeURIComponent(node.path)}`;
|
||||
window.location.href = `/?path=${encodeURIComponent(node.path)}`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
@ -240,7 +240,7 @@ function WorkspaceSection({ tree, onRefresh }: { tree: TreeNode[]; onRefresh: ()
|
||||
|
||||
{/* Full workspace link */}
|
||||
<a
|
||||
href="/workspace"
|
||||
href="/"
|
||||
className="flex items-center gap-1.5 mx-2 mt-2 px-2 py-1.5 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
@ -279,7 +279,7 @@ function ReportsSection({ tree }: { tree: TreeNode[] }) {
|
||||
{reports.map((report) => (
|
||||
<a
|
||||
key={report.path}
|
||||
href={`/workspace?path=${encodeURIComponent(report.path)}`}
|
||||
href={`/?path=${encodeURIComponent(report.path)}`}
|
||||
className="flex items-center gap-2 mx-2 px-2 py-1.5 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
|
||||
@ -170,7 +170,7 @@ export function DuckDBMissing() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.href = "/workspace?send=" + encodeURIComponent("install duckdb");
|
||||
window.location.href = "/?send=" + encodeURIComponent("install duckdb");
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors cursor-pointer"
|
||||
style={{
|
||||
|
||||
@ -226,7 +226,7 @@ export function MarkdownEditor({
|
||||
}, [editor, insertUploadedImages]);
|
||||
|
||||
// Handle link clicks for workspace navigation.
|
||||
// Links are real URLs like /workspace?path=... so clicking them navigates
|
||||
// Links are real URLs like /?path=... so clicking them navigates
|
||||
// within the same tab. We intercept to avoid a full page reload.
|
||||
useEffect(() => {
|
||||
if (!editor || !onNavigate) {return;}
|
||||
@ -239,7 +239,7 @@ export function MarkdownEditor({
|
||||
const href = link.getAttribute("href");
|
||||
if (!href) {return;}
|
||||
|
||||
// Intercept /workspace?... links to handle via client-side state
|
||||
// Intercept workspace links to handle via client-side state
|
||||
if (isWorkspaceLink(href)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
@ -1,123 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
// Three dramatic slash marks — like a panther raking its claws
|
||||
const CLAW_ASCII = [
|
||||
" ░░░░",
|
||||
" ░░░░░░",
|
||||
" ░░░░ ░░░░░░░",
|
||||
" ░░░░░░ ░░░░ ░░░▓▓░░░",
|
||||
" ░░░░ ░░░░░░░ ░░░░░░ ░░▓▓▓▓░░░",
|
||||
" ░░░░░░ ░░░▓▓░░░ ░░░░ ░░░░░░░ ░░▓▓▓▓▓░░ ",
|
||||
" ░░░░░░░ ░░▓▓▓▓░░░ ░░░░░░ ░░░▓▓░░░ ░░▓▓▓▓▓░░ ",
|
||||
" ░░░▓▓░░░ ░░▓▓▓▓▓░░ ░░░░░░░ ░░▓▓▓▓░░░ ░▓▓▓▓▓▓░░ ",
|
||||
" ░░▓▓▓▓░░░ ░░▓▓▓▓▓░░ ░░░▓▓░░░ ░░▓▓▓▓▓░░ ░▓▓▓▓▓▓░░ ",
|
||||
" ░░▓▓▓▓▓░░ ░▓▓▓▓▓▓░░ ░░▓▓▓▓░░░ ░░▓▓▓▓▓░░ ▓▓▓▓▓▓░░ ",
|
||||
" ░░▓▓▓▓▓░░ ░▓▓▓▓▓▓░░ ░░▓▓▓▓▓░░ ░▓▓▓▓▓▓░░ ▓▓▓▓▓▓░░ ",
|
||||
" ░░▓▓▓▓▓░░ ▓▓▓▓▓▓░░ ░░▓▓▓▓▓░░ ░▓▓▓▓▓░░ ▓▓▓▓▓░░░ ",
|
||||
" ░▓▓▓▓▓▓░░ ▓▓▓▓▓▓░░ ░░▓▓▓▓▓░░ ▓▓▓▓▓▓░░ ▓▓▓▓▓░░ ",
|
||||
" ░▓▓▓▓▓▓░░ ▓▓▓▓▓░░░ ░▓▓▓▓▓▓░░ ▓▓▓▓▓▓░░ ▓▓▓▓▓░░ ",
|
||||
" ░▓▓▓▓▓▓░░ ▓▓▓▓▓░░ ░▓▓▓▓▓▓░░ ▓▓▓▓▓░░░ ▓▓▓▓▓░░ ",
|
||||
" ░▓▓▓▓▓░░░ ▓▓▓▓▓░░ ░▓▓▓▓▓▓░░ ▓▓▓▓▓░░ ▓▓▓▓▓░░ ",
|
||||
" ░▓▓▓▓▓░░ ▓▓▓▓░░░ ░▓▓▓▓▓░░░ ▓▓▓▓▓░░ ▓▓▓▓░░░ ",
|
||||
" ░▓▓▓▓▓░░ ▓▓▓▓░░ ░░▓▓▓▓▓░░ ▓▓▓▓░░░ ▓▓▓▓░░ ",
|
||||
" ░▓▓▓▓░░░ ▓▓▓▓░░ ░░▓▓▓▓▓░░ ▓▓▓▓░░ ▓▓▓▓░░ ",
|
||||
" ░▓▓▓▓░░ ▓▓▓░░░ ░▓▓▓▓▓░░░ ▓▓▓▓░░ ▓▓▓░░░ ",
|
||||
" ░▓▓▓▓░░ ▓▓▓░░ ░░▓▓▓▓░░ ▓▓▓░░░ ▓▓▓░░ ",
|
||||
" ░▓▓▓░░░ ▓▓░░░ ░▓▓▓▓░░░ ▓▓▓░░ ▓▓▓░░ ",
|
||||
" ░▓▓▓░░ ▓▓░░ ░░▓▓▓░░ ▓▓░░░ ▓▓░░░ ",
|
||||
" ░▓▓░░░ ░▓░░ ░░▓▓▓░░ ▓▓░░ ▓▓░░ ",
|
||||
" ░▓▓░░ ░▓░░ ░▓▓▓░░░ ░▓░░░ ░▓░░ ",
|
||||
" ░▓░░░ ░░░ ░▓▓░░ ░▓░░ ░▓░░ ",
|
||||
" ░▓░░ ░░ ░░▓░░░ ░░░░ ░░░░ ",
|
||||
" ░░░░ ░ ░░▓░░ ░░░ ░░░ ",
|
||||
" ░░░ ░░░░░ ░░ ░░ ",
|
||||
" ░░ ░░░ ░ ",
|
||||
" ░ ░░ ",
|
||||
];
|
||||
|
||||
const DENCHCLAW_ASCII = [
|
||||
" ██╗██████╗ ██████╗ ███╗ ██╗ ██████╗██╗ █████╗ ██╗ ██╗",
|
||||
" ██║██╔══██╗██╔═══██╗████╗ ██║██╔════╝██║ ██╔══██╗██║ ██║",
|
||||
" ██║██████╔╝██║ ██║██╔██╗ ██║██║ ██║ ███████║██║ █╗ ██║",
|
||||
" ██║██╔══██╗██║ ██║██║╚██╗██║██║ ██║ ██╔══██║██║███╗██║",
|
||||
" ██║██║ ██║╚██████╔╝██║ ╚████║╚██████╗███████╗██║ ██║╚███╔███╔╝",
|
||||
" ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ",
|
||||
];
|
||||
import { WorkspaceShell } from "./workspace/workspace-content";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes iron-shimmer {
|
||||
0% {
|
||||
background-position: -200% center;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% center;
|
||||
}
|
||||
}
|
||||
|
||||
.ascii-banner {
|
||||
font-family: "SF Mono", "Fira Code", "Fira Mono", "Roboto Mono",
|
||||
"Courier New", monospace;
|
||||
white-space: pre;
|
||||
line-height: 1.15;
|
||||
font-size: clamp(0.5rem, 1.8vw, 1.4rem);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#374151 0%,
|
||||
#4b5563 10%,
|
||||
#6b7280 20%,
|
||||
#9ca3af 30%,
|
||||
#d1d5db 40%,
|
||||
#f3f4f6 50%,
|
||||
#d1d5db 60%,
|
||||
#9ca3af 70%,
|
||||
#6b7280 80%,
|
||||
#4b5563 90%,
|
||||
#374151 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: iron-shimmer 2.5s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="relative flex flex-col items-center justify-center min-h-screen bg-stone-50 overflow-hidden px-4">
|
||||
{/* Claw slash marks as full background — hidden on small screens */}
|
||||
<div
|
||||
className="absolute inset-0 hidden md:flex items-center justify-center select-none pointer-events-none text-stone-200/40"
|
||||
style={{
|
||||
fontFamily: '"SF Mono", "Fira Code", "Fira Mono", "Roboto Mono", "Courier New", monospace',
|
||||
whiteSpace: "pre",
|
||||
lineHeight: 1.0,
|
||||
fontSize: "clamp(0.75rem, 1.6vw, 1.5rem)",
|
||||
}}
|
||||
>
|
||||
{CLAW_ASCII.join("\n")}
|
||||
</div>
|
||||
|
||||
{/* Foreground content */}
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<div className="ascii-banner select-none hidden sm:block" aria-label="DENCHCLAW">
|
||||
{DENCHCLAW_ASCII.join("\n")}
|
||||
</div>
|
||||
<h1 className="sm:hidden text-3xl font-bold text-stone-600" style={{ fontFamily: "monospace" }}>
|
||||
DENCHCLAW
|
||||
</h1>
|
||||
<Link
|
||||
href="/workspace"
|
||||
className="mt-10 text-lg text-stone-400 hover:text-stone-600 transition-all min-h-[44px] flex items-center"
|
||||
style={{ fontFamily: "monospace" }}
|
||||
>
|
||||
enter the app →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return <WorkspaceShell />;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
3356
apps/web/app/workspace/workspace-content.tsx
Normal file
3356
apps/web/app/workspace/workspace-content.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -62,11 +62,11 @@ describe("formatWorkspaceFieldValue", () => {
|
||||
});
|
||||
|
||||
it("identifies workspace file links and includes embed metadata", () => {
|
||||
const result = formatWorkspaceFieldValue("/workspace?path=docs%2Fdeck.pdf", "text");
|
||||
const result = formatWorkspaceFieldValue("/?path=docs%2Fdeck.pdf", "text");
|
||||
expect(result.kind).toBe("link");
|
||||
expect(result.linkType).toBe("file");
|
||||
expect(result.filePath).toBe("docs/deck.pdf");
|
||||
expect(result.href).toContain("/workspace?path=");
|
||||
expect(result.href).toContain("/?path=");
|
||||
expect(result.mediaType).toBe("pdf");
|
||||
expect(result.embedUrl).toBe("/api/workspace/raw-file?path=docs%2Fdeck.pdf");
|
||||
});
|
||||
|
||||
@ -2,7 +2,14 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildEntryLink,
|
||||
buildFileLink,
|
||||
buildChatLink,
|
||||
buildSubagentLink,
|
||||
buildBrowseLink,
|
||||
buildUrl,
|
||||
parseWorkspaceLink,
|
||||
parseUrlState,
|
||||
serializeUrlState,
|
||||
migrateWorkspaceUrl,
|
||||
isWorkspaceLink,
|
||||
isInternalLink,
|
||||
isEntryLink,
|
||||
@ -11,8 +18,8 @@ import {
|
||||
// ─── buildEntryLink ────────────────────────────────────────────────
|
||||
|
||||
describe("buildEntryLink", () => {
|
||||
it("builds a basic entry link", () => {
|
||||
expect(buildEntryLink("leads", "abc123")).toBe("/workspace?entry=leads:abc123");
|
||||
it("builds a basic entry link at root route", () => {
|
||||
expect(buildEntryLink("leads", "abc123")).toBe("/?entry=leads:abc123");
|
||||
});
|
||||
|
||||
it("encodes special characters in object name", () => {
|
||||
@ -28,13 +35,12 @@ describe("buildEntryLink", () => {
|
||||
|
||||
it("handles empty object name", () => {
|
||||
const result = buildEntryLink("", "id1");
|
||||
expect(result).toBe("/workspace?entry=:id1");
|
||||
expect(result).toBe("/?entry=:id1");
|
||||
});
|
||||
|
||||
it("handles unicode characters", () => {
|
||||
const result = buildEntryLink("対象", "エントリ");
|
||||
expect(result).toContain("/workspace?entry=");
|
||||
// Should decode back correctly
|
||||
expect(result).toContain("/?entry=");
|
||||
const url = new URL(result, "http://localhost");
|
||||
expect(url.searchParams.get("entry")).toBe("対象:エントリ");
|
||||
});
|
||||
@ -43,8 +49,8 @@ describe("buildEntryLink", () => {
|
||||
// ─── buildFileLink ────────────────────────────────────────────────
|
||||
|
||||
describe("buildFileLink", () => {
|
||||
it("builds a basic file link", () => {
|
||||
expect(buildFileLink("knowledge/doc.md")).toBe("/workspace?path=knowledge%2Fdoc.md");
|
||||
it("builds a basic file link at root route", () => {
|
||||
expect(buildFileLink("knowledge/doc.md")).toBe("/?path=knowledge%2Fdoc.md");
|
||||
});
|
||||
|
||||
it("builds link for nested path", () => {
|
||||
@ -66,33 +72,77 @@ describe("buildFileLink", () => {
|
||||
});
|
||||
|
||||
it("handles empty path", () => {
|
||||
expect(buildFileLink("")).toBe("/workspace?path=");
|
||||
expect(buildFileLink("")).toBe("/?path=");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildChatLink ────────────────────────────────────────────────
|
||||
|
||||
describe("buildChatLink", () => {
|
||||
it("builds a chat session link", () => {
|
||||
expect(buildChatLink("sess-123")).toBe("/?chat=sess-123");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildSubagentLink ────────────────────────────────────────────
|
||||
|
||||
describe("buildSubagentLink", () => {
|
||||
it("includes both chat and subagent params", () => {
|
||||
const result = buildSubagentLink("parent-id", "child-key");
|
||||
expect(result).toBe("/?chat=parent-id&subagent=child-key");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildBrowseLink ──────────────────────────────────────────────
|
||||
|
||||
describe("buildBrowseLink", () => {
|
||||
it("builds browse link for an absolute directory", () => {
|
||||
const result = buildBrowseLink("/Users/me/Desktop");
|
||||
expect(result).toContain("browse=");
|
||||
const url = new URL(result, "http://localhost");
|
||||
expect(url.searchParams.get("browse")).toBe("/Users/me/Desktop");
|
||||
});
|
||||
|
||||
it("includes hidden flag when requested", () => {
|
||||
const result = buildBrowseLink("/tmp", true);
|
||||
const url = new URL(result, "http://localhost");
|
||||
expect(url.searchParams.get("hidden")).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parseWorkspaceLink ───────────────────────────────────────────
|
||||
|
||||
describe("parseWorkspaceLink", () => {
|
||||
it("parses file link from path param", () => {
|
||||
const result = parseWorkspaceLink("/workspace?path=knowledge/doc.md");
|
||||
it("parses file link from root route path param", () => {
|
||||
const result = parseWorkspaceLink("/?path=knowledge/doc.md");
|
||||
expect(result).toEqual({ kind: "file", path: "knowledge/doc.md" });
|
||||
});
|
||||
|
||||
it("parses entry link from entry param", () => {
|
||||
const result = parseWorkspaceLink("/workspace?entry=leads:abc123");
|
||||
it("parses entry link from root route entry param", () => {
|
||||
const result = parseWorkspaceLink("/?entry=leads:abc123");
|
||||
expect(result).toEqual({ kind: "entry", objectName: "leads", entryId: "abc123" });
|
||||
});
|
||||
|
||||
it("parses entry link from full URL", () => {
|
||||
const result = parseWorkspaceLink("http://localhost:3100/workspace?entry=deals:xyz");
|
||||
const result = parseWorkspaceLink("http://localhost:3100/?entry=deals:xyz");
|
||||
expect(result).toEqual({ kind: "entry", objectName: "deals", entryId: "xyz" });
|
||||
});
|
||||
|
||||
it("parses file link from full URL", () => {
|
||||
const result = parseWorkspaceLink("http://localhost:3100/workspace?path=readme.md");
|
||||
const result = parseWorkspaceLink("http://localhost:3100/?path=readme.md");
|
||||
expect(result).toEqual({ kind: "file", path: "readme.md" });
|
||||
});
|
||||
|
||||
it("parses legacy /workspace file link (backward compat)", () => {
|
||||
const result = parseWorkspaceLink("/workspace?path=knowledge/doc.md");
|
||||
expect(result).toEqual({ kind: "file", path: "knowledge/doc.md" });
|
||||
});
|
||||
|
||||
it("parses legacy /workspace entry link (backward compat)", () => {
|
||||
const result = parseWorkspaceLink("/workspace?entry=leads:abc123");
|
||||
expect(result).toEqual({ kind: "entry", objectName: "leads", entryId: "abc123" });
|
||||
});
|
||||
|
||||
it("parses legacy @entry/ format", () => {
|
||||
const result = parseWorkspaceLink("@entry/leads/abc123");
|
||||
expect(result).toEqual({ kind: "entry", objectName: "leads", entryId: "abc123" });
|
||||
@ -102,25 +152,25 @@ describe("parseWorkspaceLink", () => {
|
||||
expect(parseWorkspaceLink("not a url ://bad")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no params present", () => {
|
||||
expect(parseWorkspaceLink("/workspace")).toBeNull();
|
||||
it("returns null when no params present on root", () => {
|
||||
expect(parseWorkspaceLink("/")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for hash-only link", () => {
|
||||
expect(parseWorkspaceLink("/workspace#section")).toBeNull();
|
||||
expect(parseWorkspaceLink("/#section")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for entry param without colon", () => {
|
||||
expect(parseWorkspaceLink("/workspace?entry=nocolon")).toBeNull();
|
||||
expect(parseWorkspaceLink("/?entry=nocolon")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles deeply nested file path", () => {
|
||||
const result = parseWorkspaceLink("/workspace?path=a/b/c/d/e/f.txt");
|
||||
const result = parseWorkspaceLink("/?path=a/b/c/d/e/f.txt");
|
||||
expect(result).toEqual({ kind: "file", path: "a/b/c/d/e/f.txt" });
|
||||
});
|
||||
|
||||
it("handles encoded characters in path", () => {
|
||||
const result = parseWorkspaceLink("/workspace?path=my%20docs%2Ffile.md");
|
||||
const result = parseWorkspaceLink("/?path=my%20docs%2Ffile.md");
|
||||
expect(result).toEqual({ kind: "file", path: "my docs/file.md" });
|
||||
});
|
||||
|
||||
@ -129,12 +179,12 @@ describe("parseWorkspaceLink", () => {
|
||||
});
|
||||
|
||||
it("entry param takes priority over path param", () => {
|
||||
const result = parseWorkspaceLink("/workspace?entry=obj:id&path=file.md");
|
||||
const result = parseWorkspaceLink("/?entry=obj:id&path=file.md");
|
||||
expect(result).toEqual({ kind: "entry", objectName: "obj", entryId: "id" });
|
||||
});
|
||||
|
||||
it("handles entry with colon in ID", () => {
|
||||
const result = parseWorkspaceLink("/workspace?entry=obj:id:with:colons");
|
||||
const result = parseWorkspaceLink("/?entry=obj:id:with:colons");
|
||||
expect(result).toEqual({ kind: "entry", objectName: "obj", entryId: "id:with:colons" });
|
||||
});
|
||||
|
||||
@ -146,27 +196,35 @@ describe("parseWorkspaceLink", () => {
|
||||
// ─── isWorkspaceLink ──────────────────────────────────────────────
|
||||
|
||||
describe("isWorkspaceLink", () => {
|
||||
it("returns true for /workspace?path=...", () => {
|
||||
expect(isWorkspaceLink("/workspace?path=doc.md")).toBe(true);
|
||||
it("returns true for /?path=...", () => {
|
||||
expect(isWorkspaceLink("/?path=doc.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for /workspace#...", () => {
|
||||
expect(isWorkspaceLink("/workspace#section")).toBe(true);
|
||||
it("returns true for /#...", () => {
|
||||
expect(isWorkspaceLink("/#section")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for /workspace alone", () => {
|
||||
expect(isWorkspaceLink("/workspace")).toBe(true);
|
||||
it("returns true for / alone", () => {
|
||||
expect(isWorkspaceLink("/")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for @entry/ format", () => {
|
||||
expect(isWorkspaceLink("@entry/leads/abc")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for legacy /workspace?path=... (backward compat)", () => {
|
||||
expect(isWorkspaceLink("/workspace?path=doc.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for legacy /workspace alone", () => {
|
||||
expect(isWorkspaceLink("/workspace")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for external URL", () => {
|
||||
expect(isWorkspaceLink("https://example.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for random path", () => {
|
||||
it("returns false for non-root path", () => {
|
||||
expect(isWorkspaceLink("/other-page")).toBe(false);
|
||||
});
|
||||
|
||||
@ -191,7 +249,7 @@ describe("isInternalLink", () => {
|
||||
});
|
||||
|
||||
it("returns true for relative paths", () => {
|
||||
expect(isInternalLink("/workspace?path=doc.md")).toBe(true);
|
||||
expect(isInternalLink("/?path=doc.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for @entry/ links", () => {
|
||||
@ -206,7 +264,11 @@ describe("isInternalLink", () => {
|
||||
// ─── isEntryLink ──────────────────────────────────────────────────
|
||||
|
||||
describe("isEntryLink", () => {
|
||||
it("returns true for new format entry link", () => {
|
||||
it("returns true for new format entry link at root", () => {
|
||||
expect(isEntryLink("/?entry=leads:abc")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for legacy /workspace entry link", () => {
|
||||
expect(isEntryLink("/workspace?entry=leads:abc")).toBe(true);
|
||||
});
|
||||
|
||||
@ -215,14 +277,266 @@ describe("isEntryLink", () => {
|
||||
});
|
||||
|
||||
it("returns false for file workspace link", () => {
|
||||
expect(isEntryLink("/workspace?path=doc.md")).toBe(false);
|
||||
expect(isEntryLink("/?path=doc.md")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for external URL", () => {
|
||||
expect(isEntryLink("https://example.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for plain /workspace", () => {
|
||||
expect(isEntryLink("/workspace")).toBe(false);
|
||||
it("returns false for plain root", () => {
|
||||
expect(isEntryLink("/")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── migrateWorkspaceUrl ──────────────────────────────────────────
|
||||
|
||||
describe("migrateWorkspaceUrl", () => {
|
||||
it("migrates /workspace to /", () => {
|
||||
expect(migrateWorkspaceUrl("/workspace")).toBe("/");
|
||||
});
|
||||
|
||||
it("migrates /workspace?path=doc.md preserving query params", () => {
|
||||
expect(migrateWorkspaceUrl("/workspace?path=doc.md")).toBe("/?path=doc.md");
|
||||
});
|
||||
|
||||
it("migrates /workspace?chat=abc&entry=obj:id", () => {
|
||||
expect(migrateWorkspaceUrl("/workspace?chat=abc&entry=obj:id")).toBe("/?chat=abc&entry=obj:id");
|
||||
});
|
||||
|
||||
it("preserves hash fragments", () => {
|
||||
expect(migrateWorkspaceUrl("/workspace#section")).toBe("/#section");
|
||||
});
|
||||
|
||||
it("preserves both query and hash", () => {
|
||||
expect(migrateWorkspaceUrl("/workspace?path=a.md#heading")).toBe("/?path=a.md#heading");
|
||||
});
|
||||
|
||||
it("returns null for non-workspace URLs", () => {
|
||||
expect(migrateWorkspaceUrl("/other")).toBeNull();
|
||||
expect(migrateWorkspaceUrl("https://example.com")).toBeNull();
|
||||
expect(migrateWorkspaceUrl("/")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parseUrlState ────────────────────────────────────────────────
|
||||
|
||||
describe("parseUrlState", () => {
|
||||
it("parses path from search params", () => {
|
||||
const state = parseUrlState("path=knowledge/doc.md");
|
||||
expect(state.path).toBe("knowledge/doc.md");
|
||||
expect(state.chat).toBeNull();
|
||||
});
|
||||
|
||||
it("parses chat session", () => {
|
||||
const state = parseUrlState("chat=sess-123");
|
||||
expect(state.chat).toBe("sess-123");
|
||||
expect(state.path).toBeNull();
|
||||
});
|
||||
|
||||
it("parses subagent with parent chat", () => {
|
||||
const state = parseUrlState("chat=parent&subagent=child-key");
|
||||
expect(state.chat).toBe("parent");
|
||||
expect(state.subagent).toBe("child-key");
|
||||
});
|
||||
|
||||
it("parses entry param", () => {
|
||||
const state = parseUrlState("entry=leads:abc123");
|
||||
expect(state.entry).toEqual({ objectName: "leads", entryId: "abc123" });
|
||||
});
|
||||
|
||||
it("handles entry with colon in ID", () => {
|
||||
const state = parseUrlState("entry=obj:id:with:colons");
|
||||
expect(state.entry).toEqual({ objectName: "obj", entryId: "id:with:colons" });
|
||||
});
|
||||
|
||||
it("returns null entry for entry param without colon", () => {
|
||||
const state = parseUrlState("entry=nocolon");
|
||||
expect(state.entry).toBeNull();
|
||||
});
|
||||
|
||||
it("parses browse mode", () => {
|
||||
const state = parseUrlState("browse=/Users/me/Desktop&hidden=1");
|
||||
expect(state.browse).toBe("/Users/me/Desktop");
|
||||
expect(state.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("hidden defaults to false", () => {
|
||||
const state = parseUrlState("browse=/tmp");
|
||||
expect(state.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("parses preview target", () => {
|
||||
const state = parseUrlState("path=file.md&preview=other.md");
|
||||
expect(state.preview).toBe("other.md");
|
||||
});
|
||||
|
||||
it("parses object view params", () => {
|
||||
const state = parseUrlState("path=leads&viewType=kanban&view=MyView&search=hello&page=3&pageSize=50&cols=name,email,status");
|
||||
expect(state.viewType).toBe("kanban");
|
||||
expect(state.view).toBe("MyView");
|
||||
expect(state.search).toBe("hello");
|
||||
expect(state.page).toBe(3);
|
||||
expect(state.pageSize).toBe(50);
|
||||
expect(state.cols).toEqual(["name", "email", "status"]);
|
||||
});
|
||||
|
||||
it("rejects invalid view types", () => {
|
||||
const state = parseUrlState("viewType=invalid");
|
||||
expect(state.viewType).toBeNull();
|
||||
});
|
||||
|
||||
it("parses base64-encoded filters", () => {
|
||||
const fg = { id: "root", conjunction: "and" as const, rules: [{ id: "r1", field: "status", operator: "equals" as const, value: "active" }] };
|
||||
const encoded = btoa(JSON.stringify(fg));
|
||||
const state = parseUrlState(`filters=${encoded}`);
|
||||
expect(state.filters).toEqual(fg);
|
||||
});
|
||||
|
||||
it("parses base64-encoded sort rules", () => {
|
||||
const rules = [{ field: "name", direction: "asc" as const }];
|
||||
const encoded = btoa(JSON.stringify(rules));
|
||||
const state = parseUrlState(`sort=${encoded}`);
|
||||
expect(state.sort).toEqual(rules);
|
||||
});
|
||||
|
||||
it("handles invalid base64 filters gracefully", () => {
|
||||
const state = parseUrlState("filters=!!!invalid!!!");
|
||||
expect(state.filters).toBeNull();
|
||||
});
|
||||
|
||||
it("handles invalid base64 sort gracefully", () => {
|
||||
const state = parseUrlState("sort=!!!invalid!!!");
|
||||
expect(state.sort).toBeNull();
|
||||
});
|
||||
|
||||
it("returns all nulls/defaults for empty search", () => {
|
||||
const state = parseUrlState("");
|
||||
expect(state.path).toBeNull();
|
||||
expect(state.chat).toBeNull();
|
||||
expect(state.subagent).toBeNull();
|
||||
expect(state.fileChat).toBeNull();
|
||||
expect(state.entry).toBeNull();
|
||||
expect(state.send).toBeNull();
|
||||
expect(state.browse).toBeNull();
|
||||
expect(state.hidden).toBe(false);
|
||||
expect(state.preview).toBeNull();
|
||||
expect(state.view).toBeNull();
|
||||
expect(state.viewType).toBeNull();
|
||||
expect(state.filters).toBeNull();
|
||||
expect(state.search).toBeNull();
|
||||
expect(state.sort).toBeNull();
|
||||
expect(state.page).toBeNull();
|
||||
expect(state.pageSize).toBeNull();
|
||||
expect(state.cols).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts URLSearchParams directly", () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("chat", "s1");
|
||||
const state = parseUrlState(params);
|
||||
expect(state.chat).toBe("s1");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── serializeUrlState ────────────────────────────────────────────
|
||||
|
||||
describe("serializeUrlState", () => {
|
||||
it("omits null/default values", () => {
|
||||
expect(serializeUrlState({})).toBe("");
|
||||
});
|
||||
|
||||
it("serializes path only", () => {
|
||||
const qs = serializeUrlState({ path: "doc.md" });
|
||||
expect(qs).toBe("path=doc.md");
|
||||
});
|
||||
|
||||
it("serializes chat and subagent", () => {
|
||||
const qs = serializeUrlState({ chat: "parent", subagent: "child" });
|
||||
expect(qs).toContain("chat=parent");
|
||||
expect(qs).toContain("subagent=child");
|
||||
});
|
||||
|
||||
it("serializes entry", () => {
|
||||
const qs = serializeUrlState({ entry: { objectName: "leads", entryId: "x" } });
|
||||
// URLSearchParams encodes : as %3A in values
|
||||
expect(qs).toContain("entry=leads");
|
||||
expect(qs).toContain("x");
|
||||
// Round-trip must restore the entry
|
||||
const parsed = parseUrlState(qs);
|
||||
expect(parsed.entry).toEqual({ objectName: "leads", entryId: "x" });
|
||||
});
|
||||
|
||||
it("serializes browse with hidden", () => {
|
||||
const qs = serializeUrlState({ browse: "/tmp", hidden: true });
|
||||
expect(qs).toContain("browse=%2Ftmp");
|
||||
expect(qs).toContain("hidden=1");
|
||||
});
|
||||
|
||||
it("omits page=1 (default)", () => {
|
||||
const qs = serializeUrlState({ page: 1 });
|
||||
expect(qs).toBe("");
|
||||
});
|
||||
|
||||
it("includes page > 1", () => {
|
||||
const qs = serializeUrlState({ page: 3 });
|
||||
expect(qs).toBe("page=3");
|
||||
});
|
||||
|
||||
it("serializes cols as comma-separated", () => {
|
||||
const qs = serializeUrlState({ cols: ["name", "email"] });
|
||||
expect(qs).toBe("cols=name%2Cemail");
|
||||
});
|
||||
|
||||
it("round-trips through parseUrlState", () => {
|
||||
const original: Partial<import("./workspace-links").WorkspaceUrlState> = {
|
||||
path: "leads",
|
||||
viewType: "kanban",
|
||||
search: "hello",
|
||||
page: 2,
|
||||
pageSize: 50,
|
||||
cols: ["name", "email"],
|
||||
};
|
||||
const qs = serializeUrlState(original);
|
||||
const parsed = parseUrlState(qs);
|
||||
expect(parsed.path).toBe("leads");
|
||||
expect(parsed.viewType).toBe("kanban");
|
||||
expect(parsed.search).toBe("hello");
|
||||
expect(parsed.page).toBe(2);
|
||||
expect(parsed.pageSize).toBe(50);
|
||||
expect(parsed.cols).toEqual(["name", "email"]);
|
||||
});
|
||||
|
||||
it("round-trips filters through serialize/parse", () => {
|
||||
const fg = { id: "root", conjunction: "and" as const, rules: [{ id: "r1", field: "status", operator: "equals" as const, value: "active" }] };
|
||||
const qs = serializeUrlState({ filters: fg });
|
||||
const parsed = parseUrlState(qs);
|
||||
expect(parsed.filters).toEqual(fg);
|
||||
});
|
||||
|
||||
it("round-trips sort through serialize/parse", () => {
|
||||
const sort = [{ field: "name", direction: "desc" as const }];
|
||||
const qs = serializeUrlState({ sort });
|
||||
const parsed = parseUrlState(qs);
|
||||
expect(parsed.sort).toEqual(sort);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildUrl ─────────────────────────────────────────────────────
|
||||
|
||||
describe("buildUrl", () => {
|
||||
it("returns / for empty state", () => {
|
||||
expect(buildUrl({})).toBe("/");
|
||||
});
|
||||
|
||||
it("builds root URL with query params", () => {
|
||||
expect(buildUrl({ path: "doc.md" })).toBe("/?path=doc.md");
|
||||
});
|
||||
|
||||
it("builds complex URL with multiple params", () => {
|
||||
const url = buildUrl({ chat: "s1", subagent: "sa1" });
|
||||
expect(url).toContain("/?");
|
||||
expect(url).toContain("chat=s1");
|
||||
expect(url).toContain("subagent=sa1");
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,45 +1,251 @@
|
||||
/**
|
||||
* Workspace link utilities.
|
||||
*
|
||||
* All workspace links use REAL URLs so they work if the browser follows them:
|
||||
* Files/docs: /workspace?path=knowledge/path/to/doc.md
|
||||
* Objects: /workspace?path=knowledge/leads
|
||||
* Entries: /workspace?entry=leads:abc123
|
||||
* All workspace links use the root route with query params:
|
||||
* Files/docs: /?path=knowledge/path/to/doc.md
|
||||
* Objects: /?path=knowledge/leads
|
||||
* Entries: /?entry=leads:abc123
|
||||
* Chat: /?chat=session-id
|
||||
* Subagent: /?chat=parent-id&subagent=child-key
|
||||
* Browse: /?browse=/abs/path&hidden=1
|
||||
* Cron: /?path=~cron or /?path=~cron/job-id
|
||||
* Object view: /?path=leads&viewType=kanban&filters=...&sort=...&search=...&page=1&pageSize=50&cols=a,b,c&view=MyView
|
||||
* Preview: /?path=file.md&preview=other.md
|
||||
* Send: /?send=install+duckdb (consumed immediately)
|
||||
*
|
||||
* Legacy /workspace?... links are accepted by parseWorkspaceLink and
|
||||
* migrateWorkspaceUrl for backward compat.
|
||||
*/
|
||||
|
||||
import type { FilterGroup, SortRule, ViewType } from "./object-filters";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsed link (simple)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WorkspaceLink =
|
||||
| { kind: "file"; path: string }
|
||||
| { kind: "entry"; objectName: string; entryId: string };
|
||||
|
||||
// --- Builders ---
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full URL state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build a real URL for an entry detail modal. */
|
||||
export function buildEntryLink(objectName: string, entryId: string): string {
|
||||
return `/workspace?entry=${encodeURIComponent(objectName)}:${encodeURIComponent(entryId)}`;
|
||||
}
|
||||
export type WorkspaceUrlState = {
|
||||
path: string | null;
|
||||
chat: string | null;
|
||||
subagent: string | null;
|
||||
/** File-scoped chat session (active when a file is open in the main panel). */
|
||||
fileChat: string | null;
|
||||
entry: { objectName: string; entryId: string } | null;
|
||||
send: string | null;
|
||||
browse: string | null;
|
||||
hidden: boolean;
|
||||
preview: string | null;
|
||||
view: string | null;
|
||||
viewType: ViewType | null;
|
||||
filters: FilterGroup | null;
|
||||
search: string | null;
|
||||
sort: SortRule[] | null;
|
||||
page: number | null;
|
||||
pageSize: number | null;
|
||||
cols: string[] | null;
|
||||
};
|
||||
|
||||
/** Build a real URL for a file or object in the workspace. */
|
||||
export function buildFileLink(path: string): string {
|
||||
return `/workspace?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
const VALID_VIEW_TYPES: ViewType[] = [
|
||||
"table", "kanban", "calendar", "timeline", "gallery", "list",
|
||||
];
|
||||
|
||||
// --- Parsers ---
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL state codec
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Parse a workspace URL into a structured link. Returns null if not a workspace link. */
|
||||
export function parseWorkspaceLink(href: string): WorkspaceLink | null {
|
||||
// Handle full or relative /workspace?... URLs
|
||||
let url: URL | null = null;
|
||||
try {
|
||||
if (href.startsWith("/workspace")) {
|
||||
url = new URL(href, "http://localhost");
|
||||
} else if (href.includes("/workspace?")) {
|
||||
url = new URL(href);
|
||||
}
|
||||
} catch {
|
||||
// not a valid URL
|
||||
/** Parse search params (from any origin) into a typed WorkspaceUrlState. */
|
||||
export function parseUrlState(search: string | URLSearchParams): WorkspaceUrlState {
|
||||
const params = typeof search === "string"
|
||||
? new URLSearchParams(search)
|
||||
: search;
|
||||
|
||||
let entry: WorkspaceUrlState["entry"] = null;
|
||||
const entryRaw = params.get("entry");
|
||||
if (entryRaw && entryRaw.includes(":")) {
|
||||
const idx = entryRaw.indexOf(":");
|
||||
entry = {
|
||||
objectName: entryRaw.slice(0, idx),
|
||||
entryId: entryRaw.slice(idx + 1),
|
||||
};
|
||||
}
|
||||
|
||||
if (url) {
|
||||
let filters: FilterGroup | null = null;
|
||||
const filtersRaw = params.get("filters");
|
||||
if (filtersRaw) {
|
||||
try {
|
||||
filters = JSON.parse(atob(filtersRaw)) as FilterGroup;
|
||||
} catch { /* invalid — ignore */ }
|
||||
}
|
||||
|
||||
let sort: SortRule[] | null = null;
|
||||
const sortRaw = params.get("sort");
|
||||
if (sortRaw) {
|
||||
try {
|
||||
sort = JSON.parse(atob(sortRaw)) as SortRule[];
|
||||
} catch { /* invalid — ignore */ }
|
||||
}
|
||||
|
||||
const pageRaw = params.get("page");
|
||||
const pageSizeRaw = params.get("pageSize");
|
||||
|
||||
const colsRaw = params.get("cols");
|
||||
const viewTypeRaw = params.get("viewType") as ViewType | null;
|
||||
|
||||
return {
|
||||
path: params.get("path"),
|
||||
chat: params.get("chat"),
|
||||
subagent: params.get("subagent"),
|
||||
fileChat: params.get("fileChat"),
|
||||
entry,
|
||||
send: params.get("send"),
|
||||
browse: params.get("browse"),
|
||||
hidden: params.get("hidden") === "1",
|
||||
preview: params.get("preview"),
|
||||
view: params.get("view"),
|
||||
viewType:
|
||||
viewTypeRaw && VALID_VIEW_TYPES.includes(viewTypeRaw)
|
||||
? viewTypeRaw
|
||||
: null,
|
||||
filters,
|
||||
search: params.get("search"),
|
||||
sort,
|
||||
page: pageRaw ? parseInt(pageRaw, 10) || null : null,
|
||||
pageSize: pageSizeRaw ? parseInt(pageSizeRaw, 10) || null : null,
|
||||
cols: colsRaw ? colsRaw.split(",").filter(Boolean) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialize a (partial) WorkspaceUrlState to a query string. Omits null/default values. */
|
||||
export function serializeUrlState(state: Partial<WorkspaceUrlState>): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (state.path) params.set("path", state.path);
|
||||
if (state.chat) params.set("chat", state.chat);
|
||||
if (state.subagent) params.set("subagent", state.subagent);
|
||||
if (state.fileChat) params.set("fileChat", state.fileChat);
|
||||
if (state.entry) {
|
||||
params.set("entry", `${state.entry.objectName}:${state.entry.entryId}`);
|
||||
}
|
||||
if (state.send) params.set("send", state.send);
|
||||
if (state.browse) params.set("browse", state.browse);
|
||||
if (state.hidden) params.set("hidden", "1");
|
||||
if (state.preview) params.set("preview", state.preview);
|
||||
if (state.view) params.set("view", state.view);
|
||||
if (state.viewType) params.set("viewType", state.viewType);
|
||||
if (state.filters && state.filters.rules.length > 0) {
|
||||
params.set("filters", btoa(JSON.stringify(state.filters)));
|
||||
}
|
||||
if (state.search) params.set("search", state.search);
|
||||
if (state.sort && state.sort.length > 0) {
|
||||
params.set("sort", btoa(JSON.stringify(state.sort)));
|
||||
}
|
||||
if (state.page != null && state.page > 1) params.set("page", String(state.page));
|
||||
if (state.pageSize != null) params.set("pageSize", String(state.pageSize));
|
||||
if (state.cols && state.cols.length > 0) params.set("cols", state.cols.join(","));
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
/** Build a full root-route URL string from partial state. */
|
||||
export function buildUrl(state: Partial<WorkspaceUrlState>): string {
|
||||
const qs = serializeUrlState(state);
|
||||
return qs ? `/?${qs}` : "/";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy migration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a legacy /workspace?... URL to the equivalent root URL.
|
||||
* Returns null if the href is not a legacy workspace URL.
|
||||
*/
|
||||
export function migrateWorkspaceUrl(href: string): string | null {
|
||||
const isLegacy =
|
||||
href === "/workspace" ||
|
||||
href.startsWith("/workspace?") ||
|
||||
href.startsWith("/workspace#");
|
||||
|
||||
if (!isLegacy) return null;
|
||||
|
||||
if (href === "/workspace") return "/";
|
||||
|
||||
const qIdx = href.indexOf("?");
|
||||
const hIdx = href.indexOf("#");
|
||||
|
||||
let qs = "";
|
||||
let hash = "";
|
||||
|
||||
if (qIdx >= 0) {
|
||||
const endOfQs = hIdx > qIdx ? hIdx : href.length;
|
||||
qs = href.slice(qIdx, endOfQs);
|
||||
}
|
||||
if (hIdx >= 0) {
|
||||
hash = href.slice(hIdx);
|
||||
}
|
||||
|
||||
return `/${qs}${hash}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simple link builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build a URL for an entry detail modal. */
|
||||
export function buildEntryLink(objectName: string, entryId: string): string {
|
||||
return `/?entry=${encodeURIComponent(objectName)}:${encodeURIComponent(entryId)}`;
|
||||
}
|
||||
|
||||
/** Build a URL for a file or object in the workspace. */
|
||||
export function buildFileLink(path: string): string {
|
||||
return `/?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
/** Build a URL for a chat session. */
|
||||
export function buildChatLink(sessionId: string): string {
|
||||
return `/?chat=${encodeURIComponent(sessionId)}`;
|
||||
}
|
||||
|
||||
/** Build a URL for a subagent panel. */
|
||||
export function buildSubagentLink(chatId: string, subagentKey: string): string {
|
||||
return `/?chat=${encodeURIComponent(chatId)}&subagent=${encodeURIComponent(subagentKey)}`;
|
||||
}
|
||||
|
||||
/** Build a URL for browse mode. */
|
||||
export function buildBrowseLink(dir: string, showHidden?: boolean): string {
|
||||
const p = new URLSearchParams();
|
||||
p.set("browse", dir);
|
||||
if (showHidden) p.set("hidden", "1");
|
||||
return `/?${p.toString()}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simple link parsers / predicates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function tryParseAppUrl(href: string): URL | null {
|
||||
try {
|
||||
if (href.startsWith("/")) {
|
||||
return new URL(href, "http://localhost");
|
||||
}
|
||||
const u = new URL(href);
|
||||
if (u.pathname === "/" || u.pathname === "/workspace") return u;
|
||||
} catch { /* invalid */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse a workspace URL into a structured link. Accepts both root and legacy /workspace URLs. */
|
||||
export function parseWorkspaceLink(href: string): WorkspaceLink | null {
|
||||
const url = tryParseAppUrl(href);
|
||||
|
||||
if (url && (url.pathname === "/" || url.pathname === "/workspace")) {
|
||||
const entryParam = url.searchParams.get("entry");
|
||||
if (entryParam && entryParam.includes(":")) {
|
||||
const colonIdx = entryParam.indexOf(":");
|
||||
@ -72,14 +278,20 @@ export function parseWorkspaceLink(href: string): WorkspaceLink | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Check if an href is a workspace link (either /workspace?... or legacy @entry/). */
|
||||
/** Check if an href is an app-internal workspace link (root, legacy /workspace, or @entry/). */
|
||||
export function isWorkspaceLink(href: string): boolean {
|
||||
return (
|
||||
href.startsWith("/workspace?") ||
|
||||
href.startsWith("/workspace#") ||
|
||||
if (href.startsWith("@entry/")) return true;
|
||||
// Legacy /workspace links
|
||||
if (
|
||||
href === "/workspace" ||
|
||||
href.startsWith("@entry/")
|
||||
);
|
||||
href.startsWith("/workspace?") ||
|
||||
href.startsWith("/workspace#")
|
||||
) return true;
|
||||
// New root links with query params
|
||||
if (href === "/") return true;
|
||||
if (href.startsWith("/?")) return true;
|
||||
if (href.startsWith("/#")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Check if an href is a workspace-internal link (not external URL). */
|
||||
@ -93,7 +305,7 @@ export function isInternalLink(href: string): boolean {
|
||||
|
||||
/** Check if an href is an entry link (any format). */
|
||||
export function isEntryLink(href: string): boolean {
|
||||
if (href.startsWith("@entry/")) {return true;}
|
||||
if (href.startsWith("/workspace") && href.includes("entry=")) {return true;}
|
||||
if (href.startsWith("@entry/")) return true;
|
||||
if ((href.startsWith("/") || href.startsWith("/workspace")) && href.includes("entry=")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
483
apps/web/lib/workspace-url-state.test.ts
Normal file
483
apps/web/lib/workspace-url-state.test.ts
Normal file
@ -0,0 +1,483 @@
|
||||
/**
|
||||
* Tests for the workspace URL state machine.
|
||||
*
|
||||
* These tests verify invariants around deep-linking, state restoration,
|
||||
* URL serialization round-trips, and precedence rules that keep the app
|
||||
* navigable via copied/bookmarked URLs.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseUrlState,
|
||||
serializeUrlState,
|
||||
buildUrl,
|
||||
buildEntryLink,
|
||||
buildFileLink,
|
||||
buildChatLink,
|
||||
buildSubagentLink,
|
||||
buildBrowseLink,
|
||||
migrateWorkspaceUrl,
|
||||
parseWorkspaceLink,
|
||||
isWorkspaceLink,
|
||||
isEntryLink,
|
||||
type WorkspaceUrlState,
|
||||
} from "./workspace-links";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deep-link restoration invariants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("deep-link restoration", () => {
|
||||
it("restores file path from copied URL (prevents lost context on page reload)", () => {
|
||||
const url = buildFileLink("knowledge/CRM/contacts.md");
|
||||
const state = parseUrlState(new URL(url, "http://localhost").search);
|
||||
expect(state.path).toBe("knowledge/CRM/contacts.md");
|
||||
});
|
||||
|
||||
it("restores chat session from copied URL (prevents lost conversation on refresh)", () => {
|
||||
const url = buildChatLink("sess-abc-123");
|
||||
const state = parseUrlState(new URL(url, "http://localhost").search);
|
||||
expect(state.chat).toBe("sess-abc-123");
|
||||
});
|
||||
|
||||
it("restores subagent panel from copied URL (prevents lost subagent context on refresh)", () => {
|
||||
const url = buildSubagentLink("parent-sess", "child-key-456");
|
||||
const state = parseUrlState(new URL(url, "http://localhost").search);
|
||||
expect(state.chat).toBe("parent-sess");
|
||||
expect(state.subagent).toBe("child-key-456");
|
||||
});
|
||||
|
||||
it("restores entry modal from copied URL (prevents lost entry detail on refresh)", () => {
|
||||
const url = buildEntryLink("leads", "entry-789");
|
||||
const state = parseUrlState(new URL(url, "http://localhost").search);
|
||||
expect(state.entry).toEqual({ objectName: "leads", entryId: "entry-789" });
|
||||
});
|
||||
|
||||
it("restores browse mode from copied URL (prevents lost directory context on refresh)", () => {
|
||||
const url = buildBrowseLink("/Users/me/projects/app", true);
|
||||
const state = parseUrlState(new URL(url, "http://localhost").search);
|
||||
expect(state.browse).toBe("/Users/me/projects/app");
|
||||
expect(state.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("restores cron dashboard from virtual path (prevents lost cron view on refresh)", () => {
|
||||
const url = buildFileLink("~cron");
|
||||
const state = parseUrlState(new URL(url, "http://localhost").search);
|
||||
expect(state.path).toBe("~cron");
|
||||
});
|
||||
|
||||
it("restores cron job detail from virtual path (prevents lost job detail on refresh)", () => {
|
||||
const url = buildFileLink("~cron/daily-sync");
|
||||
const state = parseUrlState(new URL(url, "http://localhost").search);
|
||||
expect(state.path).toBe("~cron/daily-sync");
|
||||
});
|
||||
|
||||
it("restores object view with filter/sort/search/page from URL (prevents lost table state on refresh)", () => {
|
||||
const filters = { id: "root", conjunction: "and" as const, rules: [{ id: "r1", field: "status", operator: "equals" as const, value: "active" }] };
|
||||
const sort = [{ field: "name", direction: "asc" as const }];
|
||||
const qs = serializeUrlState({
|
||||
path: "knowledge/leads",
|
||||
viewType: "table",
|
||||
filters,
|
||||
sort,
|
||||
search: "acme",
|
||||
page: 3,
|
||||
pageSize: 25,
|
||||
cols: ["name", "email", "status"],
|
||||
view: "Active Leads",
|
||||
});
|
||||
const state = parseUrlState(qs);
|
||||
expect(state.path).toBe("knowledge/leads");
|
||||
expect(state.viewType).toBe("table");
|
||||
expect(state.filters).toEqual(filters);
|
||||
expect(state.sort).toEqual(sort);
|
||||
expect(state.search).toBe("acme");
|
||||
expect(state.page).toBe(3);
|
||||
expect(state.pageSize).toBe(25);
|
||||
expect(state.cols).toEqual(["name", "email", "status"]);
|
||||
expect(state.view).toBe("Active Leads");
|
||||
});
|
||||
|
||||
it("restores sidebar preview from copied URL (prevents lost preview on refresh)", () => {
|
||||
const qs = serializeUrlState({ path: "README.md", preview: "other/file.md" });
|
||||
const state = parseUrlState(qs);
|
||||
expect(state.path).toBe("README.md");
|
||||
expect(state.preview).toBe("other/file.md");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Precedence rules when multiple params coexist
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("URL parameter precedence", () => {
|
||||
it("entry takes priority over path in parseWorkspaceLink (prevents ambiguous navigation)", () => {
|
||||
const link = parseWorkspaceLink("/?entry=leads:abc&path=some/file.md");
|
||||
expect(link?.kind).toBe("entry");
|
||||
});
|
||||
|
||||
it("path and chat are mutually exclusive in serialization (prevents dual-mode confusion)", () => {
|
||||
const qs = serializeUrlState({ path: "doc.md", chat: "sess1" });
|
||||
const params = new URLSearchParams(qs);
|
||||
expect(params.has("path")).toBe(true);
|
||||
expect(params.has("chat")).toBe(true);
|
||||
// Both are present but the page-level effect chooses path over chat
|
||||
});
|
||||
|
||||
it("subagent param is only meaningful with a chat param (prevents orphan subagent state)", () => {
|
||||
const state = parseUrlState("subagent=child-key");
|
||||
expect(state.subagent).toBe("child-key");
|
||||
expect(state.chat).toBeNull();
|
||||
// Without chat, the subagent param has no parent session to attach to
|
||||
});
|
||||
|
||||
it("browse and path can coexist (browse controls sidebar, path controls main panel)", () => {
|
||||
const qs = serializeUrlState({ path: "some/file.md", browse: "/absolute/dir" });
|
||||
const state = parseUrlState(qs);
|
||||
expect(state.path).toBe("some/file.md");
|
||||
expect(state.browse).toBe("/absolute/dir");
|
||||
});
|
||||
|
||||
it("hidden flag only applies when browse is set (prevents hidden leak into workspace mode)", () => {
|
||||
const state = parseUrlState("hidden=1");
|
||||
expect(state.hidden).toBe(true);
|
||||
expect(state.browse).toBeNull();
|
||||
// The page-level handler should ignore hidden without browse
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Object view URL state edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("object view URL state", () => {
|
||||
it("omits default view type to keep URLs clean (prevents URL bloat)", () => {
|
||||
const qs = serializeUrlState({ path: "leads" });
|
||||
expect(qs).not.toContain("viewType");
|
||||
});
|
||||
|
||||
it("omits page=1 since it is the default (prevents URL bloat)", () => {
|
||||
const qs = serializeUrlState({ path: "leads", page: 1 });
|
||||
expect(qs).not.toContain("page=");
|
||||
});
|
||||
|
||||
it("preserves page > 1 in URL (prevents pagination loss on refresh)", () => {
|
||||
const qs = serializeUrlState({ path: "leads", page: 5 });
|
||||
expect(qs).toContain("page=5");
|
||||
});
|
||||
|
||||
it("omits empty filter group (prevents URL noise)", () => {
|
||||
const qs = serializeUrlState({ filters: { id: "root", conjunction: "and", rules: [] } });
|
||||
expect(qs).not.toContain("filters");
|
||||
});
|
||||
|
||||
it("preserves non-empty filter group (prevents filter loss on refresh)", () => {
|
||||
const fg = { id: "root", conjunction: "and" as const, rules: [{ id: "r1", field: "f", operator: "equals" as const, value: "v" }] };
|
||||
const qs = serializeUrlState({ filters: fg });
|
||||
expect(qs).toContain("filters=");
|
||||
const restored = parseUrlState(qs);
|
||||
expect(restored.filters?.rules).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("omits empty sort array (prevents URL noise)", () => {
|
||||
const qs = serializeUrlState({ sort: [] });
|
||||
expect(qs).not.toContain("sort");
|
||||
});
|
||||
|
||||
it("preserves sort rules (prevents sort loss on refresh)", () => {
|
||||
const sort = [{ field: "created_at", direction: "desc" as const }];
|
||||
const qs = serializeUrlState({ sort });
|
||||
const restored = parseUrlState(qs);
|
||||
expect(restored.sort).toEqual(sort);
|
||||
});
|
||||
|
||||
it("columns survive round-trip with special characters in names (prevents data loss)", () => {
|
||||
const cols = ["full name", "email & phone", "status"];
|
||||
const qs = serializeUrlState({ cols });
|
||||
const restored = parseUrlState(qs);
|
||||
expect(restored.cols).toEqual(cols);
|
||||
});
|
||||
|
||||
it("rejects unknown view types (prevents invalid state from corrupted URLs)", () => {
|
||||
const state = parseUrlState("viewType=pivot");
|
||||
expect(state.viewType).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts all valid view types (ensures all view modes are deep-linkable)", () => {
|
||||
for (const vt of ["table", "kanban", "calendar", "timeline", "gallery", "list"] as const) {
|
||||
const qs = serializeUrlState({ viewType: vt });
|
||||
const state = parseUrlState(qs);
|
||||
expect(state.viewType).toBe(vt);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles complex filter with nested groups (prevents structured data loss)", () => {
|
||||
const fg = {
|
||||
id: "root",
|
||||
conjunction: "and" as const,
|
||||
rules: [
|
||||
{ id: "r1", field: "status", operator: "equals" as const, value: "active" },
|
||||
{
|
||||
id: "g1",
|
||||
conjunction: "or" as const,
|
||||
rules: [
|
||||
{ id: "r2", field: "priority", operator: "equals" as const, value: "high" },
|
||||
{ id: "r3", field: "priority", operator: "equals" as const, value: "critical" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const qs = serializeUrlState({ filters: fg });
|
||||
const restored = parseUrlState(qs);
|
||||
expect(restored.filters).toEqual(fg);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy /workspace migration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("legacy /workspace URL migration", () => {
|
||||
it("migrates bare /workspace to / (prevents stale bookmarks)", () => {
|
||||
expect(migrateWorkspaceUrl("/workspace")).toBe("/");
|
||||
});
|
||||
|
||||
it("migrates /workspace?path=doc.md preserving all params (prevents param loss)", () => {
|
||||
const migrated = migrateWorkspaceUrl("/workspace?path=doc.md&entry=obj:id");
|
||||
expect(migrated).toBe("/?path=doc.md&entry=obj:id");
|
||||
});
|
||||
|
||||
it("migrates /workspace#hash preserving fragment (prevents anchor loss)", () => {
|
||||
expect(migrateWorkspaceUrl("/workspace#section")).toBe("/#section");
|
||||
});
|
||||
|
||||
it("migrates /workspace?query#hash preserving both (prevents combined loss)", () => {
|
||||
expect(migrateWorkspaceUrl("/workspace?path=x#y")).toBe("/?path=x#y");
|
||||
});
|
||||
|
||||
it("returns null for non-workspace URLs (prevents false positive migration)", () => {
|
||||
expect(migrateWorkspaceUrl("/")).toBeNull();
|
||||
expect(migrateWorkspaceUrl("/other")).toBeNull();
|
||||
expect(migrateWorkspaceUrl("https://example.com/workspace")).toBeNull();
|
||||
});
|
||||
|
||||
it("isWorkspaceLink recognizes both old and new formats (prevents broken link detection)", () => {
|
||||
expect(isWorkspaceLink("/workspace")).toBe(true);
|
||||
expect(isWorkspaceLink("/workspace?path=x")).toBe(true);
|
||||
expect(isWorkspaceLink("/")).toBe(true);
|
||||
expect(isWorkspaceLink("/?path=x")).toBe(true);
|
||||
});
|
||||
|
||||
it("parseWorkspaceLink handles both old and new entry formats (prevents entry link breakage)", () => {
|
||||
const oldResult = parseWorkspaceLink("/workspace?entry=leads:abc");
|
||||
const newResult = parseWorkspaceLink("/?entry=leads:abc");
|
||||
expect(oldResult).toEqual(newResult);
|
||||
expect(oldResult).toEqual({ kind: "entry", objectName: "leads", entryId: "abc" });
|
||||
});
|
||||
|
||||
it("parseWorkspaceLink handles both old and new file formats (prevents file link breakage)", () => {
|
||||
const oldResult = parseWorkspaceLink("/workspace?path=doc.md");
|
||||
const newResult = parseWorkspaceLink("/?path=doc.md");
|
||||
expect(oldResult).toEqual(newResult);
|
||||
expect(oldResult).toEqual({ kind: "file", path: "doc.md" });
|
||||
});
|
||||
|
||||
it("isEntryLink recognizes entries in both old and new format (prevents entry detection failure)", () => {
|
||||
expect(isEntryLink("/workspace?entry=obj:id")).toBe(true);
|
||||
expect(isEntryLink("/?entry=obj:id")).toBe(true);
|
||||
expect(isEntryLink("@entry/obj/id")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL builder correctness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("URL builders produce correct root-route URLs", () => {
|
||||
it("buildFileLink produces root URL (prevents /workspace leak)", () => {
|
||||
expect(buildFileLink("doc.md")).toMatch(/^\/\?path=/);
|
||||
expect(buildFileLink("doc.md")).not.toContain("/workspace");
|
||||
});
|
||||
|
||||
it("buildEntryLink produces root URL (prevents /workspace leak)", () => {
|
||||
expect(buildEntryLink("obj", "id")).toMatch(/^\/\?entry=/);
|
||||
expect(buildEntryLink("obj", "id")).not.toContain("/workspace");
|
||||
});
|
||||
|
||||
it("buildChatLink produces root URL (prevents /workspace leak)", () => {
|
||||
expect(buildChatLink("sess")).toMatch(/^\/\?chat=/);
|
||||
});
|
||||
|
||||
it("buildSubagentLink produces root URL with both params (prevents /workspace leak)", () => {
|
||||
const url = buildSubagentLink("parent", "child");
|
||||
expect(url).toMatch(/^\/\?/);
|
||||
expect(url).toContain("chat=parent");
|
||||
expect(url).toContain("subagent=child");
|
||||
});
|
||||
|
||||
it("buildBrowseLink produces root URL (prevents /workspace leak)", () => {
|
||||
expect(buildBrowseLink("/tmp")).toMatch(/^\/\?browse=/);
|
||||
});
|
||||
|
||||
it("buildUrl returns / for empty state (prevents URL corruption)", () => {
|
||||
expect(buildUrl({})).toBe("/");
|
||||
});
|
||||
|
||||
it("buildUrl produces valid query string for complex state (prevents encoding errors)", () => {
|
||||
const url = buildUrl({
|
||||
path: "leads",
|
||||
viewType: "kanban",
|
||||
search: "hello world",
|
||||
page: 2,
|
||||
});
|
||||
const parsed = new URL(url, "http://localhost");
|
||||
expect(parsed.pathname).toBe("/");
|
||||
expect(parsed.searchParams.get("path")).toBe("leads");
|
||||
expect(parsed.searchParams.get("viewType")).toBe("kanban");
|
||||
expect(parsed.searchParams.get("search")).toBe("hello world");
|
||||
expect(parsed.searchParams.get("page")).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serialization round-trip completeness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("full state round-trip", () => {
|
||||
it("round-trips a maximally-populated URL state (prevents any field from being lost)", () => {
|
||||
const original: Partial<WorkspaceUrlState> = {
|
||||
path: "knowledge/leads",
|
||||
entry: { objectName: "leads", entryId: "e1" },
|
||||
browse: "/Users/me/Desktop",
|
||||
hidden: true,
|
||||
preview: "sidebar/preview.md",
|
||||
view: "Active View",
|
||||
viewType: "kanban",
|
||||
filters: { id: "root", conjunction: "and", rules: [{ id: "r1", field: "status", operator: "equals" as const, value: "done" }] },
|
||||
search: "urgent",
|
||||
sort: [{ field: "priority", direction: "desc" }],
|
||||
page: 4,
|
||||
pageSize: 25,
|
||||
cols: ["name", "priority", "status"],
|
||||
};
|
||||
const qs = serializeUrlState(original);
|
||||
const restored = parseUrlState(qs);
|
||||
|
||||
expect(restored.path).toBe(original.path);
|
||||
expect(restored.entry).toEqual(original.entry);
|
||||
expect(restored.browse).toBe(original.browse);
|
||||
expect(restored.hidden).toBe(original.hidden);
|
||||
expect(restored.preview).toBe(original.preview);
|
||||
expect(restored.view).toBe(original.view);
|
||||
expect(restored.viewType).toBe(original.viewType);
|
||||
expect(restored.filters).toEqual(original.filters);
|
||||
expect(restored.search).toBe(original.search);
|
||||
expect(restored.sort).toEqual(original.sort);
|
||||
expect(restored.page).toBe(original.page);
|
||||
expect(restored.pageSize).toBe(original.pageSize);
|
||||
expect(restored.cols).toEqual(original.cols);
|
||||
});
|
||||
|
||||
it("round-trips chat + subagent state (prevents subagent session loss)", () => {
|
||||
const qs = serializeUrlState({ chat: "sess-main", subagent: "child-key" });
|
||||
const state = parseUrlState(qs);
|
||||
expect(state.chat).toBe("sess-main");
|
||||
expect(state.subagent).toBe("child-key");
|
||||
});
|
||||
|
||||
it("round-trips file-scoped chat session alongside file path (prevents file chat loss)", () => {
|
||||
const qs = serializeUrlState({ path: "docs/readme.md", fileChat: "file-sess-42" });
|
||||
const state = parseUrlState(qs);
|
||||
expect(state.path).toBe("docs/readme.md");
|
||||
expect(state.fileChat).toBe("file-sess-42");
|
||||
});
|
||||
|
||||
it("round-trips send parameter (prevents auto-send messages from being lost)", () => {
|
||||
const qs = serializeUrlState({ send: "install duckdb" });
|
||||
const state = parseUrlState(qs);
|
||||
expect(state.send).toBe("install duckdb");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases from real usage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("real-world edge cases", () => {
|
||||
it("handles path with spaces and special characters (prevents encoding breakage)", () => {
|
||||
const url = buildFileLink("my docs/notes & ideas/2024 (Q1).md");
|
||||
const state = parseUrlState(new URL(url, "http://localhost").search);
|
||||
expect(state.path).toBe("my docs/notes & ideas/2024 (Q1).md");
|
||||
});
|
||||
|
||||
it("handles unicode in entry IDs (prevents internationalization breakage)", () => {
|
||||
const url = buildEntryLink("商品", "アイテム-42");
|
||||
const state = parseUrlState(new URL(url, "http://localhost").search);
|
||||
expect(state.entry?.objectName).toBe("商品");
|
||||
expect(state.entry?.entryId).toBe("アイテム-42");
|
||||
});
|
||||
|
||||
it("handles entry ID containing colons (prevents ID parsing corruption)", () => {
|
||||
const state = parseUrlState("entry=obj:id:with:many:colons");
|
||||
expect(state.entry).toEqual({ objectName: "obj", entryId: "id:with:many:colons" });
|
||||
});
|
||||
|
||||
it("handles base64 filter string with padding (prevents truncation)", () => {
|
||||
const fg = { id: "root", conjunction: "and" as const, rules: [{ id: "r", field: "x", operator: "equals" as const, value: "a" }] };
|
||||
const b64 = btoa(JSON.stringify(fg));
|
||||
expect(b64).toContain("="); // verify padding exists
|
||||
const state = parseUrlState(`filters=${encodeURIComponent(b64)}`);
|
||||
expect(state.filters).toEqual(fg);
|
||||
});
|
||||
|
||||
it("survives corrupted filter param without crashing (prevents app crash from tampered URLs)", () => {
|
||||
const state = parseUrlState("filters=not-valid-base64!!!");
|
||||
expect(state.filters).toBeNull();
|
||||
});
|
||||
|
||||
it("survives corrupted sort param without crashing (prevents app crash from tampered URLs)", () => {
|
||||
const state = parseUrlState("sort=garbage");
|
||||
expect(state.sort).toBeNull();
|
||||
});
|
||||
|
||||
it("survives corrupted page param without crashing (prevents NaN propagation)", () => {
|
||||
const state = parseUrlState("page=abc");
|
||||
expect(state.page).toBeNull();
|
||||
});
|
||||
|
||||
it("handles empty string for cols (prevents empty-string column names)", () => {
|
||||
const state = parseUrlState("cols=");
|
||||
expect(state.cols).toBeNull();
|
||||
});
|
||||
|
||||
it("handles single column in cols param (prevents off-by-one)", () => {
|
||||
const state = parseUrlState("cols=name");
|
||||
expect(state.cols).toEqual(["name"]);
|
||||
});
|
||||
|
||||
it("handles browse path with encoded slashes (prevents path corruption)", () => {
|
||||
const url = buildBrowseLink("/Users/me/My Projects/app");
|
||||
const state = parseUrlState(new URL(url, "http://localhost").search);
|
||||
expect(state.browse).toBe("/Users/me/My Projects/app");
|
||||
});
|
||||
|
||||
it("empty URL returns all-null default state (prevents undefined-field errors)", () => {
|
||||
const state = parseUrlState("");
|
||||
expect(state.path).toBeNull();
|
||||
expect(state.chat).toBeNull();
|
||||
expect(state.subagent).toBeNull();
|
||||
expect(state.fileChat).toBeNull();
|
||||
expect(state.entry).toBeNull();
|
||||
expect(state.send).toBeNull();
|
||||
expect(state.browse).toBeNull();
|
||||
expect(state.hidden).toBe(false);
|
||||
expect(state.preview).toBeNull();
|
||||
expect(state.view).toBeNull();
|
||||
expect(state.viewType).toBeNull();
|
||||
expect(state.filters).toBeNull();
|
||||
expect(state.search).toBeNull();
|
||||
expect(state.sort).toBeNull();
|
||||
expect(state.page).toBeNull();
|
||||
expect(state.pageSize).toBeNull();
|
||||
expect(state.cols).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user