kumarabhirup dee323b7ad
fix lint/build errors and bump to 2026.2.15-1.4
- Fix all oxlint errors (curly, no-unused-vars, no-base-to-string,
  no-floating-promises, approx-constant, restrict-template-expressions)
- Fix TS build errors: rewrite update-cli.ts as thin wrapper over
  submodules, restore missing chat abort helpers in chat.ts
- Fix web build: wrap handleNewSession in async for ChatPanelHandle,
  add missing safeString helper to entry-detail-modal
- Bump version to 2026.2.15-1.4 and publish

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 00:30:13 -08:00

435 lines
13 KiB
TypeScript

import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
import { join, dirname, resolve, basename } from "node:path";
import { homedir } from "node:os";
import {
resolveWorkspaceRoot,
duckdbQueryAllAsync,
discoverDuckDBPaths,
duckdbQueryOnFileAsync,
parseSimpleYaml,
} from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
type SuggestItem = {
name: string;
path: string;
type: "folder" | "file" | "document" | "database" | "object" | "entry";
/** Icon hint (emoji) for objects/entries */
icon?: string;
/** Object name that owns this entry */
objectName?: string;
/** DB entry ID */
entryId?: string;
};
const SKIP_DIRS = new Set([
"node_modules",
".git",
".Trash",
"__pycache__",
".cache",
".DS_Store",
]);
/** List entries in a directory, sorted folders-first then alphabetically. */
function listDir(absDir: string, filter?: string): SuggestItem[] {
let entries: Dirent[];
try {
entries = readdirSync(absDir, { withFileTypes: true });
} catch {
return [];
}
const lowerFilter = filter?.toLowerCase();
const sorted = entries
.filter((e) => !e.name.startsWith("."))
.filter((e) => !(e.isDirectory() && SKIP_DIRS.has(e.name)))
.filter((e) => !lowerFilter || e.name.toLowerCase().includes(lowerFilter))
.toSorted((a, b) => {
if (a.isDirectory() && !b.isDirectory()) {return -1;}
if (!a.isDirectory() && b.isDirectory()) {return 1;}
return a.name.localeCompare(b.name);
});
const items: SuggestItem[] = [];
for (const entry of sorted) {
if (items.length >= 30) {break;}
const absPath = join(absDir, entry.name);
if (entry.isDirectory()) {
items.push({ name: entry.name, path: absPath, type: "folder" });
} else if (entry.isFile()) {
const ext = entry.name.split(".").pop()?.toLowerCase();
const isDocument = ext === "md" || ext === "mdx";
const isDatabase =
ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db";
items.push({
name: entry.name,
path: absPath,
type: isDatabase ? "database" : isDocument ? "document" : "file",
});
}
}
return items;
}
/** Recursively search for files matching a query, up to a limit. */
function searchFiles(
absDir: string,
query: string,
results: SuggestItem[],
maxResults: number,
depth = 0,
): void {
if (depth > 6 || results.length >= maxResults) {return;}
let entries: Dirent[];
try {
entries = readdirSync(absDir, { withFileTypes: true });
} catch {
return;
}
const lowerQuery = query.toLowerCase();
for (const entry of entries) {
if (results.length >= maxResults) {return;}
if (entry.name.startsWith(".")) {continue;}
if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) {continue;}
const absPath = join(absDir, entry.name);
if (entry.isFile() && entry.name.toLowerCase().includes(lowerQuery)) {
const ext = entry.name.split(".").pop()?.toLowerCase();
const isDocument = ext === "md" || ext === "mdx";
const isDatabase =
ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db";
results.push({
name: entry.name,
path: absPath,
type: isDatabase ? "database" : isDocument ? "document" : "file",
});
} else if (
entry.isDirectory() &&
entry.name.toLowerCase().includes(lowerQuery)
) {
results.push({ name: entry.name, path: absPath, type: "folder" });
}
if (entry.isDirectory()) {
searchFiles(absPath, query, results, maxResults, depth + 1);
}
}
}
/**
* Resolve a user-typed path query into a directory to list and an optional filter.
*
* Examples:
* "../" → list parent of workspace root
* "/" → list filesystem root
* "~/" → list home dir
* "~/Doc" → list home dir, filter "Doc"
* "src/utils" → list <workspace>/src, filter "utils"
* "foo.ts" → search by filename
*/
function resolvePath(
raw: string,
workspaceRoot: string,
): { dir: string; filter?: string } | null {
const home = homedir();
if (raw.startsWith("~/")) {
const rest = raw.slice(2);
if (!rest || rest.endsWith("/")) {
// List the directory
const dir = rest ? resolve(home, rest) : home;
return { dir };
}
// Has a trailing segment → list parent, filter by segment
const dir = resolve(home, dirname(rest));
return { dir, filter: basename(rest) };
}
if (raw.startsWith("/")) {
if (raw === "/") {return { dir: "/" };}
if (raw.endsWith("/")) {
return { dir: resolve(raw) };
}
const dir = dirname(resolve(raw));
return { dir, filter: basename(raw) };
}
if (raw.startsWith("../") || raw === "..") {
const resolved = resolve(workspaceRoot, raw);
if (raw.endsWith("/") || raw === "..") {
return { dir: resolved };
}
return { dir: dirname(resolved), filter: basename(resolved) };
}
if (raw.startsWith("./")) {
const rest = raw.slice(2);
if (!rest || rest.endsWith("/")) {
const dir = rest ? resolve(workspaceRoot, rest) : workspaceRoot;
return { dir };
}
const dir = resolve(workspaceRoot, dirname(rest));
return { dir, filter: basename(rest) };
}
// Contains a slash → treat as relative path from workspace
if (raw.includes("/")) {
if (raw.endsWith("/")) {
return { dir: resolve(workspaceRoot, raw) };
}
const dir = resolve(workspaceRoot, dirname(raw));
return { dir, filter: basename(raw) };
}
// No path separator → this is a filename search
return null;
}
// ---------------------------------------------------------------------------
// DuckDB object & entry search
// ---------------------------------------------------------------------------
type ObjectRow = {
id: string;
name: string;
description?: string;
icon?: string;
display_field?: string;
};
type FieldRow = {
id: string;
name: string;
type: string;
sort_order?: number;
};
function sqlEscape(s: string): string {
return s.replace(/'/g, "''");
}
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";
}
/** Read icon from .object.yaml if present. */
function readObjectIcon(workspaceRoot: string, objName: string): string | undefined {
// Walk workspace to find a folder matching objName that has .object.yaml
function walk(dir: string, depth: number): string | undefined {
if (depth > 4) {return undefined;}
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith(".")) {continue;}
if (entry.name === objName) {
const yamlPath = join(dir, entry.name, ".object.yaml");
if (existsSync(yamlPath)) {
const parsed = parseSimpleYaml(readFileSync(yamlPath, "utf-8"));
if (parsed.icon) {return dbStr(parsed.icon);}
}
}
const found = walk(join(dir, entry.name), depth + 1);
if (found) {return found;}
}
} catch { /* skip */ }
return undefined;
}
return walk(workspaceRoot, 0);
}
/** Search objects by name (case-insensitive substring). */
async function searchObjects(
query: string,
workspaceRoot: string,
max: number,
): Promise<SuggestItem[]> {
const sql = query
? `SELECT * FROM objects WHERE LOWER(name) LIKE LOWER('%${sqlEscape(query)}%') ORDER BY name LIMIT ${max}`
: `SELECT * FROM objects ORDER BY name LIMIT ${max}`;
const objects = await duckdbQueryAllAsync<ObjectRow>(sql, "name");
const items: SuggestItem[] = [];
for (const obj of objects) {
const yamlIcon = readObjectIcon(workspaceRoot, obj.name);
items.push({
name: obj.name,
path: `workspace:object:${obj.name}`,
type: "object",
icon: yamlIcon ?? obj.icon,
});
}
return items;
}
/** Safely convert an unknown DB value to a display string. */
function dbStr(val: unknown): string {
if (val == null) {return "";}
if (typeof val === "object") {return JSON.stringify(val);}
return String(val as string | number | boolean);
}
/**
* Search entries across all objects using a single UNION ALL query per DB.
* Each object's pivot view (v_<name>) is searched by display field with ILIKE.
* This avoids spawning N DuckDB CLI processes per object.
*/
async function searchEntries(
query: string,
max: number,
): Promise<SuggestItem[]> {
const dbPaths = discoverDuckDBPaths();
if (dbPaths.length === 0 || !query) {return [];}
const items: SuggestItem[] = [];
const seenObjects = new Set<string>();
const likePattern = `%${sqlEscape(query)}%`;
for (const dbPath of dbPaths) {
if (items.length >= max) {break;}
// Step 1: get objects + display fields in a single query
type ObjFieldRow = ObjectRow & { field_name: string; field_type: string };
const objFields = await duckdbQueryOnFileAsync<ObjFieldRow>(
dbPath,
`SELECT o.*, f.name as field_name, f.type as field_type
FROM objects o
LEFT JOIN fields f ON f.object_id = o.id
ORDER BY o.name, f.sort_order`,
);
// Group fields by object and resolve display fields
const objectMap = new Map<string, { obj: ObjectRow; displayField: string }>();
const fieldsByObj = new Map<string, FieldRow[]>();
for (const row of objFields) {
if (seenObjects.has(row.name)) {continue;}
if (!fieldsByObj.has(row.id)) {fieldsByObj.set(row.id, []);}
if (row.field_name) {
fieldsByObj.get(row.id)!.push({
id: row.id,
name: row.field_name,
type: row.field_type,
});
}
if (!objectMap.has(row.name)) {
const fields = fieldsByObj.get(row.id) ?? [];
objectMap.set(row.name, {
obj: row,
displayField: resolveDisplayField(row, fields),
});
}
}
// Re-resolve display fields now that all fields are collected
for (const [name, entry] of objectMap) {
const fields = fieldsByObj.get(entry.obj.id) ?? [];
entry.displayField = resolveDisplayField(entry.obj, fields);
seenObjects.add(name);
}
if (objectMap.size === 0) {continue;}
// Step 2: build a single UNION ALL query searching all pivot views
// Wrap each SELECT in parens so per-view LIMIT is valid DuckDB syntax
const unionParts: string[] = [];
for (const [name, { displayField }] of objectMap) {
const safeDisplay = sqlEscape(displayField);
unionParts.push(
`(SELECT '${sqlEscape(name)}' as _obj_name, entry_id, "${safeDisplay}" as _display
FROM v_${name}
WHERE LOWER(CAST("${safeDisplay}" AS VARCHAR)) LIKE LOWER('${likePattern}')
LIMIT ${max})`,
);
}
if (unionParts.length === 0) {continue;}
type EntryHit = { _obj_name: string; entry_id: string; _display: string };
const hits = await duckdbQueryOnFileAsync<EntryHit>(
dbPath,
`${unionParts.join(" UNION ALL ")} LIMIT ${max}`,
);
for (const hit of hits) {
if (items.length >= max) {return items;}
if (!hit.entry_id || !hit._display) {continue;}
const objInfo = objectMap.get(hit._obj_name);
items.push({
name: String(hit._display),
path: `workspace:entry:${hit._obj_name}:${hit.entry_id}`,
type: "entry",
icon: objInfo?.obj.icon,
objectName: hit._obj_name,
entryId: hit.entry_id,
});
}
}
return items;
}
export async function GET(req: Request) {
const url = new URL(req.url);
const pathQuery = url.searchParams.get("path");
const searchQuery = url.searchParams.get("q");
const workspaceRoot = resolveWorkspaceRoot() ?? homedir();
// Search mode: find files, objects, and entries by name
if (searchQuery) {
// File search: workspace only (skip expensive home dir traversal)
const fileResults: SuggestItem[] = [];
searchFiles(workspaceRoot, searchQuery, fileResults, 15);
// DuckDB search: objects and entries (sequential to avoid lock contention)
const objectResults = await searchObjects(searchQuery, workspaceRoot, 10);
const entryResults = await searchEntries(searchQuery, 15);
// Deduplicate: if an object matches, remove the duplicate folder
const objectNames = new Set(objectResults.map((o) => o.name));
const dedupedFiles = fileResults.filter(
(f) => !(f.type === "folder" && objectNames.has(f.name)),
);
// Merge: objects first, then entries, then files
const items = [...objectResults, ...entryResults, ...dedupedFiles].slice(0, 30);
return Response.json({ items });
}
// Browse mode: resolve path and list directory
if (pathQuery) {
const resolved = resolvePath(pathQuery, workspaceRoot);
if (!resolved) {
const results: SuggestItem[] = [];
searchFiles(workspaceRoot, pathQuery, results, 20);
return Response.json({ items: results });
}
const items = listDir(resolved.dir, resolved.filter);
return Response.json({ items });
}
// Default: list workspace root + all objects
const fileItems = listDir(workspaceRoot);
const objectItems = await searchObjects("", workspaceRoot, 20);
// Deduplicate: if an object also appears as a folder, keep the object version
const objectNames = new Set(objectItems.map((o) => o.name));
const dedupedFiles = fileItems.filter(
(f) => !(f.type === "folder" && objectNames.has(f.name)),
);
return Response.json({ items: [...objectItems, ...dedupedFiles] });
}