diff --git a/apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts b/apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts new file mode 100644 index 00000000000..2fd0ce7f7fa --- /dev/null +++ b/apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts @@ -0,0 +1,304 @@ +import { duckdbQuery, duckdbPath, parseRelationValue } from "@/lib/workspace"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +// --- Types --- + +type ObjectRow = { + id: string; + name: string; + description?: string; + icon?: string; + default_view?: string; + display_field?: string; +}; + +type FieldRow = { + id: string; + name: string; + type: string; + description?: string; + required?: boolean; + enum_values?: string; + enum_colors?: string; + enum_multiple?: boolean; + related_object_id?: string; + relationship_type?: string; + sort_order?: number; +}; + +// --- Helpers --- + +function sqlEscape(s: string): string { + return s.replace(/'/g, "''"); +} + +function tryParseJson(value: unknown): unknown { + if (typeof value !== "string") {return value;} + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function resolveDisplayField(obj: ObjectRow, fields: FieldRow[]): string { + if (obj.display_field) {return obj.display_field;} + const nameField = fields.find( + (f) => /\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name), + ); + if (nameField) {return nameField.name;} + const textField = fields.find((f) => f.type === "text"); + if (textField) {return textField.name;} + return fields[0]?.name ?? "id"; +} + +// --- Route handler --- + +export async function GET( + _req: Request, + { params }: { params: Promise<{ name: string; id: string }> }, +) { + const { name, id } = await params; + + if (!duckdbPath()) { + return Response.json({ error: "DuckDB not found" }, { status: 404 }); + } + + // Validate inputs + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { + return Response.json({ error: "Invalid object name" }, { status: 400 }); + } + if (!id || id.length > 64) { + return Response.json({ error: "Invalid entry ID" }, { status: 400 }); + } + + // Fetch object + const objects = duckdbQuery( + `SELECT * FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`, + ); + if (objects.length === 0) { + return Response.json({ error: `Object '${name}' not found` }, { status: 404 }); + } + const obj = objects[0]; + + // Fetch fields + const fields = duckdbQuery( + `SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`, + ); + + // Fetch entry field values + const entryRows = duckdbQuery<{ + entry_id: string; + created_at: string; + updated_at: string; + field_name: string; + value: string | null; + }>( + `SELECT e.id as entry_id, e.created_at, e.updated_at, + f.name as field_name, ef.value + FROM entries e + JOIN entry_fields ef ON ef.entry_id = e.id + JOIN fields f ON f.id = ef.field_id + WHERE e.id = '${sqlEscape(id)}' + AND e.object_id = '${sqlEscape(obj.id)}'`, + ); + + if (entryRows.length === 0) { + // Check if entry exists at all + const exists = duckdbQuery<{ cnt: number }>( + `SELECT COUNT(*) as cnt FROM entries WHERE id = '${sqlEscape(id)}' AND object_id = '${sqlEscape(obj.id)}'`, + ); + if (!exists[0] || exists[0].cnt === 0) { + return Response.json({ error: "Entry not found" }, { status: 404 }); + } + } + + // Pivot into a single record + const entry: Record = { entry_id: id }; + for (const row of entryRows) { + entry.created_at ??= row.created_at; + entry.updated_at ??= row.updated_at; + if (row.field_name) {entry[row.field_name] = row.value;} + } + + // Parse enum JSON strings in fields + const parsedFields = fields.map((f) => ({ + ...f, + enum_values: f.enum_values ? tryParseJson(f.enum_values) : undefined, + enum_colors: f.enum_colors ? tryParseJson(f.enum_colors) : undefined, + })); + + // Resolve relation labels for this entry + const relationLabels: Record> = {}; + const relatedObjectNames: Record = {}; + + const relationFields = fields.filter( + (f) => f.type === "relation" && f.related_object_id, + ); + + for (const rf of relationFields) { + const relatedObjs = duckdbQuery( + `SELECT * FROM objects WHERE id = '${sqlEscape(rf.related_object_id!)}' LIMIT 1`, + ); + if (relatedObjs.length === 0) {continue;} + const relObj = relatedObjs[0]; + relatedObjectNames[rf.name] = relObj.name; + + const val = entry[rf.name]; + if (val == null || val === "") { + relationLabels[rf.name] = {}; + continue; + } + + const ids = parseRelationValue(String(val)); + if (ids.length === 0) { + relationLabels[rf.name] = {}; + continue; + } + + const relFields = duckdbQuery( + `SELECT * FROM fields WHERE object_id = '${sqlEscape(relObj.id)}' ORDER BY sort_order`, + ); + const displayFieldName = resolveDisplayField(relObj, relFields); + + const idList = ids.map((i) => `'${sqlEscape(i)}'`).join(","); + const displayRows = duckdbQuery<{ entry_id: string; value: string }>( + `SELECT e.id as entry_id, ef.value + FROM entries e + JOIN entry_fields ef ON ef.entry_id = e.id + JOIN fields f ON f.id = ef.field_id + WHERE e.id IN (${idList}) + AND f.object_id = '${sqlEscape(relObj.id)}' + AND f.name = '${sqlEscape(displayFieldName)}'`, + ); + + const labelMap: Record = {}; + for (const row of displayRows) { + labelMap[row.entry_id] = row.value || row.entry_id; + } + for (const i of ids) { + if (!labelMap[i]) {labelMap[i] = i;} + } + relationLabels[rf.name] = labelMap; + } + + // Enrich fields with related object names + const enrichedFields = parsedFields.map((f) => ({ + ...f, + related_object_name: + f.type === "relation" ? relatedObjectNames[f.name] : undefined, + })); + + // Find reverse relations: other objects linking TO this entry + const reverseRelations = findReverseRelationsForEntry(obj.id, id); + + const effectiveDisplayField = resolveDisplayField(obj, fields); + + return Response.json({ + object: obj, + fields: enrichedFields, + entry, + relationLabels, + reverseRelations, + effectiveDisplayField, + }); +} + +// --- Reverse relations for a single entry --- + +type ReverseRelation = { + fieldName: string; + sourceObjectName: string; + sourceObjectId: string; + displayField: string; + links: Array<{ id: string; label: string }>; +}; + +/** + * Find entries in other objects that link TO this specific entry via relation fields. + */ +function findReverseRelationsForEntry( + objectId: string, + entryId: string, +): ReverseRelation[] { + // Find all relation fields in other objects that point to this object + const reverseFields = duckdbQuery< + { id: string; name: string; object_id: string; source_object_name: string } + >( + `SELECT f.id, f.name, f.object_id, o.name as source_object_name + FROM fields f + JOIN objects o ON o.id = f.object_id + WHERE f.type = 'relation' + AND f.related_object_id = '${sqlEscape(objectId)}'`, + ); + + if (reverseFields.length === 0) {return [];} + + const result: ReverseRelation[] = []; + + for (const rrf of reverseFields) { + // Find source entries that reference this specific entry ID + const refRows = duckdbQuery<{ source_entry_id: string; target_value: string }>( + `SELECT ef.entry_id as source_entry_id, ef.value as target_value + FROM entry_fields ef + WHERE ef.field_id = '${sqlEscape(rrf.id)}' + AND ef.value IS NOT NULL + AND ef.value != ''`, + ); + + // Filter to only rows that actually reference our entryId + const matchingSourceIds: string[] = []; + for (const row of refRows) { + const targetIds = parseRelationValue(row.target_value); + if (targetIds.includes(entryId)) { + matchingSourceIds.push(row.source_entry_id); + } + } + + if (matchingSourceIds.length === 0) {continue;} + + // Get source object's fields to resolve display labels + const sourceObj = duckdbQuery( + `SELECT * FROM objects WHERE id = '${sqlEscape(rrf.object_id)}' LIMIT 1`, + ); + if (sourceObj.length === 0) {continue;} + + const sourceFields = duckdbQuery( + `SELECT * FROM fields WHERE object_id = '${sqlEscape(rrf.object_id)}' ORDER BY sort_order`, + ); + const displayFieldName = resolveDisplayField(sourceObj[0], sourceFields); + + // Get display labels for matching source entries + const idList = matchingSourceIds.map((i) => `'${sqlEscape(i)}'`).join(","); + const displayRows = duckdbQuery<{ entry_id: string; value: string }>( + `SELECT ef.entry_id, ef.value + FROM entry_fields ef + JOIN fields f ON f.id = ef.field_id + WHERE ef.entry_id IN (${idList}) + AND f.name = '${sqlEscape(displayFieldName)}' + AND f.object_id = '${sqlEscape(rrf.object_id)}'`, + ); + + const displayMap: Record = {}; + for (const row of displayRows) { + displayMap[row.entry_id] = row.value || row.entry_id; + } + + const links = matchingSourceIds.map((sid) => ({ + id: sid, + label: displayMap[sid] || sid, + })); + + result.push({ + fieldName: rrf.name, + sourceObjectName: rrf.source_object_name, + sourceObjectId: rrf.object_id, + displayField: displayFieldName, + links, + }); + } + + return result; +} diff --git a/apps/web/app/api/workspace/search-index/route.ts b/apps/web/app/api/workspace/search-index/route.ts new file mode 100644 index 00000000000..979c9977764 --- /dev/null +++ b/apps/web/app/api/workspace/search-index/route.ts @@ -0,0 +1,302 @@ +import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs"; +import { join } from "node:path"; +import { + resolveDenchRoot, + parseSimpleYaml, + duckdbQuery, + duckdbPath, + isDatabaseFile, +} from "@/lib/workspace"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +// --- Types --- + +export type SearchIndexItem = { + /** Unique key: relative path for files, entryId for entries */ + id: string; + /** Primary display text (filename or display-field value) */ + label: string; + /** Secondary text (path for files, object name for entries) */ + sublabel?: string; + /** Item kind for grouping and icons */ + kind: "file" | "object" | "entry"; + /** Icon hint */ + icon?: string; + + // Entry-specific + objectName?: string; + entryId?: string; + /** First few field key-value pairs for search and preview */ + fields?: Record; + + // File/object-specific + path?: string; + nodeType?: "document" | "folder" | "file" | "report" | "database"; +}; + +// --- DB types --- + +type ObjectRow = { + id: string; + name: string; + description?: string; + icon?: string; + default_view?: string; + display_field?: string; +}; + +type FieldRow = { + id: string; + name: string; + type: string; + sort_order?: number; +}; + +type EavRow = { + entry_id: string; + created_at: string; + updated_at: string; + field_name: string; + value: string | null; +}; + +// --- Helpers --- + +function sqlEscape(s: string): string { + return s.replace(/'/g, "''"); +} + +/** Determine the display field (same heuristic as the objects route). */ +function resolveDisplayField(obj: ObjectRow, fields: FieldRow[]): string { + if (obj.display_field) {return obj.display_field;} + + const nameField = fields.find( + (f) => /\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name), + ); + if (nameField) {return nameField.name;} + + const textField = fields.find((f) => f.type === "text"); + if (textField) {return textField.name;} + + return fields[0]?.name ?? "id"; +} + +/** Flatten a tree recursively to produce file/object search items. */ +function flattenTree( + absDir: string, + relBase: string, + dbObjects: Map, + items: SearchIndexItem[], +) { + let entries: Dirent[]; + try { + entries = readdirSync(absDir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (entry.name.startsWith(".")) {continue;} + + const absPath = join(absDir, entry.name); + const relPath = relBase ? `${relBase}/${entry.name}` : entry.name; + + if (entry.isDirectory()) { + const dbObj = dbObjects.get(entry.name); + // Check for .object.yaml + const yamlPath = join(absPath, ".object.yaml"); + const hasYaml = existsSync(yamlPath); + + if (dbObj || hasYaml) { + let icon: string | undefined; + if (hasYaml) { + try { + const parsed = parseSimpleYaml( + readFileSync(yamlPath, "utf-8"), + ); + icon = parsed.icon as string | undefined; + } catch { + // ignore + } + } + + items.push({ + id: relPath, + label: entry.name, + sublabel: relPath, + kind: "object", + icon: icon ?? dbObj?.icon, + path: relPath, + nodeType: undefined, + }); + } else { + // Regular folder -- don't add as item, but recurse + } + + flattenTree(absPath, relPath, dbObjects, items); + } else if (entry.isFile()) { + const isReport = entry.name.endsWith(".report.json"); + const ext = entry.name.split(".").pop()?.toLowerCase(); + const isDocument = ext === "md" || ext === "mdx"; + const isDatabase = isDatabaseFile(entry.name); + + items.push({ + id: relPath, + label: entry.name.replace(/\.md$/, ""), + sublabel: relPath, + kind: "file", + path: relPath, + nodeType: isReport + ? "report" + : isDatabase + ? "database" + : isDocument + ? "document" + : "file", + }); + } + } +} + +/** Fetch all entries from all objects and produce search items. */ +function buildEntryItems(): SearchIndexItem[] { + const items: SearchIndexItem[] = []; + + const objects = duckdbQuery( + "SELECT * FROM objects ORDER BY name", + ); + + for (const obj of objects) { + const fields = duckdbQuery( + `SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`, + ); + const displayField = resolveDisplayField(obj, fields); + // Pick the first few text-like fields for searchable preview (max 4) + const previewFields = fields + .filter((f) => !["relation", "richtext"].includes(f.type)) + .slice(0, 4); + + // Try PIVOT view first, then raw EAV + let entries: Record[] = duckdbQuery( + `SELECT * FROM v_${obj.name} ORDER BY created_at DESC LIMIT 500`, + ); + + if (entries.length === 0) { + const rawRows = duckdbQuery( + `SELECT e.id as entry_id, e.created_at, e.updated_at, + f.name as field_name, ef.value + FROM entries e + JOIN entry_fields ef ON ef.entry_id = e.id + JOIN fields f ON f.id = ef.field_id + WHERE e.object_id = '${sqlEscape(obj.id)}' + ORDER BY e.created_at DESC + LIMIT 2500`, + ); + + // Pivot manually + const grouped = new Map>(); + for (const row of rawRows) { + let entry = grouped.get(row.entry_id); + if (!entry) { + entry = { entry_id: row.entry_id }; + grouped.set(row.entry_id, entry); + } + if (row.field_name) {entry[row.field_name] = row.value;} + } + entries = Array.from(grouped.values()); + } + + for (const entry of entries) { + const entryId = String(entry.entry_id ?? ""); + if (!entryId) {continue;} + + const displayValue = String(entry[displayField] ?? ""); + const fieldPreview: Record = {}; + for (const f of previewFields) { + const val = entry[f.name]; + if (val != null && val !== "") { + fieldPreview[f.name] = String(val); + } + } + + items.push({ + id: `entry:${obj.name}:${entryId}`, + label: displayValue || `(${obj.name} entry)`, + sublabel: obj.name, + kind: "entry", + icon: obj.icon, + objectName: obj.name, + entryId, + fields: Object.keys(fieldPreview).length > 0 ? fieldPreview : undefined, + }); + } + } + + return items; +} + +// --- Route handler --- + +export async function GET() { + const items: SearchIndexItem[] = []; + + // 1. Files + objects from tree + const root = resolveDenchRoot(); + if (root) { + const dbObjects = new Map(); + if (duckdbPath()) { + const objs = duckdbQuery( + "SELECT * FROM objects", + ); + for (const o of objs) {dbObjects.set(o.name, o);} + } + + const knowledgeDir = join(root, "knowledge"); + if (existsSync(knowledgeDir)) { + flattenTree(knowledgeDir, "knowledge", dbObjects, items); + } + + const reportsDir = join(root, "reports"); + if (existsSync(reportsDir)) { + flattenTree(reportsDir, "reports", dbObjects, items); + } + + // Top-level files + try { + const topLevel = readdirSync(root, { withFileTypes: true }); + for (const entry of topLevel) { + if (!entry.isFile() || entry.name.startsWith(".")) {continue;} + const ext = entry.name.split(".").pop()?.toLowerCase(); + const isDoc = ext === "md" || ext === "mdx"; + const isDb = isDatabaseFile(entry.name); + const isReport = entry.name.endsWith(".report.json"); + + items.push({ + id: entry.name, + label: entry.name.replace(/\.md$/, ""), + sublabel: entry.name, + kind: "file", + path: entry.name, + nodeType: isReport + ? "report" + : isDb + ? "database" + : isDoc + ? "document" + : "file", + }); + } + } catch { + // skip + } + } + + // 2. Entries from all objects + if (duckdbPath()) { + items.push(...buildEntryItems()); + } + + return Response.json({ items }); +} diff --git a/apps/web/app/components/workspace/document-view.tsx b/apps/web/app/components/workspace/document-view.tsx index 0e18ee07268..905763a53e7 100644 --- a/apps/web/app/components/workspace/document-view.tsx +++ b/apps/web/app/components/workspace/document-view.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState } from "react"; +import { useState, useCallback, type MouseEvent as ReactMouseEvent } from "react"; import dynamic from "next/dynamic"; import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks"; -import type { TreeNode } from "./slash-command"; +import { isWorkspaceLink } from "@/lib/workspace-links"; +import type { TreeNode, MentionSearchFn } from "./slash-command"; // Load markdown renderer client-only to avoid SSR issues with ESM-only packages const MarkdownContent = dynamic( @@ -58,6 +59,7 @@ type DocumentViewProps = { tree?: TreeNode[]; onSave?: () => void; onNavigate?: (path: string) => void; + searchFn?: MentionSearchFn; }; export function DocumentView({ @@ -67,6 +69,7 @@ export function DocumentView({ tree, onSave, onNavigate, + searchFn, }: DocumentViewProps) { const [editMode, setEditMode] = useState(!!filePath); @@ -91,6 +94,7 @@ export function DocumentView({ onSave={onSave} onNavigate={onNavigate} onSwitchToRead={() => setEditMode(false)} + searchFn={searchFn} /> ); @@ -99,8 +103,27 @@ export function DocumentView({ // Check if the markdown contains embedded report-json blocks const hasReports = hasReportBlocks(markdownBody); + // Intercept workspace-internal links in read mode (delegated click handler) + const handleLinkClick = useCallback( + (event: ReactMouseEvent) => { + if (!onNavigate) {return;} + const target = event.target as HTMLElement; + const link = target.closest("a"); + if (!link) {return;} + const href = link.getAttribute("href"); + if (!href) {return;} + if (isWorkspaceLink(href)) { + event.preventDefault(); + event.stopPropagation(); + onNavigate(href); + } + }, + [onNavigate], + ); + return ( -
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
{/* Header row with title + edit button */}
{displayTitle && ( diff --git a/apps/web/app/components/workspace/entry-detail-modal.tsx b/apps/web/app/components/workspace/entry-detail-modal.tsx new file mode 100644 index 00000000000..897984b6188 --- /dev/null +++ b/apps/web/app/components/workspace/entry-detail-modal.tsx @@ -0,0 +1,504 @@ +"use client"; + +import { useEffect, useState, useCallback, useRef } from "react"; +import { buildEntryLink } from "@/lib/workspace-links"; + +// --- Types --- + +type Field = { + id: string; + name: string; + type: string; + enum_values?: string[]; + enum_colors?: string[]; + enum_multiple?: boolean; + related_object_id?: string; + relationship_type?: string; + related_object_name?: string; + sort_order?: number; +}; + +type ReverseRelation = { + fieldName: string; + sourceObjectName: string; + sourceObjectId: string; + displayField: string; + links: Array<{ id: string; label: string }>; +}; + +type EntryDetailData = { + object: { + id: string; + name: string; + description?: string; + icon?: string; + }; + fields: Field[]; + entry: Record; + relationLabels?: Record>; + reverseRelations?: ReverseRelation[]; + effectiveDisplayField?: string; +}; + +type EntryDetailModalProps = { + objectName: string; + entryId: string; + members?: Array<{ id: string; name: string; email: string; role: string }>; + onClose: () => void; + /** Navigate to another entry (opens new modal). */ + onNavigateEntry?: (objectName: string, entryId: string) => void; + /** Navigate to an object table view. */ + onNavigateObject?: (objectName: string) => void; +}; + +// --- Helpers --- + +function parseRelationValue(value: string | null | undefined): string[] { + if (!value) {return [];} + const trimmed = value.trim(); + if (!trimmed) {return [];} + if (trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) {return parsed.map(String).filter(Boolean);} + } catch { + // not JSON + } + } + return [trimmed]; +} + +// --- Cell renderers (lightweight variants of object-table ones) --- + +function EnumBadge({ + value, + enumValues, + enumColors, +}: { + value: string; + enumValues?: string[]; + enumColors?: string[]; +}) { + const idx = enumValues?.indexOf(value) ?? -1; + const color = idx >= 0 && enumColors ? enumColors[idx] : "#94a3b8"; + return ( + + {value} + + ); +} + +function UserBadge({ + value, + members, +}: { + value: unknown; + members?: Array<{ id: string; name: string }>; +}) { + const memberId = String(value); + const member = members?.find((m) => m.id === memberId); + return ( + + + {(member?.name ?? memberId).charAt(0).toUpperCase()} + + {member?.name ?? memberId} + + ); +} + +function RelationChips({ + value, + field, + relationLabels, + onNavigateEntry, +}: { + value: unknown; + field: Field; + relationLabels?: Record>; + onNavigateEntry?: (objectName: string, entryId: string) => void; +}) { + const fieldLabels = relationLabels?.[field.name]; + const ids = parseRelationValue(String(value)); + if (ids.length === 0) {return ;} + + return ( + + {ids.map((id) => { + const label = fieldLabels?.[id] ?? id; + const handleClick = field.related_object_name && onNavigateEntry + ? () => onNavigateEntry(field.related_object_name!, id) + : undefined; + return ( + + ); + })} + + ); +} + +function EmptyValue() { + return ( + -- + ); +} + +/** Render a set of reverse relation links (incoming references from another object). */ +function ReverseRelationSection({ + relation, + onNavigateEntry, +}: { + relation: ReverseRelation; + onNavigateEntry?: (objectName: string, entryId: string) => void; +}) { + const displayLinks = relation.links.slice(0, 10); + const overflow = relation.links.length - displayLinks.length; + + return ( +
+ +
+ {displayLinks.map((link) => ( + + ))} + {overflow > 0 && ( + + +{overflow} more + + )} +
+
+ ); +} + +function FieldValue({ + value, + field, + members, + relationLabels, + onNavigateEntry, +}: { + value: unknown; + field: Field; + members?: Array<{ id: string; name: string }>; + relationLabels?: Record>; + onNavigateEntry?: (objectName: string, entryId: string) => void; +}) { + if (value === null || value === undefined || value === "") {return ;} + + switch (field.type) { + case "enum": + return ( + + ); + case "boolean": { + const isTrue = value === true || value === "true" || value === "1" || value === "yes"; + return {isTrue ? "Yes" : "No"}; + } + case "user": + return ; + case "relation": + return ( + + ); + case "email": + return ( + + {String(value)} + + ); + case "richtext": + return {String(value)}; + case "number": + return {String(value)}; + case "date": + return {String(value)}; + default: + return {String(value)}; + } +} + +// --- Modal Component --- + +export function EntryDetailModal({ + objectName, + entryId, + members, + onClose, + onNavigateEntry, + onNavigateObject, +}: EntryDetailModalProps) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const backdropRef = useRef(null); + + // Fetch entry data + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + + async function load() { + try { + const res = await fetch( + `/api/workspace/objects/${encodeURIComponent(objectName)}/entries/${encodeURIComponent(entryId)}`, + ); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Failed to load" })); + if (!cancelled) { + setError(err.error ?? "Failed to load entry"); + setLoading(false); + } + return; + } + const json = await res.json(); + if (!cancelled) { + setData(json); + setLoading(false); + } + } catch { + if (!cancelled) { + setError("Network error"); + setLoading(false); + } + } + } + + load(); + return () => { cancelled = true; }; + }, [objectName, entryId]); + + // Close on Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") {onClose();} + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + // Close on backdrop click + const handleBackdropClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === backdropRef.current) {onClose();} + }, + [onClose], + ); + + const displayField = data?.effectiveDisplayField; + const title = displayField && data?.entry[displayField] + ? String(data.entry[displayField]) + : `${objectName} entry`; + + return ( +
+
+ {/* Header */} +
+
+ {/* Object badge */} + +

+ {loading ? "Loading..." : title} +

+
+ +
+ + {/* Content */} +
+ {loading && ( +
+
+
+ )} + {error && ( +
+

{error}

+
+ )} + {data && !loading && ( +
+ {data.fields.map((field) => { + const value = data.entry[field.name]; + return ( +
+ +
+ +
+
+ ); + })} + + {/* Reverse relations (incoming links from other objects) */} + {data.reverseRelations && data.reverseRelations.length > 0 && ( +
+
+ Linked from +
+ {data.reverseRelations.map((rr) => ( + + ))} +
+ )} + + {/* Timestamps */} + {(data.entry.created_at != null || data.entry.updated_at != null) && ( +
+ {data.entry.created_at != null && ( + Created: {String(data.entry.created_at as string)} + )} + {data.entry.updated_at != null && ( + Updated: {String(data.entry.updated_at as string)} + )} +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/components/workspace/markdown-editor.tsx b/apps/web/app/components/workspace/markdown-editor.tsx index 9a693a09ae5..0d7a8e5024c 100644 --- a/apps/web/app/components/workspace/markdown-editor.tsx +++ b/apps/web/app/components/workspace/markdown-editor.tsx @@ -16,7 +16,8 @@ import Placeholder from "@tiptap/extension-placeholder"; import { useState, useCallback, useEffect, useRef, useMemo } from "react"; import { ReportBlockNode, preprocessReportBlocks, postprocessReportBlocks } from "./report-block-node"; -import { createSlashCommand, createFileMention, type TreeNode } from "./slash-command"; +import { createSlashCommand, createWorkspaceMention, createFileMention, type TreeNode, type MentionSearchFn } from "./slash-command"; +import { isWorkspaceLink } from "@/lib/workspace-links"; // --- Types --- @@ -31,6 +32,8 @@ export type MarkdownEditorProps = { onNavigate?: (path: string) => void; /** Switch to read-only mode (renders a "Read" button in the top bar). */ onSwitchToRead?: () => void; + /** Optional search function from useSearchIndex for fuzzy @ mention search. */ + searchFn?: MentionSearchFn; }; // --- Main component --- @@ -49,6 +52,7 @@ export function MarkdownEditor({ onSave, onNavigate, onSwitchToRead, + searchFn, }: MarkdownEditorProps) { const [saving, setSaving] = useState(false); const [saveStatus, setSaveStatus] = useState<"idle" | "saved" | "error">("idle"); @@ -60,9 +64,15 @@ export function MarkdownEditor({ // Preserve frontmatter so save can prepend it back const frontmatterRef = useRef(extractFrontmatter(rawContent ?? "")); - // "/" for block commands, "@" for file mentions + // "/" for block commands, "@" for workspace search (files + entries) const slashCommand = useMemo(() => createSlashCommand(), []); - const fileMention = useMemo(() => createFileMention(tree), [tree]); + const fileMention = useMemo( + () => searchFn ? createWorkspaceMention(searchFn) : createFileMention(tree), + // searchFn from useSearchIndex is a stable ref-based function, so this + // only re-runs on initial mount or if tree changes as fallback. + // eslint-disable-next-line react-hooks/exhaustive-deps + [searchFn, tree], + ); const editor = useEditor({ extensions: [ @@ -81,7 +91,12 @@ export function MarkdownEditor({ }), Link.configure({ openOnClick: false, - HTMLAttributes: { class: "editor-link" }, + autolink: true, + HTMLAttributes: { + class: "editor-link", + // Prevent browser from following workspace links as real URLs + rel: "noopener", + }, }), Table.configure({ resizable: false }), TableRow, @@ -212,7 +227,9 @@ export function MarkdownEditor({ }; }, [editor, insertUploadedImages]); - // Handle link clicks for workspace navigation + // Handle link clicks for workspace navigation. + // Links are real URLs like /workspace?path=... so clicking them navigates + // within the same tab. We intercept to avoid a full page reload. useEffect(() => { if (!editor || !onNavigate) {return;} @@ -224,8 +241,8 @@ export function MarkdownEditor({ const href = link.getAttribute("href"); if (!href) {return;} - // Workspace-internal link (relative path, no protocol) - if (!href.startsWith("http://") && !href.startsWith("https://") && !href.startsWith("mailto:")) { + // Intercept /workspace?... links to handle via client-side state + if (isWorkspaceLink(href)) { event.preventDefault(); event.stopPropagation(); onNavigate(href); @@ -233,8 +250,8 @@ export function MarkdownEditor({ }; const editorElement = editor.view.dom; - editorElement.addEventListener("click", handleClick); - return () => editorElement.removeEventListener("click", handleClick); + editorElement.addEventListener("click", handleClick, true); + return () => editorElement.removeEventListener("click", handleClick, true); }, [editor, onNavigate]); // Save handler diff --git a/apps/web/app/components/workspace/object-table.tsx b/apps/web/app/components/workspace/object-table.tsx index 893919afb8a..8105d9812af 100644 --- a/apps/web/app/components/workspace/object-table.tsx +++ b/apps/web/app/components/workspace/object-table.tsx @@ -33,6 +33,7 @@ type ObjectTableProps = { relationLabels?: Record>; reverseRelations?: ReverseRelation[]; onNavigateToObject?: (objectName: string) => void; + onEntryClick?: (entryId: string) => void; }; // --- Helpers --- @@ -352,6 +353,7 @@ export function ObjectTable({ relationLabels, reverseRelations, onNavigateToObject, + onEntryClick, }: ObjectTableProps) { const [sort, setSort] = useState(null); @@ -478,11 +480,15 @@ export function ObjectTable({ {sortedEntries.map((entry, idx) => ( { + const eid = String(entry.entry_id ?? ""); + if (eid && onEntryClick) {onEntryClick(eid);} + }} onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; diff --git a/apps/web/app/components/workspace/slash-command.tsx b/apps/web/app/components/workspace/slash-command.tsx index b46bad03bff..03d217038e5 100644 --- a/apps/web/app/components/workspace/slash-command.tsx +++ b/apps/web/app/components/workspace/slash-command.tsx @@ -14,6 +14,8 @@ import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion"; import { PluginKey } from "@tiptap/pm/state"; import { createPortal } from "react-dom"; import type { Editor, Range } from "@tiptap/core"; +import type { SearchIndexItem } from "@/lib/search-index"; +import { buildEntryLink, buildFileLink } from "@/lib/workspace-links"; // Unique plugin keys so both suggestions can coexist const slashCommandPluginKey = new PluginKey("slashCommand"); @@ -32,25 +34,14 @@ export type TreeNode = { type SlashItem = { title: string; description?: string; + badge?: string; icon: React.ReactNode; - category: "file" | "block"; + category: "file" | "block" | "entry"; command: (props: { editor: Editor; range: Range }) => void; }; -// --- Helpers --- - -function flattenTree(nodes: TreeNode[]): TreeNode[] { - const result: TreeNode[] = []; - for (const node of nodes) { - if (node.type !== "folder") { - result.push(node); - } - if (node.children) { - result.push(...flattenTree(node.children)); - } - } - return result; -} +/** Search function signature accepted by createWorkspaceMention. */ +export type MentionSearchFn = (query: string, limit?: number) => SearchIndexItem[]; function nodeTypeIcon(type: string) { switch (type) { @@ -152,8 +143,72 @@ const reportIcon = ( ); +const entryIcon = ( + + + + + +); + // --- Build items --- +/** Convert a SearchIndexItem to a SlashItem for the @ mention popup. */ +function searchItemToSlashItem(item: SearchIndexItem): SlashItem { + if (item.kind === "entry") { + const label = item.label || `(${item.objectName} entry)`; + return { + title: label, + description: item.fields + ? Object.entries(item.fields) + .slice(0, 2) + .map(([k, v]) => `${k}: ${v}`) + .join(" | ") + : undefined, + badge: item.objectName, + icon: entryIcon, + category: "entry", + command: ({ editor, range }: { editor: Editor; range: Range }) => { + const href = buildEntryLink(item.objectName!, item.entryId!); + editor + .chain() + .focus() + .deleteRange(range) + .insertContent({ + type: "text", + text: label, + marks: [{ type: "link", attrs: { href, target: null } }], + }) + .run(); + }, + }; + } + + const nodeType = item.nodeType ?? (item.kind === "object" ? "object" : "file"); + const label = item.label || item.path || item.id; + return { + title: label, + description: item.sublabel, + icon: nodeTypeIcon(nodeType), + category: "file", + command: ({ editor, range }: { editor: Editor; range: Range }) => { + const href = buildFileLink(item.path ?? item.id); + editor + .chain() + .focus() + .deleteRange(range) + .insertContent({ + type: "text", + text: label, + marks: [ + { type: "link", attrs: { href, target: null } }, + ], + }) + .run(); + }, + }; +} + function buildBlockCommands(): SlashItem[] { return [ { @@ -300,35 +355,7 @@ function buildBlockCommands(): SlashItem[] { ]; } -function buildFileItems(tree: TreeNode[]): SlashItem[] { - const flatFiles = flattenTree(tree); - return flatFiles.map((node) => ({ - title: node.name.replace(/\.md$/, ""), - description: node.path, - icon: nodeTypeIcon(node.type), - category: "file" as const, - command: ({ editor, range }: { editor: Editor; range: Range }) => { - const label = node.name.replace(/\.md$/, ""); - // Insert as structured content so the link mark is applied properly - // (raw HTML strings get escaped by the Markdown extension) - editor - .chain() - .focus() - .deleteRange(range) - .insertContent({ - type: "text", - text: label, - marks: [ - { - type: "link", - attrs: { href: node.path, target: null }, - }, - ], - }) - .run(); - }, - })); -} +// buildFileItems removed -- replaced by searchItemToSlashItem + search index // --- Popup Component --- @@ -397,14 +424,19 @@ const CommandList = forwardRef( {items.map((item, index) => (
); } @@ -372,6 +542,9 @@ function ContentRenderer({ onNavigateToObject, onRefreshObject, onRefreshTree, + onNavigate, + onOpenEntry, + searchFn, }: { content: ContentState; workspaceExists: boolean; @@ -382,6 +555,9 @@ function ContentRenderer({ onNavigateToObject: (objectName: string) => void; onRefreshObject: () => void; onRefreshTree: () => void; + onNavigate: (href: string) => void; + onOpenEntry: (objectName: string, entryId: string) => void; + searchFn: (query: string, limit?: number) => import("@/lib/search-index").SearchIndexItem[]; }) { switch (content.kind) { case "loading": @@ -404,6 +580,7 @@ function ContentRenderer({ members={members} onNavigateToObject={onNavigateToObject} onRefreshObject={onRefreshObject} + onOpenEntry={onOpenEntry} /> ); @@ -415,13 +592,8 @@ function ContentRenderer({ filePath={activePath ?? undefined} tree={tree} onSave={onRefreshTree} - onNavigate={(path) => { - // Find the node in the tree and navigate to it - const node = findNode(tree, path); - if (node) { - onNodeSelect(node); - } - }} + onNavigate={onNavigate} + searchFn={searchFn} /> ); @@ -473,11 +645,13 @@ function ObjectView({ members, onNavigateToObject, onRefreshObject, + onOpenEntry, }: { data: ObjectData; members?: Array<{ id: string; name: string; email: string; role: string }>; onNavigateToObject: (objectName: string) => void; onRefreshObject: () => void; + onOpenEntry?: (objectName: string, entryId: string) => void; }) { const [updatingDisplayField, setUpdatingDisplayField] = useState(false); @@ -493,7 +667,6 @@ function ObjectView({ }, ); if (res.ok) { - // Refresh the object data to get updated relation labels onRefreshObject(); } } catch { @@ -503,7 +676,6 @@ function ObjectView({ } }; - // Fields eligible to be the display field (text-like types) const displayFieldCandidates = data.fields.filter( (f) => !["relation", "boolean", "richtext"].includes(f.type), ); @@ -554,7 +726,6 @@ function ObjectView({ {data.fields.length} fields - {/* Relation info badges */} {hasRelationFields && ( - {/* Display field selector */} {displayFieldCandidates.length > 0 && (
onOpenEntry(data.object.name, entryId) : undefined} /> )}
@@ -755,7 +926,6 @@ function WelcomeView({ tree: TreeNode[]; onNodeSelect: (node: TreeNode) => void; }) { - // Collect all objects and documents for quick access const objects: TreeNode[] = []; const documents: TreeNode[] = []; @@ -780,7 +950,6 @@ function WelcomeView({ Select an item from the sidebar, or browse the sections below.

- {/* Objects section */} {objects.length > 0 && (

)} - {/* Documents section */} {documents.length > 0 && (

; + path?: string; + nodeType?: "document" | "folder" | "file" | "report" | "database"; +}; + +// --- Fuse.js config --- + +const FUSE_OPTIONS: ConstructorParameters>[1] = { + keys: [ + { name: "label", weight: 3 }, + { name: "sublabel", weight: 1 }, + { name: "objectName", weight: 1.5 }, + // Search within field values for entries + { name: "fieldValues", weight: 2 }, + ], + threshold: 0.4, + distance: 200, + includeScore: true, + shouldSort: true, + minMatchCharLength: 1, +}; + +/** Flatten field values into a searchable string for Fuse.js. */ +function enrichForSearch( + items: SearchIndexItem[], +): Array { + return items.map((item) => ({ + ...item, + fieldValues: item.fields + ? Object.values(item.fields).join(" ") + : undefined, + })); +} + +// --- Hook --- + +/** + * Hook that fetches the workspace search index and provides fuzzy search. + * Refetches when `refreshSignal` changes (wire to tree watcher refresh count). + */ +export function useSearchIndex(refreshSignal?: number) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const mountedRef = useRef(true); + + const fetchIndex = useCallback(async () => { + try { + const res = await fetch("/api/workspace/search-index"); + const data = await res.json(); + if (mountedRef.current) { + setItems(data.items ?? []); + setLoading(false); + } + } catch { + if (mountedRef.current) {setLoading(false);} + } + }, []); + + useEffect(() => { + mountedRef.current = true; + fetchIndex(); + return () => { + mountedRef.current = false; + }; + }, [fetchIndex, refreshSignal]); + + // Build the Fuse instance whenever items change + const fuse = useMemo(() => { + if (items.length === 0) {return null;} + const enriched = enrichForSearch(items); + return new Fuse(enriched, FUSE_OPTIONS); + }, [items]); + + /** Inner search implementation (recreated when fuse/items change). */ + const searchImpl = useCallback( + (query: string, limit = 20): SearchIndexItem[] => { + if (!query.trim()) { + // No query: return first N items, files/objects first, then entries + const sorted = [...items].toSorted((a, b) => { + const kindOrder = { object: 0, file: 1, entry: 2 }; + return (kindOrder[a.kind] ?? 9) - (kindOrder[b.kind] ?? 9); + }); + return sorted.slice(0, limit); + } + + if (!fuse) {return [];} + + const results = fuse.search(query, { limit }); + return results.map((r) => r.item); + }, + [fuse, items], + ); + + // Keep a ref to the latest implementation so the tiptap extension + // (which captures the search function at creation time) always calls + // the current version, not a stale closure. + const searchImplRef = useRef(searchImpl); + searchImplRef.current = searchImpl; + + /** + * Stable search function -- identity never changes, but always delegates + * to the latest searchImpl via ref. Safe to capture in closures/extensions. + */ + const search = useCallback( + (query: string, limit?: number): SearchIndexItem[] => { + return searchImplRef.current(query, limit); + }, + [], + ); + + return { items, loading, search }; +} diff --git a/apps/web/lib/workspace-links.ts b/apps/web/lib/workspace-links.ts new file mode 100644 index 00000000000..57358cd3ff7 --- /dev/null +++ b/apps/web/lib/workspace-links.ts @@ -0,0 +1,99 @@ +/** + * 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 + */ + +export type WorkspaceLink = + | { kind: "file"; path: string } + | { kind: "entry"; objectName: string; entryId: string }; + +// --- Builders --- + +/** Build a real URL for an entry detail modal. */ +export function buildEntryLink(objectName: string, entryId: string): string { + return `/workspace?entry=${encodeURIComponent(objectName)}:${encodeURIComponent(entryId)}`; +} + +/** Build a real URL for a file or object in the workspace. */ +export function buildFileLink(path: string): string { + return `/workspace?path=${encodeURIComponent(path)}`; +} + +// --- Parsers --- + +/** 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 + } + + if (url) { + const entryParam = url.searchParams.get("entry"); + if (entryParam && entryParam.includes(":")) { + const colonIdx = entryParam.indexOf(":"); + return { + kind: "entry", + objectName: entryParam.slice(0, colonIdx), + entryId: entryParam.slice(colonIdx + 1), + }; + } + + const pathParam = url.searchParams.get("path"); + if (pathParam) { + return { kind: "file", path: pathParam }; + } + } + + // Legacy: handle old @entry/ format for backward compat + if (href.startsWith("@entry/")) { + const rest = href.slice("@entry/".length); + const slashIdx = rest.indexOf("/"); + if (slashIdx > 0) { + return { + kind: "entry", + objectName: rest.slice(0, slashIdx), + entryId: rest.slice(slashIdx + 1), + }; + } + } + + return null; +} + +/** Check if an href is a workspace link (either /workspace?... or legacy @entry/). */ +export function isWorkspaceLink(href: string): boolean { + return ( + href.startsWith("/workspace?") || + href.startsWith("/workspace#") || + href === "/workspace" || + href.startsWith("@entry/") + ); +} + +/** Check if an href is a workspace-internal link (not external URL). */ +export function isInternalLink(href: string): boolean { + return ( + !href.startsWith("http://") && + !href.startsWith("https://") && + !href.startsWith("mailto:") + ); +} + +/** 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;} + return false; +}