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:
Rohith Gilla 2026-04-15 18:05:25 +05:30 committed by GitHub
parent 5f6a9ea63e
commit 626dcbfbe9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2372 additions and 93 deletions

View 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')
})
})

View 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')
})
})
})

View file

@ -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)

View file

@ -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'

View 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)
}
}
})
}

View 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
}

View 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()
}
}

View file

@ -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>
}

View file

@ -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[]>> =>

View file

@ -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;
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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,

View 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>
)
}

View 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) + '…'
}

View file

@ -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}

View file

@ -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'

View 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 }
}

View file

@ -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'],

View file

@ -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",

View file

@ -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'

View 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