csvtool/app.go
exe.dev user 55a25f6d63 CSV Tool - Wails-based CSV editor with spreadsheet UI
Features:
- Spreadsheet-like table with cell navigation (arrow keys)
- Formula bar for editing cell values
- Click and drag cell selection with Shift+Arrow extend
- Column resize by dragging header borders, double-click for best fit
- Editable headers via double-click
- Command palette (Cmd+P) with 12 commands
- Copy/Cut/Paste with CSV, Markdown, and Jira formats
- Insert rows/columns above/below/left/right
- File drag-and-drop to open CSV files
- Native Open/Save dialogs
- Go backend for CSV parsing, formatting, and file I/O
- Vanilla JS frontend, no frameworks

Co-authored-by: Shelley <shelley@exe.dev>
2026-03-03 21:34:09 +00:00

322 lines
7.5 KiB
Go

package main
import (
"bytes"
"context"
"encoding/csv"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// CSVData represents the CSV data passed between Go and the frontend.
type CSVData struct {
Headers []string `json:"Headers"`
Rows [][]string `json:"Rows"`
FilePath string `json:"FilePath"`
}
// App struct holds the application state.
type App struct {
ctx context.Context
headers []string
rows [][]string
currentFile string
}
// NewApp creates a new App application struct.
func NewApp() *App {
return &App{}
}
// startup is called when the app starts. The context is saved
// so we can call the runtime methods.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
// Set up file drop handler
runtime.OnFileDrop(ctx, func(x, y int, paths []string) {
if len(paths) > 0 {
runtime.EventsEmit(ctx, "file-dropped", paths[0])
}
})
}
// domReady is called after the front-end DOM is ready.
func (a *App) domReady(ctx context.Context) {
// placeholder for any post-DOM-ready initialisation
}
// LoadCSV reads a CSV file and returns its data. The first row is treated as headers.
func (a *App) LoadCSV(filePath string) (*CSVData, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
reader := csv.NewReader(bytes.NewReader(data))
reader.FieldsPerRecord = -1 // allow variable field counts
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("failed to parse CSV: %w", err)
}
if len(records) == 0 {
a.headers = []string{}
a.rows = [][]string{}
a.currentFile = filePath
a.updateWindowTitle()
return &CSVData{Headers: a.headers, Rows: a.rows, FilePath: filePath}, nil
}
a.headers = records[0]
if len(records) > 1 {
a.rows = records[1:]
} else {
a.rows = [][]string{}
}
a.currentFile = filePath
a.updateWindowTitle()
return &CSVData{Headers: a.headers, Rows: a.rows, FilePath: filePath}, nil
}
// SaveCSV writes headers and rows to the given file path as CSV.
func (a *App) SaveCSV(filePath string, headers []string, rows [][]string) error {
f, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer f.Close()
writer := csv.NewWriter(f)
defer writer.Flush()
if err := writer.Write(headers); err != nil {
return fmt.Errorf("failed to write headers: %w", err)
}
for _, row := range rows {
if err := writer.Write(row); err != nil {
return fmt.Errorf("failed to write row: %w", err)
}
}
a.headers = headers
a.rows = rows
a.currentFile = filePath
a.updateWindowTitle()
return nil
}
// SaveCurrentFile saves to the currently open file.
func (a *App) SaveCurrentFile(headers []string, rows [][]string) error {
if a.currentFile == "" {
return fmt.Errorf("no file is currently open")
}
return a.SaveCSV(a.currentFile, headers, rows)
}
// GetTableData returns the current CSV state.
func (a *App) GetTableData() *CSVData {
return &CSVData{
Headers: a.headers,
Rows: a.rows,
FilePath: a.currentFile,
}
}
// SetTableData updates the in-memory state from the frontend.
func (a *App) SetTableData(headers []string, rows [][]string) {
a.headers = headers
a.rows = rows
}
// OpenFileDialog shows a native open dialog filtered for CSV files.
func (a *App) OpenFileDialog() (string, error) {
return runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Open CSV File",
Filters: []runtime.FileFilter{
{
DisplayName: "CSV Files (*.csv)",
Pattern: "*.csv",
},
{
DisplayName: "All Files (*.*)",
Pattern: "*.*",
},
},
})
}
// SaveFileDialog shows a native save dialog.
func (a *App) SaveFileDialog() (string, error) {
return runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "Save CSV File",
DefaultFilename: "untitled.csv",
Filters: []runtime.FileFilter{
{
DisplayName: "CSV Files (*.csv)",
Pattern: "*.csv",
},
{
DisplayName: "All Files (*.*)",
Pattern: "*.*",
},
},
})
}
// ParseCSVString parses CSV text content and returns structured data.
func (a *App) ParseCSVString(content string) (*CSVData, error) {
reader := csv.NewReader(strings.NewReader(content))
reader.FieldsPerRecord = -1
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("failed to parse CSV string: %w", err)
}
if len(records) == 0 {
return &CSVData{Headers: []string{}, Rows: [][]string{}}, nil
}
headers := records[0]
var rows [][]string
if len(records) > 1 {
rows = records[1:]
} else {
rows = [][]string{}
}
return &CSVData{Headers: headers, Rows: rows}, nil
}
// FormatAsCSV formats headers and rows as a CSV string.
func (a *App) FormatAsCSV(headers []string, rows [][]string) string {
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
_ = writer.Write(headers)
for _, row := range rows {
_ = writer.Write(row)
}
writer.Flush()
return buf.String()
}
// FormatRowsAsCSV formats just the rows (no headers) as CSV text.
func (a *App) FormatRowsAsCSV(rows [][]string) string {
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
for _, row := range rows {
_ = writer.Write(row)
}
writer.Flush()
return buf.String()
}
// FormatAsMarkdown formats headers and rows as a Markdown table.
func (a *App) FormatAsMarkdown(headers []string, rows [][]string) string {
if len(headers) == 0 {
return ""
}
var sb strings.Builder
// Header row
sb.WriteString("| ")
sb.WriteString(strings.Join(headers, " | "))
sb.WriteString(" |\n")
// Separator row
separators := make([]string, len(headers))
for i := range separators {
separators[i] = "---"
}
sb.WriteString("| ")
sb.WriteString(strings.Join(separators, " | "))
sb.WriteString(" |\n")
// Data rows
for _, row := range rows {
padded := padRow(row, len(headers))
sb.WriteString("| ")
sb.WriteString(strings.Join(padded, " | "))
sb.WriteString(" |\n")
}
return sb.String()
}
// FormatAsJira formats headers and rows as Jira table markup.
func (a *App) FormatAsJira(headers []string, rows [][]string) string {
if len(headers) == 0 {
return ""
}
var sb strings.Builder
// Header row: || Header1 || Header2 ||
sb.WriteString("|| ")
sb.WriteString(strings.Join(headers, " || "))
sb.WriteString(" ||\n")
// Data rows: | val1 | val2 |
for _, row := range rows {
padded := padRow(row, len(headers))
sb.WriteString("| ")
sb.WriteString(strings.Join(padded, " | "))
sb.WriteString(" |\n")
}
return sb.String()
}
// FormatAsSingleColumn formats a single column of data as newline-separated lines.
func (a *App) FormatAsSingleColumn(rows [][]string) string {
var lines []string
for _, row := range rows {
if len(row) > 0 {
lines = append(lines, row[0])
} else {
lines = append(lines, "")
}
}
return strings.Join(lines, "\n")
}
// SetWindowTitle sets the window title. If title is empty, resets to default.
func (a *App) SetWindowTitle(title string) {
if title == "" {
runtime.WindowSetTitle(a.ctx, "CSV Tool")
} else {
runtime.WindowSetTitle(a.ctx, title+" - CSV Tool")
}
}
// updateWindowTitle sets the window title based on the current file.
func (a *App) updateWindowTitle() {
if a.currentFile == "" {
runtime.WindowSetTitle(a.ctx, "CSV Tool")
} else {
name := filepath.Base(a.currentFile)
runtime.WindowSetTitle(a.ctx, name+" - CSV Tool")
}
}
// padRow ensures a row has exactly n columns, padding with empty strings if needed.
func padRow(row []string, n int) []string {
if len(row) >= n {
return row[:n]
}
padded := make([]string, n)
copy(padded, row)
return padded
}