mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 02:45:07 +03:00
explorer prototype
This commit is contained in:
1 parent
1123a74813
commit
0eeef6982f
22 files changed
+3073
No files matched your search
@@ -314,6 +314,7 @@ These are the layout-only TanStack files. Most hold a single product layout comp
|
||||
- [x] A `routes/project/$ref/sql/$id.tsx` ← `pages/project/[ref]/sql/[id].tsx`
|
||||
- [x] A `routes/project/$ref/sql/templates.tsx` ← `pages/project/[ref]/sql/templates.tsx`
|
||||
- [x] A `routes/project/$ref/sql/quickstarts.tsx` ← `pages/project/[ref]/sql/quickstarts.tsx`
|
||||
- [ ] `routes/project/$ref/explorer-prototype.tsx` ← `pages/project/[ref]/explorer-prototype.tsx` — **intentionally deferred.** Throwaway Explorer/Notebooks reference prototype; it runs on the Next runtime (the default) only. Adding the mirror requires `routeTree.gen.ts` to be regenerated by the Vite plugin, so create the route while running `vite dev` if you need it under TanStack. Delete this entry and the page together when the real Explorer lands.
|
||||
|
||||
### Project shell — `/editor/*`
|
||||
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* PROTOTYPE — seed data and a fake query runner.
|
||||
*
|
||||
* `runMockQuery` stands in for PR E2's `useQuerySourceRun`. It has the same
|
||||
* shape as the real thing (source in, rows out, latency, occasional failure)
|
||||
* so the surfaces around it are wired the way they will be for real.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CellSource,
|
||||
ChatSession,
|
||||
NotebookContent,
|
||||
RecentItem,
|
||||
ResultRow,
|
||||
SnippetDoc,
|
||||
Source,
|
||||
Tab,
|
||||
} from './ExplorerPrototype.types'
|
||||
|
||||
/** Sources are declared by the application, once. Cells only reference them by id. */
|
||||
export const SOURCES: Source[] = [
|
||||
{ id: 'database', type: 'database', endpoint: '/platform/pg-meta/{ref}/query' },
|
||||
{
|
||||
id: 'logs',
|
||||
type: 'logs',
|
||||
endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all.otel',
|
||||
},
|
||||
]
|
||||
|
||||
export const MOCK_REPLICAS = [
|
||||
{ identifier: 'primary', label: 'Primary database' },
|
||||
{ identifier: 'replica-eu-west-1', label: 'Read replica — eu-west-1' },
|
||||
{ identifier: 'replica-us-east-1', label: 'Read replica — us-east-1' },
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed documents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const INITIAL_NOTEBOOKS: Record<string, NotebookContent> = {
|
||||
'nb-auth-health': {
|
||||
schema_version: 1,
|
||||
settings: { run_mode: 'manual', default_row_limit: 100 },
|
||||
cells: [
|
||||
{
|
||||
id: 'cell-heading',
|
||||
type: 'markdown',
|
||||
markdown:
|
||||
'## Authentication health\n\nRun the cells below to check signup volume and recent auth failures. Cells run top to bottom.',
|
||||
},
|
||||
{
|
||||
id: 'cell-signups',
|
||||
type: 'query',
|
||||
name: 'Signups by day',
|
||||
query: {
|
||||
type: 'inline',
|
||||
source: { id: 'database', parameters: { identifier: 'primary' } },
|
||||
sql: "select date_trunc('day', created_at)::date as day,\n count(*) as signups,\n count(*) filter (where email_confirmed_at is not null) as confirmed\nfrom auth.users\ngroup by 1\norder by 1",
|
||||
},
|
||||
display: {
|
||||
type: 'chart',
|
||||
chart: {
|
||||
type: 'line',
|
||||
x_axis: { field: 'day' },
|
||||
series: [
|
||||
{ field: 'signups', label: 'Signups' },
|
||||
{ field: 'confirmed', label: 'Confirmed' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cell-auth-errors',
|
||||
type: 'query',
|
||||
name: 'Recent authentication errors',
|
||||
query: {
|
||||
type: 'inline',
|
||||
source: {
|
||||
id: 'logs',
|
||||
parameters: { time_range: { type: 'relative', amount: 24, unit: 'hour' } },
|
||||
},
|
||||
sql: "select timestamp, event_message\nfrom logs\nwhere event_message ilike '%error%'\norder by timestamp desc",
|
||||
},
|
||||
display: { type: 'table' },
|
||||
execution: { row_limit: 50 },
|
||||
},
|
||||
{
|
||||
id: 'cell-active-users',
|
||||
type: 'query',
|
||||
name: 'Active database users',
|
||||
query: {
|
||||
type: 'inline',
|
||||
source: { id: 'database', parameters: { identifier: 'primary' } },
|
||||
sql: 'select id, email, created_at\nfrom auth.users\nwhere deleted_at is null\norder by created_at desc',
|
||||
},
|
||||
display: { type: 'table' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const INITIAL_SNIPPETS: Record<string, SnippetDoc> = {
|
||||
'snip-slow-queries': {
|
||||
id: 'snip-slow-queries',
|
||||
name: 'Slowest queries',
|
||||
contentType: 'sql',
|
||||
sql: 'select calls, mean_exec_time, query\nfrom pg_stat_statements\norder by mean_exec_time desc\nlimit 20',
|
||||
display: { type: 'table' },
|
||||
},
|
||||
'snip-edge-errors': {
|
||||
id: 'snip-edge-errors',
|
||||
name: 'Edge errors by hour',
|
||||
contentType: 'log_sql',
|
||||
sql: 'select toStartOfHour(timestamp) as hour, count(*) as errors\nfrom logs\nwhere status_code >= 500\ngroup by hour\norder by hour',
|
||||
display: {
|
||||
type: 'chart',
|
||||
chart: {
|
||||
type: 'bar',
|
||||
x_axis: { field: 'hour' },
|
||||
series: [{ field: 'errors', label: 'Errors' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const INITIAL_CHATS: Record<string, ChatSession> = {
|
||||
'chat-1': {
|
||||
id: 'chat-1',
|
||||
name: 'Debugging signups',
|
||||
messages: [
|
||||
{ id: 'm1', role: 'user', text: 'Why did signups drop off last week?' },
|
||||
{
|
||||
id: 'm2',
|
||||
role: 'assistant',
|
||||
text: "Let's start by looking at daily signups alongside confirmation rate. I can run this for you:",
|
||||
},
|
||||
{
|
||||
id: 'm3',
|
||||
role: 'assistant',
|
||||
approval: 'pending',
|
||||
cell: {
|
||||
id: 'agent-cell-1',
|
||||
type: 'query',
|
||||
name: 'Signups vs confirmations, last 14 days',
|
||||
query: {
|
||||
type: 'inline',
|
||||
source: { id: 'database', parameters: { identifier: 'primary' } },
|
||||
sql: "select date_trunc('day', created_at)::date as day,\n count(*) as signups,\n count(*) filter (where email_confirmed_at is not null) as confirmed\nfrom auth.users\nwhere created_at > now() - interval '14 days'\ngroup by 1\norder by 1",
|
||||
},
|
||||
display: {
|
||||
type: 'chart',
|
||||
chart: {
|
||||
type: 'bar',
|
||||
x_axis: { field: 'day' },
|
||||
series: [
|
||||
{ field: 'signups', label: 'Signups' },
|
||||
{ field: 'confirmed', label: 'Confirmed' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const INITIAL_TABS: Tab[] = [
|
||||
{
|
||||
id: 'tab-nb',
|
||||
title: 'Authentication health',
|
||||
resource: { type: 'notebook', id: 'nb-auth-health' },
|
||||
},
|
||||
{
|
||||
id: 'tab-snip',
|
||||
title: 'Slowest queries',
|
||||
resource: { type: 'snippet', id: 'snip-slow-queries' },
|
||||
},
|
||||
{ id: 'tab-chat', title: 'Debugging signups', resource: { type: 'chat', id: 'chat-1' } },
|
||||
]
|
||||
|
||||
/** Seeded so the "Recent" group isn't empty on first load. Mixed across types. */
|
||||
export const INITIAL_RECENT_ITEMS: RecentItem[] = [
|
||||
{
|
||||
resource: { type: 'notebook', id: 'nb-auth-health' },
|
||||
modifiedAt: Date.now() - 4 * 60_000,
|
||||
},
|
||||
{
|
||||
resource: { type: 'chat', id: 'chat-1' },
|
||||
modifiedAt: Date.now() - 26 * 60_000,
|
||||
},
|
||||
{
|
||||
resource: { type: 'snippet', id: 'snip-slow-queries' },
|
||||
modifiedAt: Date.now() - 3 * 3_600_000,
|
||||
},
|
||||
{
|
||||
resource: { type: 'snippet', id: 'snip-edge-errors' },
|
||||
modifiedAt: Date.now() - 2 * 86_400_000,
|
||||
},
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DAYS = 14
|
||||
|
||||
const buildSignupRows = (): ResultRow[] =>
|
||||
Array.from({ length: DAYS }, (_, index) => {
|
||||
const date = new Date(Date.now() - (DAYS - 1 - index) * 86_400_000)
|
||||
const signups = Math.round(120 + Math.sin(index / 2) * 40 + index * 4)
|
||||
return {
|
||||
day: date.toISOString().slice(0, 10),
|
||||
signups,
|
||||
confirmed: Math.round(signups * (0.62 + (index % 3) * 0.05)),
|
||||
}
|
||||
})
|
||||
|
||||
const buildLogRows = (): ResultRow[] =>
|
||||
Array.from({ length: 24 }, (_, index) => {
|
||||
const stamp = new Date(Date.now() - index * 3_600_000)
|
||||
return {
|
||||
timestamp: stamp.toISOString(),
|
||||
event_message: [
|
||||
'AuthApiError: Invalid login credentials',
|
||||
'AuthApiError: Email rate limit exceeded',
|
||||
'error: token has expired or is invalid',
|
||||
][index % 3],
|
||||
}
|
||||
})
|
||||
|
||||
const buildHourlyErrorRows = (): ResultRow[] =>
|
||||
Array.from({ length: 24 }, (_, index) => {
|
||||
const stamp = new Date(Date.now() - (23 - index) * 3_600_000)
|
||||
return {
|
||||
hour: stamp.toISOString().slice(0, 13).replace('T', ' ') + ':00',
|
||||
errors: Math.max(0, Math.round(18 + Math.sin(index / 3) * 12 - (index % 5))),
|
||||
}
|
||||
})
|
||||
|
||||
const buildUserRows = (): ResultRow[] =>
|
||||
Array.from({ length: 40 }, (_, index) => ({
|
||||
id: `8f14e45f-ceea-467a-9f1a-${String(index).padStart(12, '0')}`,
|
||||
email: `user${index + 1}@example.com`,
|
||||
created_at: new Date(Date.now() - index * 5_400_000).toISOString(),
|
||||
}))
|
||||
|
||||
const buildSlowQueryRows = (): ResultRow[] => [
|
||||
{ calls: 12_408, mean_exec_time: 812.4, query: 'select * from orders where customer_id = $1' },
|
||||
{
|
||||
calls: 3_201,
|
||||
mean_exec_time: 431.9,
|
||||
query: 'select count(*) from events where created_at > $1',
|
||||
},
|
||||
{
|
||||
calls: 890,
|
||||
mean_exec_time: 288.1,
|
||||
query: 'update inventory set stock = stock - $1 where id = $2',
|
||||
},
|
||||
{ calls: 22_910, mean_exec_time: 96.3, query: 'select id, email from auth.users where id = $1' },
|
||||
]
|
||||
|
||||
export const isWriteQuery = (sql: string) =>
|
||||
/^\s*(insert|update|delete|drop|alter|truncate|create)\b/i.test(sql)
|
||||
|
||||
export type MockRunOutcome =
|
||||
| { status: 'success'; rows: ResultRow[] }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
/** Picks canned rows by keyword. Stands in for a real endpoint call. */
|
||||
export const runMockQuery = async ({
|
||||
source,
|
||||
sql,
|
||||
rowLimit,
|
||||
}: {
|
||||
source: CellSource
|
||||
sql: string
|
||||
rowLimit: number
|
||||
}): Promise<MockRunOutcome> => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 420))
|
||||
|
||||
const normalized = sql.toLowerCase()
|
||||
|
||||
if (normalized.includes('nonexistent_table')) {
|
||||
return { status: 'error', message: 'relation "nonexistent_table" does not exist' }
|
||||
}
|
||||
|
||||
if (source.id === 'logs') {
|
||||
const rows = normalized.includes('count(') ? buildHourlyErrorRows() : buildLogRows()
|
||||
return { status: 'success', rows: rows.slice(0, rowLimit) }
|
||||
}
|
||||
|
||||
if (normalized.includes('pg_stat_statements')) {
|
||||
return { status: 'success', rows: buildSlowQueryRows().slice(0, rowLimit) }
|
||||
}
|
||||
if (normalized.includes('group by')) {
|
||||
return { status: 'success', rows: buildSignupRows().slice(0, rowLimit) }
|
||||
}
|
||||
return { status: 'success', rows: buildUserRows().slice(0, rowLimit) }
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { act, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
INITIAL_CHATS,
|
||||
INITIAL_NOTEBOOKS,
|
||||
INITIAL_RECENT_ITEMS,
|
||||
INITIAL_SNIPPETS,
|
||||
isWriteQuery,
|
||||
runMockQuery,
|
||||
} from './ExplorerPrototype.mocks'
|
||||
import type {
|
||||
CellResultState,
|
||||
QueryCellModel,
|
||||
RecentItem,
|
||||
SnippetDoc,
|
||||
} from './ExplorerPrototype.types'
|
||||
import { ExplorerSidebar } from './ExplorerSidebar'
|
||||
import { applyCumulative } from './QueryCellDisplay'
|
||||
import {
|
||||
resolveEffectiveRowLimit,
|
||||
resolveEffectiveRunMode,
|
||||
useExplorerPrototypeState,
|
||||
} from './useExplorerPrototypeState'
|
||||
import { ChatView } from './views/ChatView'
|
||||
import { queryCellToSnippet, snippetToQueryCell } from './views/snippetAdapter'
|
||||
import { customRender, customRenderHook } from '@/tests/lib/custom-render'
|
||||
|
||||
describe('snippet adapter', () => {
|
||||
const snippet: SnippetDoc = {
|
||||
id: 'snip-1',
|
||||
name: 'Slowest queries',
|
||||
contentType: 'sql',
|
||||
sql: 'select 1',
|
||||
display: { type: 'table' },
|
||||
}
|
||||
|
||||
it('maps a database snippet to a one-cell query model and back unchanged', () => {
|
||||
const cell = snippetToQueryCell(snippet)
|
||||
|
||||
expect(cell.type).toBe('query')
|
||||
expect(cell.query.type).toBe('inline')
|
||||
expect(cell.query.source.id).toBe('database')
|
||||
expect(queryCellToSnippet(cell, snippet)).toEqual(snippet)
|
||||
})
|
||||
|
||||
it('maps a logs snippet onto the logs source with a default time range', () => {
|
||||
const logsSnippet: SnippetDoc = { ...snippet, contentType: 'log_sql' }
|
||||
const cell = snippetToQueryCell(logsSnippet)
|
||||
|
||||
expect(cell.query.source.id).toBe('logs')
|
||||
expect(queryCellToSnippet(cell, logsSnippet)).toEqual(logsSnippet)
|
||||
})
|
||||
|
||||
it('writes the content type back from the chosen source', () => {
|
||||
const cell = snippetToQueryCell(snippet)
|
||||
const switched: QueryCellModel = {
|
||||
...cell,
|
||||
query: {
|
||||
...cell.query,
|
||||
source: {
|
||||
id: 'logs',
|
||||
parameters: { time_range: { type: 'relative', amount: 1, unit: 'hour' } },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(queryCellToSnippet(switched, snippet).contentType).toBe('log_sql')
|
||||
})
|
||||
})
|
||||
|
||||
describe('run mode and row limit resolution', () => {
|
||||
const settings = { run_mode: 'manual', default_row_limit: 100 } as const
|
||||
const cell: QueryCellModel = {
|
||||
id: 'cell-1',
|
||||
type: 'query',
|
||||
name: 'Test',
|
||||
query: {
|
||||
type: 'inline',
|
||||
source: { id: 'database', parameters: {} },
|
||||
sql: 'select 1',
|
||||
},
|
||||
display: { type: 'table' },
|
||||
}
|
||||
|
||||
it('falls back to the notebook settings when the cell has no override', () => {
|
||||
expect(resolveEffectiveRunMode(cell, settings)).toBe('manual')
|
||||
expect(resolveEffectiveRowLimit(cell, settings)).toBe(100)
|
||||
})
|
||||
|
||||
it('prefers the cell override', () => {
|
||||
const overridden: QueryCellModel = {
|
||||
...cell,
|
||||
execution: { run_mode: 'on_open', row_limit: 50 },
|
||||
}
|
||||
|
||||
expect(resolveEffectiveRunMode(overridden, settings)).toBe('on_open')
|
||||
expect(resolveEffectiveRowLimit(overridden, settings)).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('write query detection', () => {
|
||||
it.each([
|
||||
['select * from users', false],
|
||||
[' select 1', false],
|
||||
['insert into users values (1)', true],
|
||||
['UPDATE users set name = 1', true],
|
||||
['delete from users', true],
|
||||
['drop table users', true],
|
||||
])('%s → isWrite %s', (sql, expected) => {
|
||||
expect(isWriteQuery(sql)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cumulative display transform', () => {
|
||||
it('accumulates each series independently', () => {
|
||||
const rows = [
|
||||
{ day: '1', a: 1, b: 10 },
|
||||
{ day: '2', a: 2, b: 20 },
|
||||
{ day: '3', a: 3, b: 30 },
|
||||
]
|
||||
|
||||
expect(applyCumulative(rows, ['a', 'b'])).toEqual([
|
||||
{ day: '1', a: 1, b: 10 },
|
||||
{ day: '2', a: 3, b: 30 },
|
||||
{ day: '3', a: 6, b: 60 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mock query runner honors the source and row limit', () => {
|
||||
it('limits returned rows', async () => {
|
||||
const outcome = await runMockQuery({
|
||||
source: { id: 'database', parameters: { identifier: 'primary' } },
|
||||
sql: 'select id, email from auth.users',
|
||||
rowLimit: 5,
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe('success')
|
||||
if (outcome.status === 'success') expect(outcome.rows).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('returns log-shaped rows for the logs source', async () => {
|
||||
const outcome = await runMockQuery({
|
||||
source: {
|
||||
id: 'logs',
|
||||
parameters: { time_range: { type: 'relative', amount: 1, unit: 'hour' } },
|
||||
},
|
||||
sql: 'select timestamp, event_message from logs',
|
||||
rowLimit: 3,
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe('success')
|
||||
if (outcome.status === 'success') {
|
||||
expect(Object.keys(outcome.rows[0])).toEqual(['timestamp', 'event_message'])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The chat surface renders the shared QueryCell read-only, so this also proves
|
||||
* the component mounts and the approval-gated run path produces results.
|
||||
*/
|
||||
const ChatHarness = () => {
|
||||
const [chat, setChat] = useState(INITIAL_CHATS['chat-1'])
|
||||
const [results, setResults] = useState<Record<string, CellResultState>>({})
|
||||
|
||||
const approve = async (messageId: string, cell: QueryCellModel) => {
|
||||
setChat((current) => ({
|
||||
...current,
|
||||
messages: current.messages.map((message) =>
|
||||
message.id === messageId && 'approval' in message
|
||||
? { ...message, approval: 'approved' as const }
|
||||
: message
|
||||
),
|
||||
}))
|
||||
setResults((current) => ({ ...current, [cell.id]: { status: 'running' } }))
|
||||
const outcome = await runMockQuery({
|
||||
source: cell.query.source,
|
||||
sql: cell.query.sql,
|
||||
rowLimit: 100,
|
||||
})
|
||||
setResults((current) => ({
|
||||
...current,
|
||||
[cell.id]:
|
||||
outcome.status === 'success'
|
||||
? { status: 'success', rows: outcome.rows, ranAt: '', rowLimitApplied: 100 }
|
||||
: { status: 'error', message: outcome.message },
|
||||
}))
|
||||
}
|
||||
|
||||
const deny = (messageId: string) =>
|
||||
setChat((current) => ({
|
||||
...current,
|
||||
messages: current.messages.map((message) =>
|
||||
message.id === messageId && 'approval' in message
|
||||
? { ...message, approval: 'denied' as const }
|
||||
: message
|
||||
),
|
||||
}))
|
||||
|
||||
return <ChatView chat={chat} results={results} onApprove={approve} onDeny={deny} />
|
||||
}
|
||||
|
||||
describe('agent chat surface', () => {
|
||||
it('renders the agent query block with an approval prompt and no editable SQL', async () => {
|
||||
customRender(<ChatHarness />)
|
||||
|
||||
expect(await screen.findByText('The Assistant wants to run this query.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Signups vs confirmations, last 14 days')).toBeInTheDocument()
|
||||
// Read-only surface: the cell name is text, not an input.
|
||||
expect(screen.queryByLabelText('Cell name')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('runs the query once approved and reports the row count', async () => {
|
||||
const user = userEvent.setup()
|
||||
customRender(<ChatHarness />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Run query' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/14 rows · limit 100/)).toBeInTheDocument(), {
|
||||
timeout: 3000,
|
||||
})
|
||||
expect(
|
||||
screen.getByText(/data sharing is off, so rows are not sent to the Assistant/)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not run the query when skipped', async () => {
|
||||
const user = userEvent.setup()
|
||||
customRender(<ChatHarness />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Skip' }))
|
||||
|
||||
expect(await screen.findByText('Skipped — the query was not run.')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/rows · limit/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Both drill-down levels stay mounted inside the sliding track, so these assert
|
||||
* through role queries — the off-screen level is `aria-hidden` and therefore
|
||||
* absent from the accessibility tree that `getByRole` searches.
|
||||
*/
|
||||
describe('sidebar drill-down', () => {
|
||||
const renderSidebar = (onOpen = vi.fn()) => {
|
||||
customRender(
|
||||
<ExplorerSidebar
|
||||
notebooks={INITIAL_NOTEBOOKS}
|
||||
snippets={INITIAL_SNIPPETS}
|
||||
chats={INITIAL_CHATS}
|
||||
recentItems={INITIAL_RECENT_ITEMS}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
)
|
||||
return { onOpen }
|
||||
}
|
||||
|
||||
const rootPanel = () => within(screen.getByRole('group', { name: 'All resources' }))
|
||||
|
||||
it('shows the three resource types and a mixed recent group at root', () => {
|
||||
renderSidebar()
|
||||
const root = rootPanel()
|
||||
|
||||
expect(root.getByRole('button', { name: /Snippets/ })).toBeInTheDocument()
|
||||
expect(root.getByRole('button', { name: /Notebooks/ })).toBeInTheDocument()
|
||||
expect(root.getByRole('button', { name: /Chats/ })).toBeInTheDocument()
|
||||
|
||||
expect(root.getByText('Recent')).toBeInTheDocument()
|
||||
// Recent mixes types: a notebook, a chat and a snippet all appear.
|
||||
expect(root.getByRole('button', { name: /Authentication health/ })).toBeInTheDocument()
|
||||
expect(root.getByRole('button', { name: /Debugging signups/ })).toBeInTheDocument()
|
||||
expect(root.getByRole('button', { name: /Slowest queries/ })).toBeInTheDocument()
|
||||
|
||||
// The drilled level exists in the DOM but is out of the a11y tree.
|
||||
expect(screen.queryByRole('group', { name: 'Snippets' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('drills into a type, showing its list with search and a back button', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSidebar()
|
||||
|
||||
await user.click(rootPanel().getByRole('button', { name: /Snippets/ }))
|
||||
|
||||
const panel = within(await screen.findByRole('group', { name: 'Snippets' }))
|
||||
expect(panel.getByRole('button', { name: 'Back to all resources' })).toBeInTheDocument()
|
||||
expect(panel.getByPlaceholderText('Search snippets')).toBeInTheDocument()
|
||||
expect(panel.getByRole('button', { name: /Slowest queries/ })).toBeInTheDocument()
|
||||
expect(panel.getByRole('button', { name: /Edge errors by hour/ })).toBeInTheDocument()
|
||||
|
||||
// Root is now the hidden level.
|
||||
expect(screen.queryByRole('group', { name: 'All resources' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters the drilled list by search', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSidebar()
|
||||
|
||||
await user.click(rootPanel().getByRole('button', { name: /Snippets/ }))
|
||||
const panel = within(await screen.findByRole('group', { name: 'Snippets' }))
|
||||
await user.type(panel.getByPlaceholderText('Search snippets'), 'edge')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(panel.queryByRole('button', { name: /Slowest queries/ })).not.toBeInTheDocument()
|
||||
)
|
||||
expect(panel.getByRole('button', { name: /Edge errors by hour/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports when a search matches nothing', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSidebar()
|
||||
|
||||
await user.click(rootPanel().getByRole('button', { name: /Notebooks/ }))
|
||||
const panel = within(await screen.findByRole('group', { name: 'Notebooks' }))
|
||||
await user.type(panel.getByPlaceholderText('Search notebooks'), 'zzz')
|
||||
|
||||
expect(await panel.findByText(/No notebooks match "zzz"/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns to root via the back button and clears the search', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSidebar()
|
||||
|
||||
await user.click(rootPanel().getByRole('button', { name: /Chats/ }))
|
||||
const panel = within(await screen.findByRole('group', { name: 'Chats' }))
|
||||
await user.type(panel.getByPlaceholderText('Search chats'), 'debug')
|
||||
await user.click(panel.getByRole('button', { name: 'Back to all resources' }))
|
||||
|
||||
expect(await screen.findByRole('group', { name: 'All resources' })).toBeInTheDocument()
|
||||
|
||||
// Re-entering starts with an empty search, not the previous term.
|
||||
await user.click(rootPanel().getByRole('button', { name: /Chats/ }))
|
||||
const reopened = within(await screen.findByRole('group', { name: 'Chats' }))
|
||||
expect(reopened.getByPlaceholderText('Search chats')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('opens the chosen resource with its type and id', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onOpen } = renderSidebar()
|
||||
|
||||
await user.click(rootPanel().getByRole('button', { name: /Notebooks/ }))
|
||||
const panel = within(await screen.findByRole('group', { name: 'Notebooks' }))
|
||||
await user.click(panel.getByRole('button', { name: /Authentication health/ }))
|
||||
|
||||
expect(onOpen).toHaveBeenCalledWith(
|
||||
{ type: 'notebook', id: 'nb-auth-health' },
|
||||
'Authentication health'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recent items track modification, not navigation', () => {
|
||||
it('does not reorder when a tab is merely opened', () => {
|
||||
const { result } = customRenderHook(() => useExplorerPrototypeState())
|
||||
const before = result.current.recentItems
|
||||
|
||||
act(() => result.current.openTab({ type: 'snippet', id: 'snip-edge-errors' }, 'Edge errors'))
|
||||
|
||||
expect(result.current.recentItems).toEqual(before)
|
||||
})
|
||||
|
||||
it('moves a resource to the top when it is edited', () => {
|
||||
const { result } = customRenderHook(() => useExplorerPrototypeState())
|
||||
expect(result.current.recentItems[0].resource.id).not.toBe('snip-edge-errors')
|
||||
|
||||
act(() =>
|
||||
result.current.updateSnippet('snip-edge-errors', {
|
||||
...INITIAL_SNIPPETS['snip-edge-errors'],
|
||||
sql: 'select 1',
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.recentItems[0].resource).toEqual({
|
||||
type: 'snippet',
|
||||
id: 'snip-edge-errors',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not duplicate a resource that is edited twice', () => {
|
||||
const { result } = customRenderHook(() => useExplorerPrototypeState())
|
||||
const notebookId = 'nb-auth-health'
|
||||
|
||||
act(() => result.current.addCell(notebookId, 'markdown'))
|
||||
act(() => result.current.addCell(notebookId, 'markdown'))
|
||||
|
||||
const matching = (result.current.recentItems as RecentItem[]).filter(
|
||||
(item) => item.resource.id === notebookId && item.resource.type === 'notebook'
|
||||
)
|
||||
expect(matching).toHaveLength(1)
|
||||
expect(result.current.recentItems[0].resource.id).toBe(notebookId)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* PROTOTYPE — the Explorer content area.
|
||||
*
|
||||
* A tab strip plus a renderer that switches on `tab.resource.type`. This is the
|
||||
* top of the design diagram: the shell knows about resource *types*, and nothing
|
||||
* else about what a resource contains.
|
||||
*
|
||||
* It sits inside the same project chrome as the SQL editor — `DefaultLayout` →
|
||||
* `ProjectLayoutWithAuth` (see the page's `getLayout`) — and fills the same
|
||||
* slots: the sidebar goes in `productMenu`, this goes in `children`. The inner
|
||||
* markup deliberately mirrors `EditorBaseLayout` so the tab strip lines up with
|
||||
* the real editor's.
|
||||
*
|
||||
* Reference only — mock data, fake execution, throwaway state.
|
||||
*/
|
||||
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
import type { Tab } from './ExplorerPrototype.types'
|
||||
import { useExplorerPrototype } from './ExplorerPrototypeContext'
|
||||
import { ExplorerTabBar } from './ExplorerTabBar'
|
||||
import { ChatView } from './views/ChatView'
|
||||
import { NotebookView } from './views/NotebookView'
|
||||
import { snippetToQueryCell } from './views/snippetAdapter'
|
||||
import { SnippetView } from './views/SnippetView'
|
||||
|
||||
export const ExplorerPrototype = () => {
|
||||
const state = useExplorerPrototype()
|
||||
const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId)
|
||||
|
||||
const renderTab = (tab: Tab) => {
|
||||
if (tab.resource.type === 'snippet') {
|
||||
const snippet = state.snippets[tab.resource.id]
|
||||
if (!snippet) return null
|
||||
return (
|
||||
<SnippetView
|
||||
snippet={snippet}
|
||||
result={state.results[snippet.id] ?? { status: 'idle' }}
|
||||
onChange={(next) => state.updateSnippet(snippet.id, next)}
|
||||
onRun={(rowLimit) => state.runCell(snippetToQueryCell(snippet), rowLimit)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (tab.resource.type === 'notebook') {
|
||||
const notebookId = tab.resource.id
|
||||
const notebook = state.notebooks[notebookId]
|
||||
if (!notebook) return null
|
||||
return (
|
||||
<NotebookView
|
||||
title={tab.title}
|
||||
notebook={notebook}
|
||||
results={state.results}
|
||||
onCellChange={(cellId, cell) => state.updateCell(notebookId, cellId, cell)}
|
||||
onAddCell={(type, afterCellId) => state.addCell(notebookId, type, afterCellId)}
|
||||
onRemoveCell={(cellId) => state.removeCell(notebookId, cellId)}
|
||||
onMoveCell={(cellId, direction) => state.moveCell(notebookId, cellId, direction)}
|
||||
onSettingsChange={(settings) => state.updateNotebookSettings(notebookId, settings)}
|
||||
onRunCell={(cell, rowLimit) => state.runCell(cell, rowLimit)}
|
||||
onRunAll={() => state.runNotebook(notebookId)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const chat = state.chats[tab.resource.id]
|
||||
if (!chat) return null
|
||||
return (
|
||||
<ChatView
|
||||
chat={chat}
|
||||
results={state.results}
|
||||
onApprove={(messageId, cell) => {
|
||||
state.setChatApproval(chat.id, messageId, 'approved')
|
||||
state.runCell(cell, 100)
|
||||
}}
|
||||
onDeny={(messageId) => state.setChatApproval(chat.id, messageId, 'denied')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
// `bg-background` is the page surface the active tab blends into.
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
<div className="flex h-10 items-center border-b bg-muted md:min-h-(--header-height)">
|
||||
<ExplorerTabBar
|
||||
tabs={state.tabs}
|
||||
activeTabId={state.activeTabId}
|
||||
dirtyResources={state.dirtyResources}
|
||||
onSelect={state.setActiveTabId}
|
||||
onClose={state.closeTab}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{activeTab ? (
|
||||
renderTab(activeTab)
|
||||
) : (
|
||||
<div className="mx-auto max-w-lg p-10">
|
||||
<Admonition
|
||||
type="default"
|
||||
title="No tabs open"
|
||||
description="Choose a notebook, snippet, or chat from the sidebar."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* PROTOTYPE — reference implementation of the Explorer / Notebooks schema.
|
||||
*
|
||||
* This file is the schema from the design spec, expressed as TypeScript. It is
|
||||
* deliberately standalone: nothing here imports from `state/` or `data/`, so the
|
||||
* shapes can be reviewed on their own before PR N1 turns them into zod schemas.
|
||||
*
|
||||
* Two deliberate departures from the spec text:
|
||||
*
|
||||
* 1. `QueryBinding` is inline-only. `SnippetReference` was dropped — Notebooks
|
||||
* ship as a new feature, so there are no legacy cells to stay compatible with.
|
||||
* 2. `CellSource` is a discriminated union rather than `{ id, parameters }` with
|
||||
* loose parameter typing. Same wire shape, but the compiler can then tell that
|
||||
* `time_range` belongs to logs and `identifier` belongs to database.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tabs — a tab points at any Explorer resource. Chat internals stay outside.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TabResource =
|
||||
| { type: 'snippet'; id: string }
|
||||
| { type: 'notebook'; id: string }
|
||||
| { type: 'chat'; id: string }
|
||||
|
||||
export type Tab = {
|
||||
id: string
|
||||
title: string
|
||||
resource: TabResource
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar "Recent" group — mixed across every resource type, ordered by last
|
||||
* *modification*, not by when a tab was opened. Titles are deliberately not
|
||||
* stored: they're derived from the live documents so a rename shows up here.
|
||||
*/
|
||||
export type RecentItem = {
|
||||
resource: TabResource
|
||||
modifiedAt: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sources — application-wide. A source owns its endpoint and its parameters.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SourceId = 'database' | 'logs'
|
||||
|
||||
export type LogTimeRange =
|
||||
| { type: 'relative'; amount: number; unit: 'minute' | 'hour' | 'day' | 'week' }
|
||||
| { type: 'absolute'; from: string; to: string }
|
||||
|
||||
/** What the application declares once, at startup. */
|
||||
export type Source =
|
||||
| { id: 'database'; type: 'database'; endpoint: string }
|
||||
| { id: 'logs'; type: 'logs'; endpoint: string }
|
||||
|
||||
/** What a cell stores: which source, plus the parameter values to send to it. */
|
||||
export type CellSource =
|
||||
| { id: 'database'; parameters: { identifier?: string } }
|
||||
| { id: 'logs'; parameters: { time_range: LogTimeRange } }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display — the SELECT determines the columns; this only says how to draw them.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ChartDisplay = {
|
||||
type: 'bar' | 'line'
|
||||
x_axis: { field: string }
|
||||
series: Array<{ field: string; label?: string }>
|
||||
cumulative?: boolean
|
||||
show_labels?: boolean
|
||||
log_scale?: boolean
|
||||
}
|
||||
|
||||
export type QueryDisplay = { type: 'table' } | { type: 'chart'; chart: ChartDisplay }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cells
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type RunMode = 'manual' | 'on_open'
|
||||
|
||||
export type InlineQuery = {
|
||||
type: 'inline'
|
||||
source: CellSource
|
||||
sql: string
|
||||
}
|
||||
|
||||
export type QueryCellModel = {
|
||||
id: string
|
||||
type: 'query'
|
||||
name: string
|
||||
query: InlineQuery
|
||||
display: QueryDisplay
|
||||
execution?: {
|
||||
run_mode?: RunMode
|
||||
row_limit?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type MarkdownCell = {
|
||||
id: string
|
||||
type: 'markdown'
|
||||
markdown: string
|
||||
}
|
||||
|
||||
export type NotebookCell = QueryCellModel | MarkdownCell
|
||||
|
||||
export type NotebookContent = {
|
||||
schema_version: 1
|
||||
settings: {
|
||||
run_mode: RunMode
|
||||
default_row_limit: number
|
||||
}
|
||||
/** Array position defines display and execution order. */
|
||||
cells: NotebookCell[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execution result — caller-owned state, so each surface decides where it lives
|
||||
// (notebook session state, assistant message state, snippet session state).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ResultRow = Record<string, string | number | null>
|
||||
|
||||
export type CellResultState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'running' }
|
||||
| { status: 'success'; rows: ResultRow[]; ranAt: string; rowLimitApplied: number }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Surfaces other than notebooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A saved snippet. The snippet adapter maps this to and from a one-cell QueryCellModel. */
|
||||
export type SnippetDoc = {
|
||||
id: string
|
||||
name: string
|
||||
/** Mirrors today's content types: 'sql' means database, 'log_sql' means logs. */
|
||||
contentType: 'sql' | 'log_sql'
|
||||
sql: string
|
||||
display: QueryDisplay
|
||||
}
|
||||
|
||||
export type ChatMessage =
|
||||
| { id: string; role: 'user'; text: string }
|
||||
| { id: string; role: 'assistant'; text: string }
|
||||
| {
|
||||
id: string
|
||||
role: 'assistant'
|
||||
/** The agent query block — same QueryCell shape, rendered read-only. */
|
||||
cell: QueryCellModel
|
||||
approval: 'pending' | 'approved' | 'denied'
|
||||
}
|
||||
|
||||
export type ChatSession = {
|
||||
id: string
|
||||
name: string
|
||||
messages: ChatMessage[]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* PROTOTYPE — provider so the product menu (sidebar) and the page content share
|
||||
* one state instance.
|
||||
*
|
||||
* This mirrors how the real thing is wired: the Explorer tab store and notebook
|
||||
* save coordinator are mounted at the layout level (like
|
||||
* `SqlEditorSaveCoordinatorProvider` in `ProjectContext`), not inside the page,
|
||||
* because the sidebar lives in `ProjectLayout`'s `productMenu` slot and the
|
||||
* views live in `children`.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, type PropsWithChildren } from 'react'
|
||||
|
||||
import { useExplorerPrototypeState, type ExplorerPrototypeState } from './useExplorerPrototypeState'
|
||||
|
||||
const ExplorerPrototypeContext = createContext<ExplorerPrototypeState | undefined>(undefined)
|
||||
|
||||
export const ExplorerPrototypeProvider = ({ children }: PropsWithChildren) => {
|
||||
const state = useExplorerPrototypeState()
|
||||
|
||||
return (
|
||||
<ExplorerPrototypeContext.Provider value={state}>{children}</ExplorerPrototypeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useExplorerPrototype = () => {
|
||||
const context = useContext(ExplorerPrototypeContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useExplorerPrototype must be used within an ExplorerPrototypeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* PROTOTYPE — the one place that knows what each resource type looks like.
|
||||
*
|
||||
* The tab bar, the sidebar and each view's toolbar all read from here, so a
|
||||
* fourth resource type is a single entry rather than three parallel edits.
|
||||
*/
|
||||
|
||||
import { MessageSquare, NotebookText, SquareCode } from 'lucide-react'
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
import type { TabResource } from './ExplorerPrototype.types'
|
||||
|
||||
export type ResourceType = TabResource['type']
|
||||
|
||||
export type ResourceIcon = ComponentType<{ size?: number; className?: string }>
|
||||
|
||||
export const RESOURCE_ICON: Record<ResourceType, ResourceIcon> = {
|
||||
snippet: SquareCode,
|
||||
notebook: NotebookText,
|
||||
chat: MessageSquare,
|
||||
}
|
||||
|
||||
export const RESOURCE_SECTIONS: Array<{
|
||||
type: ResourceType
|
||||
label: string
|
||||
searchPlaceholder: string
|
||||
}> = [
|
||||
{ type: 'snippet', label: 'Snippets', searchPlaceholder: 'Search snippets' },
|
||||
{ type: 'notebook', label: 'Notebooks', searchPlaceholder: 'Search notebooks' },
|
||||
{ type: 'chat', label: 'Chats', searchPlaceholder: 'Search chats' },
|
||||
]
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* PROTOTYPE — the Explorer sidebar, as a drill-down menu.
|
||||
*
|
||||
* Root level is three resource types plus a mixed "Recent" group, ordered by
|
||||
* last modification. Choosing a type drills into that type's full list, with a
|
||||
* search field and a back button. The sidebar never renders a resource — it
|
||||
* only opens tabs.
|
||||
*
|
||||
* The drill-down is deliberately generic: `RESOURCE_SECTIONS` drives both
|
||||
* levels, so a fourth resource type is one array entry, not new UI.
|
||||
*
|
||||
* Levels cross-fade with a small directional nudge (`LEVEL_OFFSET`), not a
|
||||
* full-width slide — translating the whole panel reads as a zoom. Because
|
||||
* `AnimatePresence` is in `mode="wait"`, the outgoing level finishes before the
|
||||
* incoming one mounts, so only one level is ever in the DOM (and therefore in
|
||||
* the accessibility tree and tab order).
|
||||
*/
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
import { InnerSideBarFilters, InnerSideBarFilterSearchInput } from 'ui-patterns/InnerSideMenu'
|
||||
|
||||
import type {
|
||||
ChatSession,
|
||||
NotebookContent,
|
||||
RecentItem,
|
||||
SnippetDoc,
|
||||
TabResource,
|
||||
} from './ExplorerPrototype.types'
|
||||
import { useExplorerPrototype } from './ExplorerPrototypeContext'
|
||||
import { RESOURCE_ICON, RESOURCE_SECTIONS, type ResourceType } from './ExplorerResources'
|
||||
|
||||
/** One list entry, uniform across resource types. */
|
||||
type ResourceEntry = { id: string; title: string }
|
||||
|
||||
export const notebookTitle = (notebook: NotebookContent) => {
|
||||
const heading = notebook.cells.find((cell) => cell.type === 'markdown')
|
||||
if (heading && heading.type === 'markdown') {
|
||||
const firstLine = heading.markdown
|
||||
.split('\n')[0]
|
||||
.replace(/^#+\s*/, '')
|
||||
.trim()
|
||||
if (firstLine.length > 0) return firstLine
|
||||
}
|
||||
return 'Untitled notebook'
|
||||
}
|
||||
|
||||
const formatModifiedAt = (modifiedAt: number) => {
|
||||
const minutes = Math.round((Date.now() - modifiedAt) / 60_000)
|
||||
if (minutes < 1) return 'Just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return `${Math.round(hours / 24)}d ago`
|
||||
}
|
||||
|
||||
/**
|
||||
* Small enough to read as a nudge rather than a slide. Root sits to the left of
|
||||
* the drilled level, so each direction keeps its sense: going deeper pushes the
|
||||
* root out to the left and brings the list in from the right.
|
||||
*/
|
||||
const LEVEL_OFFSET = 8
|
||||
const LEVEL_TRANSITION = { duration: 0.09, ease: 'easeOut' } as const
|
||||
|
||||
/** Matches `InnerSideMenuItem`'s recipe — that component is a Link, and these are buttons. */
|
||||
const rowClassName = (isActive: boolean) =>
|
||||
cn(
|
||||
'group relative flex h-7 w-full items-center gap-2 rounded-md pl-3 pr-2 text-sm',
|
||||
isActive
|
||||
? 'bg-selection text-foreground'
|
||||
: 'text-foreground-light hover:bg-surface-200 hover:text-foreground'
|
||||
)
|
||||
|
||||
interface ExplorerSidebarProps {
|
||||
notebooks: Record<string, NotebookContent>
|
||||
snippets: Record<string, SnippetDoc>
|
||||
chats: Record<string, ChatSession>
|
||||
recentItems: RecentItem[]
|
||||
activeResource?: TabResource
|
||||
onOpen: (resource: TabResource, title: string) => void
|
||||
}
|
||||
|
||||
export const ExplorerSidebar = ({
|
||||
notebooks,
|
||||
snippets,
|
||||
chats,
|
||||
recentItems,
|
||||
activeResource,
|
||||
onOpen,
|
||||
}: ExplorerSidebarProps) => {
|
||||
const [drilledType, setDrilledType] = useState<ResourceType | undefined>(undefined)
|
||||
// Kept separately from `drilledType` so the level-1 panel still has a type to
|
||||
// render from while it animates out, after `drilledType` has been cleared.
|
||||
const [panelType, setPanelType] = useState<ResourceType>('snippet')
|
||||
const [search, setSearch] = useState('')
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const isDrilled = drilledType !== undefined
|
||||
|
||||
// Focus follows the drill-down. An effect rather than the click handler
|
||||
// because the input only exists after the level swap commits to the DOM.
|
||||
useEffect(() => {
|
||||
if (isDrilled) searchRef.current?.focus()
|
||||
}, [isDrilled])
|
||||
|
||||
const isActive = (type: ResourceType, id: string) =>
|
||||
activeResource?.type === type && activeResource.id === id
|
||||
|
||||
const titleFor = (resource: TabResource): string | undefined => {
|
||||
if (resource.type === 'notebook') {
|
||||
const notebook = notebooks[resource.id]
|
||||
return notebook ? notebookTitle(notebook) : undefined
|
||||
}
|
||||
if (resource.type === 'snippet') return snippets[resource.id]?.name
|
||||
return chats[resource.id]?.name
|
||||
}
|
||||
|
||||
const entriesFor = (type: ResourceType): ResourceEntry[] => {
|
||||
if (type === 'notebook') {
|
||||
return Object.entries(notebooks).map(([id, notebook]) => ({
|
||||
id,
|
||||
title: notebookTitle(notebook),
|
||||
}))
|
||||
}
|
||||
if (type === 'snippet') {
|
||||
return Object.entries(snippets).map(([id, snippet]) => ({ id, title: snippet.name }))
|
||||
}
|
||||
return Object.entries(chats).map(([id, chat]) => ({ id, title: chat.name }))
|
||||
}
|
||||
|
||||
const drillInto = (type: ResourceType) => {
|
||||
setSearch('')
|
||||
setPanelType(type)
|
||||
setDrilledType(type)
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
setSearch('')
|
||||
setDrilledType(undefined)
|
||||
}
|
||||
|
||||
const section = RESOURCE_SECTIONS.find((entry) => entry.type === panelType)
|
||||
const visibleEntries = entriesFor(panelType).filter((entry) =>
|
||||
entry.title.toLowerCase().includes(search.trim().toLowerCase())
|
||||
)
|
||||
|
||||
// Resources can be deleted out from under a recent entry.
|
||||
const resolvedRecents = recentItems.flatMap((item) => {
|
||||
const title = titleFor(item.resource)
|
||||
return title === undefined ? [] : [{ ...item, title }]
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
{!isDrilled ? (
|
||||
/* Level 0 — resource types + recents */
|
||||
<motion.div
|
||||
key="root"
|
||||
role="group"
|
||||
aria-label="All resources"
|
||||
initial={{ opacity: 0, x: -LEVEL_OFFSET }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -LEVEL_OFFSET }}
|
||||
transition={LEVEL_TRANSITION}
|
||||
className="absolute inset-0 flex flex-col gap-4 overflow-y-auto p-3"
|
||||
>
|
||||
<nav className="flex flex-col gap-px">
|
||||
{RESOURCE_SECTIONS.map(({ type, label }) => {
|
||||
const Icon = RESOURCE_ICON[type]
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className={rowClassName(false)}
|
||||
onClick={() => drillInto(type)}
|
||||
>
|
||||
<Icon size={14} className="shrink-0" />
|
||||
<span className="flex-1 text-left">{label}</span>
|
||||
<span className="text-xs text-foreground-lighter">
|
||||
{entriesFor(type).length}
|
||||
</span>
|
||||
<ChevronRight size={14} className="shrink-0 text-foreground-muted" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<section className="flex flex-col gap-px">
|
||||
<h3 className="heading-meta mb-1 px-3 text-muted-foreground">Recent</h3>
|
||||
{resolvedRecents.map((item) => {
|
||||
const Icon = RESOURCE_ICON[item.resource.type]
|
||||
return (
|
||||
<button
|
||||
key={`${item.resource.type}-${item.resource.id}`}
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className={rowClassName(isActive(item.resource.type, item.resource.id))}
|
||||
onClick={() => onOpen(item.resource, item.title)}
|
||||
>
|
||||
<Icon size={14} className="shrink-0 text-foreground-muted" />
|
||||
<span className="flex-1 truncate text-left">{item.title}</span>
|
||||
<span className="shrink-0 text-xs text-foreground-lighter">
|
||||
{formatModifiedAt(item.modifiedAt)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{resolvedRecents.length === 0 && (
|
||||
<p className="px-3 text-xs text-foreground-lighter">Nothing edited yet</p>
|
||||
)}
|
||||
</section>
|
||||
</motion.div>
|
||||
) : (
|
||||
/* Level 1 — one resource type, searchable */
|
||||
<motion.div
|
||||
key={panelType}
|
||||
role="group"
|
||||
aria-label={section?.label}
|
||||
initial={{ opacity: 0, x: LEVEL_OFFSET }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: LEVEL_OFFSET }}
|
||||
transition={LEVEL_TRANSITION}
|
||||
className="absolute inset-0 flex flex-col"
|
||||
>
|
||||
<div className="flex items-center gap-1 p-3 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
aria-label="Back to all resources"
|
||||
onClick={goBack}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-foreground-light hover:bg-surface-200 hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span id="explorer-sidebar-search-label" className="sr-only">
|
||||
{section?.searchPlaceholder}
|
||||
</span>
|
||||
<InnerSideBarFilters className="w-full gap-0 p-0">
|
||||
<InnerSideBarFilterSearchInput
|
||||
ref={searchRef}
|
||||
name="explorer-sidebar-search"
|
||||
value={search}
|
||||
placeholder={section?.searchPlaceholder}
|
||||
aria-labelledby="explorer-sidebar-search-label"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</InnerSideBarFilters>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-px overflow-y-auto px-3 pb-3">
|
||||
{visibleEntries.map((entry) => {
|
||||
const Icon = RESOURCE_ICON[panelType]
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className={rowClassName(isActive(panelType, entry.id))}
|
||||
onClick={() => onOpen({ type: panelType, id: entry.id }, entry.title)}
|
||||
>
|
||||
<Icon size={14} className="shrink-0 text-foreground-muted" />
|
||||
<span className="flex-1 truncate text-left">{entry.title}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{visibleEntries.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-foreground-lighter">
|
||||
{search.trim().length > 0
|
||||
? `No ${section?.label.toLowerCase()} match "${search.trim()}"`
|
||||
: `No ${section?.label.toLowerCase()} yet`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Connected wrapper for `ProjectLayout`'s `productMenu` slot — the same slot the
|
||||
* SQL editor fills with `SQLEditorMenu`.
|
||||
*/
|
||||
export const ExplorerSidebarMenu = () => {
|
||||
const state = useExplorerPrototype()
|
||||
const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId)
|
||||
|
||||
return (
|
||||
<ExplorerSidebar
|
||||
notebooks={state.notebooks}
|
||||
snippets={state.snippets}
|
||||
chats={state.chats}
|
||||
recentItems={state.recentItems}
|
||||
activeResource={activeTab?.resource}
|
||||
onOpen={state.openTab}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* PROTOTYPE — the Explorer tab bar.
|
||||
*
|
||||
* The bar is agnostic of what a tab contains: it reads `tab.resource.type` only
|
||||
* to pick an icon. Everything else (title, dirty state, close) is uniform, which
|
||||
* is what makes adding a fourth resource type cheap later.
|
||||
*/
|
||||
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import type { Tab } from './ExplorerPrototype.types'
|
||||
import { RESOURCE_ICON } from './ExplorerResources'
|
||||
|
||||
interface ExplorerTabBarProps {
|
||||
tabs: Tab[]
|
||||
activeTabId: string
|
||||
dirtyResources: Record<string, boolean>
|
||||
onSelect: (tabId: string) => void
|
||||
onClose: (tabId: string) => void
|
||||
}
|
||||
|
||||
export const ExplorerTabBar = ({
|
||||
tabs,
|
||||
activeTabId,
|
||||
dirtyResources,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: ExplorerTabBarProps) => (
|
||||
<div className="flex h-full items-stretch overflow-x-auto">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = RESOURCE_ICON[tab.resource.type]
|
||||
const isActive = tab.id === activeTabId
|
||||
const isDirty = dirtyResources[tab.resource.id] ?? false
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
'group/tab relative flex items-center border-r',
|
||||
// The active tab is the page surface pushed up into the muted row,
|
||||
// so it reads as continuous with the content below it.
|
||||
isActive ? 'bg-background' : 'bg-transparent hover:bg-surface-200'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
className="flex h-full items-center gap-2 pl-3 pr-8 text-xs"
|
||||
>
|
||||
<Icon size={14} className="shrink-0 text-foreground-muted" />
|
||||
<span className="max-w-40 truncate">{tab.title}</span>
|
||||
</button>
|
||||
|
||||
{isDirty && (
|
||||
<span
|
||||
aria-label="Unsaved changes"
|
||||
className="absolute right-3 size-1.5 rounded-full bg-foreground-light group-hover/tab:opacity-0"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Close ${tab.title}`}
|
||||
onClick={() => onClose(tab.id)}
|
||||
className={cn(
|
||||
'absolute right-1.5 flex size-5 items-center justify-center rounded-xs',
|
||||
'opacity-0 hover:bg-surface-300 group-hover/tab:opacity-100'
|
||||
)}
|
||||
>
|
||||
<X size={12} className="text-foreground-light" />
|
||||
</button>
|
||||
|
||||
{isActive && <div className="absolute inset-x-0 top-0 h-px bg-foreground" />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* PROTOTYPE — the shared QueryCell component (PR E4).
|
||||
*
|
||||
* This is the "generic SQL editor" from the design diagram: toolbar, SQL editor,
|
||||
* results (table or chart). It is rendered by all three surfaces:
|
||||
*
|
||||
* - Snippet view — editable, via the snippet adapter
|
||||
* - Notebook cell — editable
|
||||
* - Agent chat block — `readOnly`, still runnable behind approval
|
||||
*
|
||||
* It is fully controlled. It owns no fetching and no persistence: the caller
|
||||
* passes `result` and handles `onRun`, so each surface decides where results
|
||||
* live. Surface-specific chrome comes in through the three slots.
|
||||
*/
|
||||
|
||||
import { AlertTriangle, Play } from 'lucide-react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Badge, Button, cn } from 'ui'
|
||||
import { CodeBlock } from 'ui-patterns/CodeBlock'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { isWriteQuery } from './ExplorerPrototype.mocks'
|
||||
import type {
|
||||
CellResultState,
|
||||
CellSource,
|
||||
QueryCellModel,
|
||||
QueryDisplay,
|
||||
} from './ExplorerPrototype.types'
|
||||
import { QueryCellDisplay } from './QueryCellDisplay'
|
||||
import { QueryCellDisplayConfig } from './QueryCellDisplayConfig'
|
||||
import { QueryCellSourceMenu } from './QueryCellSourceMenu'
|
||||
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
|
||||
|
||||
const editorHeight = (sql: string) => {
|
||||
const lines = sql.split('\n').length
|
||||
return Math.min(Math.max(lines * 19 + 24, 80), 260)
|
||||
}
|
||||
|
||||
export interface QueryCellProps {
|
||||
value: QueryCellModel
|
||||
result: CellResultState
|
||||
/** Effective limit, already resolved from cell override + notebook default. */
|
||||
rowLimit: number
|
||||
readOnly?: boolean
|
||||
/**
|
||||
* Below the results — assistant approval, notebook run-mode hints.
|
||||
*
|
||||
* There is deliberately no header or actions slot: controls that belong to
|
||||
* the *surface* rather than the query (reorder, delete, insert) live outside
|
||||
* this component, so the cell renders identically everywhere it appears.
|
||||
*/
|
||||
footerSlot?: ReactNode
|
||||
onChange?: (next: QueryCellModel) => void
|
||||
onRun?: () => void
|
||||
}
|
||||
|
||||
export const QueryCell = ({
|
||||
value,
|
||||
result,
|
||||
rowLimit,
|
||||
readOnly = false,
|
||||
footerSlot,
|
||||
onChange,
|
||||
onRun,
|
||||
}: QueryCellProps) => {
|
||||
const [pendingWriteConfirm, setPendingWriteConfirm] = useState(false)
|
||||
|
||||
const isWrite = isWriteQuery(value.query.sql)
|
||||
const isRunning = result.status === 'running'
|
||||
const columns = result.status === 'success' ? Object.keys(result.rows[0] ?? {}) : []
|
||||
|
||||
const update = (next: Partial<QueryCellModel>) => onChange?.({ ...value, ...next })
|
||||
const updateSource = (source: CellSource) => update({ query: { ...value.query, source } })
|
||||
const updateSql = (sql: string) => update({ query: { ...value.query, sql } })
|
||||
const updateDisplay = (display: QueryDisplay) => update({ display })
|
||||
const updateRowLimit = (limit: number) =>
|
||||
update({ execution: { ...value.execution, row_limit: limit } })
|
||||
|
||||
const handleRun = () => {
|
||||
if (isRunning) return
|
||||
// Write queries never run without an explicit confirmation, on any surface.
|
||||
if (isWrite && !pendingWriteConfirm) {
|
||||
setPendingWriteConfirm(true)
|
||||
return
|
||||
}
|
||||
setPendingWriteConfirm(false)
|
||||
onRun?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-md border bg-surface-100 shadow-xs">
|
||||
{/* Toolbar */}
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-2">
|
||||
{readOnly ? (
|
||||
<span className="truncate px-1 text-xs font-medium">{value.name}</span>
|
||||
) : (
|
||||
// Borderless and ring-less: the cell already has a border, and the
|
||||
// input's own default border/focus ring reads as a double outline.
|
||||
<input
|
||||
value={value.name}
|
||||
aria-label="Cell name"
|
||||
onChange={(event) => update({ name: event.target.value })}
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate rounded-sm border-0 bg-transparent px-1 text-xs font-medium shadow-none',
|
||||
'outline-none ring-0 focus:border-0 focus:outline-none focus:ring-0',
|
||||
'focus-visible:outline-none focus-visible:ring-0 focus:bg-surface-200'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isWrite && (
|
||||
<Badge variant="warning" className="shrink-0">
|
||||
Write
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<QueryCellSourceMenu
|
||||
source={value.query.source}
|
||||
rowLimit={rowLimit}
|
||||
disabled={readOnly}
|
||||
onSourceChange={updateSource}
|
||||
onRowLimitChange={updateRowLimit}
|
||||
/>
|
||||
{columns.length > 0 && (
|
||||
<QueryCellDisplayConfig
|
||||
display={value.display}
|
||||
columns={columns}
|
||||
onChange={updateDisplay}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className="w-7 px-0"
|
||||
icon={<Play size={14} strokeWidth={1.5} />}
|
||||
loading={isRunning}
|
||||
disabled={isRunning || !onRun}
|
||||
aria-label="Run cell"
|
||||
onClick={handleRun}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Write confirmation */}
|
||||
{pendingWriteConfirm && (
|
||||
<div className="flex items-center gap-3 border-b bg-warning-200 px-3 py-2">
|
||||
<AlertTriangle size={16} className="shrink-0 text-warning-600" />
|
||||
<p className="flex-1 text-xs text-foreground-light">
|
||||
This query modifies data and will not run automatically.
|
||||
</p>
|
||||
<Button variant="default" size="tiny" onClick={() => setPendingWriteConfirm(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="warning" size="tiny" onClick={handleRun}>
|
||||
Run anyway
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SQL editor */}
|
||||
<div className="shrink-0 border-b" style={{ height: editorHeight(value.query.sql) }}>
|
||||
{readOnly ? (
|
||||
<CodeBlock
|
||||
hideLineNumbers
|
||||
wrapLines={false}
|
||||
value={value.query.sql}
|
||||
language="sql"
|
||||
className={cn(
|
||||
'block h-full w-full max-w-none overflow-auto rounded-none! border-0 bg-transparent! px-3.5! py-3! text-foreground',
|
||||
'[&>code]:m-0 [&>code>span]:text-foreground'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<CodeEditor
|
||||
id={value.id}
|
||||
language="pgsql"
|
||||
value={value.query.sql}
|
||||
autofocus={false}
|
||||
hideLineNumbers
|
||||
onInputChange={(next) => updateSql(next ?? '')}
|
||||
actions={{ runQuery: { enabled: true, callback: handleRun } }}
|
||||
options={{ scrollBeyondLastLine: false, padding: { top: 10, bottom: 10 } }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{result.status === 'running' && (
|
||||
<div className="w-full p-3">
|
||||
<ShimmeringLoader />
|
||||
</div>
|
||||
)}
|
||||
{result.status === 'error' && (
|
||||
<div className="w-full px-3.5 py-2">
|
||||
<span className="font-mono text-xs text-destructive-600">ERROR: {result.message}</span>
|
||||
</div>
|
||||
)}
|
||||
{result.status === 'success' && (
|
||||
<>
|
||||
<QueryCellDisplay display={value.display} rows={result.rows} />
|
||||
<p className="border-t px-3 py-1 font-mono text-xs text-foreground-lighter">
|
||||
{result.rows.length} rows · limit {result.rowLimitApplied}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{footerSlot}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* PROTOTYPE — the `QueryDisplay` renderer (PR E5).
|
||||
*
|
||||
* Two capabilities here that today's QueryBlock does not have, and which the
|
||||
* spec's `ChartDisplay` requires: **multi-series** (`series[]` with per-series
|
||||
* labels) and **line charts** (declared but unimplemented in QueryBlock today).
|
||||
*/
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import { ChartContainer, ChartTooltipContent, cn } from 'ui'
|
||||
|
||||
import type { ChartDisplay, QueryDisplay, ResultRow } from './ExplorerPrototype.types'
|
||||
import { Results } from '@/components/interfaces/SQLEditor/UtilityPanel/Results'
|
||||
import { CHART_COLORS } from '@/components/ui/Charts/Charts.constants'
|
||||
|
||||
const SERIES_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)']
|
||||
|
||||
/** Running total per series, computed independently for each field. */
|
||||
export const applyCumulative = (rows: ResultRow[], fields: string[]): ResultRow[] => {
|
||||
const totals: Record<string, number> = {}
|
||||
return rows.map((row) => {
|
||||
const next: ResultRow = { ...row }
|
||||
fields.forEach((field) => {
|
||||
totals[field] = (totals[field] ?? 0) + Number(row[field] ?? 0)
|
||||
next[field] = totals[field]
|
||||
})
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const looksLikeDate = (value: unknown) =>
|
||||
typeof value === 'string' && value.length >= 8 && dayjs(value).isValid()
|
||||
|
||||
interface ChartRendererProps {
|
||||
chart: ChartDisplay
|
||||
rows: ResultRow[]
|
||||
}
|
||||
|
||||
const ChartRenderer = ({ chart, rows }: ChartRendererProps) => {
|
||||
const seriesFields = chart.series.map((entry) => entry.field)
|
||||
const missingField = !chart.x_axis.field || seriesFields.length === 0
|
||||
|
||||
if (missingField) {
|
||||
return (
|
||||
<div className="flex h-[240px] w-full items-center justify-center">
|
||||
<p className="text-xs text-foreground-light">Choose an x-axis and at least one series</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const data = chart.cumulative ? applyCumulative(rows, seriesFields) : rows
|
||||
const isDateAxis = looksLikeDate(data[0]?.[chart.x_axis.field])
|
||||
const formatX = (value: unknown) =>
|
||||
isDateAxis ? dayjs(value as string).format('MMM D') : String(value)
|
||||
|
||||
const axes = (
|
||||
<>
|
||||
<CartesianGrid vertical={false} stroke={CHART_COLORS.AXIS} />
|
||||
<XAxis
|
||||
dataKey={chart.x_axis.field}
|
||||
tickLine={{ stroke: CHART_COLORS.AXIS }}
|
||||
axisLine={{ stroke: CHART_COLORS.AXIS }}
|
||||
tickMargin={4}
|
||||
minTickGap={24}
|
||||
tickFormatter={formatX}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={4}
|
||||
width={48}
|
||||
scale={chart.log_scale ? 'log' : 'auto'}
|
||||
domain={chart.log_scale ? [1, 'auto'] : undefined}
|
||||
allowDataOverflow={chart.log_scale}
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="min-w-[200px]"
|
||||
labelFormatter={(value) => formatX(value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{chart.series.length > 1 && <Legend iconType="circle" iconSize={8} />}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<ChartContainer className="aspect-auto px-3 py-2" style={{ height: 240, minHeight: 240 }}>
|
||||
{chart.type === 'line' ? (
|
||||
<LineChart data={data} margin={{ left: 0, right: 8, top: 10 }}>
|
||||
{axes}
|
||||
{chart.series.map((entry, index) => (
|
||||
<Line
|
||||
key={entry.field}
|
||||
type="monotone"
|
||||
dataKey={entry.field}
|
||||
name={entry.label ?? entry.field}
|
||||
stroke={SERIES_COLORS[index % SERIES_COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
label={chart.show_labels ? { fontSize: 10, position: 'top' } : undefined}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart data={data} margin={{ left: 0, right: 8, top: 10 }}>
|
||||
{axes}
|
||||
{chart.series.map((entry, index) => (
|
||||
<Bar
|
||||
key={entry.field}
|
||||
radius={1}
|
||||
dataKey={entry.field}
|
||||
name={entry.label ?? entry.field}
|
||||
fill={SERIES_COLORS[index % SERIES_COLORS.length]}
|
||||
label={chart.show_labels ? { fontSize: 10, position: 'top' } : undefined}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
|
||||
interface QueryCellDisplayProps {
|
||||
display: QueryDisplay
|
||||
rows: ResultRow[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const QueryCellDisplay = ({ display, rows, className }: QueryCellDisplayProps) => {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="flex h-24 w-full items-center justify-center">
|
||||
<p className="text-xs text-foreground-light">No rows returned</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (display.type === 'chart') {
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<ChartRenderer chart={display.chart} rows={rows} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex max-h-72 w-full flex-col overflow-auto overscroll-contain', className)}
|
||||
>
|
||||
<Results rows={rows} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* PROTOTYPE — display configuration popover.
|
||||
*
|
||||
* Same slot as today's `BlockViewConfiguration`, but it emits the spec's richer
|
||||
* shape: chart type, one x-axis field, and a **list** of series with optional
|
||||
* per-series labels.
|
||||
*/
|
||||
|
||||
import { BarChart2, Plus, Settings2, Table as TableIcon, X } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Label,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from 'ui'
|
||||
|
||||
import type { ChartDisplay, QueryDisplay } from './ExplorerPrototype.types'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
|
||||
const DEFAULT_CHART: ChartDisplay = {
|
||||
type: 'bar',
|
||||
x_axis: { field: '' },
|
||||
series: [],
|
||||
}
|
||||
|
||||
interface QueryCellDisplayConfigProps {
|
||||
display: QueryDisplay
|
||||
columns: string[]
|
||||
onChange: (display: QueryDisplay) => void
|
||||
}
|
||||
|
||||
export const QueryCellDisplayConfig = ({
|
||||
display,
|
||||
columns,
|
||||
onChange,
|
||||
}: QueryCellDisplayConfigProps) => {
|
||||
const chart = display.type === 'chart' ? display.chart : DEFAULT_CHART
|
||||
const usedFields = chart.series.map((entry) => entry.field)
|
||||
const availableFields = columns.filter(
|
||||
(column) => column !== chart.x_axis.field && !usedFields.includes(column)
|
||||
)
|
||||
|
||||
const updateChart = (next: Partial<ChartDisplay>) =>
|
||||
onChange({ type: 'chart', chart: { ...chart, ...next } })
|
||||
|
||||
return (
|
||||
<Popover modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<ButtonTooltip
|
||||
variant="text"
|
||||
className="px-1"
|
||||
icon={<Settings2 size={14} strokeWidth={1.5} />}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Display' } }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent side="bottom" align="end" className="w-[280px] p-3">
|
||||
<div className="grid gap-3">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="outline"
|
||||
value={display.type}
|
||||
className="w-full"
|
||||
onValueChange={(value: 'table' | 'chart') => {
|
||||
if (!value) return
|
||||
if (value === 'table') return onChange({ type: 'table' })
|
||||
onChange({
|
||||
type: 'chart',
|
||||
chart: {
|
||||
...chart,
|
||||
x_axis: { field: chart.x_axis.field || columns[0] || '' },
|
||||
series:
|
||||
chart.series.length > 0
|
||||
? chart.series
|
||||
: columns[1]
|
||||
? [{ field: columns[1] }]
|
||||
: [],
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem className="w-full" value="table" aria-label="Show as table">
|
||||
<TableIcon className="h-4 w-4" />
|
||||
<p className="ml-2 text-xs">Table</p>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem className="w-full" value="chart" aria-label="Show as chart">
|
||||
<BarChart2 className="h-4 w-4" />
|
||||
<p className="ml-2 text-xs">Chart</p>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
{display.type === 'chart' && (
|
||||
<>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="outline"
|
||||
value={chart.type}
|
||||
className="w-full"
|
||||
onValueChange={(value: 'bar' | 'line') => value && updateChart({ type: value })}
|
||||
>
|
||||
<ToggleGroupItem className="w-full" value="bar" aria-label="Bar chart">
|
||||
<p className="text-xs">Bar</p>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem className="w-full" value="line" aria-label="Line chart">
|
||||
<p className="text-xs">Line</p>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
<Select
|
||||
value={chart.x_axis.field}
|
||||
onValueChange={(field) => updateChart({ x_axis: { field } })}
|
||||
>
|
||||
<SelectTrigger className="text-left">
|
||||
X axis {chart.x_axis.field && `— ${chart.x_axis.field}`}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{columns.map((column) => (
|
||||
<SelectItem value={column} key={column}>
|
||||
{column}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<p className="text-xs text-foreground-light">Series</p>
|
||||
{chart.series.map((entry, index) => (
|
||||
<div key={entry.field} className="flex items-center gap-1">
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ background: `var(--chart-${(index % 4) + 1})` }}
|
||||
/>
|
||||
<span className="flex-1 truncate text-xs">{entry.label ?? entry.field}</span>
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
icon={<X size={12} />}
|
||||
aria-label={`Remove ${entry.field}`}
|
||||
onClick={() =>
|
||||
updateChart({
|
||||
series: chart.series.filter((series) => series.field !== entry.field),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{chart.series.length === 0 && (
|
||||
<p className="text-xs text-foreground-lighter">No series selected</p>
|
||||
)}
|
||||
|
||||
{availableFields.length > 0 && (
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(field) => updateChart({ series: [...chart.series, { field }] })}
|
||||
>
|
||||
<SelectTrigger className="text-left">
|
||||
<span className="flex items-center gap-1.5 text-foreground-light">
|
||||
<Plus size={12} /> Add series
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{availableFields.map((column) => (
|
||||
<SelectItem value={column} key={column}>
|
||||
{column}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 *:flex *:items-center *:gap-2 *:p-1.5 *:pl-0 *:text-foreground-light">
|
||||
<Label htmlFor="cumulative">
|
||||
<Checkbox
|
||||
id="cumulative"
|
||||
checked={chart.cumulative ?? false}
|
||||
onClick={() => updateChart({ cumulative: !chart.cumulative })}
|
||||
/>
|
||||
Cumulative
|
||||
</Label>
|
||||
<Label htmlFor="showLabels">
|
||||
<Checkbox
|
||||
id="showLabels"
|
||||
checked={chart.show_labels ?? false}
|
||||
onClick={() => updateChart({ show_labels: !chart.show_labels })}
|
||||
/>
|
||||
Show labels
|
||||
</Label>
|
||||
<Label htmlFor="logScale">
|
||||
<Checkbox
|
||||
id="logScale"
|
||||
checked={chart.log_scale ?? false}
|
||||
onClick={() => updateChart({ log_scale: !chart.log_scale })}
|
||||
/>
|
||||
Log scale
|
||||
</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* PROTOTYPE — the per-cell source picker.
|
||||
*
|
||||
* In the real build this is PR E1's controlled `QuerySourceMenu`: same idea,
|
||||
* fully driven by props, no editor context. Source is per-cell here, which is
|
||||
* the departure from today's snippets (where source is fixed by content type).
|
||||
*/
|
||||
|
||||
import { Check, ChevronDown, Database, ScrollText } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { MOCK_REPLICAS } from './ExplorerPrototype.mocks'
|
||||
import type { CellSource, LogTimeRange } from './ExplorerPrototype.types'
|
||||
|
||||
const RELATIVE_RANGES: Array<{ label: string; range: LogTimeRange }> = [
|
||||
{ label: 'Last 1 hour', range: { type: 'relative', amount: 1, unit: 'hour' } },
|
||||
{ label: 'Last 24 hours', range: { type: 'relative', amount: 24, unit: 'hour' } },
|
||||
{ label: 'Last 7 days', range: { type: 'relative', amount: 7, unit: 'day' } },
|
||||
]
|
||||
|
||||
const ROW_LIMITS = [50, 100, 500, 1000]
|
||||
|
||||
export const describeSource = (source: CellSource) => {
|
||||
if (source.id === 'database') {
|
||||
const replica = MOCK_REPLICAS.find((entry) => entry.identifier === source.parameters.identifier)
|
||||
return replica?.label ?? 'Primary database'
|
||||
}
|
||||
const range = source.parameters.time_range
|
||||
if (range.type === 'relative') {
|
||||
return `Logs · last ${range.amount} ${range.unit}${range.amount === 1 ? '' : 's'}`
|
||||
}
|
||||
return `Logs · ${range.from} to ${range.to}`
|
||||
}
|
||||
|
||||
const describeRange = (range: LogTimeRange) =>
|
||||
range.type === 'relative'
|
||||
? `Last ${range.amount} ${range.unit}${range.amount === 1 ? '' : 's'}`
|
||||
: 'Custom range'
|
||||
|
||||
interface QueryCellSourceMenuProps {
|
||||
source: CellSource
|
||||
rowLimit: number
|
||||
disabled?: boolean
|
||||
onSourceChange: (source: CellSource) => void
|
||||
onRowLimitChange: (rowLimit: number) => void
|
||||
}
|
||||
|
||||
export const QueryCellSourceMenu = ({
|
||||
source,
|
||||
rowLimit,
|
||||
disabled = false,
|
||||
onSourceChange,
|
||||
onRowLimitChange,
|
||||
}: QueryCellSourceMenuProps) => {
|
||||
const isLogs = source.id === 'logs'
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
disabled={disabled}
|
||||
icon={isLogs ? <ScrollText size={14} /> : <Database size={14} />}
|
||||
iconRight={<ChevronDown size={12} />}
|
||||
className="text-foreground-light"
|
||||
>
|
||||
{describeSource(source)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuLabel>Source</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="gap-2">
|
||||
<Database size={14} /> Database
|
||||
{!isLogs && <Check size={14} className="ml-auto" />}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-56">
|
||||
<DropdownMenuRadioGroup
|
||||
value={source.id === 'database' ? (source.parameters.identifier ?? 'primary') : ''}
|
||||
onValueChange={(identifier) =>
|
||||
onSourceChange({ id: 'database', parameters: { identifier } })
|
||||
}
|
||||
>
|
||||
{MOCK_REPLICAS.map((replica) => (
|
||||
<DropdownMenuRadioItem key={replica.identifier} value={replica.identifier}>
|
||||
{replica.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="gap-2">
|
||||
<ScrollText size={14} /> Logs
|
||||
{isLogs && <Check size={14} className="ml-auto" />}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-56">
|
||||
<DropdownMenuLabel>Time range</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={isLogs ? describeRange(source.parameters.time_range) : ''}
|
||||
onValueChange={(label) => {
|
||||
const match = RELATIVE_RANGES.find((entry) => entry.label === label)
|
||||
if (match) onSourceChange({ id: 'logs', parameters: { time_range: match.range } })
|
||||
}}
|
||||
>
|
||||
{RELATIVE_RANGES.map((entry) => (
|
||||
<DropdownMenuRadioItem key={entry.label} value={entry.label}>
|
||||
{entry.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSourceChange({
|
||||
id: 'logs',
|
||||
parameters: {
|
||||
time_range: {
|
||||
type: 'absolute',
|
||||
from: new Date(Date.now() - 86_400_000).toISOString(),
|
||||
to: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Custom range…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Row limit</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={String(rowLimit)}
|
||||
onValueChange={(value) => onRowLimitChange(Number(value))}
|
||||
>
|
||||
{ROW_LIMITS.map((limit) => (
|
||||
<DropdownMenuRadioItem key={limit} value={String(limit)}>
|
||||
{limit} rows
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* PROTOTYPE — the toolbar every tab type shows above its content.
|
||||
*
|
||||
* Deliberately uniform: icon + name on the left, primary actions on the right,
|
||||
* and the same height as the tab strip so the two rows read as one header.
|
||||
* No badges — resource type is already carried by the icon and the tab.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { ResourceIcon } from './ExplorerResources'
|
||||
|
||||
interface TabToolbarProps {
|
||||
icon: ResourceIcon
|
||||
title: string
|
||||
/** Omitted entirely when a view has no primary actions. */
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
export const TabToolbar = ({ icon: Icon, title, actions }: TabToolbarProps) => (
|
||||
<div className="flex h-10 shrink-0 items-center gap-2 border-b px-3 md:min-h-(--header-height)">
|
||||
<Icon size={14} className="shrink-0 text-foreground-muted" />
|
||||
<h2 className="truncate text-sm">{title}</h2>
|
||||
{actions !== undefined && <div className="ml-auto flex items-center gap-1">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* PROTOTYPE — one hook standing in for what will become several real stores
|
||||
* (Explorer tab store, notebook state + save coordinator, per-cell session
|
||||
* results). Kept in one place so the data flow is readable end to end.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
import {
|
||||
INITIAL_CHATS,
|
||||
INITIAL_NOTEBOOKS,
|
||||
INITIAL_RECENT_ITEMS,
|
||||
INITIAL_SNIPPETS,
|
||||
INITIAL_TABS,
|
||||
isWriteQuery,
|
||||
runMockQuery,
|
||||
} from './ExplorerPrototype.mocks'
|
||||
import type {
|
||||
CellResultState,
|
||||
ChatSession,
|
||||
NotebookCell,
|
||||
NotebookContent,
|
||||
QueryCellModel,
|
||||
RecentItem,
|
||||
RunMode,
|
||||
SnippetDoc,
|
||||
Tab,
|
||||
TabResource,
|
||||
} from './ExplorerPrototype.types'
|
||||
|
||||
let idCounter = 0
|
||||
const nextId = (prefix: string) => `${prefix}-${++idCounter}`
|
||||
|
||||
const RECENT_ITEM_LIMIT = 8
|
||||
|
||||
/**
|
||||
* The rule PR N9 extracts as a pure function so the Assistant enforces the same
|
||||
* policy: a cell's own run_mode wins, otherwise the notebook default.
|
||||
*/
|
||||
export const resolveEffectiveRunMode = (
|
||||
cell: QueryCellModel,
|
||||
settings: NotebookContent['settings']
|
||||
): RunMode => cell.execution?.run_mode ?? settings.run_mode
|
||||
|
||||
export const resolveEffectiveRowLimit = (
|
||||
cell: QueryCellModel,
|
||||
settings: NotebookContent['settings']
|
||||
): number => cell.execution?.row_limit ?? settings.default_row_limit
|
||||
|
||||
export const useExplorerPrototypeState = () => {
|
||||
const [tabs, setTabs] = useState<Tab[]>(INITIAL_TABS)
|
||||
const [activeTabId, setActiveTabId] = useState<string>(INITIAL_TABS[0].id)
|
||||
const [notebooks, setNotebooks] = useState<Record<string, NotebookContent>>(INITIAL_NOTEBOOKS)
|
||||
const [snippets, setSnippets] = useState<Record<string, SnippetDoc>>(INITIAL_SNIPPETS)
|
||||
const [chats, setChats] = useState<Record<string, ChatSession>>(INITIAL_CHATS)
|
||||
const [results, setResults] = useState<Record<string, CellResultState>>({})
|
||||
const [dirtyResources, setDirtyResources] = useState<Record<string, boolean>>({})
|
||||
const [recentItems, setRecentItems] = useState<RecentItem[]>(INITIAL_RECENT_ITEMS)
|
||||
|
||||
/**
|
||||
* Marks a resource dirty *and* bumps it to the top of "Recent". Recents track
|
||||
* modification, not navigation — opening a tab deliberately does not touch
|
||||
* this, otherwise the list would just mirror whatever you last clicked.
|
||||
*/
|
||||
const markModified = (resource: TabResource) => {
|
||||
setDirtyResources((current) => ({ ...current, [resource.id]: true }))
|
||||
setRecentItems((current) =>
|
||||
[
|
||||
{ resource, modifiedAt: Date.now() },
|
||||
...current.filter(
|
||||
(item) => !(item.resource.type === resource.type && item.resource.id === resource.id)
|
||||
),
|
||||
].slice(0, RECENT_ITEM_LIMIT)
|
||||
)
|
||||
}
|
||||
|
||||
// -- tabs -----------------------------------------------------------------
|
||||
|
||||
const openTab = (resource: TabResource, title: string) => {
|
||||
const existing = tabs.find(
|
||||
(tab) => tab.resource.type === resource.type && tab.resource.id === resource.id
|
||||
)
|
||||
if (existing) {
|
||||
setActiveTabId(existing.id)
|
||||
return
|
||||
}
|
||||
const tab: Tab = { id: nextId('tab'), title, resource }
|
||||
setTabs((current) => [...current, tab])
|
||||
setActiveTabId(tab.id)
|
||||
}
|
||||
|
||||
const closeTab = (tabId: string) => {
|
||||
setTabs((current) => {
|
||||
const remaining = current.filter((tab) => tab.id !== tabId)
|
||||
setActiveTabId((active) => {
|
||||
if (active !== tabId) return active
|
||||
const closedIndex = current.findIndex((tab) => tab.id === tabId)
|
||||
const fallback = remaining[closedIndex] ?? remaining[closedIndex - 1] ?? remaining[0]
|
||||
return fallback?.id ?? ''
|
||||
})
|
||||
return remaining
|
||||
})
|
||||
}
|
||||
|
||||
const reorderTabs = (fromId: string, toId: string) => {
|
||||
setTabs((current) => {
|
||||
const fromIndex = current.findIndex((tab) => tab.id === fromId)
|
||||
const toIndex = current.findIndex((tab) => tab.id === toId)
|
||||
if (fromIndex < 0 || toIndex < 0) return current
|
||||
const next = [...current]
|
||||
const [moved] = next.splice(fromIndex, 1)
|
||||
next.splice(toIndex, 0, moved)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// -- notebooks ------------------------------------------------------------
|
||||
|
||||
const updateNotebook = (
|
||||
notebookId: string,
|
||||
update: (notebook: NotebookContent) => NotebookContent
|
||||
) => {
|
||||
setNotebooks((current) => ({ ...current, [notebookId]: update(current[notebookId]) }))
|
||||
markModified({ type: 'notebook', id: notebookId })
|
||||
}
|
||||
|
||||
const updateCell = (notebookId: string, cellId: string, next: NotebookCell) =>
|
||||
updateNotebook(notebookId, (notebook) => ({
|
||||
...notebook,
|
||||
cells: notebook.cells.map((cell) => (cell.id === cellId ? next : cell)),
|
||||
}))
|
||||
|
||||
const addCell = (notebookId: string, type: 'query' | 'markdown', afterCellId?: string) => {
|
||||
const cell: NotebookCell =
|
||||
type === 'markdown'
|
||||
? { id: nextId('cell'), type: 'markdown', markdown: '' }
|
||||
: {
|
||||
id: nextId('cell'),
|
||||
type: 'query',
|
||||
name: 'Untitled query',
|
||||
query: {
|
||||
type: 'inline',
|
||||
source: { id: 'database', parameters: { identifier: 'primary' } },
|
||||
sql: 'select ',
|
||||
},
|
||||
display: { type: 'table' },
|
||||
}
|
||||
|
||||
updateNotebook(notebookId, (notebook) => {
|
||||
const index = afterCellId
|
||||
? notebook.cells.findIndex((existing) => existing.id === afterCellId) + 1
|
||||
: notebook.cells.length
|
||||
const cells = [...notebook.cells]
|
||||
cells.splice(index, 0, cell)
|
||||
return { ...notebook, cells }
|
||||
})
|
||||
}
|
||||
|
||||
const removeCell = (notebookId: string, cellId: string) =>
|
||||
updateNotebook(notebookId, (notebook) => ({
|
||||
...notebook,
|
||||
cells: notebook.cells.filter((cell) => cell.id !== cellId),
|
||||
}))
|
||||
|
||||
const moveCell = (notebookId: string, cellId: string, direction: -1 | 1) =>
|
||||
updateNotebook(notebookId, (notebook) => {
|
||||
const index = notebook.cells.findIndex((cell) => cell.id === cellId)
|
||||
const target = index + direction
|
||||
if (index < 0 || target < 0 || target >= notebook.cells.length) return notebook
|
||||
const cells = [...notebook.cells]
|
||||
const [moved] = cells.splice(index, 1)
|
||||
cells.splice(target, 0, moved)
|
||||
return { ...notebook, cells }
|
||||
})
|
||||
|
||||
const updateNotebookSettings = (notebookId: string, settings: NotebookContent['settings']) =>
|
||||
updateNotebook(notebookId, (notebook) => ({ ...notebook, settings }))
|
||||
|
||||
// -- snippets & chat ------------------------------------------------------
|
||||
|
||||
const updateSnippet = (snippetId: string, next: SnippetDoc) => {
|
||||
setSnippets((current) => ({ ...current, [snippetId]: next }))
|
||||
markModified({ type: 'snippet', id: snippetId })
|
||||
}
|
||||
|
||||
const setChatApproval = (chatId: string, messageId: string, approval: 'approved' | 'denied') => {
|
||||
setChats((current) => ({
|
||||
...current,
|
||||
[chatId]: {
|
||||
...current[chatId],
|
||||
messages: current[chatId].messages.map((message) =>
|
||||
message.id === messageId && 'approval' in message ? { ...message, approval } : message
|
||||
),
|
||||
},
|
||||
}))
|
||||
// Resolving an approval changes the stored session, so the chat counts as modified.
|
||||
markModified({ type: 'chat', id: chatId })
|
||||
}
|
||||
|
||||
// -- execution ------------------------------------------------------------
|
||||
|
||||
const runCell = async (cell: QueryCellModel, rowLimit: number) => {
|
||||
setResults((current) => ({ ...current, [cell.id]: { status: 'running' } }))
|
||||
|
||||
const outcome = await runMockQuery({
|
||||
source: cell.query.source,
|
||||
sql: cell.query.sql,
|
||||
rowLimit,
|
||||
})
|
||||
|
||||
setResults((current) => ({
|
||||
...current,
|
||||
[cell.id]:
|
||||
outcome.status === 'error'
|
||||
? { status: 'error', message: outcome.message }
|
||||
: {
|
||||
status: 'success',
|
||||
rows: outcome.rows,
|
||||
ranAt: new Date().toISOString(),
|
||||
rowLimitApplied: rowLimit,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Notebook "run all" and the on_open pass share this. Write-detected queries
|
||||
* never auto-run — they stop the sequence and wait for explicit confirmation.
|
||||
*/
|
||||
const runNotebook = async (notebookId: string) => {
|
||||
const notebook = notebooks[notebookId]
|
||||
if (!notebook) return
|
||||
|
||||
for (const cell of notebook.cells) {
|
||||
if (cell.type !== 'query') continue
|
||||
if (isWriteQuery(cell.query.sql)) break
|
||||
await runCell(cell, resolveEffectiveRowLimit(cell, notebook.settings))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tabs,
|
||||
activeTabId,
|
||||
setActiveTabId,
|
||||
openTab,
|
||||
closeTab,
|
||||
reorderTabs,
|
||||
notebooks,
|
||||
updateCell,
|
||||
addCell,
|
||||
removeCell,
|
||||
moveCell,
|
||||
updateNotebookSettings,
|
||||
snippets,
|
||||
updateSnippet,
|
||||
chats,
|
||||
setChatApproval,
|
||||
results,
|
||||
recentItems,
|
||||
runCell,
|
||||
runNotebook,
|
||||
dirtyResources,
|
||||
}
|
||||
}
|
||||
|
||||
export type ExplorerPrototypeState = ReturnType<typeof useExplorerPrototypeState>
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* PROTOTYPE — Agent chat view.
|
||||
*
|
||||
* The agent query block is the *same* QueryCell component the notebook and
|
||||
* snippet views render, passed `readOnly`. What differs is chrome, not machinery:
|
||||
* an approval footer in the footer slot, and a data-sharing gate on the results.
|
||||
*
|
||||
* Messages stay at a readable measure; agent query blocks run the full width,
|
||||
* matching how a query cell behaves in a notebook.
|
||||
*/
|
||||
|
||||
import { Check, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Button, cn, Switch } from 'ui'
|
||||
|
||||
import type { CellResultState, ChatSession, QueryCellModel } from '../ExplorerPrototype.types'
|
||||
import { RESOURCE_ICON } from '../ExplorerResources'
|
||||
import { QueryCell } from '../QueryCell'
|
||||
import { TabToolbar } from '../TabToolbar'
|
||||
import { Markdown } from '@/components/interfaces/Markdown'
|
||||
|
||||
interface ChatViewProps {
|
||||
chat: ChatSession
|
||||
results: Record<string, CellResultState>
|
||||
onApprove: (messageId: string, cell: QueryCellModel) => void
|
||||
onDeny: (messageId: string) => void
|
||||
}
|
||||
|
||||
const CHAT_ROW_LIMIT = 100
|
||||
|
||||
/** Message measure — agent query blocks deliberately break out of this. */
|
||||
const PROSE_WIDTH = 'mx-auto w-full max-w-3xl'
|
||||
|
||||
export const ChatView = ({ chat, results, onApprove, onDeny }: ChatViewProps) => {
|
||||
// Stands in for `useOrgAiOptInLevel()` — results only reach the model at
|
||||
// the `schema_and_log_and_data` level.
|
||||
const [canShareResults, setCanShareResults] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<TabToolbar
|
||||
icon={RESOURCE_ICON.chat}
|
||||
title={chat.name}
|
||||
actions={
|
||||
<span className="flex items-center gap-2 text-xs text-foreground-light">
|
||||
Share results with the Assistant
|
||||
<Switch
|
||||
aria-label="Share results with the Assistant"
|
||||
checked={canShareResults}
|
||||
onCheckedChange={setCanShareResults}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{chat.messages.map((message) => {
|
||||
if (!('cell' in message)) {
|
||||
return (
|
||||
<div key={message.id} className={cn('flex', PROSE_WIDTH)}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 text-sm',
|
||||
message.role === 'user' ? 'ml-auto bg-surface-200' : 'bg-transparent'
|
||||
)}
|
||||
>
|
||||
<Markdown content={message.text} className="max-w-none" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const result = results[message.cell.id] ?? { status: 'idle' as const }
|
||||
const isPending = message.approval === 'pending'
|
||||
const isDenied = message.approval === 'denied'
|
||||
|
||||
return (
|
||||
<QueryCell
|
||||
key={message.id}
|
||||
value={message.cell}
|
||||
result={result}
|
||||
rowLimit={CHAT_ROW_LIMIT}
|
||||
readOnly
|
||||
onRun={isPending ? undefined : () => onApprove(message.id, message.cell)}
|
||||
footerSlot={
|
||||
<>
|
||||
{isPending && (
|
||||
<div className="flex items-center gap-3 border-t bg-surface-200 px-3 py-2">
|
||||
<p className="flex-1 text-xs text-foreground-light">
|
||||
The Assistant wants to run this query.
|
||||
</p>
|
||||
<Button
|
||||
variant="default"
|
||||
size="tiny"
|
||||
icon={<X size={12} />}
|
||||
onClick={() => onDeny(message.id)}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="tiny"
|
||||
icon={<Check size={12} />}
|
||||
onClick={() => onApprove(message.id, message.cell)}
|
||||
>
|
||||
Run query
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isDenied && (
|
||||
<p className="border-t px-3 py-1 text-xs text-foreground-lighter">
|
||||
Skipped — the query was not run.
|
||||
</p>
|
||||
)}
|
||||
{result.status === 'success' && (
|
||||
<p className="border-t px-3 py-1 text-xs text-foreground-lighter">
|
||||
{canShareResults
|
||||
? 'Results sent to the Assistant.'
|
||||
: 'Results shown here only — data sharing is off, so rows are not sent to the Assistant.'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* PROTOTYPE — markdown cell. Click to edit, blur to preview.
|
||||
* Uses the shared CodeEditor in markdown mode and Studio's Markdown renderer.
|
||||
*
|
||||
* Like `QueryCell`, this owns no notebook chrome: reorder, delete and insert
|
||||
* are rendered by `NotebookCellShell` in the gutter beside it.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import type { MarkdownCell } from '../ExplorerPrototype.types'
|
||||
import { Markdown } from '@/components/interfaces/Markdown'
|
||||
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
|
||||
|
||||
interface MarkdownCellViewProps {
|
||||
cell: MarkdownCell
|
||||
onChange: (cell: MarkdownCell) => void
|
||||
}
|
||||
|
||||
export const MarkdownCellView = ({ cell, onChange }: MarkdownCellViewProps) => {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="rounded-md">
|
||||
{isEditing ? (
|
||||
<div className="overflow-hidden rounded-md border bg-surface-100">
|
||||
<div className="h-40">
|
||||
<CodeEditor
|
||||
id={cell.id}
|
||||
language="markdown"
|
||||
value={cell.markdown}
|
||||
hideLineNumbers
|
||||
onInputChange={(next) => onChange({ ...cell, markdown: next ?? '' })}
|
||||
options={{ scrollBeyondLastLine: false, padding: { top: 10, bottom: 10 } }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end border-t px-2 py-1">
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className="text-xs text-foreground-light hover:text-foreground"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setIsEditing(true)}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-transparent px-3 py-2 text-left',
|
||||
'hover:border-default hover:bg-surface-100'
|
||||
)}
|
||||
>
|
||||
{cell.markdown.trim().length > 0 ? (
|
||||
<Markdown content={cell.markdown} className="max-w-none" />
|
||||
) : (
|
||||
<span className="text-xs text-foreground-lighter">
|
||||
Empty markdown cell — click to edit
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* PROTOTYPE — the notebook's wrapper around a cell.
|
||||
*
|
||||
* Reorder / delete / insert are properties of the *notebook*, not of the cell,
|
||||
* so they live here in a left gutter rather than inside `QueryCell` or
|
||||
* `MarkdownCellView`. That keeps the cell components identical on every surface
|
||||
* (notebook, snippet, agent chat) and lets both cell kinds share one control
|
||||
* layout. Because nothing is stacked below a cell any more, the vertical gap
|
||||
* between cells can be tight.
|
||||
*/
|
||||
|
||||
import { ChevronDown, ChevronUp, GripVertical, Plus, Trash2 } from 'lucide-react'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
|
||||
interface NotebookCellShellProps {
|
||||
isFirst: boolean
|
||||
isLast: boolean
|
||||
/** Markdown stays at a readable measure; query cells fill the width. */
|
||||
contained?: boolean
|
||||
onMoveUp: () => void
|
||||
onMoveDown: () => void
|
||||
onRemove: () => void
|
||||
onAddCell: (type: 'query' | 'markdown') => void
|
||||
}
|
||||
|
||||
const controlClassName = 'size-6 px-0 text-foreground-muted hover:text-foreground'
|
||||
|
||||
export const NotebookCellShell = ({
|
||||
isFirst,
|
||||
isLast,
|
||||
contained = false,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onRemove,
|
||||
onAddCell,
|
||||
children,
|
||||
}: PropsWithChildren<NotebookCellShellProps>) => (
|
||||
<div className="group/cell relative pl-8">
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0 top-0 flex w-8 flex-col items-center gap-px pt-1',
|
||||
'opacity-0 transition-opacity group-hover/cell:opacity-100 focus-within:opacity-100'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-6 cursor-grab items-center justify-center text-foreground-muted"
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</span>
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className={controlClassName}
|
||||
aria-label="Move cell up"
|
||||
disabled={isFirst}
|
||||
icon={<ChevronUp size={14} />}
|
||||
onClick={onMoveUp}
|
||||
/>
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className={controlClassName}
|
||||
aria-label="Move cell down"
|
||||
disabled={isLast}
|
||||
icon={<ChevronDown size={14} />}
|
||||
onClick={onMoveDown}
|
||||
/>
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className={controlClassName}
|
||||
aria-label="Delete cell"
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={onRemove}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className={controlClassName}
|
||||
aria-label="Add cell below"
|
||||
icon={<Plus size={14} />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" side="right">
|
||||
<DropdownMenuItem onClick={() => onAddCell('query')}>Query cell</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onAddCell('markdown')}>Markdown cell</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className={cn(contained && 'mx-auto w-full max-w-3xl')}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* PROTOTYPE — Notebook view.
|
||||
*
|
||||
* An ordered list of cells. Array position is both display order and execution
|
||||
* order — there is no separate ordering field. Query cells are a thin binding
|
||||
* around the shared QueryCell component (PR N8's shape).
|
||||
*
|
||||
* Width is deliberately mixed: query cells run the full width because results
|
||||
* and charts want the room, while markdown stays at a readable measure.
|
||||
*/
|
||||
|
||||
import { Play, Settings } from 'lucide-react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import type {
|
||||
CellResultState,
|
||||
NotebookCell,
|
||||
NotebookContent,
|
||||
QueryCellModel,
|
||||
RunMode,
|
||||
} from '../ExplorerPrototype.types'
|
||||
import { RESOURCE_ICON } from '../ExplorerResources'
|
||||
import { QueryCell } from '../QueryCell'
|
||||
import { TabToolbar } from '../TabToolbar'
|
||||
import { resolveEffectiveRowLimit, resolveEffectiveRunMode } from '../useExplorerPrototypeState'
|
||||
import { MarkdownCellView } from './MarkdownCellView'
|
||||
import { NotebookCellShell } from './NotebookCellShell'
|
||||
|
||||
const ROW_LIMITS = [50, 100, 500, 1000]
|
||||
|
||||
/** Prose measure for markdown cells; query cells ignore this and fill the width. */
|
||||
const PROSE_WIDTH = 'mx-auto w-full max-w-3xl'
|
||||
|
||||
interface NotebookViewProps {
|
||||
title: string
|
||||
notebook: NotebookContent
|
||||
results: Record<string, CellResultState>
|
||||
onCellChange: (cellId: string, cell: NotebookCell) => void
|
||||
onAddCell: (type: 'query' | 'markdown', afterCellId?: string) => void
|
||||
onRemoveCell: (cellId: string) => void
|
||||
onMoveCell: (cellId: string, direction: -1 | 1) => void
|
||||
onSettingsChange: (settings: NotebookContent['settings']) => void
|
||||
onRunCell: (cell: QueryCellModel, rowLimit: number) => void
|
||||
onRunAll: () => void
|
||||
}
|
||||
|
||||
export const NotebookView = ({
|
||||
title,
|
||||
notebook,
|
||||
results,
|
||||
onCellChange,
|
||||
onAddCell,
|
||||
onRemoveCell,
|
||||
onMoveCell,
|
||||
onSettingsChange,
|
||||
onRunCell,
|
||||
onRunAll,
|
||||
}: NotebookViewProps) => {
|
||||
const hasAutoRun = useRef(false)
|
||||
|
||||
// `on_open` runs the notebook once when it is opened. Write-detected cells
|
||||
// halt the sequence — that rule lives in the state module, shared with the Assistant.
|
||||
useEffect(() => {
|
||||
if (hasAutoRun.current) return
|
||||
if (notebook.settings.run_mode !== 'on_open') return
|
||||
hasAutoRun.current = true
|
||||
onRunAll()
|
||||
}, [notebook.settings.run_mode, onRunAll])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<TabToolbar
|
||||
icon={RESOURCE_ICON.notebook}
|
||||
title={title}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="default" size="tiny" icon={<Play size={14} />} onClick={onRunAll}>
|
||||
Run all
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className="w-7 px-0"
|
||||
aria-label="Notebook settings"
|
||||
icon={<Settings size={14} />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuLabel>Run mode</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={notebook.settings.run_mode}
|
||||
onValueChange={(value) =>
|
||||
onSettingsChange({ ...notebook.settings, run_mode: value as RunMode })
|
||||
}
|
||||
>
|
||||
<DropdownMenuRadioItem value="manual">Manual</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="on_open">On open</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Default row limit</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={String(notebook.settings.default_row_limit)}
|
||||
onValueChange={(value) =>
|
||||
onSettingsChange({ ...notebook.settings, default_row_limit: Number(value) })
|
||||
}
|
||||
>
|
||||
{ROW_LIMITS.map((limit) => (
|
||||
<DropdownMenuRadioItem key={limit} value={String(limit)}>
|
||||
{limit} rows
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
{notebook.cells.map((cell, index) => (
|
||||
<NotebookCellShell
|
||||
key={cell.id}
|
||||
isFirst={index === 0}
|
||||
isLast={index === notebook.cells.length - 1}
|
||||
contained={cell.type === 'markdown'}
|
||||
onMoveUp={() => onMoveCell(cell.id, -1)}
|
||||
onMoveDown={() => onMoveCell(cell.id, 1)}
|
||||
onRemove={() => onRemoveCell(cell.id)}
|
||||
onAddCell={(type) => onAddCell(type, cell.id)}
|
||||
>
|
||||
{cell.type === 'markdown' ? (
|
||||
<MarkdownCellView cell={cell} onChange={(next) => onCellChange(cell.id, next)} />
|
||||
) : (
|
||||
<QueryCell
|
||||
value={cell}
|
||||
result={results[cell.id] ?? { status: 'idle' }}
|
||||
rowLimit={resolveEffectiveRowLimit(cell, notebook.settings)}
|
||||
footerSlot={
|
||||
resolveEffectiveRunMode(cell, notebook.settings) === 'on_open' ? (
|
||||
<p className="border-t px-3 py-1 text-xs text-foreground-lighter">
|
||||
Runs automatically when the notebook opens
|
||||
</p>
|
||||
) : undefined
|
||||
}
|
||||
onChange={(next) => onCellChange(cell.id, next)}
|
||||
onRun={() => onRunCell(cell, resolveEffectiveRowLimit(cell, notebook.settings))}
|
||||
/>
|
||||
)}
|
||||
</NotebookCellShell>
|
||||
))}
|
||||
|
||||
{notebook.cells.length === 0 && (
|
||||
<div
|
||||
className={`flex flex-col items-center gap-2 rounded-md border border-dashed py-10 ${PROSE_WIDTH}`}
|
||||
>
|
||||
<p className="text-sm">This notebook is empty</p>
|
||||
<p className="text-xs text-foreground-light">
|
||||
Add a query cell to run SQL against your database or logs.
|
||||
</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button variant="default" size="tiny" onClick={() => onAddCell('query')}>
|
||||
Add query cell
|
||||
</Button>
|
||||
<Button variant="default" size="tiny" onClick={() => onAddCell('markdown')}>
|
||||
Add markdown cell
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* PROTOTYPE — Snippet view.
|
||||
*
|
||||
* Nothing here knows how to run a query or draw a chart. It maps the snippet
|
||||
* through the adapter, hands the resulting one-cell model to the shared
|
||||
* QueryCell component, and maps edits back.
|
||||
*
|
||||
* A snippet is a single query cell, so it always spans the full width — there
|
||||
* is no prose to keep at a readable measure.
|
||||
*/
|
||||
|
||||
import type { CellResultState, SnippetDoc } from '../ExplorerPrototype.types'
|
||||
import { RESOURCE_ICON } from '../ExplorerResources'
|
||||
import { QueryCell } from '../QueryCell'
|
||||
import { TabToolbar } from '../TabToolbar'
|
||||
import { queryCellToSnippet, snippetToQueryCell } from './snippetAdapter'
|
||||
|
||||
interface SnippetViewProps {
|
||||
snippet: SnippetDoc
|
||||
result: CellResultState
|
||||
onChange: (snippet: SnippetDoc) => void
|
||||
onRun: (rowLimit: number) => void
|
||||
}
|
||||
|
||||
const SNIPPET_ROW_LIMIT = 100
|
||||
|
||||
export const SnippetView = ({ snippet, result, onChange, onRun }: SnippetViewProps) => {
|
||||
const cell = snippetToQueryCell(snippet)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<TabToolbar icon={RESOURCE_ICON.snippet} title={snippet.name} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<QueryCell
|
||||
value={cell}
|
||||
result={result}
|
||||
rowLimit={SNIPPET_ROW_LIMIT}
|
||||
onChange={(next) => onChange(queryCellToSnippet(next, snippet))}
|
||||
onRun={() => onRun(SNIPPET_ROW_LIMIT)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* PROTOTYPE — the snippet adapter from the design diagram (PR E6).
|
||||
*
|
||||
* A snippet is a one-cell notebook. These two pure functions are the whole
|
||||
* mapping, and they are what makes the Explorer snippet view able to render the
|
||||
* same shared QueryCell component as notebook cells.
|
||||
*
|
||||
* Note what the adapter does with source: today a snippet's source is *derived*
|
||||
* from its content type and is immutable. Mapping into a QueryCell makes source
|
||||
* an explicit, editable field — so mapping back has to write the content type
|
||||
* from the chosen source.
|
||||
*/
|
||||
|
||||
import type { QueryCellModel, SnippetDoc } from '../ExplorerPrototype.types'
|
||||
|
||||
const DEFAULT_LOG_RANGE = { type: 'relative' as const, amount: 1, unit: 'hour' as const }
|
||||
|
||||
export const snippetToQueryCell = (snippet: SnippetDoc): QueryCellModel => ({
|
||||
id: snippet.id,
|
||||
type: 'query',
|
||||
name: snippet.name,
|
||||
query: {
|
||||
type: 'inline',
|
||||
source:
|
||||
snippet.contentType === 'log_sql'
|
||||
? { id: 'logs', parameters: { time_range: DEFAULT_LOG_RANGE } }
|
||||
: { id: 'database', parameters: { identifier: 'primary' } },
|
||||
sql: snippet.sql,
|
||||
},
|
||||
display: snippet.display,
|
||||
})
|
||||
|
||||
export const queryCellToSnippet = (cell: QueryCellModel, base: SnippetDoc): SnippetDoc => ({
|
||||
...base,
|
||||
name: cell.name,
|
||||
contentType: cell.query.source.id === 'logs' ? 'log_sql' : 'sql',
|
||||
sql: cell.query.sql,
|
||||
display: cell.display,
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ExplorerPrototype } from '@/components/interfaces/ExplorerPrototype/ExplorerPrototype'
|
||||
import { ExplorerPrototypeProvider } from '@/components/interfaces/ExplorerPrototype/ExplorerPrototypeContext'
|
||||
import { ExplorerSidebarMenu } from '@/components/interfaces/ExplorerPrototype/ExplorerSidebar'
|
||||
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
|
||||
import { ProjectLayoutWithAuth } from '@/components/layouts/ProjectLayout'
|
||||
import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
/**
|
||||
* Throwaway reference prototype for the Explorer / Notebooks initiative.
|
||||
*
|
||||
* Uses the same project chrome as the SQL editor (`DefaultLayout` →
|
||||
* `ProjectLayoutWithAuth` with a resizable `productMenu`). It does not go
|
||||
* through `EditorBaseLayout`, because that layout renders the existing
|
||||
* snippet-only `EditorTabs`; the Explorer brings its own resource-typed tab
|
||||
* strip, which is exactly the swap PR E3 makes behind the `explorer` flag.
|
||||
*
|
||||
* Mock data only — nothing reaches a project.
|
||||
*/
|
||||
const ExplorerPrototypePage: NextPageWithLayout = () => <ExplorerPrototype />
|
||||
|
||||
ExplorerPrototypePage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<ExplorerPrototypeProvider>
|
||||
<ProjectLayoutWithAuth
|
||||
resizableSidebar
|
||||
product="Explorer"
|
||||
productMenu={<ExplorerSidebarMenu />}
|
||||
>
|
||||
{page}
|
||||
</ProjectLayoutWithAuth>
|
||||
</ExplorerPrototypeProvider>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default ExplorerPrototypePage
|
||||
Reference in new issue
Block a user