Dench workspace: unified @ mention search, entry detail modal, and workspace link system
New libraries: - workspace-links.ts: builders, parsers, and type guards for workspace URLs (/workspace?path=... for files/objects, /workspace?entry=objName:id for entries). Replaces ad-hoc relative-path links with real navigable URLs. - search-index.ts: useSearchIndex hook that fetches a unified search index from the API and provides Fuse.js-powered fuzzy search across files, objects, and database entries. Exposes a stable ref-based search function safe for capture in tiptap extensions. New API routes: - GET /api/workspace/search-index: builds a flat search index from the filesystem tree (knowledge/, reports/, top-level files) and all DuckDB object entries with display-field labels and preview fields. - GET /api/workspace/objects/[name]/entries/[id]: fetches a single entry with all field values, resolved relation labels, reverse relations (incoming links from other objects), and the effective display field. New component: - EntryDetailModal: slide-over modal for viewing an individual entry's fields, enum badges, user avatars, clickable relation chips (navigate to related entries), reverse relation sections, and timestamps. Supports Escape to close and backdrop click dismiss. Slash command refactor (slash-command.tsx): - New createWorkspaceMention(searchFn) replaces the old file-only @ mention with unified search across files, objects, and entries. - searchItemToSlashItem() converts search index items into tiptap suggestion items with proper icons, badges (object name pill for entries), and link insertion commands using real workspace URLs. - Legacy createFileMention(tree) now delegates to createWorkspaceMention with a simple substring-match fallback for when no search index is available. Editor integration (markdown-editor.tsx, document-view.tsx): - MarkdownEditor accepts optional searchFn prop; when provided, uses createWorkspaceMention instead of the legacy createFileMention. - Link click interception now uses the shared isWorkspaceLink() helper and registers handlers in capture phase for reliable interception. - DocumentView forwards searchFn to editor and adds a delegated click handler in read mode to intercept workspace links and navigate via onNavigate. Object table (object-table.tsx): - Added onEntryClick prop; table rows are now clickable with cursor-pointer styling, firing the callback with the entry ID. Workspace page (page.tsx): - Integrates useSearchIndex hook and passes search function down to editor. - Entry detail modal state with URL synchronization (?entry=objName:id param). - New resolveNode() with fallback strategies: exact match, knowledge/ prefix toggle, and last-segment object name matching. - Unified handleEditorNavigate() dispatches /workspace?entry=... to the modal and /workspace?path=... to file/object navigation. - URL bar syncs with activePath via router.replace (no full page reloads). - Top-level container click safety net catches any workspace link clicks that bubble up unhandled. Styles (globals.css): - Added .slash-cmd-item-badge for object-name pills in the @ mention popup.
This commit is contained in:
parent
624dc6b91e
commit
5d43186a2b
304
apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts
Normal file
304
apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts
Normal file
@ -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<ObjectRow>(
|
||||
`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<FieldRow>(
|
||||
`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<string, unknown> = { 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<string, Record<string, string>> = {};
|
||||
const relatedObjectNames: Record<string, string> = {};
|
||||
|
||||
const relationFields = fields.filter(
|
||||
(f) => f.type === "relation" && f.related_object_id,
|
||||
);
|
||||
|
||||
for (const rf of relationFields) {
|
||||
const relatedObjs = duckdbQuery<ObjectRow>(
|
||||
`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<FieldRow>(
|
||||
`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<string, string> = {};
|
||||
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<ObjectRow>(
|
||||
`SELECT * FROM objects WHERE id = '${sqlEscape(rrf.object_id)}' LIMIT 1`,
|
||||
);
|
||||
if (sourceObj.length === 0) {continue;}
|
||||
|
||||
const sourceFields = duckdbQuery<FieldRow>(
|
||||
`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<string, string> = {};
|
||||
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;
|
||||
}
|
||||
302
apps/web/app/api/workspace/search-index/route.ts
Normal file
302
apps/web/app/api/workspace/search-index/route.ts
Normal file
@ -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<string, string>;
|
||||
|
||||
// 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<string, ObjectRow>,
|
||||
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<ObjectRow>(
|
||||
"SELECT * FROM objects ORDER BY name",
|
||||
);
|
||||
|
||||
for (const obj of objects) {
|
||||
const fields = duckdbQuery<FieldRow>(
|
||||
`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<string, unknown>[] = duckdbQuery(
|
||||
`SELECT * FROM v_${obj.name} ORDER BY created_at DESC LIMIT 500`,
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
const rawRows = duckdbQuery<EavRow>(
|
||||
`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<string, Record<string, unknown>>();
|
||||
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<string, string> = {};
|
||||
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<string, ObjectRow>();
|
||||
if (duckdbPath()) {
|
||||
const objs = duckdbQuery<ObjectRow>(
|
||||
"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 });
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div className="max-w-3xl mx-auto px-6 py-8">
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div className="max-w-3xl mx-auto px-6 py-8" onClick={handleLinkClick}>
|
||||
{/* Header row with title + edit button */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{displayTitle && (
|
||||
|
||||
504
apps/web/app/components/workspace/entry-detail-modal.tsx
Normal file
504
apps/web/app/components/workspace/entry-detail-modal.tsx
Normal file
@ -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<string, unknown>;
|
||||
relationLabels?: Record<string, Record<string, string>>;
|
||||
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 (
|
||||
<span
|
||||
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
background: `${color}20`,
|
||||
color,
|
||||
border: `1px solid ${color}40`,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0"
|
||||
style={{ background: "var(--color-accent)", color: "white" }}
|
||||
>
|
||||
{(member?.name ?? memberId).charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span>{member?.name ?? memberId}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RelationChips({
|
||||
value,
|
||||
field,
|
||||
relationLabels,
|
||||
onNavigateEntry,
|
||||
}: {
|
||||
value: unknown;
|
||||
field: Field;
|
||||
relationLabels?: Record<string, Record<string, string>>;
|
||||
onNavigateEntry?: (objectName: string, entryId: string) => void;
|
||||
}) {
|
||||
const fieldLabels = relationLabels?.[field.name];
|
||||
const ids = parseRelationValue(String(value));
|
||||
if (ids.length === 0) {return <EmptyValue />;}
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 flex-wrap">
|
||||
{ids.map((id) => {
|
||||
const label = fieldLabels?.[id] ?? id;
|
||||
const handleClick = field.related_object_name && onNavigateEntry
|
||||
? () => onNavigateEntry(field.related_object_name!, id)
|
||||
: undefined;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
onClick={handleClick}
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${handleClick ? "cursor-pointer hover:opacity-80" : ""}`}
|
||||
style={{
|
||||
background: "rgba(96, 165, 250, 0.1)",
|
||||
color: "#60a5fa",
|
||||
border: "1px solid rgba(96, 165, 250, 0.2)",
|
||||
}}
|
||||
title={handleClick ? `Open ${label}` : label}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.5 }}>
|
||||
<path d="M7 7h10v10" /><path d="M7 17 17 7" />
|
||||
</svg>
|
||||
<span className="truncate max-w-[200px]">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyValue() {
|
||||
return (
|
||||
<span style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>--</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div>
|
||||
<label
|
||||
className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider mb-1.5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.4 }}>
|
||||
<path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
|
||||
</svg>
|
||||
<span className="capitalize">{relation.sourceObjectName}</span>
|
||||
<span className="normal-case tracking-normal font-normal opacity-60">
|
||||
via {relation.fieldName}
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5 flex-wrap text-sm min-h-[1.75rem]">
|
||||
{displayLinks.map((link) => (
|
||||
<button
|
||||
type="button"
|
||||
key={link.id}
|
||||
onClick={() => onNavigateEntry?.(relation.sourceObjectName, link.id)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium cursor-pointer hover:opacity-80"
|
||||
style={{
|
||||
background: "rgba(192, 132, 252, 0.1)",
|
||||
color: "#c084fc",
|
||||
border: "1px solid rgba(192, 132, 252, 0.2)",
|
||||
}}
|
||||
title={`Open ${link.label} in ${relation.sourceObjectName}`}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.5 }}>
|
||||
<path d="M7 7h10v10" /><path d="M7 17 17 7" />
|
||||
</svg>
|
||||
<span className="truncate max-w-[200px]">{link.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded" style={{ color: "var(--color-text-muted)" }}>
|
||||
+{overflow} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldValue({
|
||||
value,
|
||||
field,
|
||||
members,
|
||||
relationLabels,
|
||||
onNavigateEntry,
|
||||
}: {
|
||||
value: unknown;
|
||||
field: Field;
|
||||
members?: Array<{ id: string; name: string }>;
|
||||
relationLabels?: Record<string, Record<string, string>>;
|
||||
onNavigateEntry?: (objectName: string, entryId: string) => void;
|
||||
}) {
|
||||
if (value === null || value === undefined || value === "") {return <EmptyValue />;}
|
||||
|
||||
switch (field.type) {
|
||||
case "enum":
|
||||
return (
|
||||
<EnumBadge
|
||||
value={String(value)}
|
||||
enumValues={field.enum_values}
|
||||
enumColors={field.enum_colors}
|
||||
/>
|
||||
);
|
||||
case "boolean": {
|
||||
const isTrue = value === true || value === "true" || value === "1" || value === "yes";
|
||||
return <span style={{ color: isTrue ? "#22c55e" : "var(--color-text-muted)" }}>{isTrue ? "Yes" : "No"}</span>;
|
||||
}
|
||||
case "user":
|
||||
return <UserBadge value={value} members={members} />;
|
||||
case "relation":
|
||||
return (
|
||||
<RelationChips
|
||||
value={value}
|
||||
field={field}
|
||||
relationLabels={relationLabels}
|
||||
onNavigateEntry={onNavigateEntry}
|
||||
/>
|
||||
);
|
||||
case "email":
|
||||
return (
|
||||
<a href={`mailto:${value}`} className="underline underline-offset-2" style={{ color: "#60a5fa" }}>
|
||||
{String(value)}
|
||||
</a>
|
||||
);
|
||||
case "richtext":
|
||||
return <span className="whitespace-pre-wrap">{String(value)}</span>;
|
||||
case "number":
|
||||
return <span className="tabular-nums">{String(value)}</span>;
|
||||
case "date":
|
||||
return <span>{String(value)}</span>;
|
||||
default:
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Modal Component ---
|
||||
|
||||
export function EntryDetailModal({
|
||||
objectName,
|
||||
entryId,
|
||||
members,
|
||||
onClose,
|
||||
onNavigateEntry,
|
||||
onNavigateObject,
|
||||
}: EntryDetailModalProps) {
|
||||
const [data, setData] = useState<EntryDetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const backdropRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={backdropRef}
|
||||
onClick={handleBackdropClick}
|
||||
className="fixed inset-0 z-50 flex items-start justify-center"
|
||||
style={{ background: "rgba(0, 0, 0, 0.5)", backdropFilter: "blur(2px)" }}
|
||||
>
|
||||
<div
|
||||
className="relative mt-12 mb-12 w-full max-w-2xl rounded-2xl overflow-hidden shadow-2xl flex flex-col"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
maxHeight: "calc(100vh - 6rem)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-4 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{/* Object badge */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigateObject?.(objectName)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium capitalize transition-colors hover:opacity-80 flex-shrink-0"
|
||||
style={{
|
||||
background: "rgba(232, 93, 58, 0.1)",
|
||||
color: "var(--color-accent)",
|
||||
border: "1px solid rgba(232, 93, 58, 0.2)",
|
||||
}}
|
||||
title={`Go to ${objectName}`}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
|
||||
</svg>
|
||||
{objectName}
|
||||
</button>
|
||||
<h2
|
||||
className="text-lg font-semibold truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{loading ? "Loading..." : title}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md transition-colors flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Close"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{data && !loading && (
|
||||
<div className="space-y-4">
|
||||
{data.fields.map((field) => {
|
||||
const value = data.entry[field.name];
|
||||
return (
|
||||
<div key={field.id}>
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wider mb-1.5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{field.name}
|
||||
{field.type === "relation" && field.related_object_name && (
|
||||
<span className="normal-case tracking-normal font-normal opacity-60 ml-1">
|
||||
({field.related_object_name})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div
|
||||
className="text-sm min-h-[1.75rem] flex items-center"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
<FieldValue
|
||||
value={value}
|
||||
field={field}
|
||||
members={members}
|
||||
relationLabels={data.relationLabels}
|
||||
onNavigateEntry={onNavigateEntry}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Reverse relations (incoming links from other objects) */}
|
||||
{data.reverseRelations && data.reverseRelations.length > 0 && (
|
||||
<div
|
||||
className="pt-4 mt-4 border-t space-y-4"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div
|
||||
className="text-[10px] font-medium uppercase tracking-widest"
|
||||
style={{ color: "var(--color-text-muted)", opacity: 0.6 }}
|
||||
>
|
||||
Linked from
|
||||
</div>
|
||||
{data.reverseRelations.map((rr) => (
|
||||
<ReverseRelationSection
|
||||
key={`${rr.sourceObjectName}_${rr.fieldName}`}
|
||||
relation={rr}
|
||||
onNavigateEntry={onNavigateEntry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
{(data.entry.created_at != null || data.entry.updated_at != null) && (
|
||||
<div
|
||||
className="pt-4 mt-4 border-t text-xs flex gap-6"
|
||||
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{data.entry.created_at != null && (
|
||||
<span>Created: {String(data.entry.created_at as string)}</span>
|
||||
)}
|
||||
{data.entry.updated_at != null && (
|
||||
<span>Updated: {String(data.entry.updated_at as string)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -33,6 +33,7 @@ type ObjectTableProps = {
|
||||
relationLabels?: Record<string, Record<string, string>>;
|
||||
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<SortState>(null);
|
||||
|
||||
@ -478,11 +480,15 @@ export function ObjectTable({
|
||||
{sortedEntries.map((entry, idx) => (
|
||||
<tr
|
||||
key={String(entry.entry_id ?? idx)}
|
||||
className="transition-colors duration-75"
|
||||
className={`transition-colors duration-75 ${onEntryClick ? "cursor-pointer" : ""}`}
|
||||
style={{
|
||||
background:
|
||||
idx % 2 === 0 ? "transparent" : "var(--color-surface)",
|
||||
}}
|
||||
onClick={() => {
|
||||
const eid = String(entry.entry_id ?? "");
|
||||
if (eid && onEntryClick) {onEntryClick(eid);}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background =
|
||||
"var(--color-surface-hover)";
|
||||
|
||||
@ -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 = (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const entryIcon = (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8Z" />
|
||||
<path d="M15 3v4a2 2 0 0 0 2 2h4" />
|
||||
<path d="M10 16h4" /><path d="M10 12h4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// --- 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<CommandListRef, CommandListProps>(
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${item.category}-${item.title}`}
|
||||
key={`${item.category}-${item.title}-${index}`}
|
||||
className={`slash-cmd-item ${index === selectedIndex ? "slash-cmd-item-active" : ""}`}
|
||||
onClick={() => selectItem(index)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<span className="slash-cmd-item-icon">{item.icon}</span>
|
||||
<span className="slash-cmd-item-body">
|
||||
<span className="slash-cmd-item-title">{item.title}</span>
|
||||
<span className="slash-cmd-item-title">
|
||||
{item.title}
|
||||
{item.badge && (
|
||||
<span className="slash-cmd-item-badge">{item.badge}</span>
|
||||
)}
|
||||
</span>
|
||||
{item.description && (
|
||||
<span className="slash-cmd-item-desc">{item.description}</span>
|
||||
)}
|
||||
@ -562,11 +594,10 @@ export function createSlashCommand() {
|
||||
}
|
||||
|
||||
/**
|
||||
* "@" mention command -- workspace file cross-linking
|
||||
* "@" mention command -- unified workspace search (files + objects + entries).
|
||||
* Accepts a search function from the useSearchIndex hook for fast fuzzy matching.
|
||||
*/
|
||||
export function createFileMention(tree: TreeNode[]) {
|
||||
const fileItems = buildFileItems(tree);
|
||||
|
||||
export function createWorkspaceMention(searchFn: MentionSearchFn) {
|
||||
return Extension.create({
|
||||
name: "fileMention",
|
||||
|
||||
@ -580,15 +611,8 @@ export function createFileMention(tree: TreeNode[]) {
|
||||
item.command({ editor, range });
|
||||
},
|
||||
items: ({ query }: { query: string }) => {
|
||||
const q = query.toLowerCase();
|
||||
if (!q) {return fileItems.slice(0, 15);}
|
||||
return fileItems
|
||||
.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(q) ||
|
||||
(item.description?.toLowerCase().includes(q) ?? false),
|
||||
)
|
||||
.slice(0, 15);
|
||||
const results = searchFn(query, 15);
|
||||
return results.map(searchItemToSlashItem);
|
||||
},
|
||||
render: createSuggestionRenderer(),
|
||||
} satisfies Partial<SuggestionOptions<SlashItem>>,
|
||||
@ -605,3 +629,42 @@ export function createFileMention(tree: TreeNode[]) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* "@" mention command -- legacy file-only cross-linking (fallback).
|
||||
* @deprecated Use createWorkspaceMention with useSearchIndex instead.
|
||||
*/
|
||||
export function createFileMention(tree: TreeNode[]) {
|
||||
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;
|
||||
}
|
||||
|
||||
const flatFiles = flattenTree(tree);
|
||||
const searchItems: SearchIndexItem[] = flatFiles.map((node) => ({
|
||||
id: node.path,
|
||||
label: node.name.replace(/\.md$/, ""),
|
||||
sublabel: node.path,
|
||||
kind: (node.type === "object" ? "object" : "file") as SearchIndexItem["kind"],
|
||||
path: node.path,
|
||||
nodeType: node.type as SearchIndexItem["nodeType"],
|
||||
}));
|
||||
|
||||
const searchFn: MentionSearchFn = (query, limit = 15) => {
|
||||
if (!query) {return searchItems.slice(0, limit);}
|
||||
const q = query.toLowerCase();
|
||||
return searchItems
|
||||
.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(q) ||
|
||||
(item.sublabel?.toLowerCase().includes(q) ?? false),
|
||||
)
|
||||
.slice(0, limit);
|
||||
};
|
||||
|
||||
return createWorkspaceMention(searchFn);
|
||||
}
|
||||
|
||||
@ -624,6 +624,20 @@ body {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.slash-cmd-item-badge {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
border-radius: 999px;
|
||||
background: rgba(232, 93, 58, 0.12);
|
||||
color: var(--color-accent);
|
||||
vertical-align: middle;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Report Block (in-editor)
|
||||
======================================== */
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { WorkspaceSidebar } from "../components/workspace/workspace-sidebar";
|
||||
import { type TreeNode } from "../components/workspace/file-manager-tree";
|
||||
import { useWorkspaceWatcher } from "../hooks/use-workspace-watcher";
|
||||
@ -14,6 +14,9 @@ import { Breadcrumbs } from "../components/workspace/breadcrumbs";
|
||||
import { EmptyState } from "../components/workspace/empty-state";
|
||||
import { ReportViewer } from "../components/charts/report-viewer";
|
||||
import { ChatPanel } from "../components/chat-panel";
|
||||
import { EntryDetailModal } from "../components/workspace/entry-detail-modal";
|
||||
import { useSearchIndex } from "@/lib/search-index";
|
||||
import { parseWorkspaceLink, isWorkspaceLink } from "@/lib/workspace-links";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@ -81,7 +84,7 @@ type ContentState =
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/** Find a node in the tree by path. */
|
||||
/** Find a node in the tree by exact path. */
|
||||
function findNode(
|
||||
tree: TreeNode[],
|
||||
path: string,
|
||||
@ -102,20 +105,73 @@ function objectNameFromPath(path: string): string {
|
||||
return segments[segments.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path with fallback strategies:
|
||||
* 1. Exact match
|
||||
* 2. Try with knowledge/ prefix
|
||||
* 3. Try stripping knowledge/ prefix
|
||||
* 4. Match last segment against object names
|
||||
*/
|
||||
function resolveNode(
|
||||
tree: TreeNode[],
|
||||
path: string,
|
||||
): TreeNode | null {
|
||||
let node = findNode(tree, path);
|
||||
if (node) {return node;}
|
||||
|
||||
if (!path.startsWith("knowledge/")) {
|
||||
node = findNode(tree, `knowledge/${path}`);
|
||||
if (node) {return node;}
|
||||
}
|
||||
|
||||
if (path.startsWith("knowledge/")) {
|
||||
node = findNode(tree, path.slice("knowledge/".length));
|
||||
if (node) {return node;}
|
||||
}
|
||||
|
||||
const lastSegment = path.split("/").pop();
|
||||
if (lastSegment) {
|
||||
function findByName(nodes: TreeNode[]): TreeNode | null {
|
||||
for (const n of nodes) {
|
||||
if (n.type === "object" && objectNameFromPath(n.path) === lastSegment) {return n;}
|
||||
if (n.children) {
|
||||
const found = findByName(n.children);
|
||||
if (found) {return found;}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
node = findByName(tree);
|
||||
if (node) {return node;}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Main Page ---
|
||||
|
||||
export default function WorkspacePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const initialPathHandled = useRef(false);
|
||||
|
||||
// Live-reactive tree via SSE watcher
|
||||
const { tree, loading: treeLoading, exists: workspaceExists, refresh: refreshTree } = useWorkspaceWatcher();
|
||||
|
||||
// Search index for @ mention fuzzy search (files + entries)
|
||||
const { search: searchIndex } = useSearchIndex();
|
||||
|
||||
const [context, setContext] = useState<WorkspaceContext | null>(null);
|
||||
const [activePath, setActivePath] = useState<string | null>(null);
|
||||
const [content, setContent] = useState<ContentState>({ kind: "none" });
|
||||
const [showChatSidebar, setShowChatSidebar] = useState(true);
|
||||
|
||||
// Entry detail modal state
|
||||
const [entryModal, setEntryModal] = useState<{
|
||||
objectName: string;
|
||||
entryId: string;
|
||||
} | null>(null);
|
||||
|
||||
// Derive file context for chat sidebar directly from activePath (stable across loading)
|
||||
const fileContext = useMemo(() => {
|
||||
if (!activePath) {return undefined;}
|
||||
@ -213,18 +269,61 @@ export default function WorkspacePage() {
|
||||
[loadContent],
|
||||
);
|
||||
|
||||
// Sync URL bar when activePath changes
|
||||
useEffect(() => {
|
||||
const currentPath = searchParams.get("path");
|
||||
const currentEntry = searchParams.get("entry");
|
||||
|
||||
if (activePath && activePath !== currentPath) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("path", activePath);
|
||||
if (currentEntry) {params.set("entry", currentEntry);}
|
||||
router.replace(`/workspace?${params.toString()}`, { scroll: false });
|
||||
}
|
||||
}, [activePath, searchParams, router]);
|
||||
|
||||
// Open entry modal handler
|
||||
const handleOpenEntry = useCallback(
|
||||
(objectName: string, entryId: string) => {
|
||||
setEntryModal({ objectName, entryId });
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("entry", `${objectName}:${entryId}`);
|
||||
router.replace(`/workspace?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[searchParams, router],
|
||||
);
|
||||
|
||||
// Close entry modal handler
|
||||
const handleCloseEntry = useCallback(() => {
|
||||
setEntryModal(null);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("entry");
|
||||
const qs = params.toString();
|
||||
router.replace(qs ? `/workspace?${qs}` : "/workspace", { scroll: false });
|
||||
}, [searchParams, router]);
|
||||
|
||||
// Auto-navigate to path from URL query param after tree loads
|
||||
useEffect(() => {
|
||||
if (initialPathHandled.current || treeLoading || tree.length === 0) {return;}
|
||||
|
||||
const pathParam = searchParams.get("path");
|
||||
const entryParam = searchParams.get("entry");
|
||||
|
||||
if (pathParam) {
|
||||
const node = findNode(tree, pathParam);
|
||||
const node = resolveNode(tree, pathParam);
|
||||
if (node) {
|
||||
initialPathHandled.current = true;
|
||||
loadContent(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Also open entry modal from URL if present
|
||||
if (entryParam && entryParam.includes(":")) {
|
||||
const [objName, eid] = entryParam.split(":", 2);
|
||||
if (objName && eid) {
|
||||
setEntryModal({ objectName: objName, entryId: eid });
|
||||
}
|
||||
}
|
||||
}, [tree, treeLoading, searchParams, loadContent]);
|
||||
|
||||
const handleBreadcrumbNavigate = useCallback(
|
||||
@ -234,7 +333,7 @@ export default function WorkspacePage() {
|
||||
setContent({ kind: "none" });
|
||||
return;
|
||||
}
|
||||
const node = findNode(tree, path);
|
||||
const node = resolveNode(tree, path);
|
||||
if (node) {
|
||||
loadContent(node);
|
||||
}
|
||||
@ -245,7 +344,6 @@ export default function WorkspacePage() {
|
||||
// Navigate to an object by name (used by relation links)
|
||||
const handleNavigateToObject = useCallback(
|
||||
(objectName: string) => {
|
||||
// Find the object node in the tree
|
||||
function findObjectNode(nodes: TreeNode[]): TreeNode | null {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "object" && objectNameFromPath(node.path) === objectName) {
|
||||
@ -264,6 +362,40 @@ export default function WorkspacePage() {
|
||||
[tree, loadContent],
|
||||
);
|
||||
|
||||
/**
|
||||
* Unified navigate handler for editor links.
|
||||
* Handles both file/object paths and @entry/ links.
|
||||
*/
|
||||
/**
|
||||
* Unified navigate handler for links in the editor and read mode.
|
||||
* Handles /workspace?entry=..., /workspace?path=..., and legacy relative paths.
|
||||
*/
|
||||
const handleEditorNavigate = useCallback(
|
||||
(href: string) => {
|
||||
// Try parsing as a workspace URL first (/workspace?entry=... or /workspace?path=...)
|
||||
const parsed = parseWorkspaceLink(href);
|
||||
if (parsed) {
|
||||
if (parsed.kind === "entry") {
|
||||
handleOpenEntry(parsed.objectName, parsed.entryId);
|
||||
return;
|
||||
}
|
||||
// File/object link -- resolve using the path from the URL
|
||||
const node = resolveNode(tree, parsed.path);
|
||||
if (node) {
|
||||
handleNodeSelect(node);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: treat as a raw relative path (legacy links)
|
||||
const node = resolveNode(tree, href);
|
||||
if (node) {
|
||||
handleNodeSelect(node);
|
||||
}
|
||||
},
|
||||
[tree, handleNodeSelect, handleOpenEntry],
|
||||
);
|
||||
|
||||
// Refresh the currently displayed object (e.g. after changing display field)
|
||||
const refreshCurrentObject = useCallback(async () => {
|
||||
if (content.kind !== "object") {return;}
|
||||
@ -278,8 +410,28 @@ export default function WorkspacePage() {
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
// Top-level safety net: catch workspace link clicks anywhere in the page
|
||||
// to prevent full-page navigation and handle via client-side state instead.
|
||||
const handleContainerClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const link = target.closest("a");
|
||||
if (!link) {return;}
|
||||
const href = link.getAttribute("href");
|
||||
if (!href) {return;}
|
||||
// Intercept /workspace?... links to handle them in-app
|
||||
if (isWorkspaceLink(href)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleEditorNavigate(href);
|
||||
}
|
||||
},
|
||||
[handleEditorNavigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen" style={{ background: "var(--color-bg)" }}>
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div className="flex h-screen" style={{ background: "var(--color-bg)" }} onClick={handleContainerClick}>
|
||||
{/* Sidebar */}
|
||||
<WorkspaceSidebar
|
||||
tree={tree}
|
||||
@ -334,6 +486,9 @@ export default function WorkspacePage() {
|
||||
onNavigateToObject={handleNavigateToObject}
|
||||
onRefreshObject={refreshCurrentObject}
|
||||
onRefreshTree={refreshTree}
|
||||
onNavigate={handleEditorNavigate}
|
||||
onOpenEntry={handleOpenEntry}
|
||||
searchFn={searchIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -356,6 +511,21 @@ export default function WorkspacePage() {
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Entry detail modal (rendered on top of everything) */}
|
||||
{entryModal && (
|
||||
<EntryDetailModal
|
||||
objectName={entryModal.objectName}
|
||||
entryId={entryModal.entryId}
|
||||
members={context?.members}
|
||||
onClose={handleCloseEntry}
|
||||
onNavigateEntry={(objName, eid) => handleOpenEntry(objName, eid)}
|
||||
onNavigateObject={(objName) => {
|
||||
handleCloseEntry();
|
||||
handleNavigateToObject(objName);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
</span>
|
||||
|
||||
{/* Relation info badges */}
|
||||
{hasRelationFields && (
|
||||
<span
|
||||
className="text-xs px-2 py-1 rounded-full"
|
||||
@ -581,7 +752,6 @@ function ObjectView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Display field selector */}
|
||||
{displayFieldCandidates.length > 0 && (
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<span
|
||||
@ -643,6 +813,7 @@ function ObjectView({
|
||||
relationLabels={data.relationLabels}
|
||||
reverseRelations={data.reverseRelations}
|
||||
onNavigateToObject={onNavigateToObject}
|
||||
onEntryClick={onOpenEntry ? (entryId) => onOpenEntry(data.object.name, entryId) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -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.
|
||||
</p>
|
||||
|
||||
{/* Objects section */}
|
||||
{objects.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2
|
||||
@ -835,7 +1004,6 @@ function WelcomeView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents section */}
|
||||
{documents.length > 0 && (
|
||||
<div>
|
||||
<h2
|
||||
|
||||
127
apps/web/lib/search-index.ts
Normal file
127
apps/web/lib/search-index.ts
Normal file
@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
// --- Types (must match the API response) ---
|
||||
|
||||
export type SearchIndexItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
kind: "file" | "object" | "entry";
|
||||
icon?: string;
|
||||
objectName?: string;
|
||||
entryId?: string;
|
||||
fields?: Record<string, string>;
|
||||
path?: string;
|
||||
nodeType?: "document" | "folder" | "file" | "report" | "database";
|
||||
};
|
||||
|
||||
// --- Fuse.js config ---
|
||||
|
||||
const FUSE_OPTIONS: ConstructorParameters<typeof Fuse<SearchIndexItem>>[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<SearchIndexItem & { fieldValues?: string }> {
|
||||
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<SearchIndexItem[]>([]);
|
||||
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 };
|
||||
}
|
||||
99
apps/web/lib/workspace-links.ts
Normal file
99
apps/web/lib/workspace-links.ts
Normal file
@ -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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user