👌 IMPROVE: never miss yaml
This commit is contained in:
parent
f74327445e
commit
19259b1e15
@ -538,18 +538,41 @@ Identical patterns, but with SQL examples instead of tool call examples:
|
||||
- NEVER modify `workspace_context.yaml` -- it is read-only context from Dench
|
||||
- Members list is authoritative for user-type field resolution
|
||||
|
||||
### Section 11: Post-Mutation Pipeline (new)
|
||||
### Section 11: Post-Mutation Checklist (MANDATORY -- revised after agent testing)
|
||||
|
||||
After any schema mutation (create/update/delete object, field, or document), run a 3-step pipeline:
|
||||
**Problem identified:** In testing, the agent correctly executed SQL (object + fields + entries) but skipped the filesystem projection (.object.yaml) and sometimes the PIVOT view. Root cause: the original skill mentioned these as afterthoughts ("Then project the filesystem...") with no concrete template or examples. The agent follows examples literally -- if examples only show SQL, it only does SQL.
|
||||
|
||||
1. **Regenerate views**: `CREATE OR REPLACE VIEW v_{object}` for any affected objects
|
||||
2. **Project filesystem**: Sync the `knowledge/` directory structure from DuckDB (mkdir/rmdir for objects, write `.object.yaml` summaries, move `.md` files if nesting changed)
|
||||
3. **Sync to S3**: Run `dench/sync.sh` to persist workspace.duckdb + knowledge/ to S3
|
||||
4. **Regenerate WORKSPACE.md**: Human-readable summary of all objects, fields, entry counts, and views
|
||||
**Fix:** Every workflow example now uses an explicit 3-step structure. The post-mutation section is now a checklist, not a description.
|
||||
|
||||
After creating/modifying an OBJECT or FIELDS:
|
||||
|
||||
- `CREATE OR REPLACE VIEW v_{object_name}` -- regenerate PIVOT view
|
||||
- `mkdir -p dench/knowledge/{object_name}/` -- create directory
|
||||
- Write `.object.yaml` with id, name, description, icon, default_view, entry_count, and full field list
|
||||
- Update WORKSPACE.md
|
||||
|
||||
After adding ENTRIES:
|
||||
|
||||
- Update `entry_count` in `.object.yaml`
|
||||
- Verify: `SELECT * FROM v_{object} LIMIT 5`
|
||||
|
||||
After deleting an OBJECT:
|
||||
|
||||
- `DROP VIEW IF EXISTS v_{object_name}`
|
||||
- `rm -rf dench/knowledge/{object_name}/`
|
||||
|
||||
The skill now includes:
|
||||
|
||||
- A concrete `.object.yaml` template with example content (previously missing entirely)
|
||||
- Full bash commands for generating `.object.yaml` from DuckDB queries
|
||||
- "Step 1 / Step 2 / Step 3" structure in every workflow example (SQL, Filesystem, Verify)
|
||||
- Critical Reminders section leads with "NEVER SKIP FILESYSTEM PROJECTION" and "THREE STEPS, EVERY TIME"
|
||||
|
||||
### Section 12: Critical Reminders (adapted from `<critical_reminders>`)
|
||||
|
||||
- Handle the ENTIRE CRM operation from analysis to SQL execution to summary
|
||||
- Handle the ENTIRE CRM operation from analysis to SQL execution **to filesystem projection** to summary
|
||||
- **NEVER SKIP FILESYSTEM PROJECTION**: After any object mutation, create/update `.object.yaml` AND the `v_{object}` view. If missing, the object is invisible in the sidebar.
|
||||
- **THREE STEPS, EVERY TIME**: (1) SQL transaction, (2) filesystem projection, (3) verify
|
||||
- Always check existing data before creating (SELECT before INSERT, or ON CONFLICT)
|
||||
- Search proactively to provide better UX (PIVOT with filters)
|
||||
- Never assume field names -- always verify with `SELECT * FROM fields WHERE object_id = ?`
|
||||
@ -561,6 +584,7 @@ After any schema mutation (create/update/delete object, field, or document), run
|
||||
- KANBAN BOARDS: `default_view = 'kanban'`, auto-create Status and Assigned To fields
|
||||
- PROTECTED OBJECTS: Never delete objects listed in `workspace_context.yaml` `protected_objects`
|
||||
- ONE EXEC CALL: Batch related SQL in a single transaction whenever possible -- this is the entire point of the filesystem-first approach
|
||||
- ENTRY COUNT: After adding entries, update `entry_count` in `.object.yaml`
|
||||
|
||||
---
|
||||
|
||||
|
||||
93
apps/web/app/api/workspace/db/introspect/route.ts
Normal file
93
apps/web/app/api/workspace/db/introspect/route.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { safeResolvePath, duckdbQueryOnFile } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type TableInfo = {
|
||||
table_name: string;
|
||||
column_count: number;
|
||||
estimated_row_count: number;
|
||||
columns: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
is_nullable: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/workspace/db/introspect?path=<relative-path>
|
||||
*
|
||||
* Introspects a DuckDB / SQLite / generic DB file using the duckdb CLI.
|
||||
* Returns the list of tables with their columns and approximate row counts.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const relPath = searchParams.get("path");
|
||||
|
||||
if (!relPath) {
|
||||
return Response.json(
|
||||
{ error: "Missing required `path` query parameter" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const absPath = safeResolvePath(relPath);
|
||||
if (!absPath) {
|
||||
return Response.json(
|
||||
{ error: "File not found or path traversal rejected" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get all user tables (skip internal DuckDB catalogs)
|
||||
const rawTables = duckdbQueryOnFile<{
|
||||
table_name: string;
|
||||
table_type: string;
|
||||
}>(
|
||||
absPath,
|
||||
"SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = 'main' ORDER BY table_name",
|
||||
);
|
||||
|
||||
if (rawTables.length === 0) {
|
||||
return Response.json({ tables: [], path: relPath });
|
||||
}
|
||||
|
||||
const tables: TableInfo[] = [];
|
||||
|
||||
for (const t of rawTables) {
|
||||
// Fetch columns for this table
|
||||
const cols = duckdbQueryOnFile<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
}>(
|
||||
absPath,
|
||||
`SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = 'main' AND table_name = '${t.table_name.replace(/'/g, "''")}' ORDER BY ordinal_position`,
|
||||
);
|
||||
|
||||
// Get approximate row count
|
||||
let rowCount = 0;
|
||||
try {
|
||||
const countResult = duckdbQueryOnFile<{ cnt: number }>(
|
||||
absPath,
|
||||
`SELECT count(*) as cnt FROM "${t.table_name.replace(/"/g, '""')}"`,
|
||||
);
|
||||
rowCount = countResult[0]?.cnt ?? 0;
|
||||
} catch {
|
||||
// skip if we can't count
|
||||
}
|
||||
|
||||
tables.push({
|
||||
table_name: t.table_name,
|
||||
column_count: cols.length,
|
||||
estimated_row_count: rowCount,
|
||||
columns: cols.map((c) => ({
|
||||
name: c.column_name,
|
||||
type: c.data_type,
|
||||
is_nullable: c.is_nullable === "YES",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json({ tables, path: relPath });
|
||||
}
|
||||
56
apps/web/app/api/workspace/db/query/route.ts
Normal file
56
apps/web/app/api/workspace/db/query/route.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { safeResolvePath, duckdbQueryOnFile } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* POST /api/workspace/db/query
|
||||
* Body: { path: string, sql: string }
|
||||
*
|
||||
* Executes a read-only SQL query against a database file and returns JSON rows.
|
||||
* Only SELECT statements are allowed for safety.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
let body: { path?: string; sql?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { path: relPath, sql } = body;
|
||||
|
||||
if (!relPath || !sql) {
|
||||
return Response.json(
|
||||
{ error: "Missing required `path` and `sql` fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Basic safety: only allow SELECT-like statements
|
||||
const trimmedSql = sql.trim().toUpperCase();
|
||||
if (
|
||||
!trimmedSql.startsWith("SELECT") &&
|
||||
!trimmedSql.startsWith("PRAGMA") &&
|
||||
!trimmedSql.startsWith("DESCRIBE") &&
|
||||
!trimmedSql.startsWith("SHOW") &&
|
||||
!trimmedSql.startsWith("EXPLAIN") &&
|
||||
!trimmedSql.startsWith("WITH")
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "Only read-only queries (SELECT, DESCRIBE, SHOW, EXPLAIN, WITH) are allowed" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const absPath = safeResolvePath(relPath);
|
||||
if (!absPath) {
|
||||
return Response.json(
|
||||
{ error: "File not found or path traversal rejected" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const rows = duckdbQueryOnFile(absPath, sql);
|
||||
return Response.json({ rows, sql });
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveDenchRoot, parseSimpleYaml, duckdbQuery } from "@/lib/workspace";
|
||||
import { resolveDenchRoot, parseSimpleYaml, duckdbQuery, isDatabaseFile } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@ -8,7 +8,7 @@ export const runtime = "nodejs";
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
path: string; // relative to dench/
|
||||
type: "object" | "document" | "folder" | "file";
|
||||
type: "object" | "document" | "folder" | "file" | "database";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
@ -118,11 +118,12 @@ function buildTree(
|
||||
} else if (entry.isFile()) {
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase();
|
||||
const isDocument = ext === "md" || ext === "mdx";
|
||||
const isDatabase = isDatabaseFile(entry.name);
|
||||
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: relPath,
|
||||
type: isDocument ? "document" : "file",
|
||||
type: isDatabase ? "database" : isDocument ? "document" : "file",
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -130,6 +131,14 @@ function buildTree(
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** Classify a top-level file's type. */
|
||||
function classifyFileType(name: string): TreeNode["type"] {
|
||||
if (isDatabaseFile(name)) {return "database";}
|
||||
const ext = name.split(".").pop()?.toLowerCase();
|
||||
if (ext === "md" || ext === "mdx") {return "document";}
|
||||
return "file";
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const root = resolveDenchRoot();
|
||||
if (!root) {
|
||||
@ -147,19 +156,17 @@ export async function GET() {
|
||||
tree.push(...buildTree(knowledgeDir, "knowledge", dbObjects));
|
||||
}
|
||||
|
||||
// Add top-level files (WORKSPACE.md, workspace_context.yaml, etc.)
|
||||
// Add top-level files (WORKSPACE.md, workspace_context.yaml, workspace.duckdb, etc.)
|
||||
try {
|
||||
const topLevel = readdirSync(root, { withFileTypes: true });
|
||||
for (const entry of topLevel) {
|
||||
if (!entry.isFile()) {continue;}
|
||||
if (entry.name.startsWith(".")) {continue;}
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase();
|
||||
const isDocument = ext === "md" || ext === "mdx";
|
||||
|
||||
tree.push({
|
||||
name: entry.name,
|
||||
path: entry.name,
|
||||
type: isDocument ? "document" : "file",
|
||||
type: classifyFileType(entry.name),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
|
||||
@ -27,7 +27,7 @@ type MemoryFile = {
|
||||
type TreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "object" | "document" | "folder" | "file";
|
||||
type: "object" | "document" | "folder" | "file" | "database";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
@ -231,7 +231,9 @@ function WorkspaceTreeNode({
|
||||
? "var(--color-accent)"
|
||||
: node.type === "document"
|
||||
? "#60a5fa"
|
||||
: "var(--color-text-muted)";
|
||||
: node.type === "database"
|
||||
? "#c084fc"
|
||||
: "var(--color-text-muted)";
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -241,7 +243,7 @@ function WorkspaceTreeNode({
|
||||
onClick={() => {
|
||||
if (isExpandable) {onToggle(node.path);}
|
||||
// Navigate to workspace page for actionable items
|
||||
if (node.type === "object" || node.type === "document" || node.type === "file") {
|
||||
if (node.type === "object" || node.type === "document" || node.type === "file" || node.type === "database") {
|
||||
window.location.href = `/workspace?path=${encodeURIComponent(node.path)}`;
|
||||
}
|
||||
}}
|
||||
@ -279,6 +281,12 @@ function WorkspaceTreeNode({
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
) : node.type === "database" ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
|
||||
<path d="M3 12A9 3 0 0 0 21 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
|
||||
918
apps/web/app/components/workspace/database-viewer.tsx
Normal file
918
apps/web/app/components/workspace/database-viewer.tsx
Normal file
@ -0,0 +1,918 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
type ColumnInfo = {
|
||||
name: string;
|
||||
type: string;
|
||||
is_nullable: boolean;
|
||||
};
|
||||
|
||||
type TableInfo = {
|
||||
table_name: string;
|
||||
column_count: number;
|
||||
estimated_row_count: number;
|
||||
columns: ColumnInfo[];
|
||||
};
|
||||
|
||||
type SortState = {
|
||||
column: string;
|
||||
direction: "asc" | "desc";
|
||||
} | null;
|
||||
|
||||
type DatabaseViewerProps = {
|
||||
/** Relative path to the database file within the dench workspace */
|
||||
dbPath: string;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
function DatabaseIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
|
||||
<path d="M3 12A9 3 0 0 0 21 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TableIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" 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>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="6 3 20 12 6 21 6 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ direction }: { direction: "left" | "right" }) {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{direction === "left" ? (
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
) : (
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SortIndicator({ active, direction }: { active: boolean; direction: "asc" | "desc" }) {
|
||||
return (
|
||||
<svg
|
||||
width="10" height="10" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ opacity: active ? 1 : 0.25 }}
|
||||
>
|
||||
{direction === "asc" ? <path d="m5 12 7-7 7 7" /> : <path d="m19 12-7 7-7-7" />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function formatRowCount(n: number): string {
|
||||
if (n >= 1_000_000) {return `${(n / 1_000_000).toFixed(1)}M`;}
|
||||
if (n >= 1_000) {return `${(n / 1_000).toFixed(1)}K`;}
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** Map DuckDB type names to short display labels + color hints */
|
||||
function typeDisplay(dtype: string): { label: string; color: string } {
|
||||
const t = dtype.toUpperCase();
|
||||
if (t.includes("INT") || t.includes("BIGINT") || t.includes("SMALLINT") || t.includes("TINYINT") || t.includes("HUGEINT"))
|
||||
{return { label: "int", color: "#c084fc" };}
|
||||
if (t.includes("FLOAT") || t.includes("DOUBLE") || t.includes("DECIMAL") || t.includes("NUMERIC") || t.includes("REAL"))
|
||||
{return { label: "float", color: "#c084fc" };}
|
||||
if (t.includes("BOOL"))
|
||||
{return { label: "bool", color: "#f59e0b" };}
|
||||
if (t.includes("VARCHAR") || t.includes("TEXT") || t.includes("STRING") || t.includes("CHAR") || t === "UUID" || t === "BLOB")
|
||||
{return { label: t.includes("UUID") ? "uuid" : "text", color: "#22c55e" };}
|
||||
if (t.includes("TIMESTAMP") || t.includes("DATETIME"))
|
||||
{return { label: "timestamp", color: "#60a5fa" };}
|
||||
if (t.includes("DATE"))
|
||||
{return { label: "date", color: "#60a5fa" };}
|
||||
if (t.includes("TIME"))
|
||||
{return { label: "time", color: "#60a5fa" };}
|
||||
if (t.includes("JSON"))
|
||||
{return { label: "json", color: "#fb923c" };}
|
||||
return { label: dtype.toLowerCase(), color: "var(--color-text-muted)" };
|
||||
}
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
export function DatabaseViewer({ dbPath, filename }: DatabaseViewerProps) {
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Selected table
|
||||
const [selectedTable, setSelectedTable] = useState<string | null>(null);
|
||||
|
||||
// Table data
|
||||
const [tableData, setTableData] = useState<Record<string, unknown>[]>([]);
|
||||
const [dataLoading, setDataLoading] = useState(false);
|
||||
const [sort, setSort] = useState<SortState>(null);
|
||||
|
||||
// Pagination
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 100;
|
||||
|
||||
// Custom SQL query
|
||||
const [queryMode, setQueryMode] = useState(false);
|
||||
const [sqlInput, setSqlInput] = useState("");
|
||||
const [queryResult, setQueryResult] = useState<Record<string, unknown>[] | null>(null);
|
||||
const [queryError, setQueryError] = useState<string | null>(null);
|
||||
const [queryRunning, setQueryRunning] = useState(false);
|
||||
|
||||
// Schema panel toggle
|
||||
const [showSchema, setShowSchema] = useState(false);
|
||||
|
||||
// Fetch table list on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function introspect() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/workspace/db/introspect?path=${encodeURIComponent(dbPath)}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!cancelled) {
|
||||
setTables(data.tables ?? []);
|
||||
// Auto-select first table
|
||||
if (data.tables?.length > 0) {
|
||||
setSelectedTable(data.tables[0].table_name);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to introspect database");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {setLoading(false);}
|
||||
}
|
||||
}
|
||||
|
||||
introspect();
|
||||
return () => { cancelled = true; };
|
||||
}, [dbPath]);
|
||||
|
||||
// Fetch table data when selection or page changes
|
||||
const fetchTableData = useCallback(
|
||||
async (tableName: string, offset: number) => {
|
||||
setDataLoading(true);
|
||||
try {
|
||||
const safeName = tableName.replace(/"/g, '""');
|
||||
const sql = `SELECT * FROM "${safeName}" LIMIT ${pageSize} OFFSET ${offset}`;
|
||||
const res = await fetch("/api/workspace/db/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: dbPath, sql }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setTableData([]);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setTableData(data.rows ?? []);
|
||||
} catch {
|
||||
setTableData([]);
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
},
|
||||
[dbPath],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTable) {
|
||||
setSort(null);
|
||||
fetchTableData(selectedTable, page * pageSize);
|
||||
}
|
||||
}, [selectedTable, page, fetchTableData]);
|
||||
|
||||
// Run custom query
|
||||
const runQuery = useCallback(async () => {
|
||||
if (!sqlInput.trim()) {return;}
|
||||
setQueryRunning(true);
|
||||
setQueryError(null);
|
||||
setQueryResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/workspace/db/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: dbPath, sql: sqlInput }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setQueryError(data.error || `HTTP ${res.status}`);
|
||||
} else {
|
||||
setQueryResult(data.rows ?? []);
|
||||
}
|
||||
} catch (err) {
|
||||
setQueryError(err instanceof Error ? err.message : "Query failed");
|
||||
} finally {
|
||||
setQueryRunning(false);
|
||||
}
|
||||
}, [dbPath, sqlInput]);
|
||||
|
||||
// Get selected table info
|
||||
const selectedTableInfo = useMemo(
|
||||
() => tables.find((t) => t.table_name === selectedTable) ?? null,
|
||||
[tables, selectedTable],
|
||||
);
|
||||
|
||||
// Sort client-side
|
||||
const sortedData = useMemo(() => {
|
||||
const data = queryMode && queryResult ? queryResult : tableData;
|
||||
if (!sort) {return data;}
|
||||
return [...data].toSorted((a, b) => {
|
||||
const aVal = String(a[sort.column] ?? "");
|
||||
const bVal = String(b[sort.column] ?? "");
|
||||
const cmp = aVal.localeCompare(bVal, undefined, { numeric: true });
|
||||
return sort.direction === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [queryMode, queryResult, tableData, sort]);
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
setSort((prev) => {
|
||||
if (prev?.column === column) {
|
||||
return prev.direction === "asc"
|
||||
? { column, direction: "desc" }
|
||||
: null;
|
||||
}
|
||||
return { column, direction: "asc" };
|
||||
});
|
||||
};
|
||||
|
||||
// Derive columns from data
|
||||
const dataColumns = useMemo(() => {
|
||||
const data = queryMode && queryResult ? queryResult : tableData;
|
||||
if (data.length === 0) {return [];}
|
||||
return Object.keys(data[0]);
|
||||
}, [queryMode, queryResult, tableData]);
|
||||
|
||||
// Detect database engine from filename
|
||||
const dbEngine = useMemo(() => {
|
||||
const ext = filename.split(".").pop()?.toLowerCase();
|
||||
if (ext === "duckdb") {return "DuckDB";}
|
||||
if (ext === "sqlite" || ext === "sqlite3") {return "SQLite";}
|
||||
if (ext === "postgres") {return "PostgreSQL";}
|
||||
if (ext === "db") {return "Database";}
|
||||
return "Database";
|
||||
}, [filename]);
|
||||
|
||||
// --- Loading state ---
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full gap-3">
|
||||
<div
|
||||
className="w-5 h-5 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
Loading database...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Error state ---
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 p-8">
|
||||
<DatabaseIcon size={48} />
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
Failed to open database
|
||||
</p>
|
||||
<p
|
||||
className="text-xs px-3 py-2 rounded-lg max-w-md text-center"
|
||||
style={{ background: "var(--color-surface)", color: "#f87171" }}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Left panel: Table list */}
|
||||
<div
|
||||
className="w-56 flex-shrink-0 border-r flex flex-col overflow-hidden"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
{/* Database header */}
|
||||
<div className="px-3 py-3 border-b flex items-center gap-2" style={{ borderColor: "var(--color-border)" }}>
|
||||
<span style={{ color: "var(--color-accent)" }}>
|
||||
<DatabaseIcon size={18} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium truncate" style={{ color: "var(--color-text)" }}>
|
||||
{filename}
|
||||
</div>
|
||||
<div className="text-[10px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
{dbEngine} · {tables.length} table{tables.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table list */}
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{tables.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
No tables found
|
||||
</div>
|
||||
) : (
|
||||
tables.map((t) => {
|
||||
const isView = t.table_name.startsWith("v_");
|
||||
const isActive = selectedTable === t.table_name;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={t.table_name}
|
||||
onClick={() => {
|
||||
setSelectedTable(t.table_name);
|
||||
setPage(0);
|
||||
setQueryMode(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors duration-75 cursor-pointer"
|
||||
style={{
|
||||
background: isActive ? "var(--color-surface-hover)" : "transparent",
|
||||
color: isActive ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {(e.currentTarget as HTMLElement).style.background = "transparent";}
|
||||
}}
|
||||
>
|
||||
<span className="flex-shrink-0" style={{ color: isView ? "#60a5fa" : "var(--color-accent)" }}>
|
||||
{isView ? <ViewIcon /> : <TableIcon />}
|
||||
</span>
|
||||
<span className="truncate flex-1">{t.table_name}</span>
|
||||
<span className="flex-shrink-0 text-[10px] tabular-nums" style={{ color: "var(--color-text-muted)" }}>
|
||||
{formatRowCount(t.estimated_row_count)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Query mode toggle */}
|
||||
<div className="px-3 py-2 border-t" style={{ borderColor: "var(--color-border)" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQueryMode(!queryMode)}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors duration-100 cursor-pointer"
|
||||
style={{
|
||||
background: queryMode ? "rgba(232, 93, 58, 0.15)" : "var(--color-surface-hover)",
|
||||
color: queryMode ? "var(--color-accent)" : "var(--color-text-muted)",
|
||||
border: `1px solid ${queryMode ? "var(--color-accent)" : "var(--color-border)"}`,
|
||||
}}
|
||||
>
|
||||
<PlayIcon />
|
||||
SQL Query
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel: Data / Query */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{queryMode ? (
|
||||
<QueryPanel
|
||||
sqlInput={sqlInput}
|
||||
setSqlInput={setSqlInput}
|
||||
queryResult={queryResult}
|
||||
queryError={queryError}
|
||||
queryRunning={queryRunning}
|
||||
runQuery={runQuery}
|
||||
dataColumns={dataColumns}
|
||||
sortedData={sortedData}
|
||||
sort={sort}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
) : selectedTableInfo ? (
|
||||
<TableDataPanel
|
||||
table={selectedTableInfo}
|
||||
data={sortedData}
|
||||
dataLoading={dataLoading}
|
||||
dataColumns={dataColumns}
|
||||
sort={sort}
|
||||
onSort={handleSort}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
showSchema={showSchema}
|
||||
onToggleSchema={() => setShowSchema(!showSchema)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
Select a table to view its data
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Table Data Panel ---
|
||||
|
||||
function TableDataPanel({
|
||||
table,
|
||||
data,
|
||||
dataLoading,
|
||||
dataColumns,
|
||||
sort,
|
||||
onSort,
|
||||
page,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
showSchema,
|
||||
onToggleSchema,
|
||||
}: {
|
||||
table: TableInfo;
|
||||
data: Record<string, unknown>[];
|
||||
dataLoading: boolean;
|
||||
dataColumns: string[];
|
||||
sort: SortState;
|
||||
onSort: (col: string) => void;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
showSchema: boolean;
|
||||
onToggleSchema: () => void;
|
||||
}) {
|
||||
const totalRows = table.estimated_row_count;
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Table header bar */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-2.5 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<span style={{ color: "var(--color-accent)" }}>
|
||||
<TableIcon />
|
||||
</span>
|
||||
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{table.table_name}
|
||||
</span>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<span
|
||||
className="text-[10px] px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{table.estimated_row_count.toLocaleString()} rows
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{table.column_count} columns
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSchema}
|
||||
className="text-[10px] px-2 py-0.5 rounded-full cursor-pointer transition-colors duration-100"
|
||||
style={{
|
||||
background: showSchema ? "rgba(96, 165, 250, 0.15)" : "var(--color-surface)",
|
||||
color: showSchema ? "#60a5fa" : "var(--color-text-muted)",
|
||||
border: `1px solid ${showSchema ? "#60a5fa" : "var(--color-border)"}`,
|
||||
}}
|
||||
>
|
||||
Schema
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schema panel (collapsible) */}
|
||||
{showSchema && (
|
||||
<div
|
||||
className="px-4 py-3 border-b overflow-x-auto flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1.5">
|
||||
{table.columns.map((col) => {
|
||||
const display = typeDisplay(col.type);
|
||||
return (
|
||||
<div key={col.name} className="flex items-center gap-1.5 text-xs">
|
||||
<span style={{ color: "var(--color-text-muted)" }}>
|
||||
<ColumnIcon />
|
||||
</span>
|
||||
<span className="font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{col.name}
|
||||
</span>
|
||||
<span
|
||||
className="px-1 py-px rounded text-[10px]"
|
||||
style={{ background: `${display.color}18`, color: display.color }}
|
||||
>
|
||||
{display.label}
|
||||
</span>
|
||||
{col.is_nullable && (
|
||||
<span className="text-[10px]" style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>
|
||||
null
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data table */}
|
||||
<div className="flex-1 overflow-auto relative">
|
||||
{dataLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div
|
||||
className="w-5 h-5 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
|
||||
/>
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-2">
|
||||
<span style={{ color: "var(--color-text-muted)", opacity: 0.4 }}>
|
||||
<TableIcon />
|
||||
</span>
|
||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
No data
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={dataColumns}
|
||||
rows={data}
|
||||
sort={sort}
|
||||
onSort={onSort}
|
||||
schemaColumns={table.columns}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalRows > pageSize && (
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2 border-t flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
Page {page + 1} of {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === 0}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
className="p-1 rounded transition-colors duration-100 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<ChevronIcon direction="left" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
className="p-1 rounded transition-colors duration-100 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<ChevronIcon direction="right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Query Panel ---
|
||||
|
||||
function QueryPanel({
|
||||
sqlInput,
|
||||
setSqlInput,
|
||||
queryResult,
|
||||
queryError,
|
||||
queryRunning,
|
||||
runQuery,
|
||||
dataColumns,
|
||||
sortedData,
|
||||
sort,
|
||||
onSort,
|
||||
}: {
|
||||
sqlInput: string;
|
||||
setSqlInput: (v: string) => void;
|
||||
queryResult: Record<string, unknown>[] | null;
|
||||
queryError: string | null;
|
||||
queryRunning: boolean;
|
||||
runQuery: () => void;
|
||||
dataColumns: string[];
|
||||
sortedData: Record<string, unknown>[];
|
||||
sort: SortState;
|
||||
onSort: (col: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* SQL input */}
|
||||
<div
|
||||
className="px-4 py-3 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
value={sqlInput}
|
||||
onChange={(e) => setSqlInput(e.target.value)}
|
||||
placeholder="SELECT * FROM table_name LIMIT 100"
|
||||
className="w-full text-xs rounded-lg px-3 py-2 resize-none outline-none"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text)",
|
||||
border: "1px solid var(--color-border)",
|
||||
fontFamily: "'SF Mono', 'Fira Code', 'JetBrains Mono', monospace",
|
||||
minHeight: "60px",
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
runQuery();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="text-[10px] mt-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
Press Cmd+Enter to run
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runQuery}
|
||||
disabled={queryRunning || !sqlInput.trim()}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors duration-100 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: "var(--color-accent)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{queryRunning ? (
|
||||
<div
|
||||
className="w-3.5 h-3.5 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: "rgba(255,255,255,0.3)", borderTopColor: "white" }}
|
||||
/>
|
||||
) : (
|
||||
<PlayIcon />
|
||||
)}
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{queryError && (
|
||||
<div className="px-4 py-3">
|
||||
<div
|
||||
className="px-3 py-2 rounded-lg text-xs"
|
||||
style={{ background: "rgba(248, 113, 113, 0.1)", color: "#f87171", border: "1px solid rgba(248, 113, 113, 0.2)" }}
|
||||
>
|
||||
{queryError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queryResult !== null && queryResult.length === 0 && !queryError && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
Query returned no results
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queryResult !== null && queryResult.length > 0 && (
|
||||
<>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-[10px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
{queryResult.length} row{queryResult.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={dataColumns}
|
||||
rows={sortedData}
|
||||
sort={sort}
|
||||
onSort={onSort}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{queryResult === null && !queryError && (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<span style={{ color: "var(--color-text-muted)", opacity: 0.3 }}>
|
||||
<PlayIcon />
|
||||
</span>
|
||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
Write a SQL query and press Run
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Shared Data Table ---
|
||||
|
||||
function DataTable({
|
||||
columns,
|
||||
rows,
|
||||
sort,
|
||||
onSort,
|
||||
schemaColumns,
|
||||
}: {
|
||||
columns: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
sort: SortState;
|
||||
onSort: (col: string) => void;
|
||||
schemaColumns?: ColumnInfo[];
|
||||
}) {
|
||||
return (
|
||||
<table
|
||||
className="w-full text-xs"
|
||||
style={{ borderCollapse: "separate", borderSpacing: 0 }}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{/* Row number column */}
|
||||
<th
|
||||
className="text-right px-2 py-2 font-normal whitespace-nowrap border-b sticky top-0 z-[1]"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
width: "2.5rem",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
#
|
||||
</th>
|
||||
{columns.map((col) => {
|
||||
const schema = schemaColumns?.find((c) => c.name === col);
|
||||
const display = schema ? typeDisplay(schema.type) : null;
|
||||
return (
|
||||
<th
|
||||
key={col}
|
||||
className="text-left px-3 py-2 font-medium whitespace-nowrap border-b cursor-pointer select-none sticky top-0 z-[1]"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
}}
|
||||
onClick={() => onSort(col)}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{col}
|
||||
{display && (
|
||||
<span
|
||||
className="text-[9px] px-1 rounded"
|
||||
style={{ color: display.color, opacity: 0.6 }}
|
||||
>
|
||||
{display.label}
|
||||
</span>
|
||||
)}
|
||||
<SortIndicator
|
||||
active={sort?.column === col}
|
||||
direction={sort?.column === col ? sort.direction : "asc"}
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
className="transition-colors duration-75 group"
|
||||
style={{
|
||||
background: idx % 2 === 0 ? "transparent" : "var(--color-surface)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background =
|
||||
idx % 2 === 0 ? "transparent" : "var(--color-surface)";
|
||||
}}
|
||||
>
|
||||
{/* Row number */}
|
||||
<td
|
||||
className="text-right px-2 py-1.5 border-b tabular-nums"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
borderColor: "var(--color-border)",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
>
|
||||
{idx + 1}
|
||||
</td>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col}
|
||||
className="px-3 py-1.5 border-b whitespace-nowrap"
|
||||
style={{ borderColor: "var(--color-border)", color: "var(--color-text)" }}
|
||||
>
|
||||
<CellContent value={row[col]} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Cell content renderer ---
|
||||
|
||||
function CellContent({ value }: { value: unknown }) {
|
||||
if (value === null || value === undefined) {
|
||||
return (
|
||||
<span style={{ color: "var(--color-text-muted)", opacity: 0.4, fontStyle: "italic" }}>
|
||||
null
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return (
|
||||
<span style={{ color: value ? "#22c55e" : "#f87171" }}>
|
||||
{value ? "true" : "false"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return <span className="tabular-nums">{value}</span>;
|
||||
}
|
||||
|
||||
const str = String(value);
|
||||
|
||||
// Truncate very long values
|
||||
if (str.length > 120) {
|
||||
return (
|
||||
<span title={str} className="cursor-help">
|
||||
{str.slice(0, 120)}
|
||||
<span style={{ color: "var(--color-text-muted)" }}>...</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{str}</span>;
|
||||
}
|
||||
@ -5,7 +5,7 @@ import { useState, useCallback } from "react";
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "object" | "document" | "folder" | "file";
|
||||
type: "object" | "document" | "folder" | "file" | "database";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
@ -57,6 +57,16 @@ function FileIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function DatabaseIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
|
||||
<path d="M3 12A9 3 0 0 0 21 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
@ -88,6 +98,8 @@ function NodeIcon({ node, open }: { node: TreeNode; open?: boolean }) {
|
||||
return <DocumentIcon />;
|
||||
case "folder":
|
||||
return <FolderIcon open={open} />;
|
||||
case "database":
|
||||
return <DatabaseIcon />;
|
||||
default:
|
||||
return <FileIcon />;
|
||||
}
|
||||
@ -127,7 +139,9 @@ function TreeNodeItem({
|
||||
? "var(--color-accent)"
|
||||
: node.type === "document"
|
||||
? "#60a5fa"
|
||||
: "var(--color-text-muted)";
|
||||
: node.type === "database"
|
||||
? "#c084fc"
|
||||
: "var(--color-text-muted)";
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@ -8,6 +8,7 @@ import { ObjectTable } from "../components/workspace/object-table";
|
||||
import { ObjectKanban } from "../components/workspace/object-kanban";
|
||||
import { DocumentView } from "../components/workspace/document-view";
|
||||
import { FileViewer } from "../components/workspace/file-viewer";
|
||||
import { DatabaseViewer } from "../components/workspace/database-viewer";
|
||||
import { Breadcrumbs } from "../components/workspace/breadcrumbs";
|
||||
import { EmptyState } from "../components/workspace/empty-state";
|
||||
|
||||
@ -56,6 +57,7 @@ type ContentState =
|
||||
| { kind: "object"; data: ObjectData }
|
||||
| { kind: "document"; data: FileData; title: string }
|
||||
| { kind: "file"; data: FileData; filename: string }
|
||||
| { kind: "database"; dbPath: string; filename: string }
|
||||
| { kind: "directory"; node: TreeNode };
|
||||
|
||||
// --- Helpers ---
|
||||
@ -160,6 +162,9 @@ export default function WorkspacePage() {
|
||||
data,
|
||||
title: node.name.replace(/\.md$/, ""),
|
||||
});
|
||||
} else if (node.type === "database") {
|
||||
// Database files are handled entirely by the DatabaseViewer component
|
||||
setContent({ kind: "database", dbPath: node.path, filename: node.name });
|
||||
} else if (node.type === "file") {
|
||||
const res = await fetch(
|
||||
`/api/workspace/file?path=${encodeURIComponent(node.path)}`,
|
||||
@ -366,6 +371,14 @@ function ContentRenderer({
|
||||
/>
|
||||
);
|
||||
|
||||
case "database":
|
||||
return (
|
||||
<DatabaseViewer
|
||||
dbPath={content.dbPath}
|
||||
filename={content.filename}
|
||||
/>
|
||||
);
|
||||
|
||||
case "directory":
|
||||
return (
|
||||
<DirectoryListing
|
||||
@ -441,13 +454,17 @@ function DirectoryListing({
|
||||
? "rgba(232, 93, 58, 0.1)"
|
||||
: child.type === "document"
|
||||
? "rgba(96, 165, 250, 0.1)"
|
||||
: "var(--color-surface-hover)",
|
||||
: child.type === "database"
|
||||
? "rgba(192, 132, 252, 0.1)"
|
||||
: "var(--color-surface-hover)",
|
||||
color:
|
||||
child.type === "object"
|
||||
? "var(--color-accent)"
|
||||
: child.type === "document"
|
||||
? "#60a5fa"
|
||||
: "var(--color-text-muted)",
|
||||
: child.type === "database"
|
||||
? "#c084fc"
|
||||
: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<NodeTypeIcon type={child.type} />
|
||||
@ -640,6 +657,14 @@ function NodeTypeIcon({ type }: { type: string }) {
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
);
|
||||
case "database":
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
|
||||
<path d="M3 12A9 3 0 0 0 21 12" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
||||
@ -91,6 +91,49 @@ export function duckdbQuery<T = Record<string, unknown>>(
|
||||
}
|
||||
}
|
||||
|
||||
/** Database file extensions that trigger the database viewer. */
|
||||
export const DB_EXTENSIONS = new Set([
|
||||
"duckdb",
|
||||
"sqlite",
|
||||
"sqlite3",
|
||||
"db",
|
||||
"postgres",
|
||||
]);
|
||||
|
||||
/** Check whether a filename has a database extension. */
|
||||
export function isDatabaseFile(filename: string): boolean {
|
||||
const ext = filename.split(".").pop()?.toLowerCase();
|
||||
return ext ? DB_EXTENSIONS.has(ext) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a DuckDB query against an arbitrary database file and return parsed JSON rows.
|
||||
* This is used by the database viewer to introspect any .duckdb/.sqlite/.db file.
|
||||
*/
|
||||
export function duckdbQueryOnFile<T = Record<string, unknown>>(
|
||||
dbFilePath: string,
|
||||
sql: string,
|
||||
): T[] {
|
||||
const bin = resolveDuckdbBin();
|
||||
if (!bin) {return [];}
|
||||
|
||||
try {
|
||||
const escapedSql = sql.replace(/'/g, "'\\''");
|
||||
const result = execSync(`'${bin}' -json '${dbFilePath}' '${escapedSql}'`, {
|
||||
encoding: "utf-8",
|
||||
timeout: 15_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
shell: "/bin/sh",
|
||||
});
|
||||
|
||||
const trimmed = result.trim();
|
||||
if (!trimmed || trimmed === "[]") {return [];}
|
||||
return JSON.parse(trimmed) as T[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and resolve a path within the dench workspace.
|
||||
* Prevents path traversal by ensuring the resolved path stays within root.
|
||||
|
||||
@ -29,6 +29,74 @@ dench/
|
||||
WORKSPACE.md # Auto-generated schema summary
|
||||
```
|
||||
|
||||
## .object.yaml Format
|
||||
|
||||
Every object directory MUST contain a `.object.yaml` file. This is a lightweight metadata projection that the sidebar reads. Generate it from DuckDB after creating or modifying any object.
|
||||
|
||||
Template:
|
||||
|
||||
```yaml
|
||||
id: "<object_id from DuckDB>"
|
||||
name: "<object_name>"
|
||||
description: "<object_description>"
|
||||
icon: "<lucide_icon_name>"
|
||||
default_view: "<table|kanban>"
|
||||
entry_count: <number>
|
||||
fields:
|
||||
- name: "Full Name"
|
||||
type: text
|
||||
required: true
|
||||
- name: "Email Address"
|
||||
type: email
|
||||
required: true
|
||||
- name: "Status"
|
||||
type: enum
|
||||
values: ["New", "Contacted", "Qualified", "Converted"]
|
||||
- name: "Assigned To"
|
||||
type: user
|
||||
```
|
||||
|
||||
Generate by querying DuckDB then writing the file:
|
||||
|
||||
```bash
|
||||
# 1. Query object + fields from DuckDB
|
||||
duckdb dench/workspace.duckdb -json "
|
||||
SELECT o.id, o.name, o.description, o.icon, o.default_view,
|
||||
(SELECT COUNT(*) FROM entries WHERE object_id = o.id) as entry_count
|
||||
FROM objects o WHERE o.name = 'lead'
|
||||
"
|
||||
duckdb dench/workspace.duckdb -json "
|
||||
SELECT name, type, required, enum_values FROM fields
|
||||
WHERE object_id = (SELECT id FROM objects WHERE name = 'lead')
|
||||
ORDER BY sort_order
|
||||
"
|
||||
|
||||
# 2. Write .object.yaml from the query results
|
||||
mkdir -p dench/knowledge/lead
|
||||
cat > dench/knowledge/lead/.object.yaml << 'YAML'
|
||||
id: "AbCdEfGh..."
|
||||
name: "lead"
|
||||
description: "Sales leads tracking"
|
||||
icon: "user-plus"
|
||||
default_view: "table"
|
||||
entry_count: 20
|
||||
fields:
|
||||
- name: "Full Name"
|
||||
type: text
|
||||
required: true
|
||||
- name: "Email Address"
|
||||
type: email
|
||||
required: true
|
||||
- name: "Status"
|
||||
type: enum
|
||||
values: ["New", "Contacted", "Qualified", "Converted"]
|
||||
- name: "Score"
|
||||
type: number
|
||||
- name: "Notes"
|
||||
type: richtext
|
||||
YAML
|
||||
```
|
||||
|
||||
## Startup
|
||||
|
||||
On every conversation:
|
||||
@ -268,17 +336,19 @@ COPY (SELECT * FROM v_leads) TO 'dench/exports/leads.csv' (HEADER true);
|
||||
|
||||
## Full Workflow: Create CRM Structure in One Shot
|
||||
|
||||
Batch everything in a single exec call:
|
||||
EVERY object creation MUST complete ALL THREE steps below. Never stop after the SQL.
|
||||
|
||||
**Step 1 — SQL: Create object + fields + view** (single exec call):
|
||||
|
||||
```sql
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- 1. Create object
|
||||
-- 1a. Create object
|
||||
INSERT INTO objects (name, description, icon, default_view)
|
||||
VALUES ('lead', 'Sales leads tracking', 'user-plus', 'table')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- 2. Create all fields
|
||||
-- 1b. Create all fields
|
||||
INSERT INTO fields (object_id, name, type, required, sort_order) VALUES
|
||||
((SELECT id FROM objects WHERE name = 'lead'), 'Full Name', 'text', true, 0),
|
||||
((SELECT id FROM objects WHERE name = 'lead'), 'Email Address', 'email', true, 1),
|
||||
@ -295,7 +365,7 @@ INSERT INTO fields (object_id, name, type, enum_values, enum_colors, sort_order)
|
||||
'["Website","Referral","Cold Call","Social"]'::JSON, NULL, 5)
|
||||
ON CONFLICT (object_id, name) DO NOTHING;
|
||||
|
||||
-- 3. Auto-generate PIVOT view
|
||||
-- 1c. MANDATORY: auto-generate PIVOT view
|
||||
CREATE OR REPLACE VIEW v_lead AS
|
||||
PIVOT (
|
||||
SELECT e.id as entry_id, e.created_at, e.updated_at,
|
||||
@ -309,11 +379,58 @@ PIVOT (
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
Then project the filesystem: `mkdir -p dench/knowledge/lead` and write `.object.yaml`.
|
||||
**Step 2 — Filesystem: Create object directory + .object.yaml** (exec call):
|
||||
|
||||
```bash
|
||||
mkdir -p dench/knowledge/lead
|
||||
|
||||
# Query the object metadata from DuckDB to build .object.yaml
|
||||
OBJ_ID=$(duckdb dench/workspace.duckdb -noheader -list "SELECT id FROM objects WHERE name = 'lead'")
|
||||
ENTRY_COUNT=$(duckdb dench/workspace.duckdb -noheader -list "SELECT COUNT(*) FROM entries WHERE object_id = '$OBJ_ID'")
|
||||
|
||||
cat > dench/knowledge/lead/.object.yaml << 'YAML'
|
||||
id: "<use actual $OBJ_ID>"
|
||||
name: "lead"
|
||||
description: "Sales leads tracking"
|
||||
icon: "user-plus"
|
||||
default_view: "table"
|
||||
entry_count: <use actual $ENTRY_COUNT>
|
||||
fields:
|
||||
- name: "Full Name"
|
||||
type: text
|
||||
required: true
|
||||
- name: "Email Address"
|
||||
type: email
|
||||
required: true
|
||||
- name: "Phone Number"
|
||||
type: phone
|
||||
- name: "Status"
|
||||
type: enum
|
||||
values: ["New", "Contacted", "Qualified", "Converted"]
|
||||
- name: "Score"
|
||||
type: number
|
||||
- name: "Source"
|
||||
type: enum
|
||||
values: ["Website", "Referral", "Cold Call", "Social"]
|
||||
- name: "Notes"
|
||||
type: richtext
|
||||
YAML
|
||||
```
|
||||
|
||||
**Step 3 — Verify**: Confirm both the view and filesystem exist:
|
||||
|
||||
```bash
|
||||
# Verify view works
|
||||
duckdb dench/workspace.duckdb "SELECT COUNT(*) FROM v_lead"
|
||||
# Verify .object.yaml exists
|
||||
cat dench/knowledge/lead/.object.yaml
|
||||
```
|
||||
|
||||
## Kanban Boards
|
||||
|
||||
When creating task/board objects, use `default_view = 'kanban'` and auto-create Status + Assigned To fields:
|
||||
When creating task/board objects, use `default_view = 'kanban'` and auto-create Status + Assigned To fields. Remember: ALL THREE STEPS are required.
|
||||
|
||||
**Step 1 — SQL:**
|
||||
|
||||
```sql
|
||||
BEGIN TRANSACTION;
|
||||
@ -353,6 +470,28 @@ PIVOT (
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
**Step 2 — Filesystem (MANDATORY):**
|
||||
|
||||
```bash
|
||||
mkdir -p dench/knowledge/task
|
||||
cat > dench/knowledge/task/.object.yaml << 'YAML'
|
||||
id: "<query from DuckDB>"
|
||||
name: "task"
|
||||
description: "Task tracking board"
|
||||
icon: "check-square"
|
||||
default_view: "kanban"
|
||||
entry_count: 0
|
||||
fields:
|
||||
- name: "Status"
|
||||
type: enum
|
||||
values: ["In Queue", "In Progress", "Done"]
|
||||
- name: "Assigned To"
|
||||
type: user
|
||||
YAML
|
||||
```
|
||||
|
||||
**Step 3 — Verify:** `duckdb dench/workspace.duckdb "SELECT COUNT(*) FROM v_task"` and `cat dench/knowledge/task/.object.yaml`.
|
||||
|
||||
## Field Types Reference
|
||||
|
||||
| Type | Description | Storage | Query Cast |
|
||||
@ -444,17 +583,41 @@ VALUES ('Roadmap', 'map', 'projects/roadmap.md', '<parent_doc_id>', 0);
|
||||
- Field type change: warn user before changing type on field with existing data.
|
||||
- Missing required fields: validate before INSERT, report which fields are missing.
|
||||
|
||||
## Post-Mutation Pipeline
|
||||
## Post-Mutation Checklist (MANDATORY)
|
||||
|
||||
After any schema mutation (create/update/delete object, field, or document):
|
||||
You MUST complete ALL steps below after ANY schema mutation (create/update/delete object, field, or entry). Do NOT skip any step. Do NOT consider the operation complete until all steps are done.
|
||||
|
||||
1. **Regenerate views**: `CREATE OR REPLACE VIEW v_{object}` for affected objects
|
||||
2. **Project filesystem**: Sync `dench/knowledge/` directory structure from DuckDB (mkdir/rmdir for objects, write `.object.yaml`, move `.md` files if nesting changed)
|
||||
3. **Regenerate WORKSPACE.md**: Human-readable summary of all objects, fields, entry counts
|
||||
### After creating or modifying an OBJECT or its FIELDS:
|
||||
|
||||
- [ ] `CREATE OR REPLACE VIEW v_{object_name}` — regenerate the PIVOT view
|
||||
- [ ] `mkdir -p dench/knowledge/{object_name}/` — create the object directory
|
||||
- [ ] Write `dench/knowledge/{object_name}/.object.yaml` — metadata projection with id, name, description, icon, default_view, entry_count, and full field list
|
||||
- [ ] If object has a `parent_document_id`, place directory inside the parent document's directory
|
||||
- [ ] Update `WORKSPACE.md` if it exists
|
||||
|
||||
### After adding or updating ENTRIES:
|
||||
|
||||
- [ ] Update `entry_count` in the corresponding `.object.yaml`
|
||||
- [ ] Verify the view returns correct data: `SELECT * FROM v_{object} LIMIT 5`
|
||||
|
||||
### After deleting an OBJECT:
|
||||
|
||||
- [ ] `DROP VIEW IF EXISTS v_{object_name}` — remove the view
|
||||
- [ ] `rm -rf dench/knowledge/{object_name}/` — remove the directory (unless it contains nested documents that need relocating)
|
||||
- [ ] Update `WORKSPACE.md`
|
||||
|
||||
### After creating or modifying a DOCUMENT:
|
||||
|
||||
- [ ] Write the `.md` file to the correct path in `dench/knowledge/`
|
||||
- [ ] `INSERT INTO documents` — ensure metadata row exists with correct `file_path`, `parent_id`, or `parent_object_id`
|
||||
|
||||
These steps ensure the filesystem always mirrors DuckDB. The sidebar depends on `.object.yaml` files — if they are missing, objects will not appear.
|
||||
|
||||
## Critical Reminders
|
||||
|
||||
- Handle the ENTIRE CRM operation from analysis to SQL execution to summary
|
||||
- Handle the ENTIRE CRM operation from analysis to SQL execution to filesystem projection to summary
|
||||
- **NEVER SKIP FILESYSTEM PROJECTION**: After creating/modifying any object, you MUST create/update `dench/knowledge/{object}/.object.yaml` AND the `v_{object}` view. If you skip this, the object will be invisible in the sidebar. This is NOT optional.
|
||||
- **THREE STEPS, EVERY TIME**: (1) SQL transaction, (2) filesystem projection (.object.yaml + directory), (3) verify. An operation is NOT complete until all three are done.
|
||||
- Always check existing data before creating (`SELECT` before `INSERT`, or `ON CONFLICT`)
|
||||
- Use views (`v_{object}`) for all reads — never write raw PIVOT queries for search
|
||||
- Never assume field names — verify with `SELECT * FROM fields WHERE object_id = ?`
|
||||
@ -468,3 +631,4 @@ After any schema mutation (create/update/delete object, field, or document):
|
||||
- **ONE EXEC CALL**: Batch related SQL in a single transaction — this is the whole point
|
||||
- **workspace_context.yaml**: READ-ONLY. Never modify. Data flows from Dench UI only.
|
||||
- **Source of truth**: DuckDB for all structured data. Filesystem for document content and navigation tree. Never duplicate entry data to the filesystem.
|
||||
- **ENTRY COUNT**: After adding entries, update `entry_count` in `.object.yaml`.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user