kumarabhirup 0f6849a731
Web app: add syntax highlighting, diff viewer, rich chat editor, and file search
Syntax highlighting & code viewer:
- Add shiki for syntax-highlighted fenced code blocks in chat messages
- New SyntaxBlock component (lazy shiki, dual light/dark theme)
- New CodeViewer for workspace file panel (routes code files via isCodeFile())
- API routes (browse-file, virtual-file) now return "code" type for known extensions

Diff rendering:
- New DiffCard component for rendering unified diffs with add/remove colors
- diff-blocks.ts parser to extract fenced blocks from markdown
- Chain-of-thought tool steps show inline diffs for edit/write tools
  (synthetic from old_string/new_string or direct from tool output)
- Agent runner passes through diff/firstChangedLine from edit tool results
- Document view handles diff blocks alongside report blocks

Rich chat editor (Tiptap):
- Replace plain textarea with Tiptap-based ChatEditor
- File mention extension (@-mention files with autocomplete dropdown)
- File mention list with keyboard navigation and search via suggest-files API
- New suggest-files API endpoint for fuzzy file search

File search & navigation:
- FileSearch component in workspace sidebar (debounced search, keyboard nav)
- Search results navigate sidebar to file location and open in panel
- File picker modal for browsing/selecting workspace files

Drag & drop:
- File tree nodes support native HTML5 drag (application/x-file-mention)
  for cross-component drops (e.g. dragging files into chat editor)

Chat attachments reworked:
- Switch from browser File objects to path-based references (name + path)
- Simplified attachment strip (no media previews, shows shortened paths)

Also adds software-engineering skill and related CSS for code blocks/shiki.
2026-02-13 18:06:59 -08:00

219 lines
6.1 KiB
TypeScript

import { readdirSync, type Dirent } from "node:fs";
import { join, dirname, resolve, basename } from "node:path";
import { homedir } from "node:os";
import { resolveDenchRoot } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
type SuggestItem = {
name: string;
path: string;
type: "folder" | "file" | "document" | "database";
};
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;
}
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 = resolveDenchRoot() ?? homedir();
// Search mode: find files by name
if (searchQuery) {
const results: SuggestItem[] = [];
searchFiles(workspaceRoot, searchQuery, results, 20);
// Also search home dir if workspace didn't yield enough
if (results.length < 20) {
searchFiles(homedir(), searchQuery, results, 20);
}
return Response.json({ items: results });
}
// Browse mode: resolve path and list directory
if (pathQuery) {
const resolved = resolvePath(pathQuery, workspaceRoot);
if (!resolved) {
// Treat as filename search
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
const items = listDir(workspaceRoot);
return Response.json({ items });
}