diff --git a/RELEASE_TRACKER.md b/RELEASE_TRACKER.md new file mode 100644 index 00000000..a3e0752f --- /dev/null +++ b/RELEASE_TRACKER.md @@ -0,0 +1,305 @@ +# Dora v1.0 Release Tracker + +> **Current Version**: 0.0.92 +> **Target Release**: 1.0.0 +> **Branch**: `feat/delete-confirmation-and-ui-alignment` + +--- + +## Status Overview + +| Phase | Status | Progress | +|-------|--------|----------| +| Core Features | Complete | 100% | +| UX Polish | In Progress | 85% | +| Error Handling | Complete | 100% | +| Empty States | Complete | 100% | +| Form Validation | Not Started | 0% | +| Testing | Partial | 20% | + +--- + +## Session Progress (2025-02-02) + +### Completed This Session + +**Error Handling & UX** +- [x] Created `ErrorBoundary` class component (`shared/ui/error-boundary.tsx`) +- [x] Created `ErrorFallback` with smart error mapping (`shared/ui/error-fallback.tsx`) +- [x] Created `mapConnectionError` utility (`shared/utils/error-messages.ts`) +- [x] Wrapped DatabaseStudio, SqlConsole, DockerManager with ErrorBoundary +- [x] Added friendly error messages to all connection operations +- [x] Added EmptyState for "no connections" in main view + +**Non-Functional UI Fixes** +- [x] Removed dead `handleToolbarAction` code from database-sidebar.tsx +- [x] Disabled SSH Tunnel UI with "Soon" badge (was showing UI but hardcoded to null) +- [x] Updated Dora AI view to use NotImplemented component with proper description +- [x] Schema Visualizer correctly shows "(Coming Soon)" in tooltip + +**Native Dialog Replacement** +- [x] Replaced `alert()` with toast notifications in seed-view.tsx +- [x] Replaced `confirm()` with AlertDialog in database-sidebar.tsx (bulk drop/truncate) +- [x] Fixed stray "3" text in studio-toolbar.tsx + +**Cleanup** +- [x] Removed AUDIT_REPORT.html, AUDIT_TASKS.md, recap.md +- [x] Removed unused imports (Wand2, ToolbarAction, SshTunnelConfig) +- [x] Removed debug console.log statements from: + - disabled-feature.tsx + - database-studio.tsx (loadTableData, row actions) + - sql-console/api.ts (query execution logs) + +### New Files Created +``` +apps/desktop/src/shared/ui/error-boundary.tsx +apps/desktop/src/shared/ui/error-fallback.tsx +apps/desktop/src/shared/utils/error-messages.ts +``` + +### Files Modified +``` +apps/desktop/src/pages/Index.tsx + - Added ErrorBoundary wrapping + - Added EmptyState for no connections + - Updated to use NotImplemented for Dora AI + - Added mapConnectionError for all connection errors + +apps/desktop/src/features/sidebar/database-sidebar.tsx + - Removed dead handleToolbarAction function + - Removed unused ToolbarAction import + - Replaced confirm() with AlertDialog for bulk drop/truncate + +apps/desktop/src/features/docker-manager/components/seed-view.tsx + - Replaced alert() with toast notifications + +apps/desktop/src/features/database-studio/components/studio-toolbar.tsx + - Fixed stray "3" text + +apps/desktop/src/features/database-studio/database-studio.tsx + - Removed debug console.log statements + +apps/desktop/src/features/sql-console/api.ts + - Removed debug console.log statements + +apps/desktop/src/shared/ui/disabled-feature.tsx + - Removed debug console.log statement + +apps/desktop/src/features/sidebar/components/bottom-toolbar.tsx + - Made onAction prop optional + +apps/desktop/src/features/connections/components/connection-dialog/connection-form.tsx + - Disabled SSH tunnel checkbox with "Soon" badge and tooltip + - Removed unused SshTunnelConfigForm import +``` + +--- + +## What's Implemented + +### Database Studio (Core Feature) +- [x] Table browser with pagination +- [x] CRUD operations (create, read, update, delete rows) +- [x] Column management (add/drop columns) +- [x] Row selection (single + bulk) +- [x] Delete confirmation dialogs (respects settings) +- [x] Bulk edit dialog +- [x] Set NULL dialog +- [x] Drop table dialog +- [x] CSV export (selected rows) +- [x] CSV export (all rows) +- [x] Data seeder dialog +- [x] Sort and filter support +- [x] Primary key detection +- [x] Soft delete backend support (LibSQL) + +### SQL Console +- [x] Monaco editor with syntax highlighting +- [x] Query execution +- [x] Results grid with column definitions +- [x] Query history panel + zustand store +- [x] Keyboard shortcuts (Cmd+Enter to run) +- [x] Snippets sidebar +- [x] Cheatsheet panel +- [x] Toggle panels (left sidebar, history, filter) + +### Connections +- [x] Add/edit/delete connections +- [x] Connection testing +- [x] Multiple database types (LibSQL, SQLite, PostgreSQL, MySQL) +- [x] Connection list in sidebar +- [x] Friendly error messages for connection failures +- [ ] SSH tunnel UI (fields exist, not wired) + +### Docker Manager +- [x] Container list view +- [x] Container logs +- [x] Start/stop containers +- [x] Export docker-compose + +### Error Handling +- [x] ErrorBoundary wraps all major features +- [x] ErrorFallback with smart error categorization +- [x] Connection errors → friendly messages +- [x] Network errors → friendly messages +- [x] Permission errors → friendly messages +- [x] Timeout errors → friendly messages +- [x] Technical details expandable for debugging + +### Empty States +- [x] No connections → shows onboarding CTA +- [x] No database connected → shows add connection button +- [x] No tables → shows explanation +- [x] Search returns nothing → shows feedback + +### UI Components (Shared) +- [x] AlertDialog (shadcn) +- [x] All core shadcn components +- [x] EmptyState component +- [x] ErrorState component +- [x] ErrorBoundary component +- [x] ErrorFallback component +- [x] Skeleton component +- [x] DisabledFeature component +- [x] NotImplemented component + +### Backend (Rust/Tauri) +- [x] LibSQL connection handling +- [x] SQLite connection handling +- [x] Query execution with timing +- [x] Table schema introspection +- [x] Row mutations (insert, update, delete) +- [x] Soft delete support +- [x] Truncate table support +- [x] Script/snippet storage +- [x] Settings persistence +- [x] Command shortcuts system + +--- + +## What's Missing for v1.0 + +### Phase 1: Form Validation (HIGH) +Prevent invalid data submission. + +- [ ] Install zod + @hookform/resolvers +- [ ] Add record dialog validation +- [ ] Edit cell validation +- [ ] Connection form validation +- [ ] Type-specific validators (int, date, JSON, etc.) + +### Phase 2: Polish (MEDIUM) +- [ ] Consistent keyboard navigation +- [ ] ARIA labels for accessibility +- [ ] SSH tunnel actually working + +### Phase 3: Testing (MEDIUM) +- [ ] Fix existing test failures +- [ ] Add integration tests for critical paths +- [ ] Connection add/edit/delete tests +- [ ] Query execution tests + +--- + +## Uncommitted Changes + +### Backend (Rust) +- `commands.rs` - new commands +- `schema.rs` - schema updates +- `maintenance.rs` - soft delete + truncate +- `mutation.rs` - mutation updates + +### Frontend +- `Index.tsx` - ErrorBoundary wrapping, empty states, error mapping +- `database-studio.tsx` - delete confirmation, CSV export +- `sql-console.tsx` - query history integration +- `console-toolbar.tsx` - history toggle +- `data-grid.tsx` - grid improvements +- `studio-toolbar.tsx` - toolbar updates + +### New Files (Untracked) +- `error-boundary.tsx` - React error boundary +- `error-fallback.tsx` - Friendly error UI +- `error-messages.ts` - Error mapping utility +- `query-history-panel.tsx` - history UI +- `query-history-store.tsx` - zustand store +- `empty-state.tsx` - generic empty state +- `error-state.tsx` - basic error display +- `skeleton.tsx` - loading skeletons +- `disabled-feature.tsx` - feature flags +- `not-implemented.tsx` - placeholder + +--- + +## Release Checklist + +### Before Release +- [x] Error boundaries implemented +- [x] Error messages are user-friendly +- [x] Empty states provide guidance +- [ ] Form validation prevents bad data +- [ ] Tests passing +- [ ] No TypeScript errors +- [ ] Manual QA pass +- [ ] Update CHANGELOG.md +- [ ] Update version to 1.0.0 + +### Release Process +1. Commit all pending changes +2. Merge feature branch to master +3. Tag release v1.0.0 +4. Build binaries (macOS, Windows, Linux) +5. Create GitHub release +6. Update documentation + +--- + +## Commands + +```bash +# Development +bun run desktop:dev + +# Tests +bun run test + +# Build +bun run build + +# Desktop build +bun run desktop:build +``` + +--- + +## File Structure Reference + +``` +apps/desktop/ +├── src/ +│ ├── features/ +│ │ ├── database-studio/ # Main data grid feature +│ │ ├── sql-console/ # Query editor +│ │ ├── connections/ # Connection management +│ │ ├── docker-manager/ # Docker integration +│ │ └── settings/ # App settings +│ ├── shared/ +│ │ ├── ui/ # Reusable components +│ │ │ ├── error-boundary.tsx +│ │ │ ├── error-fallback.tsx +│ │ │ ├── empty-state.tsx +│ │ │ └── ... +│ │ └── utils/ +│ │ ├── error-messages.ts +│ │ └── ... +│ └── pages/ +│ └── Index.tsx # Main app shell +├── src-tauri/ +│ └── src/ +│ ├── database/ # DB operations +│ └── commands/ # Tauri commands +``` + +--- + +*Last updated: 2025-02-02* diff --git a/__tests__/apps/desktop/unit/accessibility-utilities.test.ts b/__tests__/apps/desktop/unit/accessibility-utilities.test.ts new file mode 100644 index 00000000..660ceae4 --- /dev/null +++ b/__tests__/apps/desktop/unit/accessibility-utilities.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect } from 'vitest' + +describe('Accessibility Utilities', function () { + describe('Focus Key Generation', function () { + it('should generate unique focus keys from row and column indices', function () { + function getFocusKey(row: number, col: number): string { + return `cell-${row}-${col}` + } + expect(getFocusKey(0, 0)).toBe('cell-0-0') + expect(getFocusKey(5, 10)).toBe('cell-5-10') + }) + }) + + describe('Keyboard Navigation', function () { + it('should calculate next cell in grid navigation', function () { + function getNextCell( + currentRow: number, + currentCol: number, + direction: 'up' | 'down' | 'left' | 'right', + maxRow: number, + maxCol: number + ) { + let nextRow = currentRow + let nextCol = currentCol + + switch (direction) { + case 'up': + nextRow = Math.max(0, currentRow - 1) + break + case 'down': + nextRow = Math.min(maxRow, currentRow + 1) + break + case 'left': + nextCol = Math.max(0, currentCol - 1) + break + case 'right': + nextCol = Math.min(maxCol, currentCol + 1) + break + } + + return { row: nextRow, col: nextCol } + } + + expect(getNextCell(5, 5, 'up', 10, 10)).toEqual({ row: 4, col: 5 }) + expect(getNextCell(5, 5, 'down', 10, 10)).toEqual({ row: 6, col: 5 }) + expect(getNextCell(5, 5, 'left', 10, 10)).toEqual({ row: 5, col: 4 }) + expect(getNextCell(5, 5, 'right', 10, 10)).toEqual({ row: 5, col: 6 }) + }) + + it('should clamp navigation at boundaries', function () { + function getNextCell( + currentRow: number, + currentCol: number, + direction: 'up' | 'down' | 'left' | 'right', + maxRow: number, + maxCol: number + ) { + let nextRow = currentRow + let nextCol = currentCol + + switch (direction) { + case 'up': + nextRow = Math.max(0, currentRow - 1) + break + case 'down': + nextRow = Math.min(maxRow, currentRow + 1) + break + case 'left': + nextCol = Math.max(0, currentCol - 1) + break + case 'right': + nextCol = Math.min(maxCol, currentCol + 1) + break + } + + return { row: nextRow, col: nextCol } + } + + expect(getNextCell(0, 0, 'up', 10, 10)).toEqual({ row: 0, col: 0 }) + expect(getNextCell(0, 0, 'left', 10, 10)).toEqual({ row: 0, col: 0 }) + expect(getNextCell(10, 10, 'down', 10, 10)).toEqual({ row: 10, col: 10 }) + expect(getNextCell(10, 10, 'right', 10, 10)).toEqual({ row: 10, col: 10 }) + }) + }) + + describe('Tab Navigation', function () { + it('should wrap tab navigation to next row', function () { + function getNextTabCell( + currentRow: number, + currentCol: number, + maxRow: number, + maxCol: number, + shiftKey: boolean + ) { + if (shiftKey) { + if (currentCol > 0) { + return { row: currentRow, col: currentCol - 1 } + } else if (currentRow > 0) { + return { row: currentRow - 1, col: maxCol } + } + return { row: 0, col: 0 } + } else { + if (currentCol < maxCol) { + return { row: currentRow, col: currentCol + 1 } + } else if (currentRow < maxRow) { + return { row: currentRow + 1, col: 0 } + } + return { row: maxRow, col: maxCol } + } + } + + expect(getNextTabCell(0, 5, 10, 5, false)).toEqual({ row: 1, col: 0 }) + expect(getNextTabCell(1, 0, 10, 5, true)).toEqual({ row: 0, col: 5 }) + }) + }) +}) + +describe('Feature Gating', function () { + it('should log feature_gated events', function () { + const logs: string[] = [] + function logFeatureGated(feature: string) { + logs.push(`[feature_gated] ${feature}`) + } + + logFeatureGated('Import CSV') + logFeatureGated('Create Table UI') + + expect(logs).toContain('[feature_gated] Import CSV') + expect(logs).toContain('[feature_gated] Create Table UI') + }) + + it('should generate correct tooltip text for disabled features', function () { + function getTooltipText(feature: string, disabled: boolean): string { + return disabled ? `${feature} (Coming Soon)` : feature + } + + expect(getTooltipText('Import CSV', true)).toBe('Import CSV (Coming Soon)') + expect(getTooltipText('SQL Console', false)).toBe('SQL Console') + }) +}) + +describe('Loading State Utilities', function () { + it('should determine loading skeleton rows based on viewport', function () { + function getSkeletonRows(viewportHeight: number, rowHeight: number = 40): number { + return Math.max(5, Math.ceil(viewportHeight / rowHeight)) + } + + expect(getSkeletonRows(400)).toBe(10) + expect(getSkeletonRows(100)).toBe(5) + }) +}) + +describe('Error State Formatting', function () { + it('should format error messages for display', function () { + function formatError(error: unknown): string { + if (error instanceof Error) return error.message + if (typeof error === 'string') return error + return 'An unknown error occurred' + } + + expect(formatError(new Error('Connection failed'))).toBe('Connection failed') + expect(formatError('Something went wrong')).toBe('Something went wrong') + expect(formatError({ code: 500 })).toBe('An unknown error occurred') + }) + + it('should provide retry action text', function () { + function getRetryText(hasRetryAction: boolean): string { + return hasRetryAction ? 'Try again' : '' + } + + expect(getRetryText(true)).toBe('Try again') + expect(getRetryText(false)).toBe('') + }) +}) diff --git a/__tests__/apps/desktop/unit/database-studio.test.ts b/__tests__/apps/desktop/unit/database-studio.test.ts new file mode 100644 index 00000000..f333b480 --- /dev/null +++ b/__tests__/apps/desktop/unit/database-studio.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from 'vitest' + +describe('Export Utilities', function () { + describe('CSV export formatting', function () { + it('should format single value correctly', function () { + const value = 'Hello World' + const escaped = value.includes(',') || value.includes('"') || value.includes('\n') + ? `"${value.replace(/"/g, '""')}"` + : value + expect(escaped).toBe('Hello World') + }) + + it('should escape values containing commas', function () { + const value = 'Hello, World' + const escaped = value.includes(',') || value.includes('"') || value.includes('\n') + ? `"${value.replace(/"/g, '""')}"` + : value + expect(escaped).toBe('"Hello, World"') + }) + + it('should escape values containing double quotes', function () { + const value = 'He said "Hello"' + const escaped = value.includes(',') || value.includes('"') || value.includes('\n') + ? `"${value.replace(/"/g, '""')}"` + : value + expect(escaped).toBe('"He said ""Hello"""') + }) + + it('should escape values containing newlines', function () { + const value = 'Line1\nLine2' + const escaped = value.includes(',') || value.includes('"') || value.includes('\n') + ? `"${value.replace(/"/g, '""')}"` + : value + expect(escaped).toBe('"Line1\nLine2"') + }) + + it('should handle null values', function () { + const value = null + const escaped = value === null ? '' : String(value) + expect(escaped).toBe('') + }) + }) + + describe('SQL INSERT formatting', function () { + it('should escape single quotes in string values', function () { + const value = "O'Brien" + const escaped = `'${String(value).replace(/'/g, "''")}'` + expect(escaped).toBe("'O''Brien'") + }) + + it('should format numbers without quotes', function () { + const value = 42 + const formatted = typeof value === 'number' ? String(value) : `'${String(value)}'` + expect(formatted).toBe('42') + }) + + it('should format NULL correctly', function () { + const value = null + const formatted = value === null ? 'NULL' : String(value) + expect(formatted).toBe('NULL') + }) + + it('should generate valid INSERT statement', function () { + const tableName = 'users' + const columns = ['id', 'name', 'email'] + const values = [1, "John", "john@example.com"] + + function formatValue(v: unknown): string { + if (v === null) return 'NULL' + if (typeof v === 'number') return String(v) + return `'${String(v).replace(/'/g, "''")}'` + } + + const insert = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${values.map(formatValue).join(', ')});` + expect(insert).toBe("INSERT INTO users (id, name, email) VALUES (1, 'John', 'john@example.com');") + }) + }) +}) + +describe('Cell Selection Utilities', function () { + function getCellKey(row: number, col: number): string { + return `${row}:${col}` + } + + function getCellsInRectangle(start: { row: number; col: number }, end: { row: number; col: number }): Set { + const minRow = Math.min(start.row, end.row) + const maxRow = Math.max(start.row, end.row) + const minCol = Math.min(start.col, end.col) + const maxCol = Math.max(start.col, end.col) + + const cells = new Set() + for (let r = minRow; r <= maxRow; r++) { + for (let c = minCol; c <= maxCol; c++) { + cells.add(getCellKey(r, c)) + } + } + return cells + } + + it('should generate correct cell key', function () { + expect(getCellKey(0, 0)).toBe('0:0') + expect(getCellKey(5, 10)).toBe('5:10') + }) + + it('should select single cell', function () { + const cells = getCellsInRectangle({ row: 2, col: 3 }, { row: 2, col: 3 }) + expect(cells.size).toBe(1) + expect(cells.has('2:3')).toBe(true) + }) + + it('should select rectangular range', function () { + const cells = getCellsInRectangle({ row: 0, col: 0 }, { row: 2, col: 2 }) + expect(cells.size).toBe(9) + expect(cells.has('0:0')).toBe(true) + expect(cells.has('1:1')).toBe(true) + expect(cells.has('2:2')).toBe(true) + }) + + it('should handle inverted selection (end before start)', function () { + const cells = getCellsInRectangle({ row: 2, col: 2 }, { row: 0, col: 0 }) + expect(cells.size).toBe(9) + }) +}) + +describe('Clipboard Formatting', function () { + it('should format cells as tab-separated values', function () { + const selectedCells = [ + { row: 0, col: 0, value: 'A1' }, + { row: 0, col: 1, value: 'B1' }, + { row: 1, col: 0, value: 'A2' }, + { row: 1, col: 1, value: 'B2' } + ] + + const minRow = 0 + const maxRow = 1 + const rowData: string[][] = [] + + for (let r = minRow; r <= maxRow; r++) { + const rowCells = selectedCells.filter(function (c) { return c.row === r }) + rowCells.sort(function (a, b) { return a.col - b.col }) + rowData.push(rowCells.map(function (c) { return String(c.value) })) + } + + const clipboardText = rowData.map(function (r) { return r.join('\t') }).join('\n') + expect(clipboardText).toBe('A1\tB1\nA2\tB2') + }) + + it('should handle null values as empty strings', function () { + const value = null + const text = value === null || value === undefined ? '' : String(value) + expect(text).toBe('') + }) + + it('should parse tab-separated clipboard data', function () { + const clipboardText = 'A1\tB1\nA2\tB2' + const rows = clipboardText.split('\n').map(function (line) { + return line.split('\t') + }) + + expect(rows).toEqual([ + ['A1', 'B1'], + ['A2', 'B2'] + ]) + }) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index da46fe35..ef3710dc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -55,6 +55,7 @@ "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "zod": "^4.3.6", "zustand": "^5.0.10" }, "devDependencies": { diff --git a/apps/desktop/src-tauri/src/database/commands.rs b/apps/desktop/src-tauri/src/database/commands.rs index baaf1915..90e477d4 100644 --- a/apps/desktop/src-tauri/src/database/commands.rs +++ b/apps/desktop/src-tauri/src/database/commands.rs @@ -245,13 +245,14 @@ pub async fn fetch_page( query_id: usize, page_index: usize, state: State<'_, AppState>, -) -> Result>, Error> { +) -> Result, Error> { let svc = QueryService { connections: &state.connections, storage: &state.storage, stmt_manager: &state.stmt_manager, }; - svc.fetch_page(query_id, page_index).await + let raw = svc.fetch_page(query_id, page_index).await?; + Ok(raw.map(|r| serde_json::from_str(r.get()).unwrap_or(serde_json::Value::Null))) } #[tauri::command] @@ -287,13 +288,14 @@ pub async fn get_page_count( pub async fn get_columns( query_id: usize, state: State<'_, AppState>, -) -> Result>, Error> { +) -> Result, Error> { let svc = QueryService { connections: &state.connections, storage: &state.storage, stmt_manager: &state.stmt_manager, }; - svc.get_columns(query_id).await + let raw = svc.get_columns(query_id).await?; + Ok(raw.map(|r| serde_json::from_str(r.get()).unwrap_or(serde_json::Value::Null))) } #[tauri::command] diff --git a/apps/desktop/src-tauri/src/database/libsql/schema.rs b/apps/desktop/src-tauri/src/database/libsql/schema.rs index e9a6ec5e..59ef09a7 100644 --- a/apps/desktop/src-tauri/src/database/libsql/schema.rs +++ b/apps/desktop/src-tauri/src/database/libsql/schema.rs @@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use crate::database::types::{ColumnInfo, DatabaseSchema, ForeignKeyInfo, TableInfo}; +use crate::database::types::{ColumnInfo, DatabaseSchema, ForeignKeyInfo, IndexInfo, TableInfo}; use crate::Error; /// Get the database schema from a libSQL connection @@ -19,8 +19,8 @@ pub async fn get_database_schema(conn: Arc) -> Result) -> Result, + table_name: &str, +) -> Result, Error> { + let mut indexes = Vec::new(); + + let query = format!("PRAGMA index_list(\"{}\")", table_name); + let mut rows = conn + .query(&query, ()) + .await + .map_err(|e| Error::Any(anyhow::anyhow!("Failed to get index list: {}", e)))?; + + while let Some(row) = rows + .next() + .await + .map_err(|e| Error::Any(anyhow::anyhow!("Failed to fetch index row: {}", e)))? + { + let name: String = row.get(1).unwrap_or_default(); + let is_unique: i64 = row.get(2).unwrap_or(0); + + let info_query = format!("PRAGMA index_info(\"{}\")", name); + let mut info_rows = conn + .query(&info_query, ()) + .await + .map_err(|e| Error::Any(anyhow::anyhow!("Failed to get index info: {}", e)))?; + + let mut column_names = Vec::new(); + while let Some(info_row) = info_rows.next().await.ok().flatten() { + let col_name: String = info_row.get(2).unwrap_or_default(); + if !col_name.is_empty() { + column_names.push(col_name); + } + } + + indexes.push(IndexInfo { + name, + column_names, + is_unique: is_unique != 0, + is_primary: false, + }); + } + + Ok(indexes) +} + diff --git a/apps/desktop/src-tauri/src/database/maintenance.rs b/apps/desktop/src-tauri/src/database/maintenance.rs index 24c7c7cf..861f0385 100644 --- a/apps/desktop/src-tauri/src/database/maintenance.rs +++ b/apps/desktop/src-tauri/src/database/maintenance.rs @@ -261,6 +261,89 @@ pub fn truncate_table_sqlite( }) } +/// Perform soft delete on LibSQL +pub async fn soft_delete_libsql( + conn: &libsql::Connection, + table_name: &str, + primary_key_column: &str, + primary_key_values: &[serde_json::Value], + soft_delete_column: &str, +) -> Result { + if primary_key_values.is_empty() { + return Ok(SoftDeleteResult { + success: true, + affected_rows: 0, + message: Some("No rows to soft delete".to_string()), + deleted_at: chrono::Utc::now().timestamp(), + undo_window_seconds: 30, + }); + } + + let placeholders: Vec<&str> = primary_key_values.iter().map(|_| "?").collect(); + let now = chrono::Utc::now().timestamp(); + + let query = format!( + "UPDATE \"{}\" SET \"{}\" = ? WHERE \"{}\" IN ({}) AND \"{}\" IS NULL", + table_name, soft_delete_column, primary_key_column, placeholders.join(", "), soft_delete_column + ); + + let mut params: Vec = vec![libsql::Value::Integer(now)]; + params.extend(primary_key_values.iter().map(|value| { + match value { + serde_json::Value::Null => libsql::Value::Null, + serde_json::Value::Bool(b) => libsql::Value::Integer(if *b { 1 } else { 0 }), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + libsql::Value::Integer(i) + } else if let Some(f) = n.as_f64() { + libsql::Value::Real(f) + } else { + libsql::Value::Text(n.to_string()) + } + } + serde_json::Value::String(s) => libsql::Value::Text(s.clone()), + _ => libsql::Value::Text(value.to_string()), + } + })); + + let affected = conn.execute(&query, params).await + .map_err(|e| Error::Any(anyhow::anyhow!("Soft delete failed: {}", e)))?; + + Ok(SoftDeleteResult { + success: affected > 0, + affected_rows: affected as usize, + message: Some(format!("Soft deleted {} row(s)", affected)), + deleted_at: now, + undo_window_seconds: 30, + }) +} + +/// Truncate a single table (LibSQL) - uses DELETE since LibSQL has no TRUNCATE +pub async fn truncate_table_libsql( + conn: &libsql::Connection, + table_name: &str, +) -> Result { + let count_query = format!("SELECT COUNT(*) FROM \"{}\"", table_name); + let mut rows = conn.query(&count_query, ()).await + .map_err(|e| Error::Any(anyhow::anyhow!("Failed to count rows: {}", e)))?; + + let row_count: i64 = if let Some(row) = rows.next().await.ok().flatten() { + row.get::(0).unwrap_or(0) + } else { + 0 + }; + + conn.execute(&format!("DELETE FROM \"{}\"", table_name), ()).await + .map_err(|e| Error::Any(anyhow::anyhow!("Truncate failed: {}", e)))?; + + Ok(TruncateResult { + success: true, + affected_rows: row_count as usize, + tables_truncated: vec![table_name.to_string()], + message: Some(format!("Truncated table '{}', removed {} rows", table_name, row_count)), + }) +} + /// Truncate all tables in the database (DANGEROUS!) pub async fn truncate_database_postgres( client: &tokio_postgres::Client, @@ -436,3 +519,82 @@ fn format_pg_value_for_sql(row: &tokio_postgres::Row, idx: usize) -> String { } "NULL".to_string() } + +pub async fn dump_database_libsql( + conn: &libsql::Connection, + schema: &DatabaseSchema, + output_path: &str, +) -> Result { + use std::io::Write; + + let mut file = std::fs::File::create(output_path) + .map_err(|e| Error::Any(anyhow::anyhow!("Failed to create dump file: {}", e)))?; + + writeln!(file, "-- LibSQL Database dump generated at {}", chrono::Utc::now().to_rfc3339()) + .map_err(|e| Error::Any(anyhow::anyhow!("Failed to write to dump file: {}", e)))?; + writeln!(file, "-- Tables: {}\n", schema.tables.len()) + .map_err(|e| Error::Any(anyhow::anyhow!("Failed to write to dump file: {}", e)))?; + + let mut total_rows: u64 = 0; + + for table in &schema.tables { + writeln!(file, "\n-- Table: {}", table.name) + .map_err(|e| Error::Any(anyhow::anyhow!("Failed to write to dump file: {}", e)))?; + + let query = format!("SELECT * FROM \"{}\"", table.name); + let mut rows = conn.query(&query, ()).await + .map_err(|e| Error::Any(anyhow::anyhow!("Failed to query table {}: {}", table.name, e)))?; + + let columns: Vec = table.columns.iter().map(|c| c.name.clone()).collect(); + + while let Some(row) = rows.next().await.ok().flatten() { + let mut values = Vec::new(); + for i in 0..columns.len() { + let value = format_libsql_value_for_sql(&row, i as i32); + values.push(value); + } + + writeln!( + file, + "INSERT INTO \"{}\" ({}) VALUES ({});", + table.name, + columns.iter().map(|c| format!("\"{}\"", c)).collect::>().join(", "), + values.join(", ") + ).map_err(|e| Error::Any(anyhow::anyhow!("Failed to write to dump file: {}", e)))?; + + total_rows += 1; + } + } + + let file_size = std::fs::metadata(output_path) + .map(|m| m.len()) + .unwrap_or(0); + + Ok(DumpResult { + success: true, + file_path: output_path.to_string(), + size_bytes: file_size, + tables_dumped: schema.tables.len() as u32, + rows_dumped: total_rows, + message: Some(format!( + "Dumped {} tables, {} rows to {}", + schema.tables.len(), + total_rows, + output_path + )), + }) +} + +fn format_libsql_value_for_sql(row: &libsql::Row, idx: i32) -> String { + if let Ok(v) = row.get::(idx) { + return v.to_string(); + } + if let Ok(v) = row.get::(idx) { + return v.to_string(); + } + if let Ok(v) = row.get::(idx) { + return format!("'{}'", v.replace('\'', "''")); + } + "NULL".to_string() +} + diff --git a/apps/desktop/src-tauri/src/database/services/mutation.rs b/apps/desktop/src-tauri/src/database/services/mutation.rs index f392d180..5d312cb6 100644 --- a/apps/desktop/src-tauri/src/database/services/mutation.rs +++ b/apps/desktop/src-tauri/src/database/services/mutation.rs @@ -503,8 +503,14 @@ impl<'a> MutationService<'a> { &soft_del_col, ) } - DatabaseClient::LibSQL { .. } => { - Err(Error::Any(anyhow!("Soft delete not yet implemented for LibSQL"))) + DatabaseClient::LibSQL { connection } => { + maintenance::soft_delete_libsql( + connection, + &table_name, + &primary_key_column, + &primary_key_values, + &soft_del_col, + ).await } } } @@ -577,8 +583,8 @@ impl<'a> MutationService<'a> { let conn = connection.lock().unwrap(); maintenance::truncate_table_sqlite(&conn, &table_name)? } - DatabaseClient::LibSQL { .. } => { - return Err(Error::Any(anyhow!("Truncate not yet implemented for LibSQL"))); + DatabaseClient::LibSQL { connection } => { + maintenance::truncate_table_libsql(connection, &table_name).await? } }; @@ -658,8 +664,12 @@ impl<'a> MutationService<'a> { let conn = connection.lock().unwrap(); maintenance::dump_database_sqlite(&conn, &output_path) } - DatabaseClient::LibSQL { .. } => { - Err(Error::Any(anyhow!("Dump not yet implemented for LibSQL"))) + DatabaseClient::LibSQL { connection } => { + maintenance::dump_database_libsql( + connection, + &schema, + &output_path, + ).await } } } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 47963f15..18f246ac 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -7,6 +7,7 @@ import { DataProvider } from '@/core/data-provider' import { PendingEditsProvider } from '@/core/pending-edits' import { RecordingProvider, RecordingOverlay } from '@/core/recording' import { SettingsProvider, useSettings } from '@/core/settings' +import { QueryHistoryProvider } from '@/features/sql-console/stores/query-history-store' import { ThemeSync } from '@/features/sidebar/components/theme-sync' import Index from './pages/Index' import NotFound from './pages/NotFound' @@ -31,20 +32,22 @@ function App() { - -
- -
- - - - - } /> - } /> - - + + +
+ +
+ + + + + } /> + } /> + + +
-
+ diff --git a/apps/desktop/src/features/connections/components/connection-dialog.tsx b/apps/desktop/src/features/connections/components/connection-dialog.tsx index c7223ef7..61f078f6 100644 --- a/apps/desktop/src/features/connections/components/connection-dialog.tsx +++ b/apps/desktop/src/features/connections/components/connection-dialog.tsx @@ -20,6 +20,7 @@ import { buildConnectionString, PROVIDER_CONFIGS } from '../utils/providers' +import { validateConnection } from '../validation' import { ConnectionForm } from './connection-dialog/connection-form' import { DatabaseTypeSelector } from './connection-dialog/database-type-selector' @@ -55,6 +56,7 @@ export function ConnectionDialog({ open, onOpenChange, onSave, initialValues }: const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle') const [testMessage, setTestMessage] = useState('') const [useConnectionString, setUseConnectionString] = useState(false) + const [validationError, setValidationError] = useState<{ field?: string; message?: string } | null>(null) useEffect( function resetFormOnOpen() { @@ -135,6 +137,10 @@ export function ConnectionDialog({ open, onOpenChange, onSave, initialValues }: return newData }) setTestStatus('idle') + // Clear validation error for this field + if (validationError?.field === field) { + setValidationError(null) + } } function handleTypeSelect(type: DatabaseType) { @@ -290,8 +296,14 @@ export function ConnectionDialog({ open, onOpenChange, onSave, initialValues }: } function handleSave() { - if (!formData.name) return + const validation = validateConnection(formData as Record, useConnectionString) + + if (!validation.success) { + setValidationError({ field: validation.field, message: validation.error }) + return + } + setValidationError(null) setIsSaving(true) setTimeout(function () { setIsSaving(false) diff --git a/apps/desktop/src/features/connections/components/connection-dialog/connection-form.tsx b/apps/desktop/src/features/connections/components/connection-dialog/connection-form.tsx index 29a016fa..4db2e45e 100644 --- a/apps/desktop/src/features/connections/components/connection-dialog/connection-form.tsx +++ b/apps/desktop/src/features/connections/components/connection-dialog/connection-form.tsx @@ -1,13 +1,14 @@ import { FolderOpen, Key } from 'lucide-react' import { useState } from 'react' import { commands } from '@/lib/bindings' +import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { Checkbox } from '@/shared/ui/checkbox' import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' -import { Connection, DatabaseType, SshTunnelConfig } from '../../types' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip' +import { Connection, DatabaseType } from '../../types' import { PROVIDER_CONFIGS, sanitizeConnectionUrl } from '../../utils/providers' -import { SshTunnelConfigForm } from './ssh-tunnel-config-form' type Props = { formData: Partial @@ -274,44 +275,30 @@ export function ConnectionForm({ {formData.type === 'postgres' && (
-
- - -
- - {formData.sshConfig?.enabled && ( - - )} + + +
+ + +
+
+ +

SSH tunnel connections are coming in a future update

+
+
)} diff --git a/apps/desktop/src/features/connections/validation.ts b/apps/desktop/src/features/connections/validation.ts new file mode 100644 index 00000000..16ca2bc9 --- /dev/null +++ b/apps/desktop/src/features/connections/validation.ts @@ -0,0 +1,135 @@ +import { z } from 'zod' + +// Base connection schema +const baseConnectionSchema = z.object({ + name: z.string().min(1, 'Connection name is required').max(100, 'Name is too long'), +}) + +// SQLite connection schema +export const sqliteConnectionSchema = baseConnectionSchema.extend({ + type: z.literal('sqlite'), + url: z.string().min(1, 'Database file path is required'), +}) + +// LibSQL connection schema +export const libsqlConnectionSchema = baseConnectionSchema.extend({ + type: z.literal('libsql'), + url: z + .string() + .min(1, 'Database URL is required') + .refine( + (val) => val.startsWith('libsql://') || val.startsWith('https://') || val.startsWith('http://'), + 'URL must start with libsql://, https://, or http://' + ), + authToken: z.string().optional(), +}) + +// PostgreSQL/MySQL connection with URL +export const connectionStringSchema = baseConnectionSchema.extend({ + type: z.enum(['postgres', 'mysql']), + url: z + .string() + .min(1, 'Connection string is required') + .refine( + (val) => + val.startsWith('postgres://') || + val.startsWith('postgresql://') || + val.startsWith('mysql://'), + 'Invalid connection string format' + ), +}) + +// PostgreSQL/MySQL connection with individual fields +export const connectionFieldsSchema = baseConnectionSchema.extend({ + type: z.enum(['postgres', 'mysql']), + host: z.string().min(1, 'Host is required'), + port: z.number().int().min(1, 'Port must be positive').max(65535, 'Port must be less than 65536'), + user: z.string().min(1, 'Username is required'), + password: z.string().optional(), + database: z.string().min(1, 'Database name is required'), + ssl: z.boolean().optional(), +}) + +// SSH tunnel config schema +export const sshTunnelSchema = z.object({ + enabled: z.literal(true), + host: z.string().min(1, 'SSH host is required'), + port: z.number().int().min(1).max(65535).default(22), + username: z.string().min(1, 'SSH username is required'), + authMethod: z.enum(['password', 'keyfile']), + password: z.string().optional(), + privateKeyPath: z.string().optional(), +}).refine( + (data) => { + if (data.authMethod === 'password') { + return !!data.password + } + if (data.authMethod === 'keyfile') { + return !!data.privateKeyPath + } + return true + }, + { + message: 'Password or private key is required based on auth method', + path: ['password'], + } +) + +// Helper type for validation results +export type ValidationResult = { + success: boolean + error?: string + field?: string +} + +// Validate connection based on type and mode +export function validateConnection( + formData: Record, + useConnectionString: boolean +): ValidationResult { + try { + const type = formData.type as string + + // Validate name first + if (!formData.name || (formData.name as string).trim() === '') { + return { success: false, error: 'Connection name is required', field: 'name' } + } + + if (type === 'sqlite') { + sqliteConnectionSchema.parse(formData) + } else if (type === 'libsql') { + libsqlConnectionSchema.parse(formData) + } else if (type === 'postgres' || type === 'mysql') { + if (useConnectionString) { + connectionStringSchema.parse(formData) + } else { + connectionFieldsSchema.parse(formData) + } + } + + return { success: true } + } catch (error) { + if (error instanceof z.ZodError) { + const firstError = error.errors[0] + return { + success: false, + error: firstError.message, + field: firstError.path[0] as string, + } + } + return { success: false, error: 'Validation failed' } + } +} + +// Get field-specific error if any +export function getFieldError( + formData: Record, + field: string, + useConnectionString: boolean +): string | undefined { + const result = validateConnection(formData, useConnectionString) + if (!result.success && result.field === field) { + return result.error + } + return undefined +} diff --git a/apps/desktop/src/features/database-studio/components/data-grid.tsx b/apps/desktop/src/features/database-studio/components/data-grid.tsx index bcc192eb..756ab7f0 100644 --- a/apps/desktop/src/features/database-studio/components/data-grid.tsx +++ b/apps/desktop/src/features/database-studio/components/data-grid.tsx @@ -534,6 +534,65 @@ export function DataGrid({ e.preventDefault() onRowSelect(row, !selectedRows.has(row)) break + case 'c': + if (e.ctrlKey || e.metaKey) { + e.preventDefault() + if (selectedCellsSet.size > 0) { + const cellsArray = Array.from(selectedCellsSet).map(function (key) { + const [r, c] = key.split(':').map(Number) + return { row: r, col: c } + }) + cellsArray.sort(function (a, b) { + return a.row === b.row ? a.col - b.col : a.row - b.row + }) + const minRow = Math.min(...cellsArray.map(function (c) { return c.row })) + const maxRow = Math.max(...cellsArray.map(function (c) { return c.row })) + const rowData: string[][] = [] + for (let r = minRow; r <= maxRow; r++) { + const rowCells = cellsArray.filter(function (c) { return c.row === r }) + const values = rowCells.map(function (cell) { + const value = rows[cell.row][columns[cell.col].name] + return value === null || value === undefined ? '' : String(value) + }) + rowData.push(values) + } + const clipboardText = rowData.map(function (r) { return r.join('\t') }).join('\n') + navigator.clipboard.writeText(clipboardText) + } else if (focusedCell) { + const value = rows[focusedCell.row][columns[focusedCell.col].name] + const text = value === null || value === undefined ? '' : String(value) + navigator.clipboard.writeText(text) + } + } + break + case 'v': + if ((e.ctrlKey || e.metaKey) && focusedCell && onBatchCellEdit) { + e.preventDefault() + navigator.clipboard.readText().then(function (clipboardText) { + if (!clipboardText || !focusedCell) return + const pasteRows = clipboardText.split('\n').map(function (line) { + return line.split('\t') + }) + const edits: { rowIndex: number; columnName: string; value: string }[] = [] + pasteRows.forEach(function (pasteRow, pasteRowIndex) { + const targetRow = focusedCell.row + pasteRowIndex + if (targetRow >= rows.length) return + pasteRow.forEach(function (pasteValue, pasteColIndex) { + const targetCol = focusedCell.col + pasteColIndex + if (targetCol >= columns.length) return + edits.push({ + rowIndex: targetRow, + columnName: columns[targetCol].name, + value: pasteValue + }) + }) + }) + if (edits.length > 0) { + onBatchCellEdit(edits) + } + }) + } + break } }, [ diff --git a/apps/desktop/src/features/database-studio/components/selection-action-bar.tsx b/apps/desktop/src/features/database-studio/components/selection-action-bar.tsx index caa2b80e..cab0dfef 100644 --- a/apps/desktop/src/features/database-studio/components/selection-action-bar.tsx +++ b/apps/desktop/src/features/database-studio/components/selection-action-bar.tsx @@ -25,6 +25,7 @@ type Props = { onSetNull?: () => void onDuplicate?: () => void onExportJson?: () => void + onExportCsv?: () => void onBulkEdit?: () => void onClearSelection: () => void mode?: 'floating' | 'static' diff --git a/apps/desktop/src/features/database-studio/components/studio-toolbar.tsx b/apps/desktop/src/features/database-studio/components/studio-toolbar.tsx index 5e4be462..6ae973c2 100644 --- a/apps/desktop/src/features/database-studio/components/studio-toolbar.tsx +++ b/apps/desktop/src/features/database-studio/components/studio-toolbar.tsx @@ -34,6 +34,8 @@ type Props = { onToggleSidebar?: () => void onRefresh: () => void onExport: () => void + onExportCsv?: () => void + onExportSql?: () => void onAddRecord?: () => void isLoading?: boolean filters?: FilterDescriptor[] @@ -56,6 +58,8 @@ export function StudioToolbar({ onToggleSidebar, onRefresh, onExport, + onExportCsv, + onExportSql, onAddRecord, isLoading, filters = [], @@ -176,7 +180,7 @@ export function StudioToolbar({ )} - 3{' '} + - + + + + + + + Export JSON + + {onExportCsv && ( + + Export CSV + + )} + {onExportSql && ( + + Export SQL INSERT + + )} + + {(onCopySchema || onCopyDrizzleSchema) && ( diff --git a/apps/desktop/src/features/database-studio/database-studio.tsx b/apps/desktop/src/features/database-studio/database-studio.tsx index 11e0f4e3..a4c6ed10 100644 --- a/apps/desktop/src/features/database-studio/database-studio.tsx +++ b/apps/desktop/src/features/database-studio/database-studio.tsx @@ -100,6 +100,11 @@ export function DatabaseStudio({ const [showAddColumnDialog, setShowAddColumnDialog] = useState(false) const [showDropTableDialog, setShowDropTableDialog] = useState(false) const [showDeleteConfirmDialog, setShowDeleteConfirmDialog] = useState(false) + const [pendingSingleDeleteRow, setPendingSingleDeleteRow] = useState<{ + row: Record + primaryKeyColumn: string + primaryKeyValue: unknown + } | null>(null) const [showBulkEditDialog, setShowBulkEditDialog] = useState(false) const [showSetNullDialog, setShowSetNullDialog] = useState(false) const [showDataSeederDialog, setShowDataSeederDialog] = useState(false) @@ -149,10 +154,7 @@ export function DatabaseStudio({ }, [tableData, isLoading]) const loadTableData = useCallback(async () => { - console.log('[DatabaseStudio] loadTableData called', { tableId, activeConnectionId }) - if (!tableId || !activeConnectionId) { - console.log('[DatabaseStudio] Skipping load - missing tableId or activeConnectionId') return } @@ -160,7 +162,6 @@ export function DatabaseStudio({ setSelectedRows(new Set()) try { - console.log('[DatabaseStudio] Fetching data for table:', tableName || tableId) const result = await adapter.fetchTableData( activeConnectionId, tableName || tableId, @@ -172,10 +173,6 @@ export function DatabaseStudio({ if (result.ok) { const data = result.data - console.log('[DatabaseStudio] Data received:', { - columns: data.columns.length, - rows: data.rows.length - }) setTableData(data) // If it's a new table or first load, reset visible columns to show all @@ -868,11 +865,15 @@ export function DatabaseStudio({ switch (action) { case 'delete': - if ( - settings.confirmBeforeDelete && - !confirm('Are you sure you want to delete this row?') - ) + if (settings.confirmBeforeDelete) { + setPendingSingleDeleteRow({ + row, + primaryKeyColumn: primaryKeyColumn.name, + primaryKeyValue: row[primaryKeyColumn.name] + }) + setShowDeleteConfirmDialog(true) return + } deleteRows.mutate( { @@ -882,10 +883,10 @@ export function DatabaseStudio({ primaryKeyValues: [row[primaryKeyColumn.name]] }, { - onSuccess: () => { + onSuccess: function onDeleteSuccess() { loadTableData() }, - onError: (error) => { + onError: function onDeleteError(error) { console.error('Failed to delete row:', error) } } @@ -913,7 +914,7 @@ export function DatabaseStudio({ // Focus will be handled by the DataGrid effect for new draft row break default: - console.log('Row action:', action, row) + break } } @@ -1012,6 +1013,63 @@ export function DatabaseStudio({ URL.revokeObjectURL(url) } + function handleExportCsvAll() { + if (!tableData || tableData.rows.length === 0) return + + const headers = tableData.columns.map(function (col) { return col.name }) + const csvRows = [ + headers.join(','), + ...tableData.rows.map(function (row) { + return headers + .map(function (header) { + const value = row[header] + if (value === null || value === undefined) return '' + const stringValue = String(value) + if ( + stringValue.includes(',') || + stringValue.includes('"') || + stringValue.includes('\n') + ) { + return `"${stringValue.replace(/"/g, '""')}"` + } + return stringValue + }) + .join(',') + }) + ] + + const csvString = csvRows.join('\n') + const blob = new Blob([csvString], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${tableName || 'data'}.csv` + a.click() + URL.revokeObjectURL(url) + } + + async function handleExportSqlAll() { + if (!activeConnectionId || !tableId || !tableData || tableData.rows.length === 0) return + + const result = await commands.exportTable( + activeConnectionId, + tableName || tableId, + null, + 'sql_insert', + null + ) + + if (result.status === 'ok') { + const blob = new Blob([result.data], { type: 'text/sql' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${tableName || 'data'}.sql` + a.click() + URL.revokeObjectURL(url) + } + } + async function handleCopySchema() { if (!activeConnectionId) return @@ -1221,6 +1279,8 @@ export function DatabaseStudio({ isSidebarOpen={isSidebarOpen} onRefresh={loadTableData} onExport={handleExport} + onExportCsv={handleExportCsvAll} + onExportSql={handleExportSqlAll} isLoading={isLoading} onCopySchema={handleCopySchema} onCopyDrizzleSchema={handleCopyDrizzleSchema} @@ -1342,6 +1402,8 @@ export function DatabaseStudio({ isSidebarOpen={isSidebarOpen} onRefresh={loadTableData} onExport={handleExport} + onExportCsv={handleExportCsvAll} + onExportSql={handleExportSqlAll} onAddRecord={handleAddRecord} isLoading={isLoading} filters={filters} @@ -1500,21 +1562,51 @@ export function DatabaseStudio({ isLoading={isDdlLoading} /> - + Are you absolutely sure? - This action cannot be undone. This will permanently delete {selectedRows.size}{' '} - selected row{selectedRows.size !== 1 ? 's' : ''} from the database. + This action cannot be undone. This will permanently delete{' '} + {pendingSingleDeleteRow ? '1 row' : `${selectedRows.size} selected row${selectedRows.size !== 1 ? 's' : ''}`}{' '} + from the database. Cancel { + onClick={function handleConfirmDelete(e) { e.preventDefault() - performBulkDelete() + if (pendingSingleDeleteRow && activeConnectionId && tableId) { + deleteRows.mutate( + { + connectionId: activeConnectionId, + tableName: tableName || tableId, + primaryKeyColumn: pendingSingleDeleteRow.primaryKeyColumn, + primaryKeyValues: [pendingSingleDeleteRow.primaryKeyValue] + }, + { + onSuccess: function onSingleDeleteSuccess() { + loadTableData() + setShowDeleteConfirmDialog(false) + setPendingSingleDeleteRow(null) + }, + onError: function onSingleDeleteError(error) { + console.error('Failed to delete row:', error) + } + } + ) + } else { + performBulkDelete() + } }} className='bg-destructive text-destructive-foreground hover:bg-destructive/90' > diff --git a/apps/desktop/src/features/docker-manager/components/seed-view.tsx b/apps/desktop/src/features/docker-manager/components/seed-view.tsx index 3ca16538..591498ca 100644 --- a/apps/desktop/src/features/docker-manager/components/seed-view.tsx +++ b/apps/desktop/src/features/docker-manager/components/seed-view.tsx @@ -1,5 +1,6 @@ import { Upload, FileCode, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react' import { useState, useRef } from 'react' +import { useToast } from '@/components/ui/use-toast' import { Button } from '@/shared/ui/button' import { useSeedDatabase } from '../api/mutations/use-seed-database' import type { DockerContainer } from '../types' @@ -11,6 +12,7 @@ type Props = { export function SeedView({ container }: Props) { const [file, setFile] = useState(null) const fileInputRef = useRef(null) + const { toast } = useToast() const seedMutation = useSeedDatabase() @@ -34,7 +36,11 @@ export function SeedView({ container }: Props) { setFile(droppedFile) seedMutation.reset() } else { - alert('Only .sql files are supported') + toast({ + title: 'Invalid file type', + description: 'Only .sql files are supported', + variant: 'destructive' + }) } } } @@ -67,7 +73,11 @@ export function SeedView({ container }: Props) { const filePath = (file as any).path if (!filePath) { - alert('Cannot determine file path. Please select file again.') + toast({ + title: 'File path error', + description: 'Cannot determine file path. Please select the file again.', + variant: 'destructive' + }) return } diff --git a/apps/desktop/src/features/drizzle-runner/components/code-editor.tsx b/apps/desktop/src/features/drizzle-runner/components/code-editor.tsx index 7ce3c283..0864b47d 100644 --- a/apps/desktop/src/features/drizzle-runner/components/code-editor.tsx +++ b/apps/desktop/src/features/drizzle-runner/components/code-editor.tsx @@ -89,7 +89,7 @@ function getRange( } function tableSnippet(table: SchemaTable): string { - return `${table.name})$0` + return `${table.name}).` } function valuesSnippet(table: SchemaTable, includePrimary: boolean): string { @@ -703,10 +703,17 @@ export function CodeEditor({ value, onChange, onExecute, isExecuting, tables }: suggestions.push({ label: `${table.name}.${column.name}`, kind: monaco.languages.CompletionItemKind.Field, - insertText: `${table.name}.${column.name}`, + insertText: `${table.name}.${column.name}, \${1})`, + insertTextRules: + monaco.languages.CompletionItemInsertTextRule + .InsertAsSnippet, detail: column.type, range: range, - sortText: String(index).padStart(3, '0') + sortText: String(index).padStart(3, '0'), + command: { + id: 'editor.action.triggerSuggest', + title: 'Trigger Suggest' + } }) }) }) diff --git a/apps/desktop/src/features/sidebar/components/bottom-toolbar.tsx b/apps/desktop/src/features/sidebar/components/bottom-toolbar.tsx index 3f67f47b..bd6e064b 100644 --- a/apps/desktop/src/features/sidebar/components/bottom-toolbar.tsx +++ b/apps/desktop/src/features/sidebar/components/bottom-toolbar.tsx @@ -27,7 +27,7 @@ const TOOLBAR_ITEMS: ToolbarItem[] = [ ] type Props = { - onAction: (action: ToolbarAction) => void + onAction?: (action: ToolbarAction) => void themeProps?: { theme: Theme onThemeChange: (theme: Theme) => void diff --git a/apps/desktop/src/features/sidebar/database-sidebar.tsx b/apps/desktop/src/features/sidebar/database-sidebar.tsx index 6b18de1c..3d855fac 100644 --- a/apps/desktop/src/features/sidebar/database-sidebar.tsx +++ b/apps/desktop/src/features/sidebar/database-sidebar.tsx @@ -7,12 +7,22 @@ import type { DatabaseSchema, TableInfo } from '@/lib/bindings' import { commands } from '@/lib/bindings' import { getAppearanceSettings, applyAppearanceToDOM } from '@/shared/lib/appearance-store' import { loadFontPair } from '@/shared/lib/font-loader' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/shared/ui/alert-dialog' import { Button } from '@/shared/ui/button' import { ScrollArea } from '@/shared/ui/scroll-area' import { ConnectionSwitcher } from '../connections/components/connection-switcher' import { Connection } from '../connections/types' import { DropTableDialog } from '../database-studio/components/drop-table-dialog' -import { BottomToolbar, ToolbarAction } from './components/bottom-toolbar' +import { BottomToolbar } from './components/bottom-toolbar' import { ManageTablesDialog, BulkAction } from './components/manage-tables-dialog' import { RenameTableDialog } from './components/rename-table-dialog' import { SchemaSelector } from './components/schema-selector' @@ -96,6 +106,10 @@ export function DatabaseSidebar({ const [refreshTrigger, setRefreshTrigger] = useState(0) const [showTableInfoDialog, setShowTableInfoDialog] = useState(false) const [tableInfoTarget, setTableInfoTarget] = useState('') + const [bulkActionConfirm, setBulkActionConfirm] = useState<{ + open: boolean + action: BulkAction | null + }>({ open: false, action: null }) useEffect(function initAppearance() { const settings = getAppearanceSettings() @@ -374,86 +388,84 @@ export function DatabaseSidebar({ } } - async function handleBulkAction(action: BulkAction) { + function handleBulkAction(action: BulkAction) { if (!activeConnectionId || selectedTableIds.length === 0) return + if (action === 'drop' || action === 'truncate') { + setBulkActionConfirm({ open: true, action }) + } + } + + async function executeBulkAction() { + if (!activeConnectionId || !bulkActionConfirm.action) return + + const action = bulkActionConfirm.action + setBulkActionConfirm({ open: false, action: null }) + if (action === 'drop') { - if ( - confirm( - `Are you sure you want to drop ${selectedTableIds.length} tables? This cannot be undone.` - ) - ) { - setIsDdlLoading(true) - try { - const drops = selectedTableIds.map(function (id) { - return `DROP TABLE IF EXISTS "${id}"` - }) - const result = await commands.executeBatch(activeConnectionId, drops) - if (result.status === 'ok') { - toast({ - title: 'Tables dropped', - description: `Successfully dropped ${selectedTableIds.length} tables.` - }) - setSelectedTableIds([]) - setIsMultiSelectMode(false) - setSchema(null) - setRefreshTrigger(function (prev) { - return prev + 1 - }) - } else { - throw new Error(String(result.error)) - } - } catch (e) { - console.error(e) + setIsDdlLoading(true) + try { + const drops = selectedTableIds.map(function (id) { + return `DROP TABLE IF EXISTS "${id}"` + }) + const result = await commands.executeBatch(activeConnectionId, drops) + if (result.status === 'ok') { toast({ - title: 'Error dropping tables', - description: String(e), - variant: 'destructive' + title: 'Tables dropped', + description: `Successfully dropped ${selectedTableIds.length} tables.` }) - } finally { - setIsDdlLoading(false) + setSelectedTableIds([]) + setIsMultiSelectMode(false) + setSchema(null) + setRefreshTrigger(function (prev) { + return prev + 1 + }) + } else { + throw new Error(String(result.error)) } + } catch (e) { + console.error(e) + toast({ + title: 'Error dropping tables', + description: String(e), + variant: 'destructive' + }) + } finally { + setIsDdlLoading(false) } } else if (action === 'truncate') { - if ( - confirm( - `Are you sure you want to truncate ${selectedTableIds.length} tables? All data will be lost.` - ) - ) { - setIsDdlLoading(true) - try { - const deletes = selectedTableIds.map(function (id) { - return `DELETE FROM "${id}"` - }) - const result = await commands.executeBatch(activeConnectionId, deletes) - if (result.status === 'ok') { - toast({ - title: 'Tables truncated', - description: `Successfully truncated ${selectedTableIds.length} tables.` - }) - setSelectedTableIds([]) - setIsMultiSelectMode(false) - setRefreshTrigger(function (prev) { - return prev + 1 - }) - } else { - throw new Error(String(result.error)) - } - } catch (e) { + setIsDdlLoading(true) + try { + const deletes = selectedTableIds.map(function (id) { + return `DELETE FROM "${id}"` + }) + const result = await commands.executeBatch(activeConnectionId, deletes) + if (result.status === 'ok') { toast({ - title: 'Error truncating tables', - description: String(e), - variant: 'destructive' + title: 'Tables truncated', + description: `Successfully truncated ${selectedTableIds.length} tables.` }) - } finally { - setIsDdlLoading(false) + setSelectedTableIds([]) + setIsMultiSelectMode(false) + setRefreshTrigger(function (prev) { + return prev + 1 + }) + } else { + throw new Error(String(result.error)) + } + } catch (e) { + toast({ + title: 'Error truncating tables', + description: String(e), + variant: 'destructive' + }) + } finally { + setIsDdlLoading(false) } } } } - function handleToolbarAction(action: ToolbarAction) { } - async function handleExportTableSchema(tableName: string) { if (!activeConnectionId) return @@ -706,7 +718,7 @@ export function DatabaseSidebar({ /> )} - + )} + + { + if (!open) setBulkActionConfirm({ open: false, action: null }) + }} + > + + + + {bulkActionConfirm.action === 'drop' ? 'Drop Tables' : 'Truncate Tables'} + + + {bulkActionConfirm.action === 'drop' + ? `Are you sure you want to drop ${selectedTableIds.length} table${selectedTableIds.length > 1 ? 's' : ''}? This action cannot be undone.` + : `Are you sure you want to truncate ${selectedTableIds.length} table${selectedTableIds.length > 1 ? 's' : ''}? All data will be permanently deleted.`} + + + + Cancel + + {bulkActionConfirm.action === 'drop' ? 'Drop' : 'Truncate'} + + + +
) } diff --git a/apps/desktop/src/features/sql-console/api.ts b/apps/desktop/src/features/sql-console/api.ts index 4bff8651..e92afdce 100644 --- a/apps/desktop/src/features/sql-console/api.ts +++ b/apps/desktop/src/features/sql-console/api.ts @@ -5,11 +5,9 @@ export async function executeSqlQuery( connectionId: string, query: string ): Promise { - console.log('[SQL Console API] Executing query:', query) const startTime = performance.now() try { const startResult = await commands.startQuery(connectionId, query) - console.log('[SQL Console API] startQuery result:', startResult) if (startResult.status !== 'ok') { throw new Error('Failed to start query: ' + JSON.stringify(startResult.error)) @@ -20,7 +18,6 @@ export async function executeSqlQuery( } const queryId = startResult.data[0] - console.log('[SQL Console API] queryId:', queryId) // Poll for query completion - backend may return "Running" initially let pageInfo @@ -29,12 +26,6 @@ export async function executeSqlQuery( while (attempts < maxAttempts) { const fetchResult = await commands.fetchQuery(queryId) - console.log( - '[SQL Console API] fetchQuery attempt', - attempts, - 'status:', - fetchResult.status - ) if (fetchResult.status !== 'ok') { throw new Error('Failed to fetch query results') @@ -44,12 +35,10 @@ export async function executeSqlQuery( // Check if query is complete if (pageInfo.status === 'Completed' || pageInfo.status === 'Error') { - console.log('[SQL Console API] Query completed with status:', pageInfo.status) break } // Query still running, wait and retry - console.log('[SQL Console API] Query still running, waiting...') await new Promise((resolve) => setTimeout(resolve, 100)) attempts++ } @@ -58,9 +47,6 @@ export async function executeSqlQuery( throw new Error('Query timed out') } - console.log('[SQL Console API] pageInfo:', pageInfo) - console.log('[SQL Console API] first_page:', pageInfo.first_page) - // Handle query error if (pageInfo.status === 'Error') { return { @@ -74,7 +60,6 @@ export async function executeSqlQuery( } const columnsResult = await commands.getColumns(queryId) - console.log('[SQL Console API] columnsResult:', columnsResult) const columns = columnsResult.status === 'ok' && Array.isArray(columnsResult.data) @@ -102,10 +87,7 @@ export async function executeSqlQuery( }) : [] - console.log('[SQL Console API] Transformed rows:', rows) - console.log('[SQL Console API] Transformed columns:', columns) - - const result = { + return { columns: columns.map((c) => c.name), rows, rowCount: pageInfo.affected_rows ?? rows.length, @@ -113,8 +95,6 @@ export async function executeSqlQuery( error: undefined, queryType: getQueryType(query) } - console.log('[SQL Console API] Final result:', result) - return result } catch (error) { console.error('[SQL Console API] Error:', error) return { diff --git a/apps/desktop/src/features/sql-console/components/console-toolbar.tsx b/apps/desktop/src/features/sql-console/components/console-toolbar.tsx index 8ccfd7c9..fb6c31a3 100644 --- a/apps/desktop/src/features/sql-console/components/console-toolbar.tsx +++ b/apps/desktop/src/features/sql-console/components/console-toolbar.tsx @@ -6,7 +6,8 @@ import { Play, Download, Braces, - Filter + Filter, + Clock } from 'lucide-react' import { Button } from '@/shared/ui/button' import { @@ -28,11 +29,14 @@ type Props = { onRun?: () => void onPrettify?: () => void onExport?: () => void + onExportCsv?: () => void hasResults?: boolean showJson?: boolean onShowJsonToggle?: () => void showFilter?: boolean onToggleFilter?: () => void + showHistory?: boolean + onToggleHistory?: () => void } function Kbd({ children, className }: { children: React.ReactNode; className?: string }) { @@ -59,11 +63,14 @@ export function ConsoleToolbar({ onRun, onPrettify, onExport, + onExportCsv, hasResults, showJson, onShowJsonToggle, showFilter, - onToggleFilter + onToggleFilter, + showHistory, + onToggleHistory }: Props) { return (
@@ -82,6 +89,21 @@ export function ConsoleToolbar({ /> + {onToggleHistory && ( + + )} + {/* Mode Switcher - Tab Style */}
+ + + + + + + Export as JSON + + + Export as CSV + + + )}
diff --git a/apps/desktop/src/features/sql-console/components/query-history-panel.tsx b/apps/desktop/src/features/sql-console/components/query-history-panel.tsx new file mode 100644 index 00000000..e2d9d9dc --- /dev/null +++ b/apps/desktop/src/features/sql-console/components/query-history-panel.tsx @@ -0,0 +1,125 @@ +import { Clock, Trash2, Play, CheckCircle, XCircle } from 'lucide-react' +import { Button } from '@/shared/ui/button' +import { ScrollArea } from '@/shared/ui/scroll-area' +import { useQueryHistory } from '../stores/query-history-store' +import { cn } from '@/shared/utils/cn' + +type Props = { + onSelectQuery: (query: string) => void + currentConnectionId?: string +} + +export function QueryHistoryPanel({ onSelectQuery, currentConnectionId }: Props) { + const { history, clearHistory, removeFromHistory } = useQueryHistory() + + function formatTimestamp(timestamp: number): string { + const date = new Date(timestamp) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffMins < 1) return 'Just now' + if (diffMins < 60) return `${diffMins}m ago` + if (diffHours < 24) return `${diffHours}h ago` + if (diffDays < 7) return `${diffDays}d ago` + return date.toLocaleDateString() + } + + function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(2)}s` + } + + function truncateQuery(query: string, maxLength: number = 80): string { + const singleLine = query.replace(/\s+/g, ' ').trim() + if (singleLine.length <= maxLength) return singleLine + return singleLine.substring(0, maxLength) + '...' + } + + const filteredHistory = currentConnectionId + ? history.filter(function (item) { return item.connectionId === currentConnectionId }) + : history + + return ( +
+
+
+ + History +
+ {filteredHistory.length > 0 && ( + + )} +
+ + + {filteredHistory.length === 0 ? ( +
+ + No query history +
+ ) : ( +
+ {filteredHistory.map(function (item) { + return ( +
+
+ {item.success ? ( + + ) : ( + + )} + + {truncateQuery(item.query)} + + +
+
+ + {formatTimestamp(item.timestamp)} + + + + {formatDuration(item.executionTimeMs)} + + {item.rowCount !== undefined && ( + <> + + + {item.rowCount} rows + + + )} +
+
+ ) + })} +
+ )} +
+
+ ) +} diff --git a/apps/desktop/src/features/sql-console/sql-console.tsx b/apps/desktop/src/features/sql-console/sql-console.tsx index e3c72d50..f0eff4e1 100644 --- a/apps/desktop/src/features/sql-console/sql-console.tsx +++ b/apps/desktop/src/features/sql-console/sql-console.tsx @@ -10,10 +10,12 @@ import { CheatsheetPanel } from '../../features/drizzle-runner/components/cheats import { CodeEditor } from '../../features/drizzle-runner/components/code-editor' import { DEFAULT_QUERY } from '../../features/drizzle-runner/data' import { ConsoleToolbar } from './components/console-toolbar' +import { QueryHistoryPanel } from './components/query-history-panel' import { SqlEditor } from './components/sql-editor' import { SqlResults } from './components/sql-results' import { UnifiedSidebar } from './components/unified-sidebar' import { DEFAULT_SQL } from './data' +import { useQueryHistory } from './stores/query-history-store' import { SqlQueryResult, ResultViewMode, SqlSnippet, TableInfo } from './types' type Props = { @@ -37,8 +39,11 @@ export function SqlConsole({ onToggleSidebar, activeConnectionId }: Props) { const [showLeftSidebar, setShowLeftSidebar] = useState(true) const [showCheatsheet, setShowCheatsheet] = useState(false) const [showFilter, setShowFilter] = useState(false) + const [showHistory, setShowHistory] = useState(false) const [tables, setTables] = useState([]) + const { addToHistory } = useQueryHistory() + const loadSnippets = useCallback(async () => { const res = await adapter.getScripts(activeConnectionId || null) if (res.ok) { @@ -143,28 +148,28 @@ export function SqlConsole({ onToggleSidebar, activeConnectionId }: Props) { // Extract column definitions if available from adapter const columnDefinitions = Array.isArray(res.data.columns) && - typeof res.data.columns[0] !== 'string' + typeof res.data.columns[0] !== 'string' ? (res.data.columns as any[]) : undefined const rows = Array.isArray(res.data.rows) ? res.data.rows.map((row: any) => { - if ( - typeof row === 'object' && - row !== null && - !Array.isArray(row) - ) { - return row - } - if (Array.isArray(row)) { - const obj: Record = {} - columns.forEach((col: string, i: number) => { - obj[col] = row[i] - }) - return obj - } - return {} - }) + if ( + typeof row === 'object' && + row !== null && + !Array.isArray(row) + ) { + return row + } + if (Array.isArray(row)) { + const obj: Record = {} + columns.forEach((col: string, i: number) => { + obj[col] = row[i] + }) + return obj + } + return {} + }) : [] setResult({ @@ -176,6 +181,14 @@ export function SqlConsole({ onToggleSidebar, activeConnectionId }: Props) { columnDefinitions, sourceTable: getTableName(queryToRun) }) + + addToHistory({ + query: queryToRun, + connectionId: activeConnectionId, + executionTimeMs: res.data.executionTime || 0, + success: true, + rowCount: res.data.rowCount + }) } else { throw new Error(res.error) } @@ -202,14 +215,23 @@ export function SqlConsole({ onToggleSidebar, activeConnectionId }: Props) { } } } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'An error occurred' setResult({ columns: [], rows: [], rowCount: 0, executionTime: 0, - error: error instanceof Error ? error.message : 'An error occurred', + error: errorMsg, queryType: 'OTHER' }) + + addToHistory({ + query: mode === 'sql' ? currentSqlQuery : currentDrizzleQuery, + connectionId: activeConnectionId || null, + executionTimeMs: 0, + success: false, + error: errorMsg + }) } finally { setIsExecuting(false) } @@ -256,7 +278,7 @@ export function SqlConsole({ onToggleSidebar, activeConnectionId }: Props) { } } - const handleExport = useCallback(() => { + const handleExport = useCallback(function () { if (!result || result.rows.length === 0) return const jsonString = JSON.stringify(result.rows, null, 2) @@ -269,6 +291,32 @@ export function SqlConsole({ onToggleSidebar, activeConnectionId }: Props) { URL.revokeObjectURL(url) }, [result]) + const handleExportCsv = useCallback(function () { + if (!result || result.rows.length === 0) return + + const headers = result.columns.join(',') + const rows = result.rows.map(function (row) { + return result.columns.map(function (col) { + const value = row[col] + if (value === null || value === undefined) return '' + const stringValue = String(value) + if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) { + return '"' + stringValue.replace(/"/g, '""') + '"' + } + return stringValue + }).join(',') + }).join('\n') + + const csvContent = headers + '\n' + rows + const blob = new Blob([csvContent], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'query-results.csv' + a.click() + URL.revokeObjectURL(url) + }, [result]) + // Unified snippet handling - works for both SQL and Drizzle const handleSnippetSelect = useCallback( (id: string) => { @@ -297,7 +345,7 @@ export function SqlConsole({ onToggleSidebar, activeConnectionId }: Props) { await adapter.saveScript( name, currentContent || - (mode === 'sql' ? '-- New SQL query' : '// New Drizzle query'), + (mode === 'sql' ? '-- New SQL query' : '// New Drizzle query'), activeConnectionId, null ) @@ -409,6 +457,15 @@ export function SqlConsole({ onToggleSidebar, activeConnectionId }: Props) { { description: 'Switch to Drizzle mode' } ) + $.key('h') + .except('typing') + .on( + function () { + setShowHistory(!showHistory) + }, + { description: 'Toggle query history' } + ) + return (
@@ -445,6 +502,30 @@ export function SqlConsole({ onToggleSidebar, activeConnectionId }: Props) { )} + {showHistory && ( + <> + + + + + + )} + {/* Main content */}
@@ -452,17 +533,20 @@ export function SqlConsole({ onToggleSidebar, activeConnectionId }: Props) { setShowLeftSidebar(!showLeftSidebar)} - onToggleCheatsheet={() => setShowCheatsheet(!showCheatsheet)} + onToggleLeftSidebar={function () { setShowLeftSidebar(!showLeftSidebar) }} + onToggleCheatsheet={function () { setShowCheatsheet(!showCheatsheet) }} showLeftSidebar={showLeftSidebar} showCheatsheet={showCheatsheet} isExecuting={isExecuting} - onRun={() => handleExecute()} + onRun={function () { handleExecute() }} onPrettify={handlePrettify} onExport={handleExport} + onExportCsv={handleExportCsv} hasResults={!!result} showFilter={showFilter} - onToggleFilter={() => setShowFilter(!showFilter)} + onToggleFilter={function () { setShowFilter(!showFilter) }} + showHistory={showHistory} + onToggleHistory={function () { setShowHistory(!showHistory) }} /> {/* Editor and Results */} diff --git a/apps/desktop/src/features/sql-console/stores/query-history-store.tsx b/apps/desktop/src/features/sql-console/stores/query-history-store.tsx new file mode 100644 index 00000000..d550383d --- /dev/null +++ b/apps/desktop/src/features/sql-console/stores/query-history-store.tsx @@ -0,0 +1,104 @@ +import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react' + +type QueryHistoryItem = { + id: string + query: string + connectionId: string | null + timestamp: number + executionTimeMs: number + success: boolean + error?: string + rowCount?: number +} + +type QueryHistoryState = { + items: QueryHistoryItem[] + maxItems: number +} + +type QueryHistoryContextValue = { + history: QueryHistoryItem[] + addToHistory: (entry: Omit) => void + clearHistory: () => void + removeFromHistory: (id: string) => void +} + +const STORAGE_KEY = 'dora-query-history' +const MAX_HISTORY_ITEMS = 50 + +const QueryHistoryContext = createContext(null) + +function loadHistoryFromStorage(): QueryHistoryItem[] { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + const parsed = JSON.parse(stored) + return Array.isArray(parsed) ? parsed : [] + } + } catch (e) { + console.warn('Failed to load query history:', e) + } + return [] +} + +function saveHistoryToStorage(items: QueryHistoryItem[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(items)) + } catch (e) { + console.warn('Failed to save query history:', e) + } +} + +type Props = { + children: ReactNode +} + +export function QueryHistoryProvider({ children }: Props) { + const [history, setHistory] = useState([]) + + useEffect(function loadHistory() { + const loaded = loadHistoryFromStorage() + setHistory(loaded) + }, []) + + const addToHistory = useCallback(function (entry: Omit) { + setHistory(function (prev) { + const newItem: QueryHistoryItem = { + ...entry, + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + timestamp: Date.now() + } + + const updated = [newItem, ...prev].slice(0, MAX_HISTORY_ITEMS) + saveHistoryToStorage(updated) + return updated + }) + }, []) + + const clearHistory = useCallback(function () { + setHistory([]) + saveHistoryToStorage([]) + }, []) + + const removeFromHistory = useCallback(function (id: string) { + setHistory(function (prev) { + const updated = prev.filter(function (item) { return item.id !== id }) + saveHistoryToStorage(updated) + return updated + }) + }, []) + + return ( + + {children} + + ) +} + +export function useQueryHistory(): QueryHistoryContextValue { + const context = useContext(QueryHistoryContext) + if (!context) { + throw new Error('useQueryHistory must be used within a QueryHistoryProvider') + } + return context +} diff --git a/apps/desktop/src/pages/Index.tsx b/apps/desktop/src/pages/Index.tsx index 974fd7ab..f88ca0bf 100644 --- a/apps/desktop/src/pages/Index.tsx +++ b/apps/desktop/src/pages/Index.tsx @@ -1,4 +1,3 @@ -import { Wand2 } from 'lucide-react' import { useState, useEffect, useRef, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' import { Toaster } from '@/components/ui/toaster' @@ -31,6 +30,11 @@ import { AlertDialogHeader, AlertDialogTitle } from '@/shared/ui/alert-dialog' +import { ErrorBoundary } from '@/shared/ui/error-boundary' +import { mapConnectionError } from '@/shared/utils/error-messages' +import { EmptyState } from '@/shared/ui/empty-state' +import { NotImplemented } from '@/shared/ui/not-implemented' +import { Plug } from 'lucide-react' export default function Index() { const [searchParams, setSearchParams] = useSearchParams() @@ -118,8 +122,8 @@ export default function Index() { } } catch (error) { toast({ - title: 'Error', - description: error instanceof Error ? error.message : 'Failed to load connections', + title: 'Failed to Load Connections', + description: mapConnectionError(error instanceof Error ? error : new Error('Unknown error')), variant: 'destructive' }) } finally { @@ -274,8 +278,8 @@ export default function Index() { } } catch (error) { toast({ - title: 'Error', - description: error instanceof Error ? error.message : 'Failed to add connection', + title: 'Failed to Add Connection', + description: mapConnectionError(error instanceof Error ? error : new Error('Unknown error')), variant: 'destructive' }) } @@ -319,8 +323,8 @@ export default function Index() { } } catch (error) { toast({ - title: 'Error', - description: error instanceof Error ? error.message : 'Failed to update connection', + title: 'Failed to Update Connection', + description: mapConnectionError(error instanceof Error ? error : new Error('Unknown error')), variant: 'destructive' }) } @@ -400,8 +404,8 @@ export default function Index() { } } catch (error) { toast({ - title: 'Error', - description: error instanceof Error ? error.message : 'Failed to delete connection', + title: 'Failed to Delete Connection', + description: mapConnectionError(error instanceof Error ? error : new Error('Unknown error')), variant: 'destructive' }) } finally { @@ -476,28 +480,43 @@ export default function Index() { )}
- {activeNavId === 'database-studio' ? ( - setIsSidebarOpen(!isSidebarOpen)} - initialRowPK={settings.lastRowPK} - onRowSelectionChange={(pk) => { - if (pk !== settings.lastRowPK) { - updateSetting('lastRowPK', pk) - } + {connections.length === 0 && !isLoading && (activeNavId === 'database-studio' || activeNavId === 'sql-console') ? ( + } + title='No Connections' + description='Add a database connection to start exploring your data.' + action={{ + label: 'Add Connection', + onClick: handleOpenNewConnection }} - activeConnectionId={activeConnectionId} - onAddConnection={handleOpenNewConnection} /> + ) : activeNavId === 'database-studio' ? ( + + setIsSidebarOpen(!isSidebarOpen)} + initialRowPK={settings.lastRowPK} + onRowSelectionChange={(pk) => { + if (pk !== settings.lastRowPK) { + updateSetting('lastRowPK', pk) + } + }} + activeConnectionId={activeConnectionId} + onAddConnection={handleOpenNewConnection} + /> + ) : activeNavId === 'sql-console' ? ( - setIsSidebarOpen(!isSidebarOpen)} - activeConnectionId={activeConnectionId} - /> + + setIsSidebarOpen(!isSidebarOpen)} + activeConnectionId={activeConnectionId} + /> + ) : activeNavId === 'docker' ? ( - + + ) : activeNavId === 'dora' ? ( -
-
- -

- Dora AI Assistant -

-

Coming soon...

-
+
+
) : ( - setIsSidebarOpen(!isSidebarOpen)} - activeConnectionId={activeConnectionId} - /> + + setIsSidebarOpen(!isSidebarOpen)} + activeConnectionId={activeConnectionId} + /> + )}
diff --git a/apps/desktop/src/shared/ui/disabled-feature.tsx b/apps/desktop/src/shared/ui/disabled-feature.tsx new file mode 100644 index 00000000..c562125a --- /dev/null +++ b/apps/desktop/src/shared/ui/disabled-feature.tsx @@ -0,0 +1,43 @@ +import { toast } from 'sonner' +import { cn } from '@/shared/lib/utils' +import { Button } from './button' +import type { ComponentProps, ReactNode } from 'react' + +type Props = { + feature: string + children: ReactNode + variant?: ComponentProps['variant'] + size?: ComponentProps['size'] + className?: string + showToast?: boolean +} + +export function DisabledFeature({ + feature, + children, + variant = 'ghost', + size = 'sm', + className, + showToast = true +}: Props) { + function handleClick() { + if (showToast) { + toast.info(`${feature} is coming soon`, { + description: 'This feature is not yet implemented' + }) + } + } + + return ( + + ) +} diff --git a/apps/desktop/src/shared/ui/empty-state.tsx b/apps/desktop/src/shared/ui/empty-state.tsx new file mode 100644 index 00000000..fad70045 --- /dev/null +++ b/apps/desktop/src/shared/ui/empty-state.tsx @@ -0,0 +1,36 @@ +import { ReactNode } from 'react' +import { Button } from '@/shared/ui/button' +import { cn } from '@/shared/utils/cn' + +type Props = { + icon?: ReactNode + title: string + description?: string + action?: { + label: string + onClick: () => void + } + className?: string +} + +export function EmptyState({ icon, title, description, action, className }: Props) { + return ( +
+ {icon &&
{icon}
} +

{title}

+ {description && ( +

{description}

+ )} + {action && ( + + )} +
+ ) +} diff --git a/apps/desktop/src/shared/ui/error-boundary.tsx b/apps/desktop/src/shared/ui/error-boundary.tsx new file mode 100644 index 00000000..558ea43d --- /dev/null +++ b/apps/desktop/src/shared/ui/error-boundary.tsx @@ -0,0 +1,51 @@ +import { Component, ErrorInfo, ReactNode } from 'react' +import { ErrorFallback } from './error-fallback' + +type Props = { + children: ReactNode + fallback?: ReactNode + onReset?: () => void + feature?: string +} + +type State = { + hasError: boolean + error: Error | null +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('[ErrorBoundary] Caught error:', error) + console.error('[ErrorBoundary] Component stack:', errorInfo.componentStack) + } + + handleReset = () => { + this.setState({ hasError: false, error: null }) + this.props.onReset?.() + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback + } + return ( + + ) + } + return this.props.children + } +} diff --git a/apps/desktop/src/shared/ui/error-fallback.tsx b/apps/desktop/src/shared/ui/error-fallback.tsx new file mode 100644 index 00000000..dd0566fe --- /dev/null +++ b/apps/desktop/src/shared/ui/error-fallback.tsx @@ -0,0 +1,128 @@ +import { AlertCircle, RefreshCw, Wifi, Lock, Clock, Server, Database } from 'lucide-react' +import { Button } from './button' +import { cn } from '@/shared/utils/cn' + +type Props = { + error: Error | null + feature?: string + onRetry?: () => void + className?: string +} + +type ErrorMapping = { + icon: typeof AlertCircle + title: string + message: string +} + +function mapError(error: Error | null): ErrorMapping { + const msg = error?.message?.toLowerCase() || '' + + if (msg.includes('connection refused') || msg.includes('econnrefused')) { + return { + icon: Server, + title: 'Connection Refused', + message: 'Unable to connect to the database server. Make sure the server is running and accessible.' + } + } + + if (msg.includes('authentication') || msg.includes('password') || msg.includes('access denied')) { + return { + icon: Lock, + title: 'Authentication Failed', + message: 'Invalid credentials. Please check your username and password.' + } + } + + if (msg.includes('timeout') || msg.includes('timed out')) { + return { + icon: Clock, + title: 'Connection Timed Out', + message: 'The server took too long to respond. It may be overloaded or unreachable.' + } + } + + if (msg.includes('network') || msg.includes('fetch') || msg.includes('enotfound')) { + return { + icon: Wifi, + title: 'Network Error', + message: 'Unable to reach the server. Check your internet connection and try again.' + } + } + + if (msg.includes('does not exist') || msg.includes('unknown database') || msg.includes('no such table')) { + return { + icon: Database, + title: 'Not Found', + message: 'The requested database or table does not exist. Verify the name and try again.' + } + } + + if (msg.includes('ssl') || msg.includes('tls') || msg.includes('certificate')) { + return { + icon: Lock, + title: 'SSL/TLS Error', + message: 'Secure connection failed. Check your SSL settings or try disabling SSL.' + } + } + + if (msg.includes('permission') || msg.includes('denied') || msg.includes('privilege')) { + return { + icon: Lock, + title: 'Permission Denied', + message: 'You do not have permission to perform this action. Contact your database administrator.' + } + } + + return { + icon: AlertCircle, + title: 'Something Went Wrong', + message: 'An unexpected error occurred. Please try again.' + } +} + +export function ErrorFallback({ error, feature, onRetry, className }: Props) { + const mapping = mapError(error) + const Icon = mapping.icon + + return ( +
+
+ +
+ +

+ {feature ? `${feature}: ${mapping.title}` : mapping.title} +

+ +

+ {mapping.message} +

+ + {error && ( +
+ + Technical details + +
+						{error.message}
+					
+
+ )} + + {onRetry && ( + + )} +
+ ) +} + +export { mapError } diff --git a/apps/desktop/src/shared/ui/error-state.tsx b/apps/desktop/src/shared/ui/error-state.tsx new file mode 100644 index 00000000..7b48ee68 --- /dev/null +++ b/apps/desktop/src/shared/ui/error-state.tsx @@ -0,0 +1,30 @@ +import { AlertCircle, RefreshCw } from 'lucide-react' +import { Button } from './button' +import { cn } from '@/shared/lib/utils' + +type Props = { + title?: string + message: string + onRetry?: () => void + className?: string +} + +export function ErrorState({ title, message, onRetry, className }: Props) { + return ( +
+
+ +
+
+ {title &&

{title}

} +

{message}

+
+ {onRetry && ( + + )} +
+ ) +} diff --git a/apps/desktop/src/shared/ui/not-implemented.tsx b/apps/desktop/src/shared/ui/not-implemented.tsx new file mode 100644 index 00000000..9600b481 --- /dev/null +++ b/apps/desktop/src/shared/ui/not-implemented.tsx @@ -0,0 +1,22 @@ +import { AlertTriangle } from 'lucide-react' + +type Props = { + feature: string + description?: string +} + +export function NotImplemented({ feature, description }: Props) { + return ( +
+
+ +
+
+

{feature}

+

+ {description || 'This feature is coming soon'} +

+
+
+ ) +} diff --git a/apps/desktop/src/shared/ui/skeleton.tsx b/apps/desktop/src/shared/ui/skeleton.tsx new file mode 100644 index 00000000..6c6b50d4 --- /dev/null +++ b/apps/desktop/src/shared/ui/skeleton.tsx @@ -0,0 +1,71 @@ +import { cn } from '@/shared/lib/utils' + +type Props = { + className?: string + rows?: number + columns?: number +} + +export function Skeleton({ className }: { className?: string }) { + return ( +
+ ) +} + +export function SkeletonText({ className }: { className?: string }) { + return +} + +export function SkeletonCard({ className }: { className?: string }) { + return ( +
+ + + +
+ ) +} + +export function SkeletonTable({ rows = 5, columns = 4, className }: Props) { + return ( +
+
+ {Array.from({ length: columns }).map(function (_, i) { + return + })} +
+ {Array.from({ length: rows }).map(function (_, rowIndex) { + return ( +
+ {Array.from({ length: columns }).map(function (_, colIndex) { + return + })} +
+ ) + })} +
+ ) +} + +export function SkeletonList({ rows = 5, className }: { rows?: number; className?: string }) { + return ( +
+ {Array.from({ length: rows }).map(function (_, i) { + return ( +
+ +
+ + +
+
+ ) + })} +
+ ) +} diff --git a/apps/desktop/src/shared/utils/error-messages.ts b/apps/desktop/src/shared/utils/error-messages.ts new file mode 100644 index 00000000..7fe14098 --- /dev/null +++ b/apps/desktop/src/shared/utils/error-messages.ts @@ -0,0 +1,95 @@ +/** + * Maps technical database/connection errors to user-friendly messages + */ +export function mapConnectionError(error: Error | string): string { + const msg = (typeof error === 'string' ? error : error.message).toLowerCase() + + if (msg.includes('connection refused') || msg.includes('econnrefused')) { + return 'Connection refused. Make sure the database server is running and accessible.' + } + + if (msg.includes('authentication') || msg.includes('password') || msg.includes('access denied')) { + return 'Authentication failed. Please check your username and password.' + } + + if (msg.includes('timeout') || msg.includes('timed out')) { + return 'Connection timed out. The server may be overloaded or unreachable.' + } + + if (msg.includes('network') || msg.includes('fetch') || msg.includes('enotfound')) { + return 'Network error. Check your internet connection and try again.' + } + + if (msg.includes('does not exist') || msg.includes('unknown database')) { + return 'Database not found. Please verify the database name.' + } + + if (msg.includes('no such table') || msg.includes('table not found')) { + return 'Table not found. It may have been deleted or renamed.' + } + + if (msg.includes('ssl') || msg.includes('tls') || msg.includes('certificate')) { + return 'SSL/TLS connection failed. Check your SSL settings or try disabling SSL.' + } + + if (msg.includes('permission') || msg.includes('denied') || msg.includes('privilege')) { + return 'Permission denied. You may not have access to perform this action.' + } + + if (msg.includes('host')) { + return 'Could not resolve host. Please check the hostname or IP address.' + } + + if (msg.includes('port')) { + return 'Connection failed on the specified port. Verify the port number is correct.' + } + + if (msg.includes('syntax') || msg.includes('parse error')) { + return 'SQL syntax error. Please check your query.' + } + + if (msg.includes('duplicate') || msg.includes('unique constraint')) { + return 'Duplicate entry. A record with this value already exists.' + } + + if (msg.includes('foreign key') || msg.includes('constraint')) { + return 'Constraint violation. This action would violate database integrity rules.' + } + + // Return original message if no mapping found + const originalMsg = typeof error === 'string' ? error : error.message + return originalMsg || 'An unexpected error occurred. Please try again.' +} + +/** + * Maps query execution errors to user-friendly messages + */ +export function mapQueryError(error: Error | string): string { + const msg = (typeof error === 'string' ? error : error.message).toLowerCase() + + if (msg.includes('syntax')) { + return 'SQL syntax error. Check your query for typos or missing keywords.' + } + + if (msg.includes('no such column') || msg.includes('unknown column')) { + return 'Column not found. The specified column does not exist in this table.' + } + + if (msg.includes('no such table') || msg.includes('table') && msg.includes('not exist')) { + return 'Table not found. The specified table does not exist.' + } + + if (msg.includes('ambiguous')) { + return 'Ambiguous column reference. Specify the table name for this column.' + } + + if (msg.includes('division by zero')) { + return 'Division by zero error in your query.' + } + + if (msg.includes('data type') || msg.includes('type mismatch')) { + return 'Data type mismatch. The value does not match the expected column type.' + } + + return mapConnectionError(error) +} diff --git a/apps/desktop/stats.html b/apps/desktop/stats.html index 4b432164..a85bbea4 100644 --- a/apps/desktop/stats.html +++ b/apps/desktop/stats.html @@ -4929,7 +4929,7 @@