Skip to content

Commit 9bc8ec4

Browse files
authored
Merge pull request #260 from koh-sh/feature/csv-export
feat: add csv export feature
2 parents b57c904 + 12e7952 commit 9bc8ec4

8 files changed

Lines changed: 868 additions & 1 deletion

File tree

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,29 @@ You can update the tree by pressing `R`, so you can see your newly created table
264264
4. Press `c` to edit, Press `<Enter>` to submit
265265
5. Press `<Ctrl+S>` to save the changes
266266

267+
### Export to CSV
268+
269+
#### From Table View
270+
271+
1. [Open a table](#openview-a-table)
272+
2. Apply filters or sorting as needed
273+
3. Press `E` to open the export dialog
274+
4. Optionally modify the file path and batch size
275+
5. Select export scope:
276+
- Export Current Page: Export only the currently displayed rows
277+
- Export All Records: Fetch and export all records from the table
278+
279+
> Batch size (default: 10000): When exporting all records, data is fetched in batches to avoid timeout or memory issues with large tables. Increase for faster exports, decrease if you encounter any errors.
280+
>
281+
> The default file path is `~/Downloads/{database}_{table}_{timestamp}.csv`.
282+
283+
#### From SQL Editor
284+
285+
1. [Execute a SQL query](#execute-sql-queries)
286+
2. Press `E` to open the export dialog
287+
3. Optionally modify the file path
288+
4. Select **Export** to save all query results
289+
267290
<p align="right">(<a href="#readme-top">back to top</a>)</p>
268291

269292
## Support
@@ -358,6 +381,7 @@ Commands = [
358381
| } | Focus next tab |
359382
| X | Close current tab |
360383
| R | Refresh the current table |
384+
| E | Export to CSV |
361385

362386
### Tree
363387

app/keymap.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ var Keymaps = KeymapSystem{
131131
Bind{Key: Key{Char: 's'}, Cmd: cmd.FocusSidebar, Description: "Focus sidebar"},
132132
Bind{Key: Key{Char: 'Z'}, Cmd: cmd.ShowRowJSONViewer, Description: "Toggle JSON viewer for row"},
133133
Bind{Key: Key{Char: 'z'}, Cmd: cmd.ShowCellJSONViewer, Description: "Toggle JSON viewer for cell"},
134+
// Export
135+
Bind{Key: Key{Char: 'E'}, Cmd: cmd.ExportCSV, Description: "Export to CSV"},
134136
},
135137
EditorGroup: {
136138
Bind{Key: Key{Code: tcell.KeyCtrlR}, Cmd: cmd.Execute, Description: "Execute query"},

commands/commands.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ const (
8080
TestConnection
8181
EditConnection
8282
DeleteConnection
83+
84+
// Export
85+
ExportCSV
8386
)
8487

8588
func (c Command) String() string {
@@ -218,6 +221,8 @@ func (c Command) String() string {
218221
return "ShowRowJSONViewer"
219222
case ShowCellJSONViewer:
220223
return "ShowCellJSONViewer"
224+
case ExportCSV:
225+
return "ExportCSV"
221226
}
222227

223228
return "Unknown"

components/constants.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ const (
4242
pageNameQueryHistory string = "QueryHistoryModal"
4343
pageNameSaveQuery string = "SaveQueryModal"
4444
pageNameSavedQueryDelete string = "SavedQueryDeleteModal"
45+
46+
// CSV Export
47+
pageNameCSVExport string = "CSVExportModal"
48+
pageNameCSVExportSuccess string = "CSVExportSuccessModal"
49+
pageNameCSVExportError string = "CSVExportErrorModal"
4550
)
4651

4752
// Tabs

components/csv_export_modal.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package components
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"strconv"
8+
"time"
9+
10+
"github.com/gdamore/tcell/v2"
11+
"github.com/rivo/tview"
12+
13+
"github.com/jorgerojas26/lazysql/app"
14+
)
15+
16+
const defaultBatchSize = 10000
17+
18+
// CSVExportScope defines the scope of data to export
19+
type CSVExportScope int
20+
21+
const (
22+
ExportCurrentPage CSVExportScope = iota
23+
ExportAllRecords
24+
)
25+
26+
// CSVExportOptions contains options for creating a CSV export modal.
27+
type CSVExportOptions struct {
28+
DatabaseName string // Database name for file naming
29+
TableName string // Table name for file naming
30+
HasPagination bool // Whether pagination exists (determines UI: 2 buttons vs 1)
31+
RowCount int // Current row count for display (excluding header)
32+
}
33+
34+
// getDefaultExportDir returns the default directory for CSV export.
35+
// Uses ~/Downloads on all platforms (standard location), falls back to home directory.
36+
func getDefaultExportDir() string {
37+
homeDir, err := os.UserHomeDir()
38+
if err != nil {
39+
return "."
40+
}
41+
42+
// ~/Downloads is the standard download location on macOS, Windows, and most Linux distros
43+
downloadDir := filepath.Join(homeDir, "Downloads")
44+
if info, err := os.Stat(downloadDir); err == nil && info.IsDir() {
45+
return downloadDir
46+
}
47+
48+
return homeDir
49+
}
50+
51+
// CSVExportModal is a modal for exporting data to CSV.
52+
type CSVExportModal struct {
53+
tview.Primitive
54+
form *tview.Form
55+
hasPagination bool
56+
onExport func(filePath string, scope CSVExportScope, batchSize int)
57+
}
58+
59+
// NewCSVExportModal creates a new CSVExportModal.
60+
func NewCSVExportModal(opts CSVExportOptions, onExport func(filePath string, scope CSVExportScope, batchSize int)) *CSVExportModal {
61+
cem := &CSVExportModal{
62+
hasPagination: opts.HasPagination,
63+
onExport: onExport,
64+
}
65+
66+
// Default file path with timestamp to avoid conflicts
67+
timestamp := time.Now().Format("20060102_150405")
68+
fileName := fmt.Sprintf("%s_%s_%s.csv", opts.DatabaseName, opts.TableName, timestamp)
69+
defaultPath := filepath.Join(getDefaultExportDir(), fileName)
70+
71+
cem.form = tview.NewForm().
72+
AddInputField("File Path", defaultPath, 0, nil, nil)
73+
74+
if opts.HasPagination {
75+
// Add batch size field for table view (used for Export All Records)
76+
cem.form.AddInputField("Batch Size", strconv.Itoa(defaultBatchSize), 0, nil, nil)
77+
78+
// Table view: show both options
79+
cem.form.AddButton("Export Current Page", func() {
80+
cem.export(ExportCurrentPage)
81+
})
82+
cem.form.AddButton("Export All Records", func() {
83+
cem.export(ExportAllRecords)
84+
})
85+
} else {
86+
// Query result: show single export button with row count
87+
buttonLabel := fmt.Sprintf("Export (%d rows)", opts.RowCount)
88+
cem.form.AddButton(buttonLabel, func() {
89+
cem.export(ExportAllRecords)
90+
})
91+
}
92+
93+
cem.form.SetFieldStyle(
94+
tcell.StyleDefault.
95+
Background(app.Styles.SecondaryTextColor).
96+
Foreground(app.Styles.ContrastSecondaryTextColor),
97+
).SetButtonActivatedStyle(tcell.StyleDefault.
98+
Background(app.Styles.SecondaryTextColor).
99+
Foreground(app.Styles.ContrastSecondaryTextColor),
100+
).SetButtonStyle(tcell.StyleDefault.
101+
Background(app.Styles.InverseTextColor).
102+
Foreground(app.Styles.ContrastSecondaryTextColor),
103+
)
104+
105+
cem.form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
106+
if event.Key() == tcell.KeyEsc {
107+
cem.cancel()
108+
return nil
109+
}
110+
return event
111+
})
112+
113+
cem.form.SetBorder(false)
114+
115+
hint := tview.NewTextView().
116+
SetText("Esc to cancel").
117+
SetTextAlign(tview.AlignCenter).
118+
SetTextColor(app.Styles.TertiaryTextColor)
119+
120+
formWithHint := tview.NewFlex().
121+
SetDirection(tview.FlexRow).
122+
AddItem(cem.form, 0, 1, true).
123+
AddItem(hint, 1, 0, false)
124+
formWithHint.SetBorder(true).SetTitle(" Export to CSV ").SetTitleAlign(tview.AlignLeft)
125+
126+
grid := tview.NewGrid().
127+
SetRows(0, 11, 0).
128+
SetColumns(0, 80, 0).
129+
AddItem(formWithHint, 1, 1, 1, 1, 0, 0, true)
130+
131+
cem.Primitive = grid
132+
133+
return cem
134+
}
135+
136+
func (cem *CSVExportModal) export(scope CSVExportScope) {
137+
filePath := cem.form.GetFormItem(0).(*tview.InputField).GetText()
138+
if filePath == "" {
139+
cem.showErrorModal("File path cannot be empty")
140+
return
141+
}
142+
143+
batchSize := 0
144+
if cem.hasPagination {
145+
batchSizeText := cem.form.GetFormItem(1).(*tview.InputField).GetText()
146+
var err error
147+
batchSize, err = strconv.Atoi(batchSizeText)
148+
if err != nil || batchSize <= 0 {
149+
cem.showErrorModal("Batch size must be a positive integer")
150+
return
151+
}
152+
}
153+
154+
mainPages.RemovePage(pageNameCSVExport)
155+
156+
if cem.onExport != nil {
157+
cem.onExport(filePath, scope, batchSize)
158+
}
159+
}
160+
161+
func (cem *CSVExportModal) showErrorModal(message string) {
162+
modal := NewErrorModal(message)
163+
modal.SetDoneFunc(func(_ int, _ string) {
164+
mainPages.RemovePage(pageNameCSVExportError)
165+
})
166+
167+
mainPages.AddPage(pageNameCSVExportError, modal, true, true)
168+
App.SetFocus(modal)
169+
}
170+
171+
func (cem *CSVExportModal) cancel() {
172+
mainPages.RemovePage(pageNameCSVExport)
173+
}

0 commit comments

Comments
 (0)