mirror of
https://github.com/Rohithgilla12/data-peek
synced 2026-04-21 12:57:16 +00:00
feat: multi-statement step-through + notebook polish (#160)
* feat(step): add shared types for step-through sessions * feat(step): add statement parser with line-range metadata and DDL detection * feat(step): add StepSessionRegistry with state machine and lifecycle management Implements the session registry that holds open DB clients across user-pause gaps, managing state transitions (paused/running/errored/done) for multi-statement step-through. Also fixes root test script and excludes pre-broken sqlite native module tests from vitest. * feat(step): add IPC handlers and wire session registry to app lifecycle * fix(step): widen state type to avoid stale TS narrowing in continue() * feat(step): expose step IPC via window.api.step * feat(step): add Zustand store for step-through session state * feat(step): add ribbon component with progress, controls, and keyboard shortcuts * feat(step): add pinned + current results tab strip * feat(step): wire Step button, ribbon, decorations, and breakpoint gutter * fix(step): register Monaco commands for keybindings + sync cursor from server Replace window-level keydown listener for Cmd+Shift+Enter with a Monaco addAction registration so it takes precedence over Monaco's built-in Shift+Enter handling. Also adds in-session Shift+Enter (next) and Escape (stop) as Monaco actions, active only while a step session is running. Add cursorIndex to all step response types (NextStepResponse, SkipStepResponse, ContinueStepResponse, RetryStepResponse) and populate it from the server in step-session.ts. The store now uses the server-provided value instead of computing cursorIndex + 1 on the client, eliminating the 0/3 counter drift bug. * fix(notebook): index result rows by column name Rows from window.api.db.query are Record<string, unknown>[], not unknown[][]. ResultTable was calling row.map, throwing TypeError at runtime. * feat(notebook): syntax highlighting via Monaco SQLEditor Replace plain textarea/pre with the existing SQLEditor in compact mode so notebook SQL cells get syntax highlighting, autocomplete (schemas, keywords, functions), and the same theme as the main query editor. Editor height grows with content up to 400px. Pinned results are stored as unknown[][] but the renderer now indexes rows by column name, so convert in both directions when pinning and when rendering pinned snapshots. * feat(notebook): drag-to-reorder cells with dnd-kit Wrap the cells map in DndContext + SortableContext (vertical strategy) and make NotebookCell sortable. Drag listeners attach only to the grip button so Monaco editor pointer events keep working. Calls existing reorderCells store action on drop, persisting the new order. * fix(step): propagate real errors, clean up failed starts, harden cleanup - Extend IPC response types with SessionSnapshot base + error fields - start() wraps connect+BEGIN with cleanup on failure (prevents connection leak) - continue() catch logs + sets errored state (no more silent swallows) - executeCurrent logs errors and includes them in response - stop() reports rollbackError so UI can surface ROLLBACK failures - before-quit preventsDefault + awaits cleanup (3s timeout) - parse-statements fallback path logs, computes correct endLine, stops silent drift - Replace 'Query failed' placeholder in store with actual error message - stoppedAt is now number | null (no more -1 sentinel) * fix(step): renderer polish — toasts, Escape hijack, monaco bundle size - Gate Step button and Monaco action to postgresql-only connections - Toast on all IPC failures in step-store (replaces console.error) - Surface ROLLBACK errors from stop() via destructive toast - Roll back optimistic breakpoint state on setBreakpoints IPC failure - Remove global Escape keydown listener (Monaco already handles it) - Remove 'monaco-editor' namespace import; use onMount's monaco arg instead (drops ~1-2MB from renderer bundle) * test(step): add coverage for critical gaps - start() cleanup on connect/BEGIN failure - Empty/whitespace SQL rejection without creating a client - Concurrent next() race (may reveal known limitation) - Continue with breakpoint at current cursor (skip-own-breakpoint semantics) - stop() idempotency - retry() state guards (paused, done) - skip on last statement transitions to done - Parser: dollar-quoted blocks with internal semicolons - Parser: duplicate statement bodies get distinct lines - Parser: CRLF line endings - Parser: no trailing semicolon on last statement
This commit is contained in:
parent
5f6a9ea63e
commit
626dcbfbe9
22 changed files with 2372 additions and 93 deletions
125
apps/desktop/src/main/__tests__/parse-statements.test.ts
Normal file
125
apps/desktop/src/main/__tests__/parse-statements.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron-log/main', () => ({
|
||||
default: {
|
||||
initialize: vi.fn(),
|
||||
transports: {
|
||||
console: { level: 'debug' },
|
||||
file: { level: 'debug', maxSize: 0, format: '' }
|
||||
},
|
||||
scope: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn()
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false
|
||||
}
|
||||
}))
|
||||
|
||||
import { parseStatementsWithLines } from '../lib/parse-statements'
|
||||
|
||||
describe('parseStatementsWithLines', () => {
|
||||
it('parses a single-line statement', () => {
|
||||
const sql = 'SELECT 1'
|
||||
const result = parseStatementsWithLines(sql, 'postgresql')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({
|
||||
index: 0,
|
||||
sql: 'SELECT 1',
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
isDDL: false
|
||||
})
|
||||
})
|
||||
|
||||
it('parses multiple single-line statements', () => {
|
||||
const sql = 'SELECT 1;\nSELECT 2;\nSELECT 3;'
|
||||
const result = parseStatementsWithLines(sql, 'postgresql')
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].startLine).toBe(1)
|
||||
expect(result[0].endLine).toBe(1)
|
||||
expect(result[1].startLine).toBe(2)
|
||||
expect(result[1].endLine).toBe(2)
|
||||
expect(result[2].startLine).toBe(3)
|
||||
expect(result[2].endLine).toBe(3)
|
||||
})
|
||||
|
||||
it('parses a statement spanning multiple lines', () => {
|
||||
const sql = 'SELECT\n id,\n name\nFROM users;'
|
||||
const result = parseStatementsWithLines(sql, 'postgresql')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].startLine).toBe(1)
|
||||
expect(result[0].endLine).toBe(4)
|
||||
})
|
||||
|
||||
it('detects DDL statements', () => {
|
||||
const sql = 'CREATE TABLE t (id int); DROP TABLE t; SELECT 1;'
|
||||
const result = parseStatementsWithLines(sql, 'postgresql')
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].isDDL).toBe(true)
|
||||
expect(result[1].isDDL).toBe(true)
|
||||
expect(result[2].isDDL).toBe(false)
|
||||
})
|
||||
|
||||
it('handles leading whitespace and comments', () => {
|
||||
const sql = '\n-- a comment\nSELECT 1;\n\n-- another\nSELECT 2;'
|
||||
const result = parseStatementsWithLines(sql, 'postgresql')
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].sql.trim()).toBe('SELECT 1')
|
||||
expect(result[1].sql.trim()).toBe('SELECT 2')
|
||||
})
|
||||
|
||||
it('assigns sequential indices', () => {
|
||||
const sql = 'SELECT 1; SELECT 2; SELECT 3;'
|
||||
const result = parseStatementsWithLines(sql, 'postgresql')
|
||||
expect(result[0].index).toBe(0)
|
||||
expect(result[1].index).toBe(1)
|
||||
expect(result[2].index).toBe(2)
|
||||
})
|
||||
|
||||
it('returns empty array for empty SQL', () => {
|
||||
expect(parseStatementsWithLines('', 'postgresql')).toEqual([])
|
||||
expect(parseStatementsWithLines(' \n\n ', 'postgresql')).toEqual([])
|
||||
})
|
||||
|
||||
it('parses DO block with internal semicolons as one statement', () => {
|
||||
const sql = `DO $$
|
||||
BEGIN
|
||||
PERFORM 1;
|
||||
PERFORM 2;
|
||||
END $$;`
|
||||
const result = parseStatementsWithLines(sql, 'postgresql')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].startLine).toBe(1)
|
||||
expect(result[0].endLine).toBe(5)
|
||||
})
|
||||
|
||||
it('assigns distinct line ranges to duplicate statements', () => {
|
||||
const sql = 'SELECT 1;\nSELECT 1;\nSELECT 1;'
|
||||
const result = parseStatementsWithLines(sql, 'postgresql')
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].startLine).toBe(1)
|
||||
expect(result[1].startLine).toBe(2)
|
||||
expect(result[2].startLine).toBe(3)
|
||||
})
|
||||
|
||||
it('handles CRLF line endings', () => {
|
||||
const sql = 'SELECT 1;\r\nSELECT 2;'
|
||||
const result = parseStatementsWithLines(sql, 'postgresql')
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[1].startLine).toBe(2)
|
||||
})
|
||||
|
||||
it('parses last statement without trailing semicolon', () => {
|
||||
const sql = 'SELECT 1; SELECT 2'
|
||||
const result = parseStatementsWithLines(sql, 'postgresql')
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[1].sql.trim()).toBe('SELECT 2')
|
||||
})
|
||||
})
|
||||
556
apps/desktop/src/main/__tests__/step-session.test.ts
Normal file
556
apps/desktop/src/main/__tests__/step-session.test.ts
Normal file
|
|
@ -0,0 +1,556 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron-log/main', () => ({
|
||||
default: {
|
||||
initialize: vi.fn(),
|
||||
transports: {
|
||||
console: { level: 'debug' },
|
||||
file: { level: 'debug', maxSize: 0, format: '' }
|
||||
},
|
||||
scope: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn()
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false
|
||||
}
|
||||
}))
|
||||
|
||||
import { StepSessionRegistry } from '../step-session'
|
||||
import type { ConnectionConfig } from '@shared/index'
|
||||
|
||||
class MockClient {
|
||||
calls: string[] = []
|
||||
responses: Array<{ rows?: unknown[]; fields?: unknown[]; rowCount?: number; error?: Error }> = []
|
||||
ended = false
|
||||
|
||||
async connect() {}
|
||||
async query(sql: string) {
|
||||
this.calls.push(sql)
|
||||
const response = this.responses.shift()
|
||||
if (!response) return { rows: [], fields: [], rowCount: 0 }
|
||||
if (response.error) throw response.error
|
||||
return {
|
||||
rows: response.rows ?? [],
|
||||
fields: response.fields ?? [],
|
||||
rowCount: response.rowCount ?? 0
|
||||
}
|
||||
}
|
||||
async end() { this.ended = true }
|
||||
}
|
||||
|
||||
const mockConfig: ConnectionConfig = {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
dbType: 'postgresql',
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
database: 'test',
|
||||
user: 'test',
|
||||
password: 'test'
|
||||
} as ConnectionConfig
|
||||
|
||||
describe('StepSessionRegistry', () => {
|
||||
let registry: StepSessionRegistry
|
||||
let mockClients: MockClient[]
|
||||
|
||||
beforeEach(() => {
|
||||
mockClients = []
|
||||
registry = new StepSessionRegistry({
|
||||
createClient: (() => {
|
||||
const client = new MockClient()
|
||||
mockClients.push(client)
|
||||
return client
|
||||
}) as never
|
||||
})
|
||||
})
|
||||
|
||||
describe('start', () => {
|
||||
it('creates a session with parsed statements', async () => {
|
||||
const { sessionId, statements } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1; SELECT 2;',
|
||||
inTransaction: false
|
||||
})
|
||||
expect(sessionId).toBeTruthy()
|
||||
expect(statements).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('runs BEGIN when inTransaction is true', async () => {
|
||||
await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1;',
|
||||
inTransaction: true
|
||||
})
|
||||
expect(mockClients[0].calls).toContain('BEGIN')
|
||||
})
|
||||
|
||||
it('does not run BEGIN when inTransaction is false', async () => {
|
||||
await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1;',
|
||||
inTransaction: false
|
||||
})
|
||||
expect(mockClients[0].calls).not.toContain('BEGIN')
|
||||
})
|
||||
})
|
||||
|
||||
describe('start error handling', () => {
|
||||
it('rejects and cleans up client when connect fails', async () => {
|
||||
const failingRegistry = new StepSessionRegistry({
|
||||
createClient: (() => {
|
||||
const c = new MockClient()
|
||||
c.connect = async () => { throw new Error('connection refused') }
|
||||
mockClients.push(c)
|
||||
return c
|
||||
}) as never
|
||||
})
|
||||
await expect(
|
||||
failingRegistry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1',
|
||||
inTransaction: false
|
||||
})
|
||||
).rejects.toThrow('connection refused')
|
||||
expect(mockClients[0].ended).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects and cleans up client when BEGIN fails in transaction mode', async () => {
|
||||
const failingRegistry = new StepSessionRegistry({
|
||||
createClient: (() => {
|
||||
const c = new MockClient()
|
||||
c.responses.push({ error: new Error('permission denied for BEGIN') })
|
||||
mockClients.push(c)
|
||||
return c
|
||||
}) as never
|
||||
})
|
||||
await expect(
|
||||
failingRegistry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1',
|
||||
inTransaction: true
|
||||
})
|
||||
).rejects.toThrow('permission denied')
|
||||
expect(mockClients[0].ended).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects on empty SQL without creating a client', async () => {
|
||||
const capturedClients: MockClient[] = []
|
||||
const emptyRegistry = new StepSessionRegistry({
|
||||
createClient: (() => {
|
||||
const c = new MockClient()
|
||||
capturedClients.push(c)
|
||||
return c
|
||||
}) as never
|
||||
})
|
||||
await expect(
|
||||
emptyRegistry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: '',
|
||||
inTransaction: false
|
||||
})
|
||||
).rejects.toThrow(/no statements/i)
|
||||
expect(capturedClients).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects on whitespace-only SQL without creating a client', async () => {
|
||||
const capturedClients: MockClient[] = []
|
||||
const wsRegistry = new StepSessionRegistry({
|
||||
createClient: (() => {
|
||||
const c = new MockClient()
|
||||
capturedClients.push(c)
|
||||
return c
|
||||
}) as never
|
||||
})
|
||||
await expect(
|
||||
wsRegistry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: ' \n\n-- only comment\n',
|
||||
inTransaction: false
|
||||
})
|
||||
).rejects.toThrow(/no statements/i)
|
||||
expect(capturedClients).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('next', () => {
|
||||
it('executes the next statement and increments cursor', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1; SELECT 2;',
|
||||
inTransaction: false
|
||||
})
|
||||
mockClients[0].responses.push({ rows: [{ a: 1 }], rowCount: 1 })
|
||||
|
||||
const response = await registry.next(sessionId)
|
||||
expect(response.statementIndex).toBe(0)
|
||||
expect(response.result.rowCount).toBe(1)
|
||||
expect(response.state).toBe('paused')
|
||||
expect(response.cursorIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('transitions to done after last statement', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1;',
|
||||
inTransaction: false
|
||||
})
|
||||
mockClients[0].responses.push({ rows: [], rowCount: 0 })
|
||||
|
||||
const response = await registry.next(sessionId)
|
||||
expect(response.state).toBe('done')
|
||||
})
|
||||
|
||||
it('transitions to errored on query failure', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1;',
|
||||
inTransaction: false
|
||||
})
|
||||
mockClients[0].responses.push({ error: new Error('syntax error') })
|
||||
|
||||
const response = await registry.next(sessionId)
|
||||
expect(response.state).toBe('errored')
|
||||
})
|
||||
})
|
||||
|
||||
describe('concurrent operations', () => {
|
||||
it('double-click on Next does not double-execute', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1; SELECT 2;',
|
||||
inTransaction: false
|
||||
})
|
||||
|
||||
// Push only ONE response — if both Next calls execute, the second
|
||||
// will get an empty response (fallback in MockClient) and we'll see
|
||||
// TWO queries run against mockClients[0]
|
||||
mockClients[0].responses.push({ rowCount: 1 })
|
||||
|
||||
const callsBefore = mockClients[0].calls.length
|
||||
|
||||
// Fire two next() concurrently without awaiting the first
|
||||
const [r1, r2] = await Promise.allSettled([
|
||||
registry.next(sessionId),
|
||||
registry.next(sessionId)
|
||||
])
|
||||
|
||||
// Exactly one should succeed; the other should reject because
|
||||
// state !== 'paused' once the first is running
|
||||
const succeeded = [r1, r2].filter((r) => r.status === 'fulfilled')
|
||||
const rejected = [r1, r2].filter((r) => r.status === 'rejected')
|
||||
|
||||
// If this test fails because both succeed, that's a real bug.
|
||||
// Expected behavior: one succeeds, one rejects.
|
||||
expect(succeeded).toHaveLength(1)
|
||||
expect(rejected).toHaveLength(1)
|
||||
// Only one statement actually executed:
|
||||
const newCalls = mockClients[0].calls.length - callsBefore
|
||||
expect(newCalls).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('skip', () => {
|
||||
it('increments cursor without executing', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1; SELECT 2;',
|
||||
inTransaction: false
|
||||
})
|
||||
const callsBefore = mockClients[0].calls.length
|
||||
|
||||
const response = await registry.skip(sessionId)
|
||||
expect(response.statementIndex).toBe(0)
|
||||
expect(response.state).toBe('paused')
|
||||
expect(mockClients[0].calls.length).toBe(callsBefore)
|
||||
})
|
||||
|
||||
it('skip on the last statement transitions to done', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1',
|
||||
inTransaction: false
|
||||
})
|
||||
const response = await registry.skip(sessionId)
|
||||
expect(response.state).toBe('done')
|
||||
})
|
||||
})
|
||||
|
||||
describe('continue', () => {
|
||||
it('runs all remaining statements when no breakpoints', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1; SELECT 2; SELECT 3;',
|
||||
inTransaction: false
|
||||
})
|
||||
mockClients[0].responses.push({ rowCount: 1 }, { rowCount: 2 }, { rowCount: 3 })
|
||||
|
||||
const response = await registry.continue(sessionId)
|
||||
expect(response.executedIndices).toEqual([0, 1, 2])
|
||||
expect(response.stoppedAt).toBe(null)
|
||||
expect(response.state).toBe('done')
|
||||
})
|
||||
|
||||
it('stops at breakpoint', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1; SELECT 2; SELECT 3;',
|
||||
inTransaction: false
|
||||
})
|
||||
await registry.setBreakpoints(sessionId, [2])
|
||||
mockClients[0].responses.push({ rowCount: 1 }, { rowCount: 2 }, { rowCount: 3 })
|
||||
|
||||
const response = await registry.continue(sessionId)
|
||||
expect(response.executedIndices).toEqual([0, 1])
|
||||
expect(response.stoppedAt).toBe(2)
|
||||
expect(response.state).toBe('paused')
|
||||
})
|
||||
|
||||
it('stops on error', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1; SELECT 2; SELECT 3;',
|
||||
inTransaction: false
|
||||
})
|
||||
mockClients[0].responses.push(
|
||||
{ rowCount: 1 },
|
||||
{ error: new Error('boom') }
|
||||
)
|
||||
|
||||
const response = await registry.continue(sessionId)
|
||||
expect(response.executedIndices).toEqual([0, 1])
|
||||
expect(response.state).toBe('errored')
|
||||
expect(response.error).toEqual({ statementIndex: 1, message: 'boom' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('continue with breakpoint at cursor', () => {
|
||||
it('does not stop on breakpoint at current cursor position (first iteration)', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1; SELECT 2; SELECT 3;',
|
||||
inTransaction: false
|
||||
})
|
||||
// Breakpoint at cursor 0 — continue() should still run statement 0
|
||||
await registry.setBreakpoints(sessionId, [0])
|
||||
mockClients[0].responses.push({ rowCount: 1 }, { rowCount: 2 }, { rowCount: 3 })
|
||||
|
||||
const response = await registry.continue(sessionId)
|
||||
|
||||
// First statement runs even though cursor started on a breakpoint
|
||||
expect(response.executedIndices).toContain(0)
|
||||
})
|
||||
|
||||
it('stops at breakpoint after advancing past current cursor', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1; SELECT 2; SELECT 3;',
|
||||
inTransaction: false
|
||||
})
|
||||
await registry.setBreakpoints(sessionId, [1])
|
||||
mockClients[0].responses.push({ rowCount: 1 }, { rowCount: 2 })
|
||||
|
||||
const response = await registry.continue(sessionId)
|
||||
|
||||
expect(response.executedIndices).toEqual([0])
|
||||
expect(response.stoppedAt).toBe(1)
|
||||
expect(response.state).toBe('paused')
|
||||
})
|
||||
})
|
||||
|
||||
describe('retry', () => {
|
||||
it('re-executes current statement in auto-commit mode', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1;',
|
||||
inTransaction: false
|
||||
})
|
||||
mockClients[0].responses.push({ error: new Error('boom') })
|
||||
await registry.next(sessionId)
|
||||
|
||||
mockClients[0].responses.push({ rowCount: 1 })
|
||||
const response = await registry.retry(sessionId)
|
||||
expect(response.result.rowCount).toBe(1)
|
||||
expect(response.state).toBe('done')
|
||||
})
|
||||
|
||||
it('rejects retry when in transaction mode', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1;',
|
||||
inTransaction: true
|
||||
})
|
||||
mockClients[0].responses.push({ error: new Error('boom') })
|
||||
await registry.next(sessionId)
|
||||
|
||||
await expect(registry.retry(sessionId)).rejects.toThrow(/transaction mode/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('retry state guard', () => {
|
||||
it('throws when state is paused', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1',
|
||||
inTransaction: false
|
||||
})
|
||||
await expect(registry.retry(sessionId)).rejects.toThrow(/errored state/i)
|
||||
})
|
||||
|
||||
it('throws when state is done', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1',
|
||||
inTransaction: false
|
||||
})
|
||||
mockClients[0].responses.push({ rowCount: 1 })
|
||||
await registry.next(sessionId)
|
||||
// Now state is 'done'
|
||||
await expect(registry.retry(sessionId)).rejects.toThrow(/errored state/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stop', () => {
|
||||
it('rolls back transaction and closes client in transaction mode', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1;',
|
||||
inTransaction: true
|
||||
})
|
||||
const response = await registry.stop(sessionId)
|
||||
expect(response.rolledBack).toBe(true)
|
||||
expect(mockClients[0].calls).toContain('ROLLBACK')
|
||||
expect(mockClients[0].ended).toBe(true)
|
||||
})
|
||||
|
||||
it('just closes client in auto-commit mode', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1;',
|
||||
inTransaction: false
|
||||
})
|
||||
const response = await registry.stop(sessionId)
|
||||
expect(response.rolledBack).toBe(false)
|
||||
expect(mockClients[0].calls).not.toContain('ROLLBACK')
|
||||
expect(mockClients[0].ended).toBe(true)
|
||||
})
|
||||
|
||||
it('removes session from registry', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1;',
|
||||
inTransaction: false
|
||||
})
|
||||
await registry.stop(sessionId)
|
||||
await expect(registry.next(sessionId)).rejects.toThrow(/not found/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stop idempotency', () => {
|
||||
it('second stop() returns rolledBack: false without throwing', async () => {
|
||||
const { sessionId } = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1',
|
||||
inTransaction: false
|
||||
})
|
||||
await registry.stop(sessionId)
|
||||
const result = await registry.stop(sessionId)
|
||||
expect(result.rolledBack).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanupWindow', () => {
|
||||
it('stops all sessions for a given window', async () => {
|
||||
const a = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-1',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 1;',
|
||||
inTransaction: false
|
||||
})
|
||||
// NOTE: each call to `start` creates a new session with its own MockClient
|
||||
// because our factory creates a fresh one each time.
|
||||
|
||||
const b = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-2',
|
||||
windowId: 1,
|
||||
sql: 'SELECT 2;',
|
||||
inTransaction: false
|
||||
})
|
||||
const c = await registry.start({
|
||||
config: mockConfig,
|
||||
tabId: 'tab-3',
|
||||
windowId: 2,
|
||||
sql: 'SELECT 3;',
|
||||
inTransaction: false
|
||||
})
|
||||
|
||||
await registry.cleanupWindow(1)
|
||||
|
||||
await expect(registry.next(a.sessionId)).rejects.toThrow(/not found/i)
|
||||
await expect(registry.next(b.sessionId)).rejects.toThrow(/not found/i)
|
||||
mockClients[2].responses.push({ rowCount: 1 })
|
||||
const result = await registry.next(c.sessionId)
|
||||
expect(result.state).toBe('done')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -19,12 +19,18 @@ import { windowManager } from './window-manager'
|
|||
import { initSchedulerService, stopAllSchedules } from './scheduler-service'
|
||||
import { initDashboardService } from './dashboard-service'
|
||||
import { cleanup as cleanupPgNotify } from './pg-notification-listener'
|
||||
import { StepSessionRegistry } from './step-session'
|
||||
import { createLogger } from './lib/logger'
|
||||
|
||||
const log = createLogger('app')
|
||||
|
||||
// Store instances
|
||||
let store: DpStorage<{ connections: ConnectionConfig[] }>
|
||||
let savedQueriesStore: DpStorage<{ savedQueries: SavedQuery[] }>
|
||||
let snippetsStore: DpStorage<{ snippets: Snippet[] }>
|
||||
|
||||
const stepSessionRegistry = new StepSessionRegistry()
|
||||
|
||||
/**
|
||||
* Initialize all persistent stores
|
||||
*/
|
||||
|
|
@ -104,11 +110,12 @@ app.whenReady().then(async () => {
|
|||
|
||||
// Register all IPC handlers
|
||||
const notebookStorage = new NotebookStorage(app.getPath('userData'))
|
||||
stepSessionRegistry.startCleanupTimer()
|
||||
registerAllHandlers({
|
||||
connections: store,
|
||||
savedQueries: savedQueriesStore,
|
||||
snippets: snippetsStore
|
||||
}, notebookStorage)
|
||||
}, notebookStorage, stepSessionRegistry)
|
||||
|
||||
// Create initial window
|
||||
await windowManager.createWindow()
|
||||
|
|
@ -124,14 +131,37 @@ app.whenReady().then(async () => {
|
|||
windowManager.showPrimaryWindow()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('browser-window-created', (_event, window) => {
|
||||
window.on('closed', () => {
|
||||
stepSessionRegistry.cleanupWindow(window.id).catch((err) => {
|
||||
console.warn('Step session cleanup failed:', err)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// macOS: set forceQuit flag before quitting
|
||||
app.on('before-quit', () => {
|
||||
let isCleaningUp = false
|
||||
app.on('before-quit', (event) => {
|
||||
if (isCleaningUp) return
|
||||
event.preventDefault()
|
||||
isCleaningUp = true
|
||||
|
||||
setForceQuit(true)
|
||||
stopPeriodicChecks()
|
||||
stopAllSchedules()
|
||||
cleanupPgNotify()
|
||||
|
||||
Promise.race([
|
||||
stepSessionRegistry.cleanupAll(),
|
||||
new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
])
|
||||
.catch((err) => log.error('cleanupAll failed during quit:', err))
|
||||
.finally(() => {
|
||||
stepSessionRegistry.stopCleanupTimer()
|
||||
app.quit()
|
||||
})
|
||||
})
|
||||
|
||||
// Quit when all windows are closed (except macOS)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import { registerHealthHandlers } from './health-handlers'
|
|||
import { registerPgExportImportHandlers } from './pg-export-import-handlers'
|
||||
import { registerNotebookHandlers } from './notebook-handlers'
|
||||
import { registerIntelHandlers } from './intel-handlers'
|
||||
import { registerStepHandlers } from './step-handlers'
|
||||
import type { StepSessionRegistry } from '../step-session'
|
||||
|
||||
const log = createLogger('ipc')
|
||||
|
||||
|
|
@ -35,7 +37,11 @@ export interface IpcStores {
|
|||
*
|
||||
* @param stores - Persistent stores required by handler categories; includes `connections` (connection configs) and `savedQueries` (saved query entries)
|
||||
*/
|
||||
export function registerAllHandlers(stores: IpcStores, notebookStorage: NotebookStorage): void {
|
||||
export function registerAllHandlers(
|
||||
stores: IpcStores,
|
||||
notebookStorage: NotebookStorage,
|
||||
stepSessionRegistry: StepSessionRegistry
|
||||
): void {
|
||||
// Connection CRUD operations
|
||||
registerConnectionHandlers(stores.connections)
|
||||
|
||||
|
|
@ -93,6 +99,9 @@ export function registerAllHandlers(stores: IpcStores, notebookStorage: Notebook
|
|||
// Schema Intel / diagnostics
|
||||
registerIntelHandlers()
|
||||
|
||||
// Step-through sessions
|
||||
registerStepHandlers(stepSessionRegistry)
|
||||
|
||||
log.debug('All handlers registered')
|
||||
}
|
||||
|
||||
|
|
@ -113,3 +122,4 @@ export { registerHealthHandlers } from './health-handlers'
|
|||
export { registerPgExportImportHandlers } from './pg-export-import-handlers'
|
||||
export { registerNotebookHandlers } from './notebook-handlers'
|
||||
export { registerIntelHandlers } from './intel-handlers'
|
||||
export { registerStepHandlers } from './step-handlers'
|
||||
|
|
|
|||
106
apps/desktop/src/main/ipc/step-handlers.ts
Normal file
106
apps/desktop/src/main/ipc/step-handlers.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { ipcMain, BrowserWindow } from 'electron'
|
||||
import type { StepSessionRegistry } from '../step-session'
|
||||
import type { ConnectionConfig, StartStepRequest } from '@shared/index'
|
||||
|
||||
export function registerStepHandlers(registry: StepSessionRegistry): void {
|
||||
ipcMain.handle(
|
||||
'step:start',
|
||||
async (event, { config, request }: { config: ConnectionConfig; request: StartStepRequest }) => {
|
||||
try {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
const windowId = win?.id ?? -1
|
||||
const data = await registry.start({
|
||||
config,
|
||||
tabId: request.tabId,
|
||||
windowId,
|
||||
sql: request.sql,
|
||||
inTransaction: request.inTransaction
|
||||
})
|
||||
return { success: true, data }
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('step:next', async (_event, sessionId: string) => {
|
||||
try {
|
||||
const data = await registry.next(sessionId)
|
||||
return { success: true, data }
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('step:skip', async (_event, sessionId: string) => {
|
||||
try {
|
||||
const data = await registry.skip(sessionId)
|
||||
return { success: true, data }
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('step:continue', async (_event, sessionId: string) => {
|
||||
try {
|
||||
const data = await registry.continue(sessionId)
|
||||
return { success: true, data }
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('step:retry', async (_event, sessionId: string) => {
|
||||
try {
|
||||
const data = await registry.retry(sessionId)
|
||||
return { success: true, data }
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'step:set-breakpoints',
|
||||
async (
|
||||
_event,
|
||||
{ sessionId, breakpoints }: { sessionId: string; breakpoints: number[] }
|
||||
) => {
|
||||
try {
|
||||
await registry.setBreakpoints(sessionId, breakpoints)
|
||||
return { success: true }
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('step:stop', async (_event, sessionId: string) => {
|
||||
try {
|
||||
const data = await registry.stop(sessionId)
|
||||
return { success: true, data }
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
96
apps/desktop/src/main/lib/parse-statements.ts
Normal file
96
apps/desktop/src/main/lib/parse-statements.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import type { DatabaseType } from '@shared/index'
|
||||
import { DDL_KEYWORD_REGEX } from '@shared/index'
|
||||
import type { ParsedStatement } from '@shared/index'
|
||||
import { splitStatements } from './sql-parser'
|
||||
import { createLogger } from './logger'
|
||||
|
||||
const log = createLogger('parse-statements')
|
||||
|
||||
/**
|
||||
* Strip leading single-line (--) and block (/* *\/) comments from a SQL statement,
|
||||
* returning only the actual statement text with surrounding whitespace trimmed.
|
||||
*/
|
||||
function stripLeadingComments(stmt: string): string {
|
||||
let s = stmt.trimStart()
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
if (s.startsWith('--')) {
|
||||
const nl = s.indexOf('\n')
|
||||
s = nl === -1 ? '' : s.slice(nl + 1).trimStart()
|
||||
changed = true
|
||||
} else if (s.startsWith('/*')) {
|
||||
const end = s.indexOf('*/')
|
||||
s = end === -1 ? '' : s.slice(end + 2).trimStart()
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return s.trimEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SQL into statements with line-range metadata.
|
||||
* Uses the existing splitStatements parser to preserve dialect-specific behavior
|
||||
* (dollar quotes, backticks, bracket identifiers, etc.) then walks the original
|
||||
* SQL to find each statement's line range.
|
||||
*/
|
||||
export function parseStatementsWithLines(
|
||||
sql: string,
|
||||
dbType: DatabaseType
|
||||
): ParsedStatement[] {
|
||||
const rawStatements = splitStatements(sql, dbType)
|
||||
if (rawStatements.length === 0) return []
|
||||
|
||||
const result: ParsedStatement[] = []
|
||||
let searchFrom = 0
|
||||
|
||||
for (let i = 0; i < rawStatements.length; i++) {
|
||||
const stmt = rawStatements[i]
|
||||
const sqlOnly = stripLeadingComments(stmt)
|
||||
if (sqlOnly.length === 0) continue
|
||||
|
||||
const startInSql = sql.indexOf(sqlOnly, searchFrom)
|
||||
if (startInSql === -1) {
|
||||
const internalNewlines = (sqlOnly.match(/\n/g) ?? []).length
|
||||
const fallbackStartLine = countLines(sql, searchFrom) + 1
|
||||
log.warn(
|
||||
`parseStatementsWithLines: could not locate statement #${result.length} in source SQL; ` +
|
||||
`using approximate line range ${fallbackStartLine}-${fallbackStartLine + internalNewlines}`
|
||||
)
|
||||
result.push({
|
||||
index: result.length,
|
||||
sql: sqlOnly,
|
||||
startLine: fallbackStartLine,
|
||||
endLine: fallbackStartLine + internalNewlines,
|
||||
isDDL: DDL_KEYWORD_REGEX.test(sqlOnly)
|
||||
})
|
||||
// Don't advance searchFrom — further statements can't be reliably located either
|
||||
// Just continue, accepting approximate line numbers for remaining statements
|
||||
continue
|
||||
}
|
||||
|
||||
const endInSql = startInSql + sqlOnly.length
|
||||
const startLine = countLines(sql, startInSql) + 1
|
||||
const endLine = countLines(sql, endInSql) + 1
|
||||
|
||||
result.push({
|
||||
index: result.length,
|
||||
sql: sqlOnly,
|
||||
startLine,
|
||||
endLine,
|
||||
isDDL: DDL_KEYWORD_REGEX.test(sqlOnly)
|
||||
})
|
||||
|
||||
searchFrom = endInSql
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function countLines(sql: string, upTo: number): number {
|
||||
let count = 0
|
||||
for (let i = 0; i < upTo && i < sql.length; i++) {
|
||||
if (sql[i] === '\n') count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
371
apps/desktop/src/main/step-session.ts
Normal file
371
apps/desktop/src/main/step-session.ts
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
import { randomUUID } from 'crypto'
|
||||
import { Client } from 'pg'
|
||||
import type {
|
||||
ConnectionConfig,
|
||||
StatementResult,
|
||||
ParsedStatement,
|
||||
SessionState,
|
||||
StepSessionError,
|
||||
StartStepResponse,
|
||||
NextStepResponse,
|
||||
SkipStepResponse,
|
||||
ContinueStepResponse,
|
||||
RetryStepResponse,
|
||||
StopStepResponse
|
||||
} from '@shared/index'
|
||||
import { STEP_SESSION_IDLE_TIMEOUT_MS, STEP_SESSION_CLEANUP_INTERVAL_MS } from '@shared/index'
|
||||
import { parseStatementsWithLines } from './lib/parse-statements'
|
||||
import { createLogger } from './lib/logger'
|
||||
|
||||
const log = createLogger('step-session')
|
||||
|
||||
export interface MinimalDbClient {
|
||||
connect(): Promise<void>
|
||||
query(sql: string): Promise<{
|
||||
rows: unknown[]
|
||||
fields?: Array<{ name: string; dataTypeID?: number }>
|
||||
rowCount: number | null
|
||||
}>
|
||||
end(): Promise<void>
|
||||
}
|
||||
|
||||
interface StepSession {
|
||||
id: string
|
||||
windowId: number
|
||||
tabId: string
|
||||
config: ConnectionConfig
|
||||
client: MinimalDbClient
|
||||
statements: ParsedStatement[]
|
||||
cursorIndex: number
|
||||
breakpoints: Set<number>
|
||||
inTransaction: boolean
|
||||
state: SessionState
|
||||
lastError: StepSessionError | null
|
||||
lastActivity: number
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
export interface StepSessionRegistryOptions {
|
||||
createClient?: (config: ConnectionConfig) => MinimalDbClient
|
||||
}
|
||||
|
||||
export class StepSessionRegistry {
|
||||
private sessions = new Map<string, StepSession>()
|
||||
private cleanupTimer: ReturnType<typeof setInterval> | null = null
|
||||
private createClient: (config: ConnectionConfig) => MinimalDbClient
|
||||
|
||||
constructor(options: StepSessionRegistryOptions = {}) {
|
||||
this.createClient = options.createClient ?? defaultClientFactory
|
||||
}
|
||||
|
||||
startCleanupTimer(): void {
|
||||
if (this.cleanupTimer) return
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
this.pruneIdleSessions()
|
||||
}, STEP_SESSION_CLEANUP_INTERVAL_MS)
|
||||
}
|
||||
|
||||
stopCleanupTimer(): void {
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer)
|
||||
this.cleanupTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async start(input: {
|
||||
config: ConnectionConfig
|
||||
tabId: string
|
||||
windowId: number
|
||||
sql: string
|
||||
inTransaction: boolean
|
||||
}): Promise<StartStepResponse> {
|
||||
const statements = parseStatementsWithLines(input.sql, input.config.dbType ?? 'postgresql')
|
||||
if (statements.length === 0) {
|
||||
throw new Error('No statements found in SQL')
|
||||
}
|
||||
|
||||
const client = this.createClient(input.config)
|
||||
try {
|
||||
await client.connect()
|
||||
if (input.inTransaction) {
|
||||
await client.query('BEGIN')
|
||||
}
|
||||
} catch (err) {
|
||||
await client.end().catch((endErr) => {
|
||||
log.warn(`Cleanup after failed start: client.end() also failed:`, endErr)
|
||||
})
|
||||
throw err
|
||||
}
|
||||
|
||||
const sessionId = randomUUID()
|
||||
const now = Date.now()
|
||||
this.sessions.set(sessionId, {
|
||||
id: sessionId,
|
||||
windowId: input.windowId,
|
||||
tabId: input.tabId,
|
||||
config: input.config,
|
||||
client,
|
||||
statements,
|
||||
cursorIndex: 0,
|
||||
breakpoints: new Set(),
|
||||
inTransaction: input.inTransaction,
|
||||
state: 'paused',
|
||||
lastError: null,
|
||||
lastActivity: now,
|
||||
startedAt: now
|
||||
})
|
||||
|
||||
log.debug(`Started step session ${sessionId} (tab=${input.tabId}, window=${input.windowId})`)
|
||||
return { sessionId, statements }
|
||||
}
|
||||
|
||||
async next(sessionId: string): Promise<NextStepResponse> {
|
||||
const session = this.requireSession(sessionId)
|
||||
if (session.state !== 'paused') {
|
||||
throw new Error(`Cannot advance session in state: ${session.state}`)
|
||||
}
|
||||
return this.executeCurrent(session, { advance: true })
|
||||
}
|
||||
|
||||
async skip(sessionId: string): Promise<SkipStepResponse> {
|
||||
const session = this.requireSession(sessionId)
|
||||
if (session.state !== 'paused') {
|
||||
throw new Error(`Cannot skip in state: ${session.state}`)
|
||||
}
|
||||
const skippedIndex = session.cursorIndex
|
||||
session.cursorIndex++
|
||||
session.lastActivity = Date.now()
|
||||
if (session.cursorIndex >= session.statements.length) {
|
||||
session.state = 'done'
|
||||
}
|
||||
return { statementIndex: skippedIndex, state: session.state, cursorIndex: session.cursorIndex }
|
||||
}
|
||||
|
||||
async continue(sessionId: string): Promise<ContinueStepResponse> {
|
||||
const session = this.requireSession(sessionId)
|
||||
if (session.state !== 'paused') {
|
||||
throw new Error(`Cannot continue in state: ${session.state}`)
|
||||
}
|
||||
const executedIndices: number[] = []
|
||||
const results: StatementResult[] = []
|
||||
let stoppedAt: number | null = null
|
||||
|
||||
while (session.cursorIndex < session.statements.length) {
|
||||
if (executedIndices.length > 0 && session.breakpoints.has(session.cursorIndex)) {
|
||||
stoppedAt = session.cursorIndex
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.executeCurrent(session, { advance: true })
|
||||
|
||||
executedIndices.push(response.statementIndex)
|
||||
results.push(response.result)
|
||||
|
||||
if ((session.state as SessionState) === 'errored') {
|
||||
break
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
log.error(`continue() loop aborted unexpectedly at index ${session.cursorIndex}:`, err)
|
||||
session.state = 'errored'
|
||||
session.lastError = {
|
||||
statementIndex: session.cursorIndex,
|
||||
message
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
session.cursorIndex >= session.statements.length &&
|
||||
(session.state as SessionState) !== 'errored'
|
||||
) {
|
||||
session.state = 'done'
|
||||
}
|
||||
|
||||
return {
|
||||
executedIndices,
|
||||
results,
|
||||
stoppedAt,
|
||||
state: session.state,
|
||||
cursorIndex: session.cursorIndex,
|
||||
error: session.lastError ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
async retry(sessionId: string): Promise<RetryStepResponse> {
|
||||
const session = this.requireSession(sessionId)
|
||||
if (session.state !== 'errored') {
|
||||
throw new Error(`Can only retry in errored state, got: ${session.state}`)
|
||||
}
|
||||
if (session.inTransaction) {
|
||||
throw new Error(
|
||||
'Cannot retry in transaction mode — transaction is poisoned. Stop and restart.'
|
||||
)
|
||||
}
|
||||
|
||||
const response = await this.executeCurrent(session, { advance: true })
|
||||
return {
|
||||
result: response.result,
|
||||
state: response.state,
|
||||
cursorIndex: response.cursorIndex,
|
||||
error: response.error
|
||||
}
|
||||
}
|
||||
|
||||
async setBreakpoints(sessionId: string, breakpoints: number[]): Promise<void> {
|
||||
const session = this.requireSession(sessionId)
|
||||
session.breakpoints = new Set(breakpoints)
|
||||
session.lastActivity = Date.now()
|
||||
}
|
||||
|
||||
async stop(sessionId: string): Promise<StopStepResponse> {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) return { rolledBack: false }
|
||||
|
||||
let rolledBack = false
|
||||
let rollbackError: string | undefined
|
||||
|
||||
if (session.inTransaction && (session.state === 'paused' || session.state === 'errored')) {
|
||||
try {
|
||||
await session.client.query('ROLLBACK')
|
||||
rolledBack = true
|
||||
} catch (err) {
|
||||
rollbackError = err instanceof Error ? err.message : String(err)
|
||||
log.warn(`ROLLBACK failed for session ${sessionId}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
await session.client.end().catch((err) => {
|
||||
log.warn(`Client.end() failed for session ${sessionId}:`, err)
|
||||
})
|
||||
|
||||
this.sessions.delete(sessionId)
|
||||
log.debug(`Stopped session ${sessionId} (rolledBack=${rolledBack})`)
|
||||
return { rolledBack, rollbackError }
|
||||
}
|
||||
|
||||
async cleanupWindow(windowId: number): Promise<void> {
|
||||
const toStop = Array.from(this.sessions.values()).filter((s) => s.windowId === windowId)
|
||||
for (const s of toStop) {
|
||||
await this.stop(s.id)
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupAll(): Promise<void> {
|
||||
const toStop = Array.from(this.sessions.keys())
|
||||
for (const id of toStop) {
|
||||
await this.stop(id)
|
||||
}
|
||||
}
|
||||
|
||||
private async pruneIdleSessions(): Promise<void> {
|
||||
const now = Date.now()
|
||||
const idle = Array.from(this.sessions.values()).filter(
|
||||
(s) => now - s.lastActivity > STEP_SESSION_IDLE_TIMEOUT_MS
|
||||
)
|
||||
for (const s of idle) {
|
||||
log.debug(`Pruning idle session ${s.id}`)
|
||||
await this.stop(s.id)
|
||||
}
|
||||
}
|
||||
|
||||
private requireSession(sessionId: string): StepSession {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error(`Step session not found: ${sessionId}`)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private async executeCurrent(
|
||||
session: StepSession,
|
||||
opts: { advance: boolean }
|
||||
): Promise<NextStepResponse> {
|
||||
const statementIndex = session.cursorIndex
|
||||
const statement = session.statements[statementIndex]
|
||||
if (!statement) {
|
||||
throw new Error(`No statement at index ${statementIndex}`)
|
||||
}
|
||||
|
||||
session.state = 'running'
|
||||
session.lastActivity = Date.now()
|
||||
const stmtStart = Date.now()
|
||||
|
||||
try {
|
||||
const res = await session.client.query(statement.sql)
|
||||
const durationMs = Date.now() - stmtStart
|
||||
const fields = (res.fields ?? []).map((f) => ({
|
||||
name: f.name,
|
||||
dataType: 'unknown',
|
||||
dataTypeID: f.dataTypeID ?? 0
|
||||
}))
|
||||
|
||||
const result: StatementResult = {
|
||||
statement: statement.sql,
|
||||
statementIndex,
|
||||
rows: (res.rows ?? []) as Record<string, unknown>[],
|
||||
fields,
|
||||
rowCount: res.rowCount ?? (res.rows?.length ?? 0),
|
||||
durationMs,
|
||||
isDataReturning: (res.rows ?? []).length > 0 || Array.isArray(res.fields)
|
||||
}
|
||||
|
||||
if (opts.advance) {
|
||||
session.cursorIndex++
|
||||
}
|
||||
session.state = session.cursorIndex >= session.statements.length ? 'done' : 'paused'
|
||||
session.lastActivity = Date.now()
|
||||
session.lastError = null
|
||||
|
||||
return { statementIndex, result, state: session.state, cursorIndex: session.cursorIndex }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
log.warn(`Statement ${statementIndex} failed:`, err)
|
||||
session.state = 'errored'
|
||||
session.lastError = { statementIndex, message }
|
||||
session.lastActivity = Date.now()
|
||||
|
||||
const result: StatementResult = {
|
||||
statement: statement.sql,
|
||||
statementIndex,
|
||||
rows: [],
|
||||
fields: [],
|
||||
rowCount: 0,
|
||||
durationMs: Date.now() - stmtStart,
|
||||
isDataReturning: false
|
||||
}
|
||||
return {
|
||||
statementIndex,
|
||||
result,
|
||||
state: session.state,
|
||||
cursorIndex: session.cursorIndex,
|
||||
error: session.lastError
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function defaultClientFactory(config: ConnectionConfig): MinimalDbClient {
|
||||
const client = new Client({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
database: config.database,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
ssl: config.ssl ? { rejectUnauthorized: false } : undefined
|
||||
})
|
||||
return {
|
||||
connect: () => client.connect(),
|
||||
query: async (sql: string) => {
|
||||
const res = await client.query(sql)
|
||||
return {
|
||||
rows: res.rows,
|
||||
fields: res.fields as unknown as Array<{ name: string; dataTypeID?: number }>,
|
||||
rowCount: res.rowCount
|
||||
}
|
||||
},
|
||||
end: () => client.end()
|
||||
}
|
||||
}
|
||||
21
apps/desktop/src/preload/index.d.ts
vendored
21
apps/desktop/src/preload/index.d.ts
vendored
|
|
@ -63,7 +63,14 @@ import type {
|
|||
CreateNotebookInput,
|
||||
UpdateNotebookInput,
|
||||
AddCellInput,
|
||||
UpdateCellInput
|
||||
UpdateCellInput,
|
||||
StartStepRequest,
|
||||
StartStepResponse,
|
||||
NextStepResponse,
|
||||
SkipStepResponse,
|
||||
ContinueStepResponse,
|
||||
RetryStepResponse,
|
||||
StopStepResponse
|
||||
} from '@shared/index'
|
||||
|
||||
// AI Types
|
||||
|
|
@ -480,6 +487,18 @@ interface DataPeekApi {
|
|||
deleteCell: (cellId: string) => Promise<IpcResponse<void>>
|
||||
reorderCells: (notebookId: string, cellIds: string[]) => Promise<IpcResponse<void>>
|
||||
}
|
||||
step: {
|
||||
start: (
|
||||
config: ConnectionConfig,
|
||||
request: StartStepRequest
|
||||
) => Promise<IpcResponse<StartStepResponse>>
|
||||
next: (sessionId: string) => Promise<IpcResponse<NextStepResponse>>
|
||||
skip: (sessionId: string) => Promise<IpcResponse<SkipStepResponse>>
|
||||
continue: (sessionId: string) => Promise<IpcResponse<ContinueStepResponse>>
|
||||
retry: (sessionId: string) => Promise<IpcResponse<RetryStepResponse>>
|
||||
setBreakpoints: (sessionId: string, breakpoints: number[]) => Promise<IpcResponse<void>>
|
||||
stop: (sessionId: string) => Promise<IpcResponse<StopStepResponse>>
|
||||
}
|
||||
files: {
|
||||
openFilePicker: () => Promise<string | null>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,14 @@ import type {
|
|||
CreateNotebookInput,
|
||||
UpdateNotebookInput,
|
||||
AddCellInput,
|
||||
UpdateCellInput
|
||||
UpdateCellInput,
|
||||
StartStepRequest,
|
||||
StartStepResponse,
|
||||
NextStepResponse,
|
||||
SkipStepResponse,
|
||||
ContinueStepResponse,
|
||||
RetryStepResponse,
|
||||
StopStepResponse
|
||||
} from '@shared/index'
|
||||
|
||||
// Re-export AI types for renderer consumers
|
||||
|
|
@ -340,6 +347,25 @@ const api = {
|
|||
reorderCells: (notebookId: string, cellIds: string[]): Promise<IpcResponse<void>> =>
|
||||
ipcRenderer.invoke('notebooks:reorder-cells', { notebookId, cellIds })
|
||||
},
|
||||
step: {
|
||||
start: (
|
||||
config: ConnectionConfig,
|
||||
request: StartStepRequest
|
||||
): Promise<IpcResponse<StartStepResponse>> =>
|
||||
ipcRenderer.invoke('step:start', { config, request }),
|
||||
next: (sessionId: string): Promise<IpcResponse<NextStepResponse>> =>
|
||||
ipcRenderer.invoke('step:next', sessionId),
|
||||
skip: (sessionId: string): Promise<IpcResponse<SkipStepResponse>> =>
|
||||
ipcRenderer.invoke('step:skip', sessionId),
|
||||
continue: (sessionId: string): Promise<IpcResponse<ContinueStepResponse>> =>
|
||||
ipcRenderer.invoke('step:continue', sessionId),
|
||||
retry: (sessionId: string): Promise<IpcResponse<RetryStepResponse>> =>
|
||||
ipcRenderer.invoke('step:retry', sessionId),
|
||||
setBreakpoints: (sessionId: string, breakpoints: number[]): Promise<IpcResponse<void>> =>
|
||||
ipcRenderer.invoke('step:set-breakpoints', { sessionId, breakpoints }),
|
||||
stop: (sessionId: string): Promise<IpcResponse<StopStepResponse>> =>
|
||||
ipcRenderer.invoke('step:stop', sessionId)
|
||||
},
|
||||
// Scheduled queries management
|
||||
scheduledQueries: {
|
||||
list: (): Promise<IpcResponse<ScheduledQuery[]>> =>
|
||||
|
|
|
|||
|
|
@ -646,3 +646,30 @@ input[type='number'] {
|
|||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Step-through Monaco decorations */
|
||||
.step-active-line {
|
||||
background: oklch(0.65 0.15 250 / 0.1);
|
||||
box-shadow: inset 2px 0 0 oklch(0.65 0.15 250);
|
||||
}
|
||||
|
||||
.step-active-marker {
|
||||
background: oklch(0.65 0.15 250);
|
||||
width: 2px !important;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.step-breakpoint {
|
||||
background: oklch(0.6 0.18 25);
|
||||
border-radius: 50%;
|
||||
width: 10px !important;
|
||||
height: 10px !important;
|
||||
margin-left: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.step-active-line {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useState, useCallback, useRef, useEffect, memo } from 'react'
|
||||
import { useState, useCallback, useRef, useEffect, useMemo, memo } from 'react'
|
||||
import { Play, MoreHorizontal, GripVertical, Check, X, Pin, PinOff } from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
|
|
@ -14,6 +16,7 @@ import {
|
|||
import type { NotebookCell as CellType, PinnedResult } from '@shared/index'
|
||||
import { useNotebookStore } from '@/stores/notebook-store'
|
||||
import { useConnectionStore } from '@/stores/connection-store'
|
||||
import { SQLEditor } from '@/components/sql-editor'
|
||||
|
||||
interface NotebookCellProps {
|
||||
cell: CellType
|
||||
|
|
@ -26,7 +29,7 @@ interface NotebookCellProps {
|
|||
|
||||
interface QueryResult {
|
||||
fields: { name: string }[]
|
||||
rows: unknown[][]
|
||||
rows: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
const MAX_DISPLAY_ROWS = 100
|
||||
|
|
@ -58,15 +61,21 @@ function ResultTable({ result }: { result: QueryResult }) {
|
|||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={ri} className="border-b border-border/30 hover:bg-muted/20">
|
||||
{(row as unknown[]).map((cell, ci) => (
|
||||
<td key={ci} className="px-2 py-1 font-mono whitespace-nowrap max-w-[300px] truncate">
|
||||
{cell === null || cell === undefined ? (
|
||||
<span className="italic text-muted-foreground/50">null</span>
|
||||
) : (
|
||||
String(cell)
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{columns.map((col, ci) => {
|
||||
const cell = row[col]
|
||||
return (
|
||||
<td
|
||||
key={ci}
|
||||
className="px-2 py-1 font-mono whitespace-nowrap max-w-[300px] truncate"
|
||||
>
|
||||
{cell === null || cell === undefined ? (
|
||||
<span className="italic text-muted-foreground/50">null</span>
|
||||
) : (
|
||||
String(cell)
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -97,6 +106,15 @@ export const NotebookCell = memo(function NotebookCell({
|
|||
onRunAndAdvance,
|
||||
onDelete
|
||||
}: NotebookCellProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: cell.id
|
||||
})
|
||||
|
||||
const sortableStyle = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition
|
||||
}
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [status, setStatus] = useState<'idle' | 'running' | 'success' | 'error'>('idle')
|
||||
const [liveResult, setLiveResult] = useState<QueryResult | null>(null)
|
||||
|
|
@ -111,9 +129,15 @@ export const NotebookCell = memo(function NotebookCell({
|
|||
const pinResult = useNotebookStore((s) => s.pinResult)
|
||||
const unpinResult = useNotebookStore((s) => s.unpinResult)
|
||||
const connections = useConnectionStore((s) => s.connections)
|
||||
const schemas = useConnectionStore((s) => s.schemas)
|
||||
|
||||
const activeConnection = connections.find((c) => c.id === connectionId) ?? null
|
||||
|
||||
const sqlEditorHeight = useMemo(() => {
|
||||
const lines = Math.max(3, cell.content.split('\n').length)
|
||||
return Math.min(400, lines * 20 + 24)
|
||||
}, [cell.content])
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && isFocused && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
|
|
@ -164,34 +188,12 @@ export const NotebookCell = memo(function NotebookCell({
|
|||
}
|
||||
}, [activeConnection, cell.content, cell.type])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setIsEditing(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
runQuery().then(() => onRunAndAdvance())
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
runQuery()
|
||||
return
|
||||
}
|
||||
},
|
||||
[runQuery, onRunAndAdvance]
|
||||
)
|
||||
|
||||
const handlePinResult = useCallback(() => {
|
||||
if (!liveResult) return
|
||||
const columns = liveResult.fields.map((f) => f.name)
|
||||
const pinned: PinnedResult = {
|
||||
columns: liveResult.fields.map((f) => f.name),
|
||||
rows: liveResult.rows,
|
||||
columns,
|
||||
rows: liveResult.rows.map((row) => columns.map((col) => row[col])),
|
||||
rowCount: liveResult.rows.length,
|
||||
executedAt: Date.now(),
|
||||
durationMs: durationMs ?? 0,
|
||||
|
|
@ -209,10 +211,13 @@ export const NotebookCell = memo(function NotebookCell({
|
|||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={sortableStyle}
|
||||
className={cn(
|
||||
'group relative rounded-lg border transition-all duration-150',
|
||||
'border-border/50 bg-card',
|
||||
isFocused && 'border-primary/30 shadow-[0_0_0_1px_oklch(0.55_0.15_250/0.3)]'
|
||||
isFocused && 'border-primary/30 shadow-[0_0_0_1px_oklch(0.55_0.15_250/0.3)]',
|
||||
isDragging && 'opacity-50 shadow-lg z-10'
|
||||
)}
|
||||
onClick={onFocus}
|
||||
>
|
||||
|
|
@ -223,12 +228,20 @@ export const NotebookCell = memo(function NotebookCell({
|
|||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border/30">
|
||||
<GripVertical
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="Drag to reorder cell"
|
||||
className={cn(
|
||||
'size-3.5 text-muted-foreground/40 shrink-0 cursor-grab',
|
||||
'opacity-0 group-hover:opacity-100 transition-opacity'
|
||||
'shrink-0 cursor-grab active:cursor-grabbing touch-none',
|
||||
'opacity-0 group-hover:opacity-100 transition-opacity',
|
||||
'text-muted-foreground/40 hover:text-muted-foreground/70'
|
||||
)}
|
||||
/>
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="size-3.5" />
|
||||
</button>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -321,36 +334,19 @@ export const NotebookCell = memo(function NotebookCell({
|
|||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
<div className={cn(cell.type === 'sql' ? 'p-2' : 'p-3')}>
|
||||
{cell.type === 'sql' ? (
|
||||
isEditing && isFocused ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={cell.content}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="SELECT * FROM ..."
|
||||
className={cn(
|
||||
'w-full min-h-[80px] resize-y font-mono text-sm bg-transparent outline-none',
|
||||
'placeholder:text-muted-foreground/40 text-foreground'
|
||||
)}
|
||||
rows={Math.max(3, cell.content.split('\n').length)}
|
||||
/>
|
||||
) : (
|
||||
<pre
|
||||
className={cn(
|
||||
'font-mono text-sm text-foreground whitespace-pre-wrap break-all cursor-text min-h-[1.5rem]',
|
||||
!cell.content && 'text-muted-foreground/40 italic'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onFocus()
|
||||
setIsEditing(true)
|
||||
}}
|
||||
>
|
||||
{cell.content || 'Click to edit SQL…'}
|
||||
</pre>
|
||||
)
|
||||
<SQLEditor
|
||||
value={cell.content}
|
||||
onChange={handleContentChange}
|
||||
onRun={() => {
|
||||
runQuery().then(() => onRunAndAdvance())
|
||||
}}
|
||||
schemas={schemas}
|
||||
compact
|
||||
height={sqlEditorHeight}
|
||||
placeholder="SELECT * FROM ..."
|
||||
/>
|
||||
) : isEditing && isFocused ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
|
|
@ -419,7 +415,13 @@ export const NotebookCell = memo(function NotebookCell({
|
|||
<ResultTable
|
||||
result={{
|
||||
fields: pinnedResult.columns.map((c) => ({ name: c })),
|
||||
rows: pinnedResult.rows as unknown[][]
|
||||
rows: pinnedResult.rows.map((row) => {
|
||||
const record: Record<string, unknown> = {}
|
||||
pinnedResult.columns.forEach((col, i) => {
|
||||
record[col] = row[i]
|
||||
})
|
||||
return record
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Button, cn } from '@data-peek/ui'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy
|
||||
} from '@dnd-kit/sortable'
|
||||
import { useNotebookStore } from '@/stores/notebook-store'
|
||||
import { useConnectionStore } from '@/stores/connection-store'
|
||||
import { NotebookCell } from './notebook-cell'
|
||||
|
|
@ -32,8 +46,14 @@ export function NotebookEditor({ tab }: NotebookEditorProps) {
|
|||
const loadNotebook = useNotebookStore((s) => s.loadNotebook)
|
||||
const addCell = useNotebookStore((s) => s.addCell)
|
||||
const deleteCell = useNotebookStore((s) => s.deleteCell)
|
||||
const reorderCells = useNotebookStore((s) => s.reorderCells)
|
||||
const updateNotebook = useNotebookStore((s) => s.updateNotebook)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
)
|
||||
|
||||
const connections = useConnectionStore((s) => s.connections)
|
||||
const connectionId = tab.connectionId
|
||||
const connection = connections.find((c) => c.id === connectionId) ?? null
|
||||
|
|
@ -71,6 +91,27 @@ export function NotebookEditor({ tab }: NotebookEditorProps) {
|
|||
[deleteCell]
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!activeNotebook || !over || active.id === over.id) return
|
||||
|
||||
const oldIndex = cells.findIndex((c) => c.id === active.id)
|
||||
const newIndex = cells.findIndex((c) => c.id === over.id)
|
||||
if (oldIndex === -1 || newIndex === -1) return
|
||||
|
||||
const reordered = [...cells]
|
||||
const [moved] = reordered.splice(oldIndex, 1)
|
||||
reordered.splice(newIndex, 0, moved)
|
||||
reorderCells(
|
||||
activeNotebook.id,
|
||||
reordered.map((c) => c.id)
|
||||
)
|
||||
setFocusedCellIndex(newIndex)
|
||||
},
|
||||
[activeNotebook, cells, reorderCells]
|
||||
)
|
||||
|
||||
const handleTitleEdit = useCallback(() => {
|
||||
if (activeNotebook) {
|
||||
setTitleValue(activeNotebook.title)
|
||||
|
|
@ -230,19 +271,32 @@ export function NotebookEditor({ tab }: NotebookEditorProps) {
|
|||
) : (
|
||||
<div className="flex flex-col gap-1 max-w-4xl mx-auto">
|
||||
<InsertPoint onInsert={(type) => handleAddCell(type, -1)} />
|
||||
{cells.map((cell, index) => (
|
||||
<div key={cell.id} className="flex flex-col">
|
||||
<NotebookCell
|
||||
cell={cell}
|
||||
connectionId={connectionId ?? ''}
|
||||
isFocused={focusedCellIndex === index}
|
||||
onFocus={() => setFocusedCellIndex(index)}
|
||||
onRunAndAdvance={() => setFocusedCellIndex(Math.min(index + 1, cells.length - 1))}
|
||||
onDelete={() => handleDeleteCell(cell.id, index)}
|
||||
/>
|
||||
<InsertPoint onInsert={(type) => handleAddCell(type, index)} />
|
||||
</div>
|
||||
))}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={cells.map((c) => c.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{cells.map((cell, index) => (
|
||||
<div key={cell.id} className="flex flex-col">
|
||||
<NotebookCell
|
||||
cell={cell}
|
||||
connectionId={connectionId ?? ''}
|
||||
isFocused={focusedCellIndex === index}
|
||||
onFocus={() => setFocusedCellIndex(index)}
|
||||
onRunAndAdvance={() =>
|
||||
setFocusedCellIndex(Math.min(index + 1, cells.length - 1))
|
||||
}
|
||||
onDelete={() => handleDeleteCell(cell.id, index)}
|
||||
/>
|
||||
<InsertPoint onInsert={(type) => handleAddCell(type, index)} />
|
||||
</div>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -465,6 +465,10 @@ export interface SQLEditorProps {
|
|||
schemas?: SchemaInfo[]
|
||||
/** SQL snippets for autocomplete */
|
||||
snippets?: Snippet[]
|
||||
/** Called when the Monaco editor instance is mounted */
|
||||
onMount?: (editor: EditorType, monaco: Monaco) => void
|
||||
/** Enable glyph margin (for breakpoint decorations) */
|
||||
glyphMargin?: boolean
|
||||
}
|
||||
|
||||
// Custom dark theme inspired by the app's aesthetic
|
||||
|
|
@ -556,7 +560,9 @@ export function SQLEditor({
|
|||
placeholder = 'SELECT * FROM your_table LIMIT 100;',
|
||||
compact = false,
|
||||
schemas = [],
|
||||
snippets = []
|
||||
snippets = [],
|
||||
onMount,
|
||||
glyphMargin = false
|
||||
}: SQLEditorProps) {
|
||||
const { theme } = useTheme()
|
||||
const editorRef = React.useRef<EditorType | null>(null)
|
||||
|
|
@ -586,6 +592,7 @@ export function SQLEditor({
|
|||
const handleEditorDidMount: OnMount = (editor, monaco) => {
|
||||
editorRef.current = editor
|
||||
monacoRef.current = monaco
|
||||
onMount?.(editor, monaco)
|
||||
|
||||
// Define custom themes
|
||||
defineCustomTheme(monaco)
|
||||
|
|
@ -627,7 +634,7 @@ export function SQLEditor({
|
|||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
lineNumbers: compact ? 'off' : 'on',
|
||||
glyphMargin: false,
|
||||
glyphMargin,
|
||||
folding: !compact,
|
||||
lineDecorationsWidth: compact ? 0 : 10,
|
||||
lineNumbersMinChars: compact ? 0 : 3,
|
||||
|
|
|
|||
145
apps/desktop/src/renderer/src/components/step-results-tabs.tsx
Normal file
145
apps/desktop/src/renderer/src/components/step-results-tabs.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { useState } from 'react'
|
||||
import { Pin, X } from 'lucide-react'
|
||||
import { Button, cn } from '@data-peek/ui'
|
||||
import type { StatementResult } from '@shared/index'
|
||||
import { useStepStore } from '@/stores/step-store'
|
||||
|
||||
interface StepResultsTabsProps {
|
||||
tabId: string
|
||||
}
|
||||
|
||||
export function StepResultsTabs({ tabId }: StepResultsTabsProps) {
|
||||
const session = useStepStore((s) => s.sessions.get(tabId))
|
||||
const pinResult = useStepStore((s) => s.pinResult)
|
||||
const unpinResult = useStepStore((s) => s.unpinResult)
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState<'current' | number>('current')
|
||||
|
||||
if (!session) return null
|
||||
if (!session.lastResult && session.pinnedResults.length === 0) return null
|
||||
|
||||
const selectedResult: StatementResult | null =
|
||||
selectedIndex === 'current'
|
||||
? session.lastResult
|
||||
: session.pinnedResults.find((p) => p.statementIndex === selectedIndex)?.result ?? null
|
||||
|
||||
const currentIsPinned =
|
||||
session.lastResult &&
|
||||
session.pinnedResults.some((p) => p.statementIndex === session.lastResult!.statementIndex)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-b bg-muted/30 overflow-x-auto">
|
||||
{session.pinnedResults.map((p) => (
|
||||
<div
|
||||
key={p.statementIndex}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-mono cursor-pointer group',
|
||||
selectedIndex === p.statementIndex
|
||||
? 'bg-card text-foreground border border-border'
|
||||
: 'text-muted-foreground hover:bg-card/50'
|
||||
)}
|
||||
onClick={() => setSelectedIndex(p.statementIndex)}
|
||||
>
|
||||
<Pin className="size-2.5 text-amber-500" />
|
||||
<span>#{p.statementIndex + 1}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
unpinResult(tabId, p.statementIndex)
|
||||
if (selectedIndex === p.statementIndex) setSelectedIndex('current')
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 hover:text-destructive"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{session.lastResult && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-mono cursor-pointer',
|
||||
selectedIndex === 'current'
|
||||
? 'bg-card text-foreground border border-primary/40'
|
||||
: 'text-muted-foreground hover:bg-card/50'
|
||||
)}
|
||||
onClick={() => setSelectedIndex('current')}
|
||||
>
|
||||
<span>Current · #{session.lastResult.statementIndex + 1}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{session.lastResult && !currentIsPinned && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 text-[10px] text-amber-500"
|
||||
onClick={() => pinResult(tabId, session.lastResult!.statementIndex)}
|
||||
>
|
||||
<Pin className="size-3 mr-1" /> Pin
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedResult && (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<ResultPreview result={selectedResult} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ResultPreview({ result }: { result: StatementResult }) {
|
||||
if (result.rows.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-xs text-muted-foreground font-mono">
|
||||
{result.rowCount} row{result.rowCount === 1 ? '' : 's'} affected · {result.durationMs}ms
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const columns = result.fields.map((f) => f.name)
|
||||
const displayRows = result.rows.slice(0, 100)
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px] font-mono">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50">
|
||||
{columns.map((c) => (
|
||||
<th
|
||||
key={c}
|
||||
className="text-left px-3 py-1.5 text-muted-foreground font-medium text-[10px] uppercase tracking-wider"
|
||||
>
|
||||
{c}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayRows.map((row, i) => (
|
||||
<tr key={i} className="border-b border-border/20">
|
||||
{columns.map((c) => {
|
||||
const val = (row as Record<string, unknown>)[c]
|
||||
return (
|
||||
<td key={c} className="px-3 py-1 text-muted-foreground">
|
||||
{val === null ? (
|
||||
<span className="italic text-muted-foreground/40">null</span>
|
||||
) : (
|
||||
String(val)
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{result.rows.length > 100 && (
|
||||
<div className="px-3 py-1 text-[10px] text-muted-foreground/60">
|
||||
Showing 100 of {result.rows.length} rows
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
160
apps/desktop/src/renderer/src/components/step-ribbon.tsx
Normal file
160
apps/desktop/src/renderer/src/components/step-ribbon.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { useMemo } from 'react'
|
||||
import { Play, SkipForward, FastForward, Square, RefreshCw } from 'lucide-react'
|
||||
import { Button, cn } from '@data-peek/ui'
|
||||
import type { SessionState } from '@shared/index'
|
||||
import { useStepStore } from '@/stores/step-store'
|
||||
|
||||
interface StepRibbonProps {
|
||||
tabId: string
|
||||
}
|
||||
|
||||
export function StepRibbon({ tabId }: StepRibbonProps) {
|
||||
const session = useStepStore((s) => s.sessions.get(tabId))
|
||||
const nextStep = useStepStore((s) => s.nextStep)
|
||||
const skipStep = useStepStore((s) => s.skipStep)
|
||||
const continueStep = useStepStore((s) => s.continueStep)
|
||||
const retryStep = useStepStore((s) => s.retryStep)
|
||||
const stopStep = useStepStore((s) => s.stopStep)
|
||||
|
||||
const progressPct = useMemo(() => {
|
||||
if (!session) return 0
|
||||
if (session.statements.length === 0) return 0
|
||||
return Math.round((session.cursorIndex / session.statements.length) * 100)
|
||||
}, [session])
|
||||
|
||||
const currentStmt = useMemo(() => {
|
||||
if (!session) return null
|
||||
return session.statements[session.cursorIndex] ?? null
|
||||
}, [session])
|
||||
|
||||
if (!session) return null
|
||||
|
||||
const canNext = session.state === 'paused'
|
||||
const canRetry = session.state === 'errored' && !session.inTransaction
|
||||
const isRunning = session.state === 'running'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center gap-3 px-4 py-2 bg-card/80 backdrop-blur border-t border-primary/30',
|
||||
'animate-in slide-in-from-bottom-2 duration-200'
|
||||
)}
|
||||
style={{
|
||||
boxShadow: '0 -4px 24px rgba(107, 140, 245, 0.1)'
|
||||
}}
|
||||
>
|
||||
{/* Top-edge progress bar */}
|
||||
<div className="absolute inset-x-0 top-0 h-[2px] bg-border/40">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-primary to-cyan-400 transition-[width] duration-300 ease-out"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status dot */}
|
||||
<StatusDot state={session.state} />
|
||||
|
||||
{/* Counter */}
|
||||
<div className="text-xs font-mono tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
<span className="font-semibold text-foreground">{session.cursorIndex}</span>
|
||||
<span> / {session.statements.length}</span>
|
||||
<span className="ml-2 text-muted-foreground/70">{progressPct}%</span>
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-border" />
|
||||
|
||||
{/* Statement preview */}
|
||||
<div className="flex-1 text-xs font-mono text-muted-foreground overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{session.state === 'done' ? (
|
||||
<span className="text-green-500">
|
||||
Complete — {session.statements.length} statements executed
|
||||
</span>
|
||||
) : session.state === 'errored' && session.lastError ? (
|
||||
<span className="text-destructive">
|
||||
Error at #{session.lastError.statementIndex + 1}: {session.lastError.message}
|
||||
</span>
|
||||
) : currentStmt ? (
|
||||
<span>{truncate(currentStmt.sql, 120)}</span>
|
||||
) : (
|
||||
<span>Done</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-border" />
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => nextStep(tabId)}
|
||||
disabled={!canNext}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Play className="size-3 mr-1" />
|
||||
Next
|
||||
<kbd className="ml-2 opacity-60 text-[10px]">⇧↵</kbd>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => skipStep(tabId)}
|
||||
disabled={!canNext}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<SkipForward className="size-3 mr-1" />
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => continueStep(tabId)}
|
||||
disabled={!canNext}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<FastForward className="size-3 mr-1" />
|
||||
Continue
|
||||
</Button>
|
||||
{canRetry && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => retryStep(tabId)}
|
||||
className="h-7 text-xs text-amber-500 hover:text-amber-400"
|
||||
>
|
||||
<RefreshCw className="size-3 mr-1" />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => stopStep(tabId)}
|
||||
disabled={isRunning}
|
||||
className="h-7 text-xs text-destructive hover:text-destructive"
|
||||
>
|
||||
<Square className="size-3 mr-1" />
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusDot({ state }: { state: SessionState }) {
|
||||
const color =
|
||||
state === 'running'
|
||||
? 'bg-amber-500 animate-pulse'
|
||||
: state === 'paused' || state === 'idle'
|
||||
? 'bg-primary'
|
||||
: state === 'errored'
|
||||
? 'bg-destructive'
|
||||
: 'bg-green-500'
|
||||
return <div className={cn('size-2 rounded-full', color)} />
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
const oneLine = s.replace(/\s+/g, ' ').trim()
|
||||
if (oneLine.length <= max) return oneLine
|
||||
return oneLine.slice(0, max - 1) + '…'
|
||||
}
|
||||
|
|
@ -19,7 +19,8 @@ import {
|
|||
Square,
|
||||
Timer,
|
||||
ActivitySquare,
|
||||
Share2
|
||||
Share2,
|
||||
StepForward
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
|
|
@ -100,6 +101,11 @@ import type { DataTableColumn as DtColumn } from '@/components/data-table'
|
|||
import { MaskingToolbar } from '@/components/masking-toolbar'
|
||||
import { useMaskingStore } from '@/stores/masking-store'
|
||||
import type { ExportData } from '@/lib/export'
|
||||
import { StepRibbon } from './step-ribbon'
|
||||
import { StepResultsTabs } from './step-results-tabs'
|
||||
import { useStepStore } from '@/stores/step-store'
|
||||
import { DDL_KEYWORD_REGEX } from '@shared/index'
|
||||
import type { editor as monacoEditor } from 'monaco-editor'
|
||||
|
||||
/** Safely coerce a value to string[] or undefined. Handles pg driver returning array_agg as a raw string. */
|
||||
function ensureArray(value: unknown): string[] | undefined {
|
||||
|
|
@ -141,6 +147,20 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
|
|||
const initializeSnippets = useSnippetStore((s) => s.initializeSnippets)
|
||||
const allSnippets = getAllSnippets()
|
||||
|
||||
const stepSession = useStepStore((s) => s.sessions.get(tabId))
|
||||
const startStep = useStepStore((s) => s.startStep)
|
||||
const toggleBreakpoint = useStepStore((s) => s.toggleBreakpoint)
|
||||
const [inTransactionMode, setInTransactionMode] = useState(false)
|
||||
|
||||
const tabQuery = tab && 'query' in tab ? tab.query : ''
|
||||
const tabType = tab?.type
|
||||
|
||||
useEffect(() => {
|
||||
if (tabType !== 'query') return
|
||||
const hasDDL = DDL_KEYWORD_REGEX.test(tabQuery)
|
||||
setInTransactionMode(!hasDDL)
|
||||
}, [tabQuery, tabType])
|
||||
|
||||
// Initialize snippets on mount
|
||||
useEffect(() => {
|
||||
initializeSnippets()
|
||||
|
|
@ -186,6 +206,13 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
|
|||
// Track if we've already attempted auto-run for this tab
|
||||
const hasAutoRun = useRef(false)
|
||||
|
||||
// Monaco editor ref for step-through decorations
|
||||
const editorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null)
|
||||
const monacoRef = useRef<typeof import('monaco-editor') | null>(null)
|
||||
const [editorMounted, setEditorMounted] = useState(false)
|
||||
const activeLineDecoIds = useRef<string[]>([])
|
||||
const breakpointDecoIds = useRef<string[]>([])
|
||||
|
||||
// Panel collapse state (extracted to hook)
|
||||
const { isEditorCollapsed, setIsEditorCollapsed, isResultsCollapsed, setIsResultsCollapsed } =
|
||||
usePanelCollapse({
|
||||
|
|
@ -676,6 +703,150 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
|
|||
updateTabQuery(tabId, value)
|
||||
}
|
||||
|
||||
const handleStartStep = useCallback(async () => {
|
||||
if (!tab || tab.type !== 'query' || !tab.query.trim() || stepSession) return
|
||||
await startStep(tabId, tab.query, inTransactionMode)
|
||||
}, [tab, stepSession, startStep, tabId, inTransactionMode])
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current
|
||||
const monaco = monacoRef.current
|
||||
if (!editor || !monaco || !editorMounted) return
|
||||
if (tabConnection?.dbType !== 'postgresql') return
|
||||
|
||||
const disposable = editor.addAction({
|
||||
id: 'datapeek.start-step',
|
||||
label: 'Start Step-Through',
|
||||
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.Enter],
|
||||
run: () => {
|
||||
handleStartStep()
|
||||
}
|
||||
})
|
||||
|
||||
return () => disposable.dispose()
|
||||
}, [handleStartStep, editorMounted, tabConnection?.dbType])
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current
|
||||
const monaco = monacoRef.current
|
||||
if (!editor || !monaco || !editorMounted || !stepSession) return
|
||||
|
||||
const nextAction = editor.addAction({
|
||||
id: 'datapeek.step-next',
|
||||
label: 'Step: Next',
|
||||
keybindings: [monaco.KeyMod.Shift | monaco.KeyCode.Enter],
|
||||
run: () => {
|
||||
const current = useStepStore.getState().sessions.get(tabId)
|
||||
if (current?.state === 'paused') useStepStore.getState().nextStep(tabId)
|
||||
}
|
||||
})
|
||||
|
||||
const stopAction = editor.addAction({
|
||||
id: 'datapeek.step-stop',
|
||||
label: 'Step: Stop',
|
||||
keybindings: [monaco.KeyCode.Escape],
|
||||
run: () => {
|
||||
useStepStore.getState().stopStep(tabId)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
nextAction.dispose()
|
||||
stopAction.dispose()
|
||||
}
|
||||
}, [stepSession, tabId, editorMounted])
|
||||
|
||||
// Active statement highlight
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
if (stepSession && stepSession.state !== 'done') {
|
||||
const current = stepSession.statements[stepSession.cursorIndex]
|
||||
if (current) {
|
||||
const updateDecorations = () => {
|
||||
activeLineDecoIds.current = editor.deltaDecorations(activeLineDecoIds.current, [
|
||||
{
|
||||
range: {
|
||||
startLineNumber: current.startLine,
|
||||
startColumn: 1,
|
||||
endLineNumber: current.endLine,
|
||||
endColumn: 1
|
||||
},
|
||||
options: {
|
||||
isWholeLine: true,
|
||||
className: 'step-active-line',
|
||||
linesDecorationsClassName: 'step-active-marker'
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
const globalDoc =
|
||||
typeof document !== 'undefined'
|
||||
? (document as Document & { startViewTransition?: (cb: () => void) => void })
|
||||
: null
|
||||
if (globalDoc?.startViewTransition) {
|
||||
globalDoc.startViewTransition(() => {
|
||||
updateDecorations()
|
||||
})
|
||||
} else {
|
||||
updateDecorations()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
activeLineDecoIds.current = editor.deltaDecorations(activeLineDecoIds.current, [])
|
||||
}
|
||||
}, [stepSession?.cursorIndex, stepSession?.state, stepSession?.statements])
|
||||
|
||||
// Breakpoint decorations
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
if (!stepSession) {
|
||||
breakpointDecoIds.current = editor.deltaDecorations(breakpointDecoIds.current, [])
|
||||
return
|
||||
}
|
||||
|
||||
const decorations = [...stepSession.breakpoints]
|
||||
.map((stmtIdx) => {
|
||||
const stmt = stepSession.statements[stmtIdx]
|
||||
if (!stmt) return null
|
||||
return {
|
||||
range: {
|
||||
startLineNumber: stmt.startLine,
|
||||
startColumn: 1,
|
||||
endLineNumber: stmt.startLine,
|
||||
endColumn: 1
|
||||
},
|
||||
options: { glyphMarginClassName: 'step-breakpoint' }
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as Parameters<typeof editor.deltaDecorations>[1]
|
||||
|
||||
breakpointDecoIds.current = editor.deltaDecorations(breakpointDecoIds.current, decorations)
|
||||
}, [stepSession?.breakpoints, stepSession?.statements, stepSession])
|
||||
|
||||
// Breakpoint gutter click handler
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor || !stepSession) return
|
||||
|
||||
const disposable = editor.onMouseDown((e) => {
|
||||
if (e.target.type !== 2) return
|
||||
const line = e.target.position?.lineNumber
|
||||
if (!line) return
|
||||
|
||||
const stmt = stepSession.statements.find((s) => s.startLine === line)
|
||||
if (!stmt) return
|
||||
|
||||
toggleBreakpoint(tabId, stmt.index)
|
||||
})
|
||||
|
||||
return () => disposable.dispose()
|
||||
}, [stepSession, tabId, toggleBreakpoint])
|
||||
|
||||
// Helper: Look up column info from schema (for FK details)
|
||||
const getColumnsWithFKInfo = useCallback((): DataTableColumn[] => {
|
||||
if (
|
||||
|
|
@ -1227,6 +1398,13 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
|
|||
placeholder="SELECT * FROM your_table LIMIT 100;"
|
||||
schemas={schemas}
|
||||
snippets={allSnippets}
|
||||
readOnly={!!stepSession}
|
||||
glyphMargin={!!stepSession}
|
||||
onMount={(editor, monaco) => {
|
||||
editorRef.current = editor
|
||||
monacoRef.current = monaco
|
||||
setEditorMounted(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1272,6 +1450,31 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
|
|||
</kbd>
|
||||
</Button>
|
||||
)}
|
||||
{tab.type === 'query' && tabConnection?.dbType === 'postgresql' && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStartStep}
|
||||
disabled={!tab.query.trim() || !!stepSession || tab.isExecuting}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<StepForward className="size-3 mr-1" />
|
||||
Step
|
||||
<kbd className="ml-2 opacity-60 text-[10px]">⇧⌘↵</kbd>
|
||||
</Button>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inTransactionMode}
|
||||
onChange={(e) => setInTransactionMode(e.target.checked)}
|
||||
disabled={!!stepSession}
|
||||
className="size-3"
|
||||
/>
|
||||
<span>Run in transaction</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -1788,6 +1991,12 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Step-through results (shown when a step session is active) */}
|
||||
{stepSession && <StepResultsTabs tabId={tabId} />}
|
||||
|
||||
{/* Step-through ribbon controls (bottom of layout) */}
|
||||
{stepSession && <StepRibbon tabId={tabId} />}
|
||||
|
||||
{/* FK Panel Stack */}
|
||||
<FKPanelStack
|
||||
panels={fkPanels}
|
||||
|
|
|
|||
|
|
@ -21,3 +21,4 @@ export * from './intel-store'
|
|||
export * from './pg-dump-store'
|
||||
export * from './pokemon-buddy-store'
|
||||
export * from './notebook-store'
|
||||
export * from './step-store'
|
||||
|
|
|
|||
250
apps/desktop/src/renderer/src/stores/step-store.ts
Normal file
250
apps/desktop/src/renderer/src/stores/step-store.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { create } from 'zustand'
|
||||
import type {
|
||||
ParsedStatement,
|
||||
SessionState,
|
||||
StatementResult,
|
||||
StepSessionError
|
||||
} from '@shared/index'
|
||||
import { useConnectionStore } from './connection-store'
|
||||
import { notify } from './notification-store'
|
||||
|
||||
export interface StepSessionState {
|
||||
sessionId: string
|
||||
statements: ParsedStatement[]
|
||||
cursorIndex: number
|
||||
breakpoints: Set<number>
|
||||
state: SessionState
|
||||
lastResult: StatementResult | null
|
||||
pinnedResults: Array<{ statementIndex: number; result: StatementResult }>
|
||||
inTransaction: boolean
|
||||
lastError: StepSessionError | null
|
||||
}
|
||||
|
||||
interface StepState {
|
||||
sessions: Map<string, StepSessionState> // keyed by tabId
|
||||
|
||||
startStep: (tabId: string, sql: string, inTransaction: boolean) => Promise<boolean>
|
||||
nextStep: (tabId: string) => Promise<void>
|
||||
skipStep: (tabId: string) => Promise<void>
|
||||
continueStep: (tabId: string) => Promise<void>
|
||||
retryStep: (tabId: string) => Promise<void>
|
||||
stopStep: (tabId: string) => Promise<void>
|
||||
toggleBreakpoint: (tabId: string, statementIndex: number) => Promise<void>
|
||||
pinResult: (tabId: string, statementIndex: number) => void
|
||||
unpinResult: (tabId: string, statementIndex: number) => void
|
||||
getSession: (tabId: string) => StepSessionState | undefined
|
||||
}
|
||||
|
||||
export const useStepStore = create<StepState>((set, get) => ({
|
||||
sessions: new Map(),
|
||||
|
||||
getSession: (tabId) => get().sessions.get(tabId),
|
||||
|
||||
startStep: async (tabId, sql, inTransaction) => {
|
||||
const connectionStore = useConnectionStore.getState()
|
||||
const activeConnection = connectionStore.connections.find(
|
||||
(c) => c.id === connectionStore.activeConnectionId
|
||||
)
|
||||
if (!activeConnection) {
|
||||
notify.error('No connection', 'Connect to a database first.')
|
||||
return false
|
||||
}
|
||||
|
||||
const result = await window.api.step.start(activeConnection, {
|
||||
tabId,
|
||||
sql,
|
||||
inTransaction
|
||||
})
|
||||
if (!result.success || !result.data) {
|
||||
notify.error('Could not start step session', result.error ?? 'Unknown error')
|
||||
return false
|
||||
}
|
||||
|
||||
set((s) => {
|
||||
const next = new Map(s.sessions)
|
||||
next.set(tabId, {
|
||||
sessionId: result.data!.sessionId,
|
||||
statements: result.data!.statements,
|
||||
cursorIndex: 0,
|
||||
breakpoints: new Set(),
|
||||
state: 'paused',
|
||||
lastResult: null,
|
||||
pinnedResults: [],
|
||||
inTransaction,
|
||||
lastError: null
|
||||
})
|
||||
return { sessions: next }
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
nextStep: async (tabId) => {
|
||||
const session = get().sessions.get(tabId)
|
||||
if (!session) return
|
||||
|
||||
set((s) => updateSession(s, tabId, (sess) => ({ ...sess, state: 'running' })))
|
||||
|
||||
const result = await window.api.step.next(session.sessionId)
|
||||
if (!result.success || !result.data) {
|
||||
notify.error('Step failed', result.error ?? 'Unknown error')
|
||||
set((s) =>
|
||||
updateSession(s, tabId, (sess) => ({
|
||||
...sess,
|
||||
state: 'errored',
|
||||
lastError: {
|
||||
statementIndex: sess.cursorIndex,
|
||||
message: result.error ?? 'Unknown error'
|
||||
}
|
||||
}))
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const { result: stmtResult, state, cursorIndex } = result.data
|
||||
set((s) =>
|
||||
updateSession(s, tabId, (sess) => ({
|
||||
...sess,
|
||||
cursorIndex,
|
||||
state,
|
||||
lastResult: stmtResult,
|
||||
lastError: result.data!.error ?? null
|
||||
}))
|
||||
)
|
||||
},
|
||||
|
||||
skipStep: async (tabId) => {
|
||||
const session = get().sessions.get(tabId)
|
||||
if (!session) return
|
||||
const result = await window.api.step.skip(session.sessionId)
|
||||
if (!result.success || !result.data) {
|
||||
notify.error('Step failed', result.error ?? 'Unknown error')
|
||||
return
|
||||
}
|
||||
set((s) =>
|
||||
updateSession(s, tabId, (sess) => ({
|
||||
...sess,
|
||||
cursorIndex: result.data!.cursorIndex,
|
||||
state: result.data!.state
|
||||
}))
|
||||
)
|
||||
},
|
||||
|
||||
continueStep: async (tabId) => {
|
||||
const session = get().sessions.get(tabId)
|
||||
if (!session) return
|
||||
set((s) => updateSession(s, tabId, (sess) => ({ ...sess, state: 'running' })))
|
||||
|
||||
const result = await window.api.step.continue(session.sessionId)
|
||||
if (!result.success || !result.data) {
|
||||
notify.error('Step failed', result.error ?? 'Unknown error')
|
||||
return
|
||||
}
|
||||
const { results, state, cursorIndex } = result.data
|
||||
set((s) =>
|
||||
updateSession(s, tabId, (sess) => ({
|
||||
...sess,
|
||||
cursorIndex,
|
||||
state,
|
||||
lastResult: results[results.length - 1] ?? sess.lastResult,
|
||||
lastError: result.data!.error ?? null
|
||||
}))
|
||||
)
|
||||
},
|
||||
|
||||
retryStep: async (tabId) => {
|
||||
const session = get().sessions.get(tabId)
|
||||
if (!session) return
|
||||
set((s) => updateSession(s, tabId, (sess) => ({ ...sess, state: 'running' })))
|
||||
|
||||
const result = await window.api.step.retry(session.sessionId)
|
||||
if (!result.success || !result.data) {
|
||||
notify.error('Step failed', result.error ?? 'Unknown error')
|
||||
set((s) => updateSession(s, tabId, (sess) => ({ ...sess, state: 'errored' })))
|
||||
return
|
||||
}
|
||||
const { result: stmtResult, state, cursorIndex } = result.data
|
||||
set((s) =>
|
||||
updateSession(s, tabId, (sess) => ({
|
||||
...sess,
|
||||
cursorIndex,
|
||||
state,
|
||||
lastResult: stmtResult,
|
||||
lastError: result.data!.error ?? null
|
||||
}))
|
||||
)
|
||||
},
|
||||
|
||||
stopStep: async (tabId) => {
|
||||
const session = get().sessions.get(tabId)
|
||||
if (!session) return
|
||||
const result = await window.api.step.stop(session.sessionId)
|
||||
if (result.success && result.data?.rollbackError) {
|
||||
notify.error(
|
||||
'Stop completed but ROLLBACK failed',
|
||||
`${result.data.rollbackError}. Verify your database state — uncommitted changes may not be reverted.`
|
||||
)
|
||||
}
|
||||
set((s) => {
|
||||
const next = new Map(s.sessions)
|
||||
next.delete(tabId)
|
||||
return { sessions: next }
|
||||
})
|
||||
},
|
||||
|
||||
toggleBreakpoint: async (tabId, statementIndex) => {
|
||||
const session = get().sessions.get(tabId)
|
||||
if (!session) return
|
||||
const originalBreakpoints = session.breakpoints
|
||||
const breakpoints = new Set(session.breakpoints)
|
||||
if (breakpoints.has(statementIndex)) {
|
||||
breakpoints.delete(statementIndex)
|
||||
} else {
|
||||
breakpoints.add(statementIndex)
|
||||
}
|
||||
set((s) => updateSession(s, tabId, (sess) => ({ ...sess, breakpoints })))
|
||||
|
||||
const result = await window.api.step.setBreakpoints(session.sessionId, [...breakpoints])
|
||||
if (!result.success) {
|
||||
set((s) => updateSession(s, tabId, (sess) => ({ ...sess, breakpoints: originalBreakpoints })))
|
||||
notify.error('Breakpoint update failed', result.error ?? 'Unknown error')
|
||||
}
|
||||
},
|
||||
|
||||
pinResult: (tabId, statementIndex) => {
|
||||
const session = get().sessions.get(tabId)
|
||||
if (!session?.lastResult) return
|
||||
if (session.lastResult.statementIndex !== statementIndex) return
|
||||
if (session.pinnedResults.some((p) => p.statementIndex === statementIndex)) return
|
||||
|
||||
set((s) =>
|
||||
updateSession(s, tabId, (sess) => ({
|
||||
...sess,
|
||||
pinnedResults: [
|
||||
...sess.pinnedResults,
|
||||
{ statementIndex, result: sess.lastResult! }
|
||||
]
|
||||
}))
|
||||
)
|
||||
},
|
||||
|
||||
unpinResult: (tabId, statementIndex) => {
|
||||
set((s) =>
|
||||
updateSession(s, tabId, (sess) => ({
|
||||
...sess,
|
||||
pinnedResults: sess.pinnedResults.filter((p) => p.statementIndex !== statementIndex)
|
||||
}))
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
function updateSession(
|
||||
state: StepState,
|
||||
tabId: string,
|
||||
updater: (s: StepSessionState) => StepSessionState
|
||||
): Partial<StepState> {
|
||||
const existing = state.sessions.get(tabId)
|
||||
if (!existing) return {}
|
||||
const next = new Map(state.sessions)
|
||||
next.set(tabId, updater(existing))
|
||||
return { sessions: next }
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ export default defineConfig({
|
|||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/__tests__/**/*.test.ts'],
|
||||
exclude: ['**/node_modules/**', '**/sqlite-adapter.test.ts', '**/notebook-storage.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "pnpm --filter @data-peek/desktop exec vitest run",
|
||||
"dev": "turbo run dev --filter=@data-peek/desktop",
|
||||
"dev:web": "turbo run dev --filter=@data-peek/web",
|
||||
"dev:webapp": "turbo run dev --filter=@data-peek/webapp",
|
||||
|
|
|
|||
|
|
@ -2313,3 +2313,21 @@ export interface PgImportResult {
|
|||
errors: Array<{ statementIndex: number; statement: string; error: string }>;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export type {
|
||||
SessionState,
|
||||
ParsedStatement,
|
||||
StepSessionError,
|
||||
StartStepRequest,
|
||||
StartStepResponse,
|
||||
NextStepResponse,
|
||||
SkipStepResponse,
|
||||
ContinueStepResponse,
|
||||
RetryStepResponse,
|
||||
StopStepResponse
|
||||
} from './step-types'
|
||||
export {
|
||||
STEP_SESSION_IDLE_TIMEOUT_MS,
|
||||
STEP_SESSION_CLEANUP_INTERVAL_MS,
|
||||
DDL_KEYWORD_REGEX
|
||||
} from './step-types'
|
||||
|
|
|
|||
66
packages/shared/src/step-types.ts
Normal file
66
packages/shared/src/step-types.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type { StatementResult } from './index'
|
||||
|
||||
export type SessionState = 'idle' | 'running' | 'paused' | 'errored' | 'done'
|
||||
|
||||
export interface ParsedStatement {
|
||||
index: number
|
||||
sql: string
|
||||
startLine: number
|
||||
endLine: number
|
||||
isDDL: boolean
|
||||
}
|
||||
|
||||
export interface StepSessionError {
|
||||
statementIndex: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface SessionSnapshot {
|
||||
state: SessionState
|
||||
cursorIndex: number
|
||||
}
|
||||
|
||||
export interface StartStepRequest {
|
||||
tabId: string
|
||||
sql: string
|
||||
inTransaction: boolean
|
||||
}
|
||||
|
||||
export interface StartStepResponse {
|
||||
sessionId: string
|
||||
statements: ParsedStatement[]
|
||||
}
|
||||
|
||||
export interface NextStepResponse extends SessionSnapshot {
|
||||
statementIndex: number
|
||||
result: StatementResult
|
||||
error?: StepSessionError
|
||||
}
|
||||
|
||||
export interface SkipStepResponse {
|
||||
statementIndex: number
|
||||
state: SessionState
|
||||
cursorIndex: number
|
||||
}
|
||||
|
||||
export interface ContinueStepResponse extends SessionSnapshot {
|
||||
executedIndices: number[]
|
||||
results: StatementResult[]
|
||||
stoppedAt: number | null
|
||||
error?: StepSessionError
|
||||
}
|
||||
|
||||
export interface RetryStepResponse extends SessionSnapshot {
|
||||
result: StatementResult
|
||||
error?: StepSessionError
|
||||
}
|
||||
|
||||
export interface StopStepResponse {
|
||||
rolledBack: boolean
|
||||
rollbackError?: string
|
||||
}
|
||||
|
||||
export const STEP_SESSION_IDLE_TIMEOUT_MS = 10 * 60 * 1000
|
||||
export const STEP_SESSION_CLEANUP_INTERVAL_MS = 60 * 1000
|
||||
|
||||
export const DDL_KEYWORD_REGEX = /^\s*(CREATE|ALTER|DROP|TRUNCATE|VACUUM|REINDEX)\b/i
|
||||
Loading…
Reference in a new issue