diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md index a30d05471c9..91fc6ca1cd6 100644 --- a/apps/studio/TANSTACK_MIGRATION.md +++ b/apps/studio/TANSTACK_MIGRATION.md @@ -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/*` diff --git a/apps/studio/components/interfaces/ExplorerPrototype/ExplorerPrototype.mocks.ts b/apps/studio/components/interfaces/ExplorerPrototype/ExplorerPrototype.mocks.ts new file mode 100644 index 00000000000..7de28ad00de --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/ExplorerPrototype.mocks.ts @@ -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 = { + '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 = { + '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 = { + '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 => { + 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) } +} diff --git a/apps/studio/components/interfaces/ExplorerPrototype/ExplorerPrototype.test.tsx b/apps/studio/components/interfaces/ExplorerPrototype/ExplorerPrototype.test.tsx new file mode 100644 index 00000000000..e5ea86be416 --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/ExplorerPrototype.test.tsx @@ -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>({}) + + 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 +} + +describe('agent chat surface', () => { + it('renders the agent query block with an approval prompt and no editable SQL', async () => { + customRender() + + 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() + + 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() + + 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( + + ) + 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) + }) +}) diff --git a/apps/studio/components/interfaces/ExplorerPrototype/ExplorerPrototype.tsx b/apps/studio/components/interfaces/ExplorerPrototype/ExplorerPrototype.tsx new file mode 100644 index 00000000000..b9b803bb7cd --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/ExplorerPrototype.tsx @@ -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 ( + 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 ( + 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 ( + { + 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. +
+
+ +
+ +
+ {activeTab ? ( + renderTab(activeTab) + ) : ( +
+ +
+ )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/ExplorerPrototype/ExplorerPrototype.types.ts b/apps/studio/components/interfaces/ExplorerPrototype/ExplorerPrototype.types.ts new file mode 100644 index 00000000000..87f29ee051c --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/ExplorerPrototype.types.ts @@ -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 + +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[] +} diff --git a/apps/studio/components/interfaces/ExplorerPrototype/ExplorerPrototypeContext.tsx b/apps/studio/components/interfaces/ExplorerPrototype/ExplorerPrototypeContext.tsx new file mode 100644 index 00000000000..31653368e5a --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/ExplorerPrototypeContext.tsx @@ -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(undefined) + +export const ExplorerPrototypeProvider = ({ children }: PropsWithChildren) => { + const state = useExplorerPrototypeState() + + return ( + {children} + ) +} + +export const useExplorerPrototype = () => { + const context = useContext(ExplorerPrototypeContext) + if (context === undefined) { + throw new Error('useExplorerPrototype must be used within an ExplorerPrototypeProvider') + } + return context +} diff --git a/apps/studio/components/interfaces/ExplorerPrototype/ExplorerResources.tsx b/apps/studio/components/interfaces/ExplorerPrototype/ExplorerResources.tsx new file mode 100644 index 00000000000..587f1a92bf3 --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/ExplorerResources.tsx @@ -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 = { + 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' }, +] diff --git a/apps/studio/components/interfaces/ExplorerPrototype/ExplorerSidebar.tsx b/apps/studio/components/interfaces/ExplorerPrototype/ExplorerSidebar.tsx new file mode 100644 index 00000000000..384ea830237 --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/ExplorerSidebar.tsx @@ -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 + snippets: Record + chats: Record + recentItems: RecentItem[] + activeResource?: TabResource + onOpen: (resource: TabResource, title: string) => void +} + +export const ExplorerSidebar = ({ + notebooks, + snippets, + chats, + recentItems, + activeResource, + onOpen, +}: ExplorerSidebarProps) => { + const [drilledType, setDrilledType] = useState(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('snippet') + const [search, setSearch] = useState('') + const searchRef = useRef(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 ( +
+ + {!isDrilled ? ( + /* Level 0 — resource types + recents */ + + + +
+

Recent

+ {resolvedRecents.map((item) => { + const Icon = RESOURCE_ICON[item.resource.type] + return ( + + ) + })} + {resolvedRecents.length === 0 && ( +

Nothing edited yet

+ )} +
+
+ ) : ( + /* Level 1 — one resource type, searchable */ + +
+ + + {section?.searchPlaceholder} + + + setSearch(event.target.value)} + /> + +
+ +
+ {visibleEntries.map((entry) => { + const Icon = RESOURCE_ICON[panelType] + return ( + + ) + })} + + {visibleEntries.length === 0 && ( +

+ {search.trim().length > 0 + ? `No ${section?.label.toLowerCase()} match "${search.trim()}"` + : `No ${section?.label.toLowerCase()} yet`} +

+ )} +
+
+ )} +
+
+ ) +} + +/** + * 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 ( + + ) +} diff --git a/apps/studio/components/interfaces/ExplorerPrototype/ExplorerTabBar.tsx b/apps/studio/components/interfaces/ExplorerPrototype/ExplorerTabBar.tsx new file mode 100644 index 00000000000..97eada67d5f --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/ExplorerTabBar.tsx @@ -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 + onSelect: (tabId: string) => void + onClose: (tabId: string) => void +} + +export const ExplorerTabBar = ({ + tabs, + activeTabId, + dirtyResources, + onSelect, + onClose, +}: ExplorerTabBarProps) => ( +
+ {tabs.map((tab) => { + const Icon = RESOURCE_ICON[tab.resource.type] + const isActive = tab.id === activeTabId + const isDirty = dirtyResources[tab.resource.id] ?? false + + return ( +
+ + + {isDirty && ( + + )} + + + + {isActive &&
} +
+ ) + })} +
+) diff --git a/apps/studio/components/interfaces/ExplorerPrototype/QueryCell.tsx b/apps/studio/components/interfaces/ExplorerPrototype/QueryCell.tsx new file mode 100644 index 00000000000..b1388a4d0e3 --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/QueryCell.tsx @@ -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) => 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 ( +
+ {/* Toolbar */} +
+ {readOnly ? ( + {value.name} + ) : ( + // Borderless and ring-less: the cell already has a border, and the + // input's own default border/focus ring reads as a double outline. + 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 && ( + + Write + + )} + +
+ + {columns.length > 0 && ( + + )} +
+
+ + {/* Write confirmation */} + {pendingWriteConfirm && ( +
+ +

+ This query modifies data and will not run automatically. +

+ + +
+ )} + + {/* SQL editor */} +
+ {readOnly ? ( + code]:m-0 [&>code>span]:text-foreground' + )} + /> + ) : ( + updateSql(next ?? '')} + actions={{ runQuery: { enabled: true, callback: handleRun } }} + options={{ scrollBeyondLastLine: false, padding: { top: 10, bottom: 10 } }} + /> + )} +
+ + {/* Results */} + {result.status === 'running' && ( +
+ +
+ )} + {result.status === 'error' && ( +
+ ERROR: {result.message} +
+ )} + {result.status === 'success' && ( + <> + +

+ {result.rows.length} rows · limit {result.rowLimitApplied} +

+ + )} + + {footerSlot} +
+ ) +} diff --git a/apps/studio/components/interfaces/ExplorerPrototype/QueryCellDisplay.tsx b/apps/studio/components/interfaces/ExplorerPrototype/QueryCellDisplay.tsx new file mode 100644 index 00000000000..c8f98ceb7d6 --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/QueryCellDisplay.tsx @@ -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 = {} + 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 ( +
+

Choose an x-axis and at least one series

+
+ ) + } + + 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 = ( + <> + + + + formatX(value)} + /> + } + /> + {chart.series.length > 1 && } + + ) + + return ( + + {chart.type === 'line' ? ( + + {axes} + {chart.series.map((entry, index) => ( + + ))} + + ) : ( + + {axes} + {chart.series.map((entry, index) => ( + + ))} + + )} + + ) +} + +interface QueryCellDisplayProps { + display: QueryDisplay + rows: ResultRow[] + className?: string +} + +export const QueryCellDisplay = ({ display, rows, className }: QueryCellDisplayProps) => { + if (rows.length === 0) { + return ( +
+

No rows returned

+
+ ) + } + + if (display.type === 'chart') { + return ( +
+ +
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/apps/studio/components/interfaces/ExplorerPrototype/QueryCellDisplayConfig.tsx b/apps/studio/components/interfaces/ExplorerPrototype/QueryCellDisplayConfig.tsx new file mode 100644 index 00000000000..ff2544da030 --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/QueryCellDisplayConfig.tsx @@ -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) => + onChange({ type: 'chart', chart: { ...chart, ...next } }) + + return ( + + + } + tooltip={{ content: { side: 'bottom', text: 'Display' } }} + /> + + + +
+ { + 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] }] + : [], + }, + }) + }} + > + + +

Table

+
+ + +

Chart

+
+
+ + {display.type === 'chart' && ( + <> + value && updateChart({ type: value })} + > + +

Bar

+
+ +

Line

+
+
+ + + +
+

Series

+ {chart.series.map((entry, index) => ( +
+ + {entry.label ?? entry.field} +
+ ))} + {chart.series.length === 0 && ( +

No series selected

+ )} + + {availableFields.length > 0 && ( + + )} +
+ +
+ + + +
+ + )} +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/ExplorerPrototype/QueryCellSourceMenu.tsx b/apps/studio/components/interfaces/ExplorerPrototype/QueryCellSourceMenu.tsx new file mode 100644 index 00000000000..6a26b17f963 --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/QueryCellSourceMenu.tsx @@ -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 ( + + + + + + + Source + + + + Database + {!isLogs && } + + + + onSourceChange({ id: 'database', parameters: { identifier } }) + } + > + {MOCK_REPLICAS.map((replica) => ( + + {replica.label} + + ))} + + + + + + + Logs + {isLogs && } + + + Time range + { + const match = RELATIVE_RANGES.find((entry) => entry.label === label) + if (match) onSourceChange({ id: 'logs', parameters: { time_range: match.range } }) + }} + > + {RELATIVE_RANGES.map((entry) => ( + + {entry.label} + + ))} + + + + onSourceChange({ + id: 'logs', + parameters: { + time_range: { + type: 'absolute', + from: new Date(Date.now() - 86_400_000).toISOString(), + to: new Date().toISOString(), + }, + }, + }) + } + > + Custom range… + + + + + + Row limit + onRowLimitChange(Number(value))} + > + {ROW_LIMITS.map((limit) => ( + + {limit} rows + + ))} + + + + ) +} diff --git a/apps/studio/components/interfaces/ExplorerPrototype/TabToolbar.tsx b/apps/studio/components/interfaces/ExplorerPrototype/TabToolbar.tsx new file mode 100644 index 00000000000..5c22d00a890 --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/TabToolbar.tsx @@ -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) => ( +
+ +

{title}

+ {actions !== undefined &&
{actions}
} +
+) diff --git a/apps/studio/components/interfaces/ExplorerPrototype/useExplorerPrototypeState.ts b/apps/studio/components/interfaces/ExplorerPrototype/useExplorerPrototypeState.ts new file mode 100644 index 00000000000..d1246420bc1 --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/useExplorerPrototypeState.ts @@ -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(INITIAL_TABS) + const [activeTabId, setActiveTabId] = useState(INITIAL_TABS[0].id) + const [notebooks, setNotebooks] = useState>(INITIAL_NOTEBOOKS) + const [snippets, setSnippets] = useState>(INITIAL_SNIPPETS) + const [chats, setChats] = useState>(INITIAL_CHATS) + const [results, setResults] = useState>({}) + const [dirtyResources, setDirtyResources] = useState>({}) + const [recentItems, setRecentItems] = useState(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 diff --git a/apps/studio/components/interfaces/ExplorerPrototype/views/ChatView.tsx b/apps/studio/components/interfaces/ExplorerPrototype/views/ChatView.tsx new file mode 100644 index 00000000000..d1c48902f2f --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/views/ChatView.tsx @@ -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 + 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 ( +
+ + Share results with the Assistant + + + } + /> + +
+
+ {chat.messages.map((message) => { + if (!('cell' in message)) { + return ( +
+
+ +
+
+ ) + } + + const result = results[message.cell.id] ?? { status: 'idle' as const } + const isPending = message.approval === 'pending' + const isDenied = message.approval === 'denied' + + return ( + onApprove(message.id, message.cell)} + footerSlot={ + <> + {isPending && ( +
+

+ The Assistant wants to run this query. +

+ + +
+ )} + {isDenied && ( +

+ Skipped — the query was not run. +

+ )} + {result.status === 'success' && ( +

+ {canShareResults + ? 'Results sent to the Assistant.' + : 'Results shown here only — data sharing is off, so rows are not sent to the Assistant.'} +

+ )} + + } + /> + ) + })} +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/ExplorerPrototype/views/MarkdownCellView.tsx b/apps/studio/components/interfaces/ExplorerPrototype/views/MarkdownCellView.tsx new file mode 100644 index 00000000000..96da061219d --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/views/MarkdownCellView.tsx @@ -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 ( +
+ {isEditing ? ( +
+
+ onChange({ ...cell, markdown: next ?? '' })} + options={{ scrollBeyondLastLine: false, padding: { top: 10, bottom: 10 } }} + /> +
+
+ +
+
+ ) : ( + + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/ExplorerPrototype/views/NotebookCellShell.tsx b/apps/studio/components/interfaces/ExplorerPrototype/views/NotebookCellShell.tsx new file mode 100644 index 00000000000..957ee6cbb10 --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/views/NotebookCellShell.tsx @@ -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) => ( +
+
+ + + +
+ +
{children}
+
+) diff --git a/apps/studio/components/interfaces/ExplorerPrototype/views/NotebookView.tsx b/apps/studio/components/interfaces/ExplorerPrototype/views/NotebookView.tsx new file mode 100644 index 00000000000..bbb35fa9cb1 --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/views/NotebookView.tsx @@ -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 + 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 ( +
+ + + + + + + +
+
+ )} + + + + ) +} diff --git a/apps/studio/components/interfaces/ExplorerPrototype/views/SnippetView.tsx b/apps/studio/components/interfaces/ExplorerPrototype/views/SnippetView.tsx new file mode 100644 index 00000000000..c84749b7b5a --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/views/SnippetView.tsx @@ -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 ( +
+ + +
+ onChange(queryCellToSnippet(next, snippet))} + onRun={() => onRun(SNIPPET_ROW_LIMIT)} + /> +
+
+ ) +} diff --git a/apps/studio/components/interfaces/ExplorerPrototype/views/snippetAdapter.ts b/apps/studio/components/interfaces/ExplorerPrototype/views/snippetAdapter.ts new file mode 100644 index 00000000000..09a770bf461 --- /dev/null +++ b/apps/studio/components/interfaces/ExplorerPrototype/views/snippetAdapter.ts @@ -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, +}) diff --git a/apps/studio/pages/project/[ref]/explorer-prototype.tsx b/apps/studio/pages/project/[ref]/explorer-prototype.tsx new file mode 100644 index 00000000000..5c5c9533129 --- /dev/null +++ b/apps/studio/pages/project/[ref]/explorer-prototype.tsx @@ -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 = () => + +ExplorerPrototypePage.getLayout = (page) => ( + + + } + > + {page} + + + +) + +export default ExplorerPrototypePage