feat(ui): 🎨 Implement schema and table management screens
* Add schema management screen with list and editor * Implement table management screen with list and editor * Create data operations for schema and table management * Define UI rules and guidelines for consistency * Ensure circular tab navigation and keyboard shortcuts * Add forms for creating and editing schemas and tables * Implement confirmation dialogs for destructive actions
This commit is contained in:
11
TODO.md
11
TODO.md
@@ -22,6 +22,17 @@
|
||||
- [✔️] GraphQL schema generation
|
||||
|
||||
|
||||
## UI
|
||||
- [✔️] Basic UI (I went with tview)
|
||||
- [✔️] Save / Load Database
|
||||
- [✔️] Schemas / Domains / Tables
|
||||
- [ ] Add Relations
|
||||
- [ ] Add Indexes
|
||||
- [ ] Add Views
|
||||
- [ ] Add Sequences
|
||||
- [ ] Add Scripts
|
||||
- [ ] Domain / Table Assignment
|
||||
|
||||
## Documentation
|
||||
- [ ] API documentation (godoc)
|
||||
- [ ] Usage examples for each format combination
|
||||
|
||||
334
cmd/relspec/edit.go
Normal file
334
cmd/relspec/edit.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/bun"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/dbml"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/dctx"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/drawdb"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/drizzle"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/gorm"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/graphql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/json"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/prisma"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/typeorm"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/ui"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
wbun "git.warky.dev/wdevs/relspecgo/pkg/writers/bun"
|
||||
wdbml "git.warky.dev/wdevs/relspecgo/pkg/writers/dbml"
|
||||
wdctx "git.warky.dev/wdevs/relspecgo/pkg/writers/dctx"
|
||||
wdrawdb "git.warky.dev/wdevs/relspecgo/pkg/writers/drawdb"
|
||||
wdrizzle "git.warky.dev/wdevs/relspecgo/pkg/writers/drizzle"
|
||||
wgorm "git.warky.dev/wdevs/relspecgo/pkg/writers/gorm"
|
||||
wgraphql "git.warky.dev/wdevs/relspecgo/pkg/writers/graphql"
|
||||
wjson "git.warky.dev/wdevs/relspecgo/pkg/writers/json"
|
||||
wpgsql "git.warky.dev/wdevs/relspecgo/pkg/writers/pgsql"
|
||||
wprisma "git.warky.dev/wdevs/relspecgo/pkg/writers/prisma"
|
||||
wtypeorm "git.warky.dev/wdevs/relspecgo/pkg/writers/typeorm"
|
||||
wyaml "git.warky.dev/wdevs/relspecgo/pkg/writers/yaml"
|
||||
)
|
||||
|
||||
var (
|
||||
editSourceType string
|
||||
editSourcePath string
|
||||
editSourceConn string
|
||||
editTargetType string
|
||||
editTargetPath string
|
||||
editSchemaFilter string
|
||||
)
|
||||
|
||||
var editCmd = &cobra.Command{
|
||||
Use: "edit",
|
||||
Short: "Edit database schema interactively with TUI",
|
||||
Long: `Edit database schemas from various formats using an interactive terminal UI.
|
||||
|
||||
Allows you to:
|
||||
- List and navigate schemas and tables
|
||||
- Create, edit, and delete schemas
|
||||
- Create, edit, and delete tables
|
||||
- Add, edit, and delete columns
|
||||
- Set table and column properties
|
||||
- Add constraints, indexes, and relationships
|
||||
|
||||
Supports reading from and writing to all supported formats:
|
||||
Input formats:
|
||||
- dbml: DBML schema files
|
||||
- dctx: DCTX schema files
|
||||
- drawdb: DrawDB JSON files
|
||||
- graphql: GraphQL schema files (.graphql, SDL)
|
||||
- json: JSON database schema
|
||||
- yaml: YAML database schema
|
||||
- gorm: GORM model files (Go, file or directory)
|
||||
- bun: Bun model files (Go, file or directory)
|
||||
- drizzle: Drizzle ORM schema files (TypeScript, file or directory)
|
||||
- prisma: Prisma schema files (.prisma)
|
||||
- typeorm: TypeORM entity files (TypeScript)
|
||||
- pgsql: PostgreSQL database (live connection)
|
||||
|
||||
Output formats:
|
||||
- dbml: DBML schema files
|
||||
- dctx: DCTX schema files
|
||||
- drawdb: DrawDB JSON files
|
||||
- graphql: GraphQL schema files (.graphql, SDL)
|
||||
- json: JSON database schema
|
||||
- yaml: YAML database schema
|
||||
- gorm: GORM model files (Go)
|
||||
- bun: Bun model files (Go)
|
||||
- drizzle: Drizzle ORM schema files (TypeScript)
|
||||
- prisma: Prisma schema files (.prisma)
|
||||
- typeorm: TypeORM entity files (TypeScript)
|
||||
- pgsql: PostgreSQL SQL schema
|
||||
|
||||
PostgreSQL Connection String Examples:
|
||||
postgres://username:password@localhost:5432/database_name
|
||||
postgres://username:password@localhost/database_name
|
||||
postgresql://user:pass@host:5432/dbname?sslmode=disable
|
||||
postgresql://user:pass@host/dbname?sslmode=require
|
||||
host=localhost port=5432 user=username password=pass dbname=mydb sslmode=disable
|
||||
|
||||
Examples:
|
||||
# Edit a DBML schema file
|
||||
relspec edit --from dbml --from-path schema.dbml --to dbml --to-path schema.dbml
|
||||
|
||||
# Edit a PostgreSQL database
|
||||
relspec edit --from pgsql --from-conn "postgres://user:pass@localhost/mydb" \
|
||||
--to pgsql --to-conn "postgres://user:pass@localhost/mydb"
|
||||
|
||||
# Edit JSON schema and output to GORM
|
||||
relspec edit --from json --from-path db.json --to gorm --to-path models/
|
||||
|
||||
# Edit GORM models in place
|
||||
relspec edit --from gorm --from-path ./models --to gorm --to-path ./models`,
|
||||
RunE: runEdit,
|
||||
}
|
||||
|
||||
func init() {
|
||||
editCmd.Flags().StringVar(&editSourceType, "from", "", "Source format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql)")
|
||||
editCmd.Flags().StringVar(&editSourcePath, "from-path", "", "Source file path (for file-based formats)")
|
||||
editCmd.Flags().StringVar(&editSourceConn, "from-conn", "", "Source connection string (for database formats)")
|
||||
editCmd.Flags().StringVar(&editTargetType, "to", "", "Target format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql)")
|
||||
editCmd.Flags().StringVar(&editTargetPath, "to-path", "", "Target file path (for file-based formats)")
|
||||
editCmd.Flags().StringVar(&editSchemaFilter, "schema", "", "Filter to a specific schema by name")
|
||||
|
||||
// Flags are now optional - if not provided, UI will prompt for load/save options
|
||||
}
|
||||
|
||||
func runEdit(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "\n=== RelSpec Schema Editor ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Started at: %s\n\n", getCurrentTimestamp())
|
||||
|
||||
var db *models.Database
|
||||
var loadConfig *ui.LoadConfig
|
||||
var saveConfig *ui.SaveConfig
|
||||
var err error
|
||||
|
||||
// Check if source parameters are provided
|
||||
if editSourceType != "" {
|
||||
// Read source database
|
||||
fmt.Fprintf(os.Stderr, "[1/3] Reading source schema...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", editSourceType)
|
||||
if editSourcePath != "" {
|
||||
fmt.Fprintf(os.Stderr, " Path: %s\n", editSourcePath)
|
||||
}
|
||||
if editSourceConn != "" {
|
||||
fmt.Fprintf(os.Stderr, " Conn: %s\n", maskPassword(editSourceConn))
|
||||
}
|
||||
|
||||
db, err = readDatabaseForEdit(editSourceType, editSourcePath, editSourceConn, "Source")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read source: %w", err)
|
||||
}
|
||||
|
||||
// Apply schema filter if specified
|
||||
if editSchemaFilter != "" {
|
||||
db = filterDatabaseBySchema(db, editSchemaFilter)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, " ✓ Successfully read database '%s'\n", db.Name)
|
||||
fmt.Fprintf(os.Stderr, " Found: %d schema(s)\n", len(db.Schemas))
|
||||
|
||||
totalTables := 0
|
||||
for _, schema := range db.Schemas {
|
||||
totalTables += len(schema.Tables)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " Found: %d table(s)\n\n", totalTables)
|
||||
|
||||
// Store load config
|
||||
loadConfig = &ui.LoadConfig{
|
||||
SourceType: editSourceType,
|
||||
FilePath: editSourcePath,
|
||||
ConnString: editSourceConn,
|
||||
}
|
||||
} else {
|
||||
// No source parameters provided, UI will show load screen
|
||||
fmt.Fprintf(os.Stderr, "[1/2] No source specified, editor will prompt for database\n\n")
|
||||
}
|
||||
|
||||
// Store save config if target parameters are provided
|
||||
if editTargetType != "" {
|
||||
saveConfig = &ui.SaveConfig{
|
||||
TargetType: editTargetType,
|
||||
FilePath: editTargetPath,
|
||||
}
|
||||
}
|
||||
|
||||
// Launch interactive TUI
|
||||
if editSourceType != "" {
|
||||
fmt.Fprintf(os.Stderr, "[2/3] Launching interactive editor...\n")
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "[2/2] Launching interactive editor...\n")
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " Use arrow keys and shortcuts to navigate\n")
|
||||
fmt.Fprintf(os.Stderr, " Press ? for help\n\n")
|
||||
|
||||
editor := ui.NewSchemaEditorWithConfigs(db, loadConfig, saveConfig)
|
||||
if err := editor.Run(); err != nil {
|
||||
return fmt.Errorf("editor failed: %w", err)
|
||||
}
|
||||
|
||||
// Only write to output if target parameters were provided and database was loaded from command line
|
||||
if editTargetType != "" && editSourceType != "" && db != nil {
|
||||
fmt.Fprintf(os.Stderr, "[3/3] Writing changes to output...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", editTargetType)
|
||||
if editTargetPath != "" {
|
||||
fmt.Fprintf(os.Stderr, " Path: %s\n", editTargetPath)
|
||||
}
|
||||
|
||||
// Get the potentially modified database from the editor
|
||||
err = writeDatabaseForEdit(editTargetType, editTargetPath, "", editor.GetDatabase(), "Target")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write output: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, " ✓ Successfully written database\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n=== Edit complete ===\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readDatabaseForEdit(dbType, filePath, connString, label string) (*models.Database, error) {
|
||||
var reader readers.Reader
|
||||
|
||||
switch strings.ToLower(dbType) {
|
||||
case "dbml":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DBML format", label)
|
||||
}
|
||||
reader = dbml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "dctx":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DCTX format", label)
|
||||
}
|
||||
reader = dctx.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "drawdb":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DrawDB format", label)
|
||||
}
|
||||
reader = drawdb.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "graphql":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for GraphQL format", label)
|
||||
}
|
||||
reader = graphql.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "json":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for JSON format", label)
|
||||
}
|
||||
reader = json.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "yaml":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for YAML format", label)
|
||||
}
|
||||
reader = yaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "gorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for GORM format", label)
|
||||
}
|
||||
reader = gorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "bun":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Bun format", label)
|
||||
}
|
||||
reader = bun.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "drizzle":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Drizzle format", label)
|
||||
}
|
||||
reader = drizzle.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "prisma":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Prisma format", label)
|
||||
}
|
||||
reader = prisma.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "typeorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for TypeORM format", label)
|
||||
}
|
||||
reader = typeorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "pgsql":
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("%s: connection string is required for PostgreSQL format", label)
|
||||
}
|
||||
reader = pgsql.NewReader(&readers.ReaderOptions{ConnectionString: connString})
|
||||
default:
|
||||
return nil, fmt.Errorf("%s: unsupported format: %s", label, dbType)
|
||||
}
|
||||
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func writeDatabaseForEdit(dbType, filePath, connString string, db *models.Database, label string) error {
|
||||
var writer writers.Writer
|
||||
|
||||
switch strings.ToLower(dbType) {
|
||||
case "dbml":
|
||||
writer = wdbml.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "dctx":
|
||||
writer = wdctx.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "drawdb":
|
||||
writer = wdrawdb.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "graphql":
|
||||
writer = wgraphql.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "json":
|
||||
writer = wjson.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "yaml":
|
||||
writer = wyaml.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "gorm":
|
||||
writer = wgorm.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "bun":
|
||||
writer = wbun.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "drizzle":
|
||||
writer = wdrizzle.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "prisma":
|
||||
writer = wprisma.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "typeorm":
|
||||
writer = wtypeorm.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "pgsql":
|
||||
writer = wpgsql.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
default:
|
||||
return fmt.Errorf("%s: unsupported format: %s", label, dbType)
|
||||
}
|
||||
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -21,4 +21,5 @@ func init() {
|
||||
rootCmd.AddCommand(inspectCmd)
|
||||
rootCmd.AddCommand(scriptsCmd)
|
||||
rootCmd.AddCommand(templCmd)
|
||||
rootCmd.AddCommand(editCmd)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
// intermediate representation for converting between various database schema formats.
|
||||
package models
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DatabaseType represents the type of database system.
|
||||
type DatabaseType string
|
||||
@@ -26,6 +29,7 @@ type Database struct {
|
||||
DatabaseType DatabaseType `json:"database_type,omitempty" yaml:"database_type,omitempty" xml:"database_type,omitempty"`
|
||||
DatabaseVersion string `json:"database_version,omitempty" yaml:"database_version,omitempty" xml:"database_version,omitempty"`
|
||||
SourceFormat string `json:"source_format,omitempty" yaml:"source_format,omitempty" xml:"source_format,omitempty"` // Source Format of the database.
|
||||
UpdatedAt string `json:"updatedat,omitempty" yaml:"updatedat,omitempty" xml:"updatedat,omitempty"`
|
||||
}
|
||||
|
||||
// SQLName returns the database name in lowercase for SQL compatibility.
|
||||
@@ -33,6 +37,11 @@ func (d *Database) SQLName() string {
|
||||
return strings.ToLower(d.Name)
|
||||
}
|
||||
|
||||
// UpdateDate sets the UpdatedAt field to the current time in RFC3339 format.
|
||||
func (d *Database) UpdateDate() {
|
||||
d.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// Domain represents a logical business domain grouping multiple tables from potentially different schemas.
|
||||
// Domains allow for organizing database tables by functional areas (e.g., authentication, user data, financial).
|
||||
type Domain struct {
|
||||
@@ -76,6 +85,15 @@ type Schema struct {
|
||||
RefDatabase *Database `json:"-" yaml:"-" xml:"-"` // Excluded to prevent circular references
|
||||
Relations []*Relationship `json:"relations,omitempty" yaml:"relations,omitempty" xml:"-"`
|
||||
Enums []*Enum `json:"enums,omitempty" yaml:"enums,omitempty" xml:"enums"`
|
||||
UpdatedAt string `json:"updatedat,omitempty" yaml:"updatedat,omitempty" xml:"updatedat,omitempty"`
|
||||
}
|
||||
|
||||
// UpdaUpdateDateted sets the UpdatedAt field to the current time in RFC3339 format.
|
||||
func (d *Schema) UpdateDate() {
|
||||
d.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
if d.RefDatabase != nil {
|
||||
d.RefDatabase.UpdateDate()
|
||||
}
|
||||
}
|
||||
|
||||
// SQLName returns the schema name in lowercase for SQL compatibility.
|
||||
@@ -98,6 +116,15 @@ type Table struct {
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty" xml:"-"`
|
||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||
RefSchema *Schema `json:"-" yaml:"-" xml:"-"` // Excluded to prevent circular references
|
||||
UpdatedAt string `json:"updatedat,omitempty" yaml:"updatedat,omitempty" xml:"updatedat,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateDate sets the UpdatedAt field to the current time in RFC3339 format.
|
||||
func (d *Table) UpdateDate() {
|
||||
d.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
if d.RefSchema != nil {
|
||||
d.RefSchema.UpdateDate()
|
||||
}
|
||||
}
|
||||
|
||||
// SQLName returns the table name in lowercase for SQL compatibility.
|
||||
|
||||
95
pkg/ui/column_dataops.go
Normal file
95
pkg/ui/column_dataops.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package ui
|
||||
|
||||
import "git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
|
||||
// Column data operations - business logic for column management
|
||||
|
||||
// CreateColumn creates a new column and adds it to a table
|
||||
func (se *SchemaEditor) CreateColumn(schemaIndex, tableIndex int, name, dataType string, isPrimaryKey, isNotNull bool) *models.Column {
|
||||
table := se.GetTable(schemaIndex, tableIndex)
|
||||
if table == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if table.Columns == nil {
|
||||
table.Columns = make(map[string]*models.Column)
|
||||
}
|
||||
|
||||
newColumn := &models.Column{
|
||||
Name: name,
|
||||
Type: dataType,
|
||||
IsPrimaryKey: isPrimaryKey,
|
||||
NotNull: isNotNull,
|
||||
}
|
||||
table.UpdateDate()
|
||||
table.Columns[name] = newColumn
|
||||
return newColumn
|
||||
}
|
||||
|
||||
// UpdateColumn updates an existing column's properties
|
||||
func (se *SchemaEditor) UpdateColumn(schemaIndex, tableIndex int, oldName, newName, dataType string, isPrimaryKey, isNotNull bool, defaultValue interface{}, description string) bool {
|
||||
table := se.GetTable(schemaIndex, tableIndex)
|
||||
if table == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
column, exists := table.Columns[oldName]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
table.UpdateDate()
|
||||
|
||||
// If name changed, remove old entry and create new one
|
||||
if oldName != newName {
|
||||
delete(table.Columns, oldName)
|
||||
column.Name = newName
|
||||
table.Columns[newName] = column
|
||||
}
|
||||
|
||||
// Update properties
|
||||
column.Type = dataType
|
||||
column.IsPrimaryKey = isPrimaryKey
|
||||
column.NotNull = isNotNull
|
||||
column.Default = defaultValue
|
||||
column.Description = description
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// DeleteColumn removes a column from a table
|
||||
func (se *SchemaEditor) DeleteColumn(schemaIndex, tableIndex int, columnName string) bool {
|
||||
table := se.GetTable(schemaIndex, tableIndex)
|
||||
if table == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, exists := table.Columns[columnName]; !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
table.UpdateDate()
|
||||
|
||||
delete(table.Columns, columnName)
|
||||
return true
|
||||
}
|
||||
|
||||
// GetColumn returns a column by name
|
||||
func (se *SchemaEditor) GetColumn(schemaIndex, tableIndex int, columnName string) *models.Column {
|
||||
table := se.GetTable(schemaIndex, tableIndex)
|
||||
if table == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return table.Columns[columnName]
|
||||
}
|
||||
|
||||
// GetAllColumns returns all columns in a table
|
||||
func (se *SchemaEditor) GetAllColumns(schemaIndex, tableIndex int) map[string]*models.Column {
|
||||
table := se.GetTable(schemaIndex, tableIndex)
|
||||
if table == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return table.Columns
|
||||
}
|
||||
208
pkg/ui/column_screens.go
Normal file
208
pkg/ui/column_screens.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// showColumnEditor shows editor for a specific column
|
||||
func (se *SchemaEditor) showColumnEditor(schemaIndex, tableIndex, colIndex int, column *models.Column) {
|
||||
form := tview.NewForm()
|
||||
|
||||
// Store original name to handle renames
|
||||
originalName := column.Name
|
||||
|
||||
// Local variables to collect changes
|
||||
newName := column.Name
|
||||
newType := column.Type
|
||||
newIsPK := column.IsPrimaryKey
|
||||
newIsNotNull := column.NotNull
|
||||
newDefault := column.Default
|
||||
newDescription := column.Description
|
||||
|
||||
// Column type options: PostgreSQL, MySQL, SQL Server, and common SQL types
|
||||
columnTypes := []string{
|
||||
// Numeric Types
|
||||
"SMALLINT", "INTEGER", "BIGINT", "INT", "TINYINT", "FLOAT", "REAL", "DOUBLE PRECISION",
|
||||
"DECIMAL(10,2)", "NUMERIC", "DECIMAL", "NUMERIC(10,2)",
|
||||
// Character Types
|
||||
"CHAR", "VARCHAR", "VARCHAR(255)", "TEXT", "NCHAR", "NVARCHAR", "NVARCHAR(255)",
|
||||
// Boolean
|
||||
"BOOLEAN", "BOOL", "BIT",
|
||||
// Date/Time Types
|
||||
"DATE", "TIME", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "INTERVAL",
|
||||
"DATETIME", "DATETIME2", "DATEFIRST",
|
||||
// UUID and JSON
|
||||
"UUID", "GUID", "JSON", "JSONB",
|
||||
// Binary Types
|
||||
"BYTEA", "BLOB", "IMAGE", "VARBINARY", "VARBINARY(MAX)", "BINARY",
|
||||
// PostgreSQL Special Types
|
||||
"int4range", "int8range", "numrange", "tsrange", "tstzrange", "daterange",
|
||||
"HSTORE", "CITEXT", "INET", "MACADDR", "POINT", "LINE", "LSEG", "BOX", "PATH", "POLYGON", "CIRCLE",
|
||||
// Array Types
|
||||
"INTEGER ARRAY", "VARCHAR ARRAY", "TEXT ARRAY", "BIGINT ARRAY",
|
||||
// MySQL Specific
|
||||
"MEDIUMINT", "DOUBLE", "FLOAT(10,2)",
|
||||
// SQL Server Specific
|
||||
"MONEY", "SMALLMONEY", "SQL_VARIANT",
|
||||
}
|
||||
selectedTypeIndex := 0
|
||||
|
||||
// Add existing type if not already in the list
|
||||
typeExists := false
|
||||
for i, opt := range columnTypes {
|
||||
if opt == column.Type {
|
||||
selectedTypeIndex = i
|
||||
typeExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !typeExists && column.Type != "" {
|
||||
columnTypes = append(columnTypes, column.Type)
|
||||
selectedTypeIndex = len(columnTypes) - 1
|
||||
}
|
||||
|
||||
form.AddInputField("Column Name", column.Name, 40, nil, func(value string) {
|
||||
newName = value
|
||||
})
|
||||
|
||||
form.AddDropDown("Type", columnTypes, selectedTypeIndex, func(option string, index int) {
|
||||
newType = option
|
||||
})
|
||||
|
||||
form.AddCheckbox("Primary Key", column.IsPrimaryKey, func(checked bool) {
|
||||
newIsPK = checked
|
||||
})
|
||||
|
||||
form.AddCheckbox("Not Null", column.NotNull, func(checked bool) {
|
||||
newIsNotNull = checked
|
||||
})
|
||||
|
||||
defaultStr := ""
|
||||
if column.Default != nil {
|
||||
defaultStr = fmt.Sprintf("%v", column.Default)
|
||||
}
|
||||
form.AddInputField("Default Value", defaultStr, 40, nil, func(value string) {
|
||||
newDefault = value
|
||||
})
|
||||
|
||||
form.AddTextArea("Description", column.Description, 40, 5, 0, func(value string) {
|
||||
newDescription = value
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
// Apply changes using dataops
|
||||
se.UpdateColumn(schemaIndex, tableIndex, originalName, newName, newType, newIsPK, newIsNotNull, newDefault, newDescription)
|
||||
|
||||
se.pages.RemovePage("column-editor")
|
||||
se.pages.SwitchToPage("table-editor")
|
||||
})
|
||||
|
||||
form.AddButton("Delete", func() {
|
||||
se.showDeleteColumnConfirm(schemaIndex, tableIndex, originalName)
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
// Discard changes - don't apply them
|
||||
se.pages.RemovePage("column-editor")
|
||||
se.pages.SwitchToPage("table-editor")
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" Edit Column ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("column-editor", "table-editor")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("column-editor", form, true, true)
|
||||
}
|
||||
|
||||
// showNewColumnDialog shows dialog to create a new column
|
||||
func (se *SchemaEditor) showNewColumnDialog(schemaIndex, tableIndex int) {
|
||||
form := tview.NewForm()
|
||||
|
||||
columnName := ""
|
||||
dataType := "VARCHAR(255)"
|
||||
|
||||
// Column type options: PostgreSQL, MySQL, SQL Server, and common SQL types
|
||||
columnTypes := []string{
|
||||
// Numeric Types
|
||||
"SMALLINT", "INTEGER", "BIGINT", "INT", "TINYINT", "FLOAT", "REAL", "DOUBLE PRECISION",
|
||||
"DECIMAL(10,2)", "NUMERIC", "DECIMAL", "NUMERIC(10,2)",
|
||||
// Character Types
|
||||
"CHAR", "VARCHAR", "VARCHAR(255)", "TEXT", "NCHAR", "NVARCHAR", "NVARCHAR(255)",
|
||||
// Boolean
|
||||
"BOOLEAN", "BOOL", "BIT",
|
||||
// Date/Time Types
|
||||
"DATE", "TIME", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "INTERVAL",
|
||||
"DATETIME", "DATETIME2", "DATEFIRST",
|
||||
// UUID and JSON
|
||||
"UUID", "GUID", "JSON", "JSONB",
|
||||
// Binary Types
|
||||
"BYTEA", "BLOB", "IMAGE", "VARBINARY", "VARBINARY(MAX)", "BINARY",
|
||||
// PostgreSQL Special Types
|
||||
"int4range", "int8range", "numrange", "tsrange", "tstzrange", "daterange",
|
||||
"HSTORE", "CITEXT", "INET", "MACADDR", "POINT", "LINE", "LSEG", "BOX", "PATH", "POLYGON", "CIRCLE",
|
||||
// Array Types
|
||||
"INTEGER ARRAY", "VARCHAR ARRAY", "TEXT ARRAY", "BIGINT ARRAY",
|
||||
// MySQL Specific
|
||||
"MEDIUMINT", "DOUBLE", "FLOAT(10,2)",
|
||||
// SQL Server Specific
|
||||
"MONEY", "SMALLMONEY", "SQL_VARIANT",
|
||||
}
|
||||
selectedTypeIndex := 0
|
||||
|
||||
form.AddInputField("Column Name", "", 40, nil, func(value string) {
|
||||
columnName = value
|
||||
})
|
||||
|
||||
form.AddDropDown("Data Type", columnTypes, selectedTypeIndex, func(option string, index int) {
|
||||
dataType = option
|
||||
})
|
||||
|
||||
form.AddCheckbox("Primary Key", false, nil)
|
||||
form.AddCheckbox("Not Null", false, nil)
|
||||
form.AddCheckbox("Unique", false, nil)
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
if columnName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Get form values
|
||||
isPK := form.GetFormItemByLabel("Primary Key").(*tview.Checkbox).IsChecked()
|
||||
isNotNull := form.GetFormItemByLabel("Not Null").(*tview.Checkbox).IsChecked()
|
||||
|
||||
se.CreateColumn(schemaIndex, tableIndex, columnName, dataType, isPK, isNotNull)
|
||||
|
||||
table := se.db.Schemas[schemaIndex].Tables[tableIndex]
|
||||
se.pages.RemovePage("new-column")
|
||||
se.pages.RemovePage("table-editor")
|
||||
se.showTableEditor(schemaIndex, tableIndex, table)
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
table := se.db.Schemas[schemaIndex].Tables[tableIndex]
|
||||
se.pages.RemovePage("new-column")
|
||||
se.pages.RemovePage("table-editor")
|
||||
se.showTableEditor(schemaIndex, tableIndex, table)
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" New Column ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("new-column", "table-editor")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("new-column", form, true, true)
|
||||
}
|
||||
15
pkg/ui/database_dataops.go
Normal file
15
pkg/ui/database_dataops.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// updateDatabase updates database properties
|
||||
func (se *SchemaEditor) updateDatabase(name, description, comment, dbType, dbVersion string) {
|
||||
se.db.Name = name
|
||||
se.db.Description = description
|
||||
se.db.Comment = comment
|
||||
se.db.DatabaseType = models.DatabaseType(dbType)
|
||||
se.db.DatabaseVersion = dbVersion
|
||||
se.db.UpdateDate()
|
||||
}
|
||||
72
pkg/ui/database_screens.go
Normal file
72
pkg/ui/database_screens.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
// showEditDatabaseForm displays a dialog to edit database properties
|
||||
func (se *SchemaEditor) showEditDatabaseForm() {
|
||||
form := tview.NewForm()
|
||||
|
||||
dbName := se.db.Name
|
||||
dbDescription := se.db.Description
|
||||
dbComment := se.db.Comment
|
||||
dbType := string(se.db.DatabaseType)
|
||||
dbVersion := se.db.DatabaseVersion
|
||||
|
||||
// Database type options
|
||||
dbTypeOptions := []string{"pgsql", "mssql", "sqlite"}
|
||||
selectedTypeIndex := 0
|
||||
for i, opt := range dbTypeOptions {
|
||||
if opt == dbType {
|
||||
selectedTypeIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
form.AddInputField("Database Name", dbName, 40, nil, func(value string) {
|
||||
dbName = value
|
||||
})
|
||||
|
||||
form.AddInputField("Description", dbDescription, 50, nil, func(value string) {
|
||||
dbDescription = value
|
||||
})
|
||||
|
||||
form.AddInputField("Comment", dbComment, 50, nil, func(value string) {
|
||||
dbComment = value
|
||||
})
|
||||
|
||||
form.AddDropDown("Database Type", dbTypeOptions, selectedTypeIndex, func(option string, index int) {
|
||||
dbType = option
|
||||
})
|
||||
|
||||
form.AddInputField("Database Version", dbVersion, 20, nil, func(value string) {
|
||||
dbVersion = value
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
if dbName == "" {
|
||||
return
|
||||
}
|
||||
se.updateDatabase(dbName, dbDescription, dbComment, dbType, dbVersion)
|
||||
se.pages.RemovePage("edit-database")
|
||||
se.pages.RemovePage("main")
|
||||
se.pages.AddPage("main", se.createMainMenu(), true, true)
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
se.pages.RemovePage("edit-database")
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" Edit Database ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("edit-database", "main")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("edit-database", form, true, true)
|
||||
}
|
||||
139
pkg/ui/dialogs.go
Normal file
139
pkg/ui/dialogs.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
// showExitConfirmation shows a confirmation dialog when trying to exit without saving
|
||||
func (se *SchemaEditor) showExitConfirmation(pageToRemove, pageToSwitchTo string) {
|
||||
modal := tview.NewModal().
|
||||
SetText("Exit without saving changes?").
|
||||
AddButtons([]string{"Cancel", "No, exit without saving"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
if buttonLabel == "No, exit without saving" {
|
||||
se.pages.RemovePage(pageToRemove)
|
||||
se.pages.SwitchToPage(pageToSwitchTo)
|
||||
}
|
||||
se.pages.RemovePage("exit-confirm")
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("exit-confirm")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("exit-confirm", modal, true, true)
|
||||
}
|
||||
|
||||
// showExitEditorConfirm shows confirmation dialog when trying to exit the entire editor
|
||||
func (se *SchemaEditor) showExitEditorConfirm() {
|
||||
modal := tview.NewModal().
|
||||
SetText("Exit RelSpec Editor? Press ESC again to confirm.").
|
||||
AddButtons([]string{"Cancel", "Exit"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
if buttonLabel == "Exit" {
|
||||
se.app.Stop()
|
||||
}
|
||||
se.pages.RemovePage("exit-editor-confirm")
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.app.Stop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("exit-editor-confirm", modal, true, true)
|
||||
}
|
||||
|
||||
// showDeleteSchemaConfirm shows confirmation dialog for schema deletion
|
||||
func (se *SchemaEditor) showDeleteSchemaConfirm(schemaIndex int) {
|
||||
modal := tview.NewModal().
|
||||
SetText(fmt.Sprintf("Delete schema '%s'? This will delete all tables in this schema.",
|
||||
se.db.Schemas[schemaIndex].Name)).
|
||||
AddButtons([]string{"Cancel", "Delete"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
if buttonLabel == "Delete" {
|
||||
se.DeleteSchema(schemaIndex)
|
||||
se.pages.RemovePage("schema-editor")
|
||||
se.pages.RemovePage("schemas")
|
||||
se.showSchemaList()
|
||||
}
|
||||
se.pages.RemovePage("confirm-delete-schema")
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("confirm-delete-schema")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("confirm-delete-schema", modal, true, true)
|
||||
}
|
||||
|
||||
// showDeleteTableConfirm shows confirmation dialog for table deletion
|
||||
func (se *SchemaEditor) showDeleteTableConfirm(schemaIndex, tableIndex int) {
|
||||
table := se.db.Schemas[schemaIndex].Tables[tableIndex]
|
||||
modal := tview.NewModal().
|
||||
SetText(fmt.Sprintf("Delete table '%s'? This action cannot be undone.",
|
||||
table.Name)).
|
||||
AddButtons([]string{"Cancel", "Delete"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
if buttonLabel == "Delete" {
|
||||
se.DeleteTable(schemaIndex, tableIndex)
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
se.pages.RemovePage("table-editor")
|
||||
se.pages.RemovePage("schema-editor")
|
||||
se.showSchemaEditor(schemaIndex, schema)
|
||||
}
|
||||
se.pages.RemovePage("confirm-delete-table")
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("confirm-delete-table")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("confirm-delete-table", modal, true, true)
|
||||
}
|
||||
|
||||
// showDeleteColumnConfirm shows confirmation dialog for column deletion
|
||||
func (se *SchemaEditor) showDeleteColumnConfirm(schemaIndex, tableIndex int, columnName string) {
|
||||
modal := tview.NewModal().
|
||||
SetText(fmt.Sprintf("Delete column '%s'? This action cannot be undone.",
|
||||
columnName)).
|
||||
AddButtons([]string{"Cancel", "Delete"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
if buttonLabel == "Delete" {
|
||||
se.DeleteColumn(schemaIndex, tableIndex, columnName)
|
||||
se.pages.RemovePage("column-editor")
|
||||
se.pages.RemovePage("confirm-delete-column")
|
||||
se.pages.SwitchToPage("table-editor")
|
||||
} else {
|
||||
se.pages.RemovePage("confirm-delete-column")
|
||||
}
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("confirm-delete-column")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("confirm-delete-column", modal, true, true)
|
||||
}
|
||||
35
pkg/ui/domain_dataops.go
Normal file
35
pkg/ui/domain_dataops.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// createDomain creates a new domain
|
||||
func (se *SchemaEditor) createDomain(name, description string) {
|
||||
domain := &models.Domain{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Tables: make([]*models.DomainTable, 0),
|
||||
Sequence: uint(len(se.db.Domains)),
|
||||
}
|
||||
|
||||
se.db.Domains = append(se.db.Domains, domain)
|
||||
se.showDomainList()
|
||||
}
|
||||
|
||||
// updateDomain updates an existing domain
|
||||
func (se *SchemaEditor) updateDomain(index int, name, description string) {
|
||||
if index >= 0 && index < len(se.db.Domains) {
|
||||
se.db.Domains[index].Name = name
|
||||
se.db.Domains[index].Description = description
|
||||
se.showDomainList()
|
||||
}
|
||||
}
|
||||
|
||||
// deleteDomain deletes a domain by index
|
||||
func (se *SchemaEditor) deleteDomain(index int) {
|
||||
if index >= 0 && index < len(se.db.Domains) {
|
||||
se.db.Domains = append(se.db.Domains[:index], se.db.Domains[index+1:]...)
|
||||
se.showDomainList()
|
||||
}
|
||||
}
|
||||
258
pkg/ui/domain_screens.go
Normal file
258
pkg/ui/domain_screens.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// showDomainList displays the domain management screen
|
||||
func (se *SchemaEditor) showDomainList() {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
|
||||
// Title
|
||||
title := tview.NewTextView().
|
||||
SetText("[::b]Manage Domains").
|
||||
SetDynamicColors(true).
|
||||
SetTextAlign(tview.AlignCenter)
|
||||
|
||||
// Create domains table
|
||||
domainTable := tview.NewTable().SetBorders(true).SetSelectable(true, false).SetFixed(1, 0)
|
||||
|
||||
// Add header row
|
||||
headers := []string{"Name", "Sequence", "Total Tables", "Description"}
|
||||
headerWidths := []int{20, 15, 20}
|
||||
for i, header := range headers {
|
||||
padding := ""
|
||||
if i < len(headerWidths) {
|
||||
padding = strings.Repeat(" ", headerWidths[i]-len(header))
|
||||
}
|
||||
cell := tview.NewTableCell(header + padding).
|
||||
SetTextColor(tcell.ColorYellow).
|
||||
SetSelectable(false).
|
||||
SetAlign(tview.AlignLeft)
|
||||
domainTable.SetCell(0, i, cell)
|
||||
}
|
||||
|
||||
// Add existing domains
|
||||
for row, domain := range se.db.Domains {
|
||||
domain := domain // capture for closure
|
||||
|
||||
// Name - pad to 20 chars
|
||||
nameStr := fmt.Sprintf("%-20s", domain.Name)
|
||||
nameCell := tview.NewTableCell(nameStr).SetSelectable(true)
|
||||
domainTable.SetCell(row+1, 0, nameCell)
|
||||
|
||||
// Sequence - pad to 15 chars
|
||||
seqStr := fmt.Sprintf("%-15s", fmt.Sprintf("%d", domain.Sequence))
|
||||
seqCell := tview.NewTableCell(seqStr).SetSelectable(true)
|
||||
domainTable.SetCell(row+1, 1, seqCell)
|
||||
|
||||
// Total Tables - pad to 20 chars
|
||||
tablesStr := fmt.Sprintf("%-20s", fmt.Sprintf("%d", len(domain.Tables)))
|
||||
tablesCell := tview.NewTableCell(tablesStr).SetSelectable(true)
|
||||
domainTable.SetCell(row+1, 2, tablesCell)
|
||||
|
||||
// Description - no padding, takes remaining space
|
||||
descCell := tview.NewTableCell(domain.Description).SetSelectable(true)
|
||||
domainTable.SetCell(row+1, 3, descCell)
|
||||
}
|
||||
|
||||
domainTable.SetTitle(" Domains ").SetBorder(true).SetTitleAlign(tview.AlignLeft)
|
||||
|
||||
// Action buttons flex
|
||||
btnFlex := tview.NewFlex()
|
||||
btnNewDomain := tview.NewButton("New Domain [n]").SetSelectedFunc(func() {
|
||||
se.showNewDomainDialog()
|
||||
})
|
||||
btnBack := tview.NewButton("Back [b]").SetSelectedFunc(func() {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage("domains")
|
||||
})
|
||||
|
||||
// Set up button input captures for Tab/Shift+Tab navigation
|
||||
btnNewDomain.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(domainTable)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnBack)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnBack.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(btnNewDomain)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(domainTable)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnFlex.AddItem(btnNewDomain, 0, 1, true).
|
||||
AddItem(btnBack, 0, 1, false)
|
||||
|
||||
domainTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage("domains")
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnNewDomain)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyEnter {
|
||||
row, _ := domainTable.GetSelection()
|
||||
if row > 0 && row <= len(se.db.Domains) { // Skip header row
|
||||
domainIndex := row - 1
|
||||
se.showDomainEditor(domainIndex, se.db.Domains[domainIndex])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if event.Rune() == 'n' {
|
||||
se.showNewDomainDialog()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'b' {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage("domains")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
flex.AddItem(title, 1, 0, false).
|
||||
AddItem(domainTable, 0, 1, true).
|
||||
AddItem(btnFlex, 1, 0, false)
|
||||
|
||||
se.pages.AddPage("domains", flex, true, true)
|
||||
}
|
||||
|
||||
// showNewDomainDialog displays a dialog to create a new domain
|
||||
func (se *SchemaEditor) showNewDomainDialog() {
|
||||
form := tview.NewForm()
|
||||
|
||||
domainName := ""
|
||||
domainDesc := ""
|
||||
|
||||
form.AddInputField("Name", "", 40, nil, func(value string) {
|
||||
domainName = value
|
||||
})
|
||||
|
||||
form.AddInputField("Description", "", 50, nil, func(value string) {
|
||||
domainDesc = value
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
if domainName == "" {
|
||||
return
|
||||
}
|
||||
se.createDomain(domainName, domainDesc)
|
||||
se.pages.RemovePage("new-domain")
|
||||
se.pages.RemovePage("domains")
|
||||
se.showDomainList()
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
se.pages.RemovePage("new-domain")
|
||||
se.pages.RemovePage("domains")
|
||||
se.showDomainList()
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" New Domain ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("new-domain", "domains")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("new-domain", form, true, true)
|
||||
}
|
||||
|
||||
// showDomainEditor displays a dialog to edit an existing domain
|
||||
func (se *SchemaEditor) showDomainEditor(index int, domain *models.Domain) {
|
||||
form := tview.NewForm()
|
||||
|
||||
domainName := domain.Name
|
||||
domainDesc := domain.Description
|
||||
|
||||
form.AddInputField("Name", domainName, 40, nil, func(value string) {
|
||||
domainName = value
|
||||
})
|
||||
|
||||
form.AddInputField("Description", domainDesc, 50, nil, func(value string) {
|
||||
domainDesc = value
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
if domainName == "" {
|
||||
return
|
||||
}
|
||||
se.updateDomain(index, domainName, domainDesc)
|
||||
se.pages.RemovePage("edit-domain")
|
||||
se.pages.RemovePage("domains")
|
||||
se.showDomainList()
|
||||
})
|
||||
|
||||
form.AddButton("Delete", func() {
|
||||
se.showDeleteDomainConfirm(index)
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
se.pages.RemovePage("edit-domain")
|
||||
se.pages.RemovePage("domains")
|
||||
se.showDomainList()
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" Edit Domain ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("edit-domain", "domains")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("edit-domain", form, true, true)
|
||||
}
|
||||
|
||||
// showDeleteDomainConfirm shows a confirmation dialog before deleting a domain
|
||||
func (se *SchemaEditor) showDeleteDomainConfirm(index int) {
|
||||
modal := tview.NewModal().
|
||||
SetText(fmt.Sprintf("Delete domain '%s'? This action cannot be undone.", se.db.Domains[index].Name)).
|
||||
AddButtons([]string{"Cancel", "Delete"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
if buttonLabel == "Delete" {
|
||||
se.deleteDomain(index)
|
||||
se.pages.RemovePage("delete-domain-confirm")
|
||||
se.pages.RemovePage("edit-domain")
|
||||
se.pages.RemovePage("domains")
|
||||
se.showDomainList()
|
||||
} else {
|
||||
se.pages.RemovePage("delete-domain-confirm")
|
||||
}
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("delete-domain-confirm")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddAndSwitchToPage("delete-domain-confirm", modal, true)
|
||||
}
|
||||
73
pkg/ui/editor.go
Normal file
73
pkg/ui/editor.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// SchemaEditor represents the interactive schema editor
|
||||
type SchemaEditor struct {
|
||||
db *models.Database
|
||||
app *tview.Application
|
||||
pages *tview.Pages
|
||||
loadConfig *LoadConfig
|
||||
saveConfig *SaveConfig
|
||||
}
|
||||
|
||||
// NewSchemaEditor creates a new schema editor
|
||||
func NewSchemaEditor(db *models.Database) *SchemaEditor {
|
||||
return &SchemaEditor{
|
||||
db: db,
|
||||
app: tview.NewApplication(),
|
||||
pages: tview.NewPages(),
|
||||
loadConfig: nil,
|
||||
saveConfig: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// NewSchemaEditorWithConfigs creates a new schema editor with load/save configurations
|
||||
func NewSchemaEditorWithConfigs(db *models.Database, loadConfig *LoadConfig, saveConfig *SaveConfig) *SchemaEditor {
|
||||
return &SchemaEditor{
|
||||
db: db,
|
||||
app: tview.NewApplication(),
|
||||
pages: tview.NewPages(),
|
||||
loadConfig: loadConfig,
|
||||
saveConfig: saveConfig,
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the interactive editor
|
||||
func (se *SchemaEditor) Run() error {
|
||||
// If no database is loaded, show load screen
|
||||
if se.db == nil {
|
||||
se.showLoadScreen()
|
||||
} else {
|
||||
// Create main menu view
|
||||
mainMenu := se.createMainMenu()
|
||||
se.pages.AddPage("main", mainMenu, true, true)
|
||||
}
|
||||
|
||||
// Run the application
|
||||
if err := se.app.SetRoot(se.pages, true).Run(); err != nil {
|
||||
return fmt.Errorf("application error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDatabase returns the current database
|
||||
func (se *SchemaEditor) GetDatabase() *models.Database {
|
||||
return se.db
|
||||
}
|
||||
|
||||
// Helper function to get sorted column names
|
||||
func getColumnNames(table *models.Table) []string {
|
||||
names := make([]string, 0, len(table.Columns))
|
||||
for name := range table.Columns {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
526
pkg/ui/load_save_screens.go
Normal file
526
pkg/ui/load_save_screens.go
Normal file
@@ -0,0 +1,526 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
rbun "git.warky.dev/wdevs/relspecgo/pkg/readers/bun"
|
||||
rdbml "git.warky.dev/wdevs/relspecgo/pkg/readers/dbml"
|
||||
rdctx "git.warky.dev/wdevs/relspecgo/pkg/readers/dctx"
|
||||
rdrawdb "git.warky.dev/wdevs/relspecgo/pkg/readers/drawdb"
|
||||
rdrizzle "git.warky.dev/wdevs/relspecgo/pkg/readers/drizzle"
|
||||
rgorm "git.warky.dev/wdevs/relspecgo/pkg/readers/gorm"
|
||||
rgraphql "git.warky.dev/wdevs/relspecgo/pkg/readers/graphql"
|
||||
rjson "git.warky.dev/wdevs/relspecgo/pkg/readers/json"
|
||||
rpgsql "git.warky.dev/wdevs/relspecgo/pkg/readers/pgsql"
|
||||
rprisma "git.warky.dev/wdevs/relspecgo/pkg/readers/prisma"
|
||||
rtypeorm "git.warky.dev/wdevs/relspecgo/pkg/readers/typeorm"
|
||||
ryaml "git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
wbun "git.warky.dev/wdevs/relspecgo/pkg/writers/bun"
|
||||
wdbml "git.warky.dev/wdevs/relspecgo/pkg/writers/dbml"
|
||||
wdctx "git.warky.dev/wdevs/relspecgo/pkg/writers/dctx"
|
||||
wdrawdb "git.warky.dev/wdevs/relspecgo/pkg/writers/drawdb"
|
||||
wdrizzle "git.warky.dev/wdevs/relspecgo/pkg/writers/drizzle"
|
||||
wgorm "git.warky.dev/wdevs/relspecgo/pkg/writers/gorm"
|
||||
wgraphql "git.warky.dev/wdevs/relspecgo/pkg/writers/graphql"
|
||||
wjson "git.warky.dev/wdevs/relspecgo/pkg/writers/json"
|
||||
wpgsql "git.warky.dev/wdevs/relspecgo/pkg/writers/pgsql"
|
||||
wprisma "git.warky.dev/wdevs/relspecgo/pkg/writers/prisma"
|
||||
wtypeorm "git.warky.dev/wdevs/relspecgo/pkg/writers/typeorm"
|
||||
wyaml "git.warky.dev/wdevs/relspecgo/pkg/writers/yaml"
|
||||
)
|
||||
|
||||
// LoadConfig holds the configuration for loading a database
|
||||
type LoadConfig struct {
|
||||
SourceType string
|
||||
FilePath string
|
||||
ConnString string
|
||||
}
|
||||
|
||||
// SaveConfig holds the configuration for saving a database
|
||||
type SaveConfig struct {
|
||||
TargetType string
|
||||
FilePath string
|
||||
ConnString string
|
||||
}
|
||||
|
||||
// showLoadScreen displays the database load screen
|
||||
func (se *SchemaEditor) showLoadScreen() {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
|
||||
// Title
|
||||
title := tview.NewTextView().
|
||||
SetText("[::b]Load Database Schema").
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetDynamicColors(true)
|
||||
|
||||
// Form
|
||||
form := tview.NewForm()
|
||||
form.SetBorder(true).SetTitle(" Load Configuration ").SetTitleAlign(tview.AlignLeft)
|
||||
|
||||
// Format selection
|
||||
formatOptions := []string{
|
||||
"dbml", "dctx", "drawdb", "graphql", "json", "yaml",
|
||||
"gorm", "bun", "drizzle", "prisma", "typeorm", "pgsql",
|
||||
}
|
||||
selectedFormat := 0
|
||||
currentFormat := formatOptions[selectedFormat]
|
||||
|
||||
// File path input
|
||||
filePath := ""
|
||||
connString := ""
|
||||
|
||||
form.AddDropDown("Format", formatOptions, 0, func(option string, index int) {
|
||||
selectedFormat = index
|
||||
currentFormat = option
|
||||
})
|
||||
|
||||
form.AddInputField("File Path", "", 50, nil, func(value string) {
|
||||
filePath = value
|
||||
})
|
||||
|
||||
form.AddInputField("Connection String", "", 50, nil, func(value string) {
|
||||
connString = value
|
||||
})
|
||||
|
||||
form.AddTextView("Help", getLoadHelpText(), 0, 5, true, false)
|
||||
|
||||
// Buttons
|
||||
form.AddButton("Load [l]", func() {
|
||||
se.loadDatabase(currentFormat, filePath, connString)
|
||||
})
|
||||
|
||||
form.AddButton("Create New [n]", func() {
|
||||
se.createNewDatabase()
|
||||
})
|
||||
|
||||
form.AddButton("Exit [q]", func() {
|
||||
se.app.Stop()
|
||||
})
|
||||
|
||||
// Keyboard shortcuts
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Key() {
|
||||
case tcell.KeyEscape:
|
||||
se.app.Stop()
|
||||
return nil
|
||||
}
|
||||
switch event.Rune() {
|
||||
case 'l':
|
||||
se.loadDatabase(currentFormat, filePath, connString)
|
||||
return nil
|
||||
case 'n':
|
||||
se.createNewDatabase()
|
||||
return nil
|
||||
case 'q':
|
||||
se.app.Stop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
// Tab navigation
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.app.Stop()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'l' || event.Rune() == 'n' || event.Rune() == 'q' {
|
||||
return event
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
flex.AddItem(title, 1, 0, false).
|
||||
AddItem(form, 0, 1, true)
|
||||
|
||||
se.pages.AddAndSwitchToPage("load-database", flex, true)
|
||||
}
|
||||
|
||||
// showSaveScreen displays the save database screen
|
||||
func (se *SchemaEditor) showSaveScreen() {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
|
||||
// Title
|
||||
title := tview.NewTextView().
|
||||
SetText("[::b]Save Database Schema").
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetDynamicColors(true)
|
||||
|
||||
// Form
|
||||
form := tview.NewForm()
|
||||
form.SetBorder(true).SetTitle(" Save Configuration ").SetTitleAlign(tview.AlignLeft)
|
||||
|
||||
// Format selection
|
||||
formatOptions := []string{
|
||||
"dbml", "dctx", "drawdb", "graphql", "json", "yaml",
|
||||
"gorm", "bun", "drizzle", "prisma", "typeorm", "pgsql",
|
||||
}
|
||||
selectedFormat := 0
|
||||
currentFormat := formatOptions[selectedFormat]
|
||||
|
||||
// File path input
|
||||
filePath := ""
|
||||
if se.saveConfig != nil {
|
||||
// Pre-populate with existing save config
|
||||
for i, format := range formatOptions {
|
||||
if format == se.saveConfig.TargetType {
|
||||
selectedFormat = i
|
||||
currentFormat = format
|
||||
break
|
||||
}
|
||||
}
|
||||
filePath = se.saveConfig.FilePath
|
||||
}
|
||||
|
||||
form.AddDropDown("Format", formatOptions, selectedFormat, func(option string, index int) {
|
||||
selectedFormat = index
|
||||
currentFormat = option
|
||||
})
|
||||
|
||||
form.AddInputField("File Path", filePath, 50, nil, func(value string) {
|
||||
filePath = value
|
||||
})
|
||||
|
||||
form.AddTextView("Help", getSaveHelpText(), 0, 5, true, false)
|
||||
|
||||
// Buttons
|
||||
form.AddButton("Save [s]", func() {
|
||||
se.saveDatabase(currentFormat, filePath)
|
||||
})
|
||||
|
||||
form.AddButton("Update Existing Database [u]", func() {
|
||||
// Use saveConfig if available, otherwise use loadConfig
|
||||
if se.saveConfig != nil {
|
||||
se.showUpdateExistingDatabaseConfirm()
|
||||
} else if se.loadConfig != nil {
|
||||
se.showUpdateExistingDatabaseConfirm()
|
||||
} else {
|
||||
se.showErrorDialog("Error", "No database source found. Use Save instead.")
|
||||
}
|
||||
})
|
||||
|
||||
form.AddButton("Back [b]", func() {
|
||||
se.pages.RemovePage("save-database")
|
||||
se.pages.SwitchToPage("main")
|
||||
})
|
||||
|
||||
// Keyboard shortcuts
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Key() {
|
||||
case tcell.KeyEscape:
|
||||
se.pages.RemovePage("save-database")
|
||||
se.pages.SwitchToPage("main")
|
||||
return nil
|
||||
}
|
||||
switch event.Rune() {
|
||||
case 's':
|
||||
se.saveDatabase(currentFormat, filePath)
|
||||
return nil
|
||||
case 'u':
|
||||
// Use saveConfig if available, otherwise use loadConfig
|
||||
if se.saveConfig != nil {
|
||||
se.showUpdateExistingDatabaseConfirm()
|
||||
} else if se.loadConfig != nil {
|
||||
se.showUpdateExistingDatabaseConfirm()
|
||||
} else {
|
||||
se.showErrorDialog("Error", "No database source found. Use Save instead.")
|
||||
}
|
||||
return nil
|
||||
case 'b':
|
||||
se.pages.RemovePage("save-database")
|
||||
se.pages.SwitchToPage("main")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
flex.AddItem(title, 1, 0, false).
|
||||
AddItem(form, 0, 1, true)
|
||||
|
||||
se.pages.AddAndSwitchToPage("save-database", flex, true)
|
||||
}
|
||||
|
||||
// loadDatabase loads a database from the specified configuration
|
||||
func (se *SchemaEditor) loadDatabase(format, filePath, connString string) {
|
||||
// Validate input
|
||||
if format == "pgsql" {
|
||||
if connString == "" {
|
||||
se.showErrorDialog("Error", "Connection string is required for PostgreSQL")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if filePath == "" {
|
||||
se.showErrorDialog("Error", "File path is required for "+format)
|
||||
return
|
||||
}
|
||||
// Expand home directory
|
||||
if len(filePath) > 0 && filePath[0] == '~' {
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
filePath = filepath.Join(home, filePath[1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create reader
|
||||
var reader readers.Reader
|
||||
switch format {
|
||||
case "dbml":
|
||||
reader = rdbml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "dctx":
|
||||
reader = rdctx.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "drawdb":
|
||||
reader = rdrawdb.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "graphql":
|
||||
reader = rgraphql.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "json":
|
||||
reader = rjson.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "yaml":
|
||||
reader = ryaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "gorm":
|
||||
reader = rgorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "bun":
|
||||
reader = rbun.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "drizzle":
|
||||
reader = rdrizzle.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "prisma":
|
||||
reader = rprisma.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "typeorm":
|
||||
reader = rtypeorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "pgsql":
|
||||
reader = rpgsql.NewReader(&readers.ReaderOptions{ConnectionString: connString})
|
||||
default:
|
||||
se.showErrorDialog("Error", "Unsupported format: "+format)
|
||||
return
|
||||
}
|
||||
|
||||
// Read database
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
se.showErrorDialog("Load Error", fmt.Sprintf("Failed to load database: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Store load config
|
||||
se.loadConfig = &LoadConfig{
|
||||
SourceType: format,
|
||||
FilePath: filePath,
|
||||
ConnString: connString,
|
||||
}
|
||||
|
||||
// Update database
|
||||
se.db = db
|
||||
|
||||
// Show success and switch to main menu
|
||||
se.showSuccessDialog("Load Complete", fmt.Sprintf("Successfully loaded database '%s'", db.Name), func() {
|
||||
se.pages.RemovePage("load-database")
|
||||
se.pages.RemovePage("main")
|
||||
se.pages.AddPage("main", se.createMainMenu(), true, true)
|
||||
})
|
||||
}
|
||||
|
||||
// saveDatabase saves the database to the specified configuration
|
||||
func (se *SchemaEditor) saveDatabase(format, filePath string) {
|
||||
// Validate input
|
||||
if format == "pgsql" {
|
||||
se.showErrorDialog("Error", "Direct PostgreSQL save is not supported from the UI. Use --to pgsql --to-path output.sql")
|
||||
return
|
||||
}
|
||||
|
||||
if filePath == "" {
|
||||
se.showErrorDialog("Error", "File path is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Expand home directory
|
||||
if len(filePath) > 0 && filePath[0] == '~' {
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
filePath = filepath.Join(home, filePath[1:])
|
||||
}
|
||||
}
|
||||
|
||||
// Create writer
|
||||
var writer writers.Writer
|
||||
switch format {
|
||||
case "dbml":
|
||||
writer = wdbml.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "dctx":
|
||||
writer = wdctx.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "drawdb":
|
||||
writer = wdrawdb.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "graphql":
|
||||
writer = wgraphql.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "json":
|
||||
writer = wjson.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "yaml":
|
||||
writer = wyaml.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "gorm":
|
||||
writer = wgorm.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "bun":
|
||||
writer = wbun.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "drizzle":
|
||||
writer = wdrizzle.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "prisma":
|
||||
writer = wprisma.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "typeorm":
|
||||
writer = wtypeorm.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "pgsql":
|
||||
writer = wpgsql.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
default:
|
||||
se.showErrorDialog("Error", "Unsupported format: "+format)
|
||||
return
|
||||
}
|
||||
|
||||
// Write database
|
||||
err := writer.WriteDatabase(se.db)
|
||||
if err != nil {
|
||||
se.showErrorDialog("Save Error", fmt.Sprintf("Failed to save database: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Store save config
|
||||
se.saveConfig = &SaveConfig{
|
||||
TargetType: format,
|
||||
FilePath: filePath,
|
||||
}
|
||||
|
||||
// Show success
|
||||
se.showSuccessDialog("Save Complete", fmt.Sprintf("Successfully saved database to %s", filePath), func() {
|
||||
se.pages.RemovePage("save-database")
|
||||
se.pages.SwitchToPage("main")
|
||||
})
|
||||
}
|
||||
|
||||
// createNewDatabase creates a new empty database
|
||||
func (se *SchemaEditor) createNewDatabase() {
|
||||
// Create a new empty database
|
||||
se.db = &models.Database{
|
||||
Name: "New Database",
|
||||
Schemas: []*models.Schema{},
|
||||
}
|
||||
|
||||
// Clear load config
|
||||
se.loadConfig = nil
|
||||
|
||||
// Show success and switch to main menu
|
||||
se.showSuccessDialog("New Database", "Created new empty database", func() {
|
||||
se.pages.RemovePage("load-database")
|
||||
se.pages.AddPage("main", se.createMainMenu(), true, true)
|
||||
})
|
||||
}
|
||||
|
||||
// showErrorDialog displays an error dialog
|
||||
func (se *SchemaEditor) showErrorDialog(_title, message string) {
|
||||
modal := tview.NewModal().
|
||||
SetText(message).
|
||||
AddButtons([]string{"OK"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
se.pages.RemovePage("error-dialog")
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("error-dialog")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("error-dialog", modal, true, true)
|
||||
}
|
||||
|
||||
// showSuccessDialog displays a success dialog
|
||||
func (se *SchemaEditor) showSuccessDialog(_title, message string, onClose func()) {
|
||||
modal := tview.NewModal().
|
||||
SetText(message).
|
||||
AddButtons([]string{"OK"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
se.pages.RemovePage("success-dialog")
|
||||
if onClose != nil {
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("success-dialog")
|
||||
if onClose != nil {
|
||||
onClose()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("success-dialog", modal, true, true)
|
||||
}
|
||||
|
||||
// getLoadHelpText returns the help text for the load screen
|
||||
func getLoadHelpText() string {
|
||||
return `File-based formats: dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm
|
||||
Database formats: pgsql (requires connection string)
|
||||
|
||||
Examples:
|
||||
- File path: ~/schemas/mydb.dbml or /path/to/schema.json
|
||||
- Connection: postgres://user:pass@localhost/dbname`
|
||||
}
|
||||
|
||||
// showUpdateExistingDatabaseConfirm displays a confirmation dialog before updating existing database
|
||||
func (se *SchemaEditor) showUpdateExistingDatabaseConfirm() {
|
||||
// Use saveConfig if available, otherwise use loadConfig
|
||||
var targetType, targetPath string
|
||||
if se.saveConfig != nil {
|
||||
targetType = se.saveConfig.TargetType
|
||||
targetPath = se.saveConfig.FilePath
|
||||
} else if se.loadConfig != nil {
|
||||
targetType = se.loadConfig.SourceType
|
||||
targetPath = se.loadConfig.FilePath
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
confirmText := fmt.Sprintf("Update existing database?\n\nFormat: %s\nPath: %s\n\nThis will overwrite the source.",
|
||||
targetType, targetPath)
|
||||
|
||||
modal := tview.NewModal().
|
||||
SetText(confirmText).
|
||||
AddButtons([]string{"Cancel", "Update"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
if buttonLabel == "Update" {
|
||||
se.pages.RemovePage("update-confirm")
|
||||
se.pages.RemovePage("save-database")
|
||||
se.saveDatabase(targetType, targetPath)
|
||||
se.pages.SwitchToPage("main")
|
||||
} else {
|
||||
se.pages.RemovePage("update-confirm")
|
||||
}
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("update-confirm")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddAndSwitchToPage("update-confirm", modal, true)
|
||||
}
|
||||
|
||||
// getSaveHelpText returns the help text for the save screen
|
||||
func getSaveHelpText() string {
|
||||
return `File-based formats: dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql (SQL export)
|
||||
|
||||
Examples:
|
||||
- File: ~/schemas/mydb.dbml
|
||||
- Directory (for code formats): ./models/`
|
||||
}
|
||||
62
pkg/ui/main_menu.go
Normal file
62
pkg/ui/main_menu.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
// createMainMenu creates the main menu screen
|
||||
func (se *SchemaEditor) createMainMenu() tview.Primitive {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
|
||||
// Title with database name
|
||||
dbName := se.db.Name
|
||||
if dbName == "" {
|
||||
dbName = "Untitled"
|
||||
}
|
||||
updateAtStr := ""
|
||||
if se.db.UpdatedAt != "" {
|
||||
updateAtStr = fmt.Sprintf("Updated @ %s", se.db.UpdatedAt)
|
||||
}
|
||||
titleText := fmt.Sprintf("[::b]RelSpec Schema Editor\n[::d]Database: %s %s\n[::d]Press arrow keys to navigate, Enter to select", dbName, updateAtStr)
|
||||
title := tview.NewTextView().
|
||||
SetText(titleText).
|
||||
SetDynamicColors(true)
|
||||
|
||||
// Menu options
|
||||
menu := tview.NewList().
|
||||
AddItem("Edit Database", "Edit database name, description, and properties", 'e', func() {
|
||||
se.showEditDatabaseForm()
|
||||
}).
|
||||
AddItem("Manage Schemas", "View, create, edit, and delete schemas", 's', func() {
|
||||
se.showSchemaList()
|
||||
}).
|
||||
AddItem("Manage Tables", "View and manage tables in schemas", 't', func() {
|
||||
se.showTableList()
|
||||
}).
|
||||
AddItem("Manage Domains", "View, create, edit, and delete domains", 'd', func() {
|
||||
se.showDomainList()
|
||||
}).
|
||||
AddItem("Save Database", "Save database to file or database", 'w', func() {
|
||||
se.showSaveScreen()
|
||||
}).
|
||||
AddItem("Exit Editor", "Exit the editor", 'q', func() {
|
||||
se.app.Stop()
|
||||
})
|
||||
|
||||
menu.SetBorder(true).SetTitle(" Menu ").SetTitleAlign(tview.AlignLeft)
|
||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitEditorConfirm()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
flex.AddItem(title, 5, 0, false).
|
||||
AddItem(menu, 0, 1, true)
|
||||
|
||||
return flex
|
||||
}
|
||||
55
pkg/ui/schema_dataops.go
Normal file
55
pkg/ui/schema_dataops.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package ui
|
||||
|
||||
import "git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
|
||||
// Schema data operations - business logic for schema management
|
||||
|
||||
// CreateSchema creates a new schema and adds it to the database
|
||||
func (se *SchemaEditor) CreateSchema(name, description string) *models.Schema {
|
||||
newSchema := &models.Schema{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Tables: make([]*models.Table, 0),
|
||||
Sequences: make([]*models.Sequence, 0),
|
||||
Enums: make([]*models.Enum, 0),
|
||||
}
|
||||
se.db.UpdateDate()
|
||||
se.db.Schemas = append(se.db.Schemas, newSchema)
|
||||
return newSchema
|
||||
}
|
||||
|
||||
// UpdateSchema updates an existing schema's properties
|
||||
func (se *SchemaEditor) UpdateSchema(schemaIndex int, name, owner, description string) {
|
||||
if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) {
|
||||
return
|
||||
}
|
||||
se.db.UpdateDate()
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
schema.Name = name
|
||||
schema.Owner = owner
|
||||
schema.Description = description
|
||||
schema.UpdateDate()
|
||||
}
|
||||
|
||||
// DeleteSchema removes a schema from the database
|
||||
func (se *SchemaEditor) DeleteSchema(schemaIndex int) bool {
|
||||
if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) {
|
||||
return false
|
||||
}
|
||||
se.db.UpdateDate()
|
||||
se.db.Schemas = append(se.db.Schemas[:schemaIndex], se.db.Schemas[schemaIndex+1:]...)
|
||||
return true
|
||||
}
|
||||
|
||||
// GetSchema returns a schema by index
|
||||
func (se *SchemaEditor) GetSchema(schemaIndex int) *models.Schema {
|
||||
if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) {
|
||||
return nil
|
||||
}
|
||||
return se.db.Schemas[schemaIndex]
|
||||
}
|
||||
|
||||
// GetAllSchemas returns all schemas
|
||||
func (se *SchemaEditor) GetAllSchemas() []*models.Schema {
|
||||
return se.db.Schemas
|
||||
}
|
||||
351
pkg/ui/schema_screens.go
Normal file
351
pkg/ui/schema_screens.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// showSchemaList displays the schema management screen
|
||||
func (se *SchemaEditor) showSchemaList() {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
|
||||
// Title
|
||||
title := tview.NewTextView().
|
||||
SetText("[::b]Manage Schemas").
|
||||
SetDynamicColors(true).
|
||||
SetTextAlign(tview.AlignCenter)
|
||||
|
||||
// Create schemas table
|
||||
schemaTable := tview.NewTable().SetBorders(true).SetSelectable(true, false).SetFixed(1, 0)
|
||||
|
||||
// Add header row with padding for full width
|
||||
headers := []string{"Name", "Sequence", "Total Tables", "Total Sequences", "Total Views", "Description"}
|
||||
headerWidths := []int{20, 15, 20, 20, 15} // Last column takes remaining space
|
||||
for i, header := range headers {
|
||||
padding := ""
|
||||
if i < len(headerWidths) {
|
||||
padding = strings.Repeat(" ", headerWidths[i]-len(header))
|
||||
}
|
||||
cell := tview.NewTableCell(header + padding).
|
||||
SetTextColor(tcell.ColorYellow).
|
||||
SetSelectable(false).
|
||||
SetAlign(tview.AlignLeft)
|
||||
schemaTable.SetCell(0, i, cell)
|
||||
}
|
||||
|
||||
// Add existing schemas
|
||||
for row, schema := range se.db.Schemas {
|
||||
schema := schema // capture for closure
|
||||
|
||||
// Name - pad to 20 chars
|
||||
nameStr := fmt.Sprintf("%-20s", schema.Name)
|
||||
nameCell := tview.NewTableCell(nameStr).SetSelectable(true)
|
||||
schemaTable.SetCell(row+1, 0, nameCell)
|
||||
|
||||
// Sequence - pad to 15 chars
|
||||
seqStr := fmt.Sprintf("%-15s", fmt.Sprintf("%d", schema.Sequence))
|
||||
seqCell := tview.NewTableCell(seqStr).SetSelectable(true)
|
||||
schemaTable.SetCell(row+1, 1, seqCell)
|
||||
|
||||
// Total Tables - pad to 20 chars
|
||||
tablesStr := fmt.Sprintf("%-20s", fmt.Sprintf("%d", len(schema.Tables)))
|
||||
tablesCell := tview.NewTableCell(tablesStr).SetSelectable(true)
|
||||
schemaTable.SetCell(row+1, 2, tablesCell)
|
||||
|
||||
// Total Sequences - pad to 20 chars
|
||||
sequencesStr := fmt.Sprintf("%-20s", fmt.Sprintf("%d", len(schema.Sequences)))
|
||||
sequencesCell := tview.NewTableCell(sequencesStr).SetSelectable(true)
|
||||
schemaTable.SetCell(row+1, 3, sequencesCell)
|
||||
|
||||
// Total Views - pad to 15 chars
|
||||
viewsStr := fmt.Sprintf("%-15s", fmt.Sprintf("%d", len(schema.Views)))
|
||||
viewsCell := tview.NewTableCell(viewsStr).SetSelectable(true)
|
||||
schemaTable.SetCell(row+1, 4, viewsCell)
|
||||
|
||||
// Description - no padding, takes remaining space
|
||||
descCell := tview.NewTableCell(schema.Description).SetSelectable(true)
|
||||
schemaTable.SetCell(row+1, 5, descCell)
|
||||
}
|
||||
|
||||
schemaTable.SetTitle(" Schemas ").SetBorder(true).SetTitleAlign(tview.AlignLeft)
|
||||
|
||||
// Action buttons flex (define before input capture)
|
||||
btnFlex := tview.NewFlex()
|
||||
btnNewSchema := tview.NewButton("New Schema [n]").SetSelectedFunc(func() {
|
||||
se.showNewSchemaDialog()
|
||||
})
|
||||
btnBack := tview.NewButton("Back [b]").SetSelectedFunc(func() {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage("schemas")
|
||||
})
|
||||
|
||||
// Set up button input captures for Tab/Shift+Tab navigation
|
||||
btnNewSchema.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(schemaTable)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnBack)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnBack.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(btnNewSchema)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(schemaTable)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnFlex.AddItem(btnNewSchema, 0, 1, true).
|
||||
AddItem(btnBack, 0, 1, false)
|
||||
|
||||
schemaTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage("schemas")
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnNewSchema)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyEnter {
|
||||
row, _ := schemaTable.GetSelection()
|
||||
if row > 0 && row <= len(se.db.Schemas) { // Skip header row
|
||||
schemaIndex := row - 1
|
||||
se.showSchemaEditor(schemaIndex, se.db.Schemas[schemaIndex])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if event.Rune() == 'n' {
|
||||
se.showNewSchemaDialog()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'b' {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage("schemas")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
flex.AddItem(title, 1, 0, false).
|
||||
AddItem(schemaTable, 0, 1, true).
|
||||
AddItem(btnFlex, 1, 0, false)
|
||||
|
||||
se.pages.AddPage("schemas", flex, true, true)
|
||||
}
|
||||
|
||||
// showSchemaEditor shows the editor for a specific schema
|
||||
func (se *SchemaEditor) showSchemaEditor(index int, schema *models.Schema) {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
|
||||
// Title
|
||||
title := tview.NewTextView().
|
||||
SetText(fmt.Sprintf("[::b]Schema: %s", schema.Name)).
|
||||
SetDynamicColors(true).
|
||||
SetTextAlign(tview.AlignCenter)
|
||||
|
||||
// Schema info display
|
||||
info := tview.NewTextView().SetDynamicColors(true)
|
||||
info.SetText(fmt.Sprintf("Tables: %d | Description: %s",
|
||||
len(schema.Tables), schema.Description))
|
||||
|
||||
// Table list
|
||||
tableList := tview.NewList().ShowSecondaryText(true)
|
||||
|
||||
for i, table := range schema.Tables {
|
||||
tableIndex := i
|
||||
table := table
|
||||
colCount := len(table.Columns)
|
||||
tableList.AddItem(table.Name, fmt.Sprintf("%d columns", colCount), rune('0'+i), func() {
|
||||
se.showTableEditor(index, tableIndex, table)
|
||||
})
|
||||
}
|
||||
|
||||
tableList.AddItem("[New Table]", "Add a new table to this schema", 'n', func() {
|
||||
se.showNewTableDialog(index)
|
||||
})
|
||||
|
||||
tableList.AddItem("[Edit Schema Info]", "Edit schema properties", 'e', func() {
|
||||
se.showEditSchemaDialog(index)
|
||||
})
|
||||
|
||||
tableList.AddItem("[Delete Schema]", "Delete this schema", 'd', func() {
|
||||
se.showDeleteSchemaConfirm(index)
|
||||
})
|
||||
|
||||
tableList.SetBorder(true).SetTitle(" Tables ").SetTitleAlign(tview.AlignLeft)
|
||||
|
||||
// Action buttons (define before input capture)
|
||||
btnFlex := tview.NewFlex()
|
||||
btnNewTable := tview.NewButton("New Table [n]").SetSelectedFunc(func() {
|
||||
se.showNewTableDialog(index)
|
||||
})
|
||||
btnBack := tview.NewButton("Back to Schemas [b]").SetSelectedFunc(func() {
|
||||
se.pages.RemovePage("schema-editor")
|
||||
se.pages.SwitchToPage("schemas")
|
||||
})
|
||||
|
||||
// Set up button input captures for Tab/Shift+Tab navigation
|
||||
btnNewTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(tableList)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnBack)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnBack.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(btnNewTable)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(tableList)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
tableList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("schema-editor")
|
||||
se.pages.SwitchToPage("schemas")
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnNewTable)
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'b' {
|
||||
se.pages.RemovePage("schema-editor")
|
||||
se.pages.SwitchToPage("schemas")
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnFlex.AddItem(btnNewTable, 0, 1, true).
|
||||
AddItem(btnBack, 0, 1, false)
|
||||
|
||||
flex.AddItem(title, 1, 0, false).
|
||||
AddItem(info, 2, 0, false).
|
||||
AddItem(tableList, 0, 1, true).
|
||||
AddItem(btnFlex, 1, 0, false)
|
||||
|
||||
se.pages.AddPage("schema-editor", flex, true, true)
|
||||
}
|
||||
|
||||
// showNewSchemaDialog shows dialog to create a new schema
|
||||
func (se *SchemaEditor) showNewSchemaDialog() {
|
||||
form := tview.NewForm()
|
||||
|
||||
schemaName := ""
|
||||
description := ""
|
||||
|
||||
form.AddInputField("Schema Name", "", 40, nil, func(value string) {
|
||||
schemaName = value
|
||||
})
|
||||
|
||||
form.AddInputField("Description", "", 40, nil, func(value string) {
|
||||
description = value
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
if schemaName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
se.CreateSchema(schemaName, description)
|
||||
|
||||
se.pages.RemovePage("new-schema")
|
||||
se.pages.RemovePage("schemas")
|
||||
se.showSchemaList()
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
se.pages.RemovePage("new-schema")
|
||||
se.pages.RemovePage("schemas")
|
||||
se.showSchemaList()
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" New Schema ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("new-schema", "schemas")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("new-schema", form, true, true)
|
||||
}
|
||||
|
||||
// showEditSchemaDialog shows dialog to edit schema properties
|
||||
func (se *SchemaEditor) showEditSchemaDialog(schemaIndex int) {
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
form := tview.NewForm()
|
||||
|
||||
// Local variables to collect changes
|
||||
newName := schema.Name
|
||||
newOwner := schema.Owner
|
||||
newDescription := schema.Description
|
||||
|
||||
form.AddInputField("Schema Name", schema.Name, 40, nil, func(value string) {
|
||||
newName = value
|
||||
})
|
||||
|
||||
form.AddInputField("Owner", schema.Owner, 40, nil, func(value string) {
|
||||
newOwner = value
|
||||
})
|
||||
|
||||
form.AddTextArea("Description", schema.Description, 40, 5, 0, func(value string) {
|
||||
newDescription = value
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
// Apply changes using dataops
|
||||
se.UpdateSchema(schemaIndex, newName, newOwner, newDescription)
|
||||
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
se.pages.RemovePage("edit-schema")
|
||||
se.pages.RemovePage("schema-editor")
|
||||
se.showSchemaEditor(schemaIndex, schema)
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
// Discard changes - don't apply them
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
se.pages.RemovePage("edit-schema")
|
||||
se.pages.RemovePage("schema-editor")
|
||||
se.showSchemaEditor(schemaIndex, schema)
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" Edit Schema ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("edit-schema", "schema-editor")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("edit-schema", form, true, true)
|
||||
}
|
||||
88
pkg/ui/table_dataops.go
Normal file
88
pkg/ui/table_dataops.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package ui
|
||||
|
||||
import "git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
|
||||
// Table data operations - business logic for table management
|
||||
|
||||
// CreateTable creates a new table and adds it to a schema
|
||||
func (se *SchemaEditor) CreateTable(schemaIndex int, name, description string) *models.Table {
|
||||
if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) {
|
||||
return nil
|
||||
}
|
||||
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
newTable := &models.Table{
|
||||
Name: name,
|
||||
Schema: schema.Name,
|
||||
Description: description,
|
||||
Columns: make(map[string]*models.Column),
|
||||
Constraints: make(map[string]*models.Constraint),
|
||||
Indexes: make(map[string]*models.Index),
|
||||
}
|
||||
schema.UpdateDate()
|
||||
schema.Tables = append(schema.Tables, newTable)
|
||||
return newTable
|
||||
}
|
||||
|
||||
// UpdateTable updates an existing table's properties
|
||||
func (se *SchemaEditor) UpdateTable(schemaIndex, tableIndex int, name, description string) {
|
||||
if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) {
|
||||
return
|
||||
}
|
||||
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
if tableIndex < 0 || tableIndex >= len(schema.Tables) {
|
||||
return
|
||||
}
|
||||
schema.UpdateDate()
|
||||
table := schema.Tables[tableIndex]
|
||||
table.Name = name
|
||||
table.Description = description
|
||||
table.UpdateDate()
|
||||
}
|
||||
|
||||
// DeleteTable removes a table from a schema
|
||||
func (se *SchemaEditor) DeleteTable(schemaIndex, tableIndex int) bool {
|
||||
if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) {
|
||||
return false
|
||||
}
|
||||
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
if tableIndex < 0 || tableIndex >= len(schema.Tables) {
|
||||
return false
|
||||
}
|
||||
schema.UpdateDate()
|
||||
schema.Tables = append(schema.Tables[:tableIndex], schema.Tables[tableIndex+1:]...)
|
||||
return true
|
||||
}
|
||||
|
||||
// GetTable returns a table by schema and table index
|
||||
func (se *SchemaEditor) GetTable(schemaIndex, tableIndex int) *models.Table {
|
||||
if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) {
|
||||
return nil
|
||||
}
|
||||
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
if tableIndex < 0 || tableIndex >= len(schema.Tables) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return schema.Tables[tableIndex]
|
||||
}
|
||||
|
||||
// GetAllTables returns all tables across all schemas
|
||||
func (se *SchemaEditor) GetAllTables() []*models.Table {
|
||||
var tables []*models.Table
|
||||
for _, schema := range se.db.Schemas {
|
||||
tables = append(tables, schema.Tables...)
|
||||
}
|
||||
return tables
|
||||
}
|
||||
|
||||
// GetTablesInSchema returns all tables in a specific schema
|
||||
func (se *SchemaEditor) GetTablesInSchema(schemaIndex int) []*models.Table {
|
||||
if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) {
|
||||
return nil
|
||||
}
|
||||
return se.db.Schemas[schemaIndex].Tables
|
||||
}
|
||||
530
pkg/ui/table_screens.go
Normal file
530
pkg/ui/table_screens.go
Normal file
@@ -0,0 +1,530 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// showTableList displays all tables across all schemas
|
||||
func (se *SchemaEditor) showTableList() {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
|
||||
// Title
|
||||
title := tview.NewTextView().
|
||||
SetText("[::b]All Tables").
|
||||
SetDynamicColors(true).
|
||||
SetTextAlign(tview.AlignCenter)
|
||||
|
||||
// Create tables table
|
||||
tableTable := tview.NewTable().SetBorders(true).SetSelectable(true, false).SetFixed(1, 0)
|
||||
|
||||
// Add header row with padding for full width
|
||||
headers := []string{"Name", "Schema", "Sequence", "Total Columns", "Total Relations", "Total Indexes", "Description", "Comment"}
|
||||
headerWidths := []int{18, 15, 12, 14, 15, 14, 0, 12} // Description gets remainder
|
||||
for i, header := range headers {
|
||||
padding := ""
|
||||
if i < len(headerWidths) && headerWidths[i] > 0 {
|
||||
padding = strings.Repeat(" ", headerWidths[i]-len(header))
|
||||
}
|
||||
cell := tview.NewTableCell(header + padding).
|
||||
SetTextColor(tcell.ColorYellow).
|
||||
SetSelectable(false).
|
||||
SetAlign(tview.AlignLeft)
|
||||
tableTable.SetCell(0, i, cell)
|
||||
}
|
||||
|
||||
var tables []*models.Table
|
||||
var tableLocations []struct{ schemaIdx, tableIdx int }
|
||||
|
||||
for si, schema := range se.db.Schemas {
|
||||
for ti, table := range schema.Tables {
|
||||
tables = append(tables, table)
|
||||
tableLocations = append(tableLocations, struct{ schemaIdx, tableIdx int }{si, ti})
|
||||
}
|
||||
}
|
||||
|
||||
for row, table := range tables {
|
||||
tableIdx := tableLocations[row]
|
||||
schema := se.db.Schemas[tableIdx.schemaIdx]
|
||||
|
||||
// Name - pad to 18 chars
|
||||
nameStr := fmt.Sprintf("%-18s", table.Name)
|
||||
nameCell := tview.NewTableCell(nameStr).SetSelectable(true)
|
||||
tableTable.SetCell(row+1, 0, nameCell)
|
||||
|
||||
// Schema - pad to 15 chars
|
||||
schemaStr := fmt.Sprintf("%-15s", schema.Name)
|
||||
schemaCell := tview.NewTableCell(schemaStr).SetSelectable(true)
|
||||
tableTable.SetCell(row+1, 1, schemaCell)
|
||||
|
||||
// Sequence - pad to 12 chars
|
||||
seqStr := fmt.Sprintf("%-12s", fmt.Sprintf("%d", table.Sequence))
|
||||
seqCell := tview.NewTableCell(seqStr).SetSelectable(true)
|
||||
tableTable.SetCell(row+1, 2, seqCell)
|
||||
|
||||
// Total Columns - pad to 14 chars
|
||||
colsStr := fmt.Sprintf("%-14s", fmt.Sprintf("%d", len(table.Columns)))
|
||||
colsCell := tview.NewTableCell(colsStr).SetSelectable(true)
|
||||
tableTable.SetCell(row+1, 3, colsCell)
|
||||
|
||||
// Total Relations - pad to 15 chars
|
||||
relsStr := fmt.Sprintf("%-15s", fmt.Sprintf("%d", len(table.Relationships)))
|
||||
relsCell := tview.NewTableCell(relsStr).SetSelectable(true)
|
||||
tableTable.SetCell(row+1, 4, relsCell)
|
||||
|
||||
// Total Indexes - pad to 14 chars
|
||||
idxStr := fmt.Sprintf("%-14s", fmt.Sprintf("%d", len(table.Indexes)))
|
||||
idxCell := tview.NewTableCell(idxStr).SetSelectable(true)
|
||||
tableTable.SetCell(row+1, 5, idxCell)
|
||||
|
||||
// Description - no padding, takes remaining space
|
||||
descCell := tview.NewTableCell(table.Description).SetSelectable(true)
|
||||
tableTable.SetCell(row+1, 6, descCell)
|
||||
|
||||
// Comment - pad to 12 chars
|
||||
commentStr := fmt.Sprintf("%-12s", table.Comment)
|
||||
commentCell := tview.NewTableCell(commentStr).SetSelectable(true)
|
||||
tableTable.SetCell(row+1, 7, commentCell)
|
||||
}
|
||||
|
||||
tableTable.SetTitle(" All Tables ").SetBorder(true).SetTitleAlign(tview.AlignLeft)
|
||||
|
||||
// Action buttons (define before input capture)
|
||||
btnFlex := tview.NewFlex()
|
||||
btnNewTable := tview.NewButton("New Table [n]").SetSelectedFunc(func() {
|
||||
se.showNewTableDialogFromList()
|
||||
})
|
||||
btnBack := tview.NewButton("Back [b]").SetSelectedFunc(func() {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage("tables")
|
||||
})
|
||||
|
||||
// Set up button input captures for Tab/Shift+Tab navigation
|
||||
btnNewTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(tableTable)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnBack)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnBack.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(btnNewTable)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(tableTable)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnFlex.AddItem(btnNewTable, 0, 1, true).
|
||||
AddItem(btnBack, 0, 1, false)
|
||||
|
||||
tableTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage("tables")
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnNewTable)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyEnter {
|
||||
row, _ := tableTable.GetSelection()
|
||||
if row > 0 && row <= len(tables) { // Skip header row
|
||||
tableIdx := tableLocations[row-1]
|
||||
se.showTableEditor(tableIdx.schemaIdx, tableIdx.tableIdx, tables[row-1])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if event.Rune() == 'n' {
|
||||
se.showNewTableDialogFromList()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'b' {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage("tables")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
flex.AddItem(title, 1, 0, false).
|
||||
AddItem(tableTable, 0, 1, true).
|
||||
AddItem(btnFlex, 1, 0, false)
|
||||
|
||||
se.pages.AddPage("tables", flex, true, true)
|
||||
}
|
||||
|
||||
// showTableEditor shows editor for a specific table
|
||||
func (se *SchemaEditor) showTableEditor(schemaIndex, tableIndex int, table *models.Table) {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
|
||||
// Title
|
||||
title := tview.NewTextView().
|
||||
SetText(fmt.Sprintf("[::b]Table: %s", table.Name)).
|
||||
SetDynamicColors(true).
|
||||
SetTextAlign(tview.AlignCenter)
|
||||
|
||||
// Table info
|
||||
info := tview.NewTextView().SetDynamicColors(true)
|
||||
info.SetText(fmt.Sprintf("Schema: %s | Columns: %d | Description: %s",
|
||||
table.Schema, len(table.Columns), table.Description))
|
||||
|
||||
// Create columns table
|
||||
colTable := tview.NewTable().SetBorders(true).SetSelectable(true, false).SetFixed(1, 0)
|
||||
|
||||
// Add header row with padding for full width
|
||||
headers := []string{"Name", "Type", "Default", "KeyType", "Description"}
|
||||
headerWidths := []int{20, 18, 15, 15} // Last column takes remaining space
|
||||
for i, header := range headers {
|
||||
padding := ""
|
||||
if i < len(headerWidths) {
|
||||
padding = strings.Repeat(" ", headerWidths[i]-len(header))
|
||||
}
|
||||
cell := tview.NewTableCell(header + padding).
|
||||
SetTextColor(tcell.ColorYellow).
|
||||
SetSelectable(false).
|
||||
SetAlign(tview.AlignLeft)
|
||||
colTable.SetCell(0, i, cell)
|
||||
}
|
||||
|
||||
// Get sorted column names
|
||||
columnNames := getColumnNames(table)
|
||||
for row, colName := range columnNames {
|
||||
column := table.Columns[colName]
|
||||
|
||||
// Name - pad to 20 chars
|
||||
nameStr := fmt.Sprintf("%-20s", colName)
|
||||
nameCell := tview.NewTableCell(nameStr).SetSelectable(true)
|
||||
colTable.SetCell(row+1, 0, nameCell)
|
||||
|
||||
// Type - pad to 18 chars
|
||||
typeStr := fmt.Sprintf("%-18s", column.Type)
|
||||
typeCell := tview.NewTableCell(typeStr).SetSelectable(true)
|
||||
colTable.SetCell(row+1, 1, typeCell)
|
||||
|
||||
// Default - pad to 15 chars
|
||||
defaultStr := ""
|
||||
if column.Default != nil {
|
||||
defaultStr = fmt.Sprintf("%v", column.Default)
|
||||
}
|
||||
defaultStr = fmt.Sprintf("%-15s", defaultStr)
|
||||
defaultCell := tview.NewTableCell(defaultStr).SetSelectable(true)
|
||||
colTable.SetCell(row+1, 2, defaultCell)
|
||||
|
||||
// KeyType - pad to 15 chars
|
||||
keyTypeStr := ""
|
||||
if column.IsPrimaryKey {
|
||||
keyTypeStr = "PRIMARY"
|
||||
} else if column.NotNull {
|
||||
keyTypeStr = "NOT NULL"
|
||||
}
|
||||
keyTypeStr = fmt.Sprintf("%-15s", keyTypeStr)
|
||||
keyTypeCell := tview.NewTableCell(keyTypeStr).SetSelectable(true)
|
||||
colTable.SetCell(row+1, 3, keyTypeCell)
|
||||
|
||||
// Description
|
||||
descCell := tview.NewTableCell(column.Description).SetSelectable(true)
|
||||
colTable.SetCell(row+1, 4, descCell)
|
||||
}
|
||||
|
||||
colTable.SetTitle(" Columns ").SetBorder(true).SetTitleAlign(tview.AlignLeft)
|
||||
|
||||
// Action buttons flex (define before input capture)
|
||||
btnFlex := tview.NewFlex()
|
||||
btnNewCol := tview.NewButton("Add Column [n]").SetSelectedFunc(func() {
|
||||
se.showNewColumnDialog(schemaIndex, tableIndex)
|
||||
})
|
||||
btnEditTable := tview.NewButton("Edit Table [e]").SetSelectedFunc(func() {
|
||||
se.showEditTableDialog(schemaIndex, tableIndex)
|
||||
})
|
||||
btnEditColumn := tview.NewButton("Edit Column [c]").SetSelectedFunc(func() {
|
||||
row, _ := colTable.GetSelection()
|
||||
if row > 0 && row <= len(columnNames) { // Skip header row
|
||||
colName := columnNames[row-1]
|
||||
column := table.Columns[colName]
|
||||
se.showColumnEditor(schemaIndex, tableIndex, row-1, column)
|
||||
}
|
||||
})
|
||||
btnDelTable := tview.NewButton("Delete Table [d]").SetSelectedFunc(func() {
|
||||
se.showDeleteTableConfirm(schemaIndex, tableIndex)
|
||||
})
|
||||
btnBack := tview.NewButton("Back to Schema [b]").SetSelectedFunc(func() {
|
||||
se.pages.RemovePage("table-editor")
|
||||
se.pages.SwitchToPage("schema-editor")
|
||||
})
|
||||
|
||||
// Set up button input captures for Tab/Shift+Tab navigation
|
||||
btnNewCol.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(colTable)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnEditColumn)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnEditColumn.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(btnNewCol)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnEditTable)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnEditTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(btnEditColumn)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnDelTable)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnDelTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(btnEditTable)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnBack)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnBack.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(btnDelTable)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(colTable)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnFlex.AddItem(btnNewCol, 0, 1, true).
|
||||
AddItem(btnEditColumn, 0, 1, false).
|
||||
AddItem(btnEditTable, 0, 1, false).
|
||||
AddItem(btnDelTable, 0, 1, false).
|
||||
AddItem(btnBack, 0, 1, false)
|
||||
|
||||
colTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.SwitchToPage("schema-editor")
|
||||
se.pages.RemovePage("table-editor")
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnNewCol)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyEnter {
|
||||
row, _ := colTable.GetSelection()
|
||||
if row > 0 { // Skip header row
|
||||
colName := columnNames[row-1]
|
||||
column := table.Columns[colName]
|
||||
se.showColumnEditor(schemaIndex, tableIndex, row-1, column)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if event.Rune() == 'c' {
|
||||
row, _ := colTable.GetSelection()
|
||||
if row > 0 && row <= len(columnNames) { // Skip header row
|
||||
colName := columnNames[row-1]
|
||||
column := table.Columns[colName]
|
||||
se.showColumnEditor(schemaIndex, tableIndex, row-1, column)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'b' {
|
||||
se.pages.RemovePage("table-editor")
|
||||
se.pages.SwitchToPage("schema-editor")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
flex.AddItem(title, 1, 0, false).
|
||||
AddItem(info, 2, 0, false).
|
||||
AddItem(colTable, 0, 1, true).
|
||||
AddItem(btnFlex, 1, 0, false)
|
||||
|
||||
se.pages.AddPage("table-editor", flex, true, true)
|
||||
}
|
||||
|
||||
// showNewTableDialog shows dialog to create a new table
|
||||
func (se *SchemaEditor) showNewTableDialog(schemaIndex int) {
|
||||
form := tview.NewForm()
|
||||
|
||||
tableName := ""
|
||||
description := ""
|
||||
|
||||
form.AddInputField("Table Name", "", 40, nil, func(value string) {
|
||||
tableName = value
|
||||
})
|
||||
|
||||
form.AddInputField("Description", "", 40, nil, func(value string) {
|
||||
description = value
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
if tableName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
se.CreateTable(schemaIndex, tableName, description)
|
||||
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
se.pages.RemovePage("new-table")
|
||||
se.pages.RemovePage("schema-editor")
|
||||
se.showSchemaEditor(schemaIndex, schema)
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
se.pages.RemovePage("new-table")
|
||||
se.pages.RemovePage("schema-editor")
|
||||
se.showSchemaEditor(schemaIndex, schema)
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" New Table ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("new-table", "schema-editor")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("new-table", form, true, true)
|
||||
}
|
||||
|
||||
// showNewTableDialogFromList shows dialog to create a new table with schema selection
|
||||
func (se *SchemaEditor) showNewTableDialogFromList() {
|
||||
form := tview.NewForm()
|
||||
|
||||
tableName := ""
|
||||
description := ""
|
||||
selectedSchemaIdx := 0
|
||||
|
||||
// Create schema dropdown options
|
||||
schemaOptions := make([]string, len(se.db.Schemas))
|
||||
for i, schema := range se.db.Schemas {
|
||||
schemaOptions[i] = schema.Name
|
||||
}
|
||||
|
||||
form.AddInputField("Table Name", "", 40, nil, func(value string) {
|
||||
tableName = value
|
||||
})
|
||||
|
||||
form.AddDropDown("Schema", schemaOptions, 0, func(option string, optionIndex int) {
|
||||
selectedSchemaIdx = optionIndex
|
||||
})
|
||||
|
||||
form.AddInputField("Description", "", 40, nil, func(value string) {
|
||||
description = value
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
if tableName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
se.CreateTable(selectedSchemaIdx, tableName, description)
|
||||
|
||||
se.pages.RemovePage("new-table-from-list")
|
||||
se.pages.RemovePage("tables")
|
||||
se.showTableList()
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
se.pages.RemovePage("new-table-from-list")
|
||||
se.pages.RemovePage("tables")
|
||||
se.showTableList()
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" New Table ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("new-table-from-list", "tables")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("new-table-from-list", form, true, true)
|
||||
}
|
||||
|
||||
// showEditTableDialog shows dialog to edit table properties
|
||||
func (se *SchemaEditor) showEditTableDialog(schemaIndex, tableIndex int) {
|
||||
table := se.db.Schemas[schemaIndex].Tables[tableIndex]
|
||||
form := tview.NewForm()
|
||||
|
||||
// Local variables to collect changes
|
||||
newName := table.Name
|
||||
newDescription := table.Description
|
||||
|
||||
form.AddInputField("Table Name", table.Name, 40, nil, func(value string) {
|
||||
newName = value
|
||||
})
|
||||
|
||||
form.AddTextArea("Description", table.Description, 40, 5, 0, func(value string) {
|
||||
newDescription = value
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
// Apply changes using dataops
|
||||
se.UpdateTable(schemaIndex, tableIndex, newName, newDescription)
|
||||
|
||||
table := se.db.Schemas[schemaIndex].Tables[tableIndex]
|
||||
se.pages.RemovePage("edit-table")
|
||||
se.pages.RemovePage("table-editor")
|
||||
se.showTableEditor(schemaIndex, tableIndex, table)
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
// Discard changes - don't apply them
|
||||
table := se.db.Schemas[schemaIndex].Tables[tableIndex]
|
||||
se.pages.RemovePage("edit-table")
|
||||
se.pages.RemovePage("table-editor")
|
||||
se.showTableEditor(schemaIndex, tableIndex, table)
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" Edit Table ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("edit-table", "table-editor")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("edit-table", form, true, true)
|
||||
}
|
||||
299
pkg/ui/ui_rules.md
Normal file
299
pkg/ui/ui_rules.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# UI Rules and Guidelines
|
||||
|
||||
## Layout Requirements
|
||||
|
||||
All layouts / forms must be in seperate files regarding their domain or entity.
|
||||
|
||||
### Screen Layout Structure
|
||||
|
||||
All screens must follow this consistent layout:
|
||||
|
||||
1. **Title at the Top** (1 row, fixed height)
|
||||
- Centered bold text: `[::b]Title Text`
|
||||
- Use `tview.NewTextView()` with `SetTextAlign(tview.AlignCenter)`
|
||||
- Enable dynamic colors: `SetDynamicColors(true)`
|
||||
|
||||
2. **Content in the Middle** (flexible height)
|
||||
- Tables, lists, forms, or info displays
|
||||
- Uses flex weight of 1 for dynamic sizing
|
||||
|
||||
3. **Action Buttons at the Bottom** (1 row, fixed height)
|
||||
- Must be in a horizontal flex container
|
||||
- Action buttons before Back button
|
||||
- Back button is always last
|
||||
|
||||
### Form Layout Structure
|
||||
|
||||
All forms must follow this button order:
|
||||
|
||||
1. **Save Button** (always first)
|
||||
- Label: "Save"
|
||||
- Primary action that commits changes
|
||||
|
||||
2. **Delete Button** (optional, only for edit forms)
|
||||
- Label: "Delete"
|
||||
- Only shown when editing existing items (not for new items)
|
||||
- Must show confirmation dialog before deletion
|
||||
|
||||
3. **Back Button** (always last)
|
||||
- Label: "Back"
|
||||
- Returns to previous screen without saving
|
||||
|
||||
**Button Order Examples:**
|
||||
- **New Item Forms:** Save, Back
|
||||
- **Edit Item Forms:** Save, Delete, Back
|
||||
|
||||
## Tab Navigation
|
||||
|
||||
All screens must implement circular tab navigation:
|
||||
|
||||
1. **Tab Key** - Moves focus to the next focusable element
|
||||
2. **Shift+Tab (BackTab)** - Moves focus to the previous focusable element
|
||||
3. **At the End** - Tab cycles back to the first element
|
||||
4. **At the Start** - Shift+Tab cycles back to the last element
|
||||
|
||||
**Navigation Flow Pattern:**
|
||||
- Each widget must handle both Tab and BackTab
|
||||
- First widget: BackTab → Last widget, Tab → Second widget
|
||||
- Middle widgets: BackTab → Previous widget, Tab → Next widget
|
||||
- Last widget: BackTab → Previous widget, Tab → First widget
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
### Standard Keys
|
||||
|
||||
- **ESC** - Cancel current operation or go back to previous screen
|
||||
- **Tab** - Move focus forward (circular)
|
||||
- **Shift+Tab** - Move focus backward (circular)
|
||||
- **Enter** - Activate/select current item in tables and lists
|
||||
|
||||
### Letter Key Shortcuts
|
||||
|
||||
- **'n'** - New (create new item)
|
||||
- **'b'** - Back (return to previous screen)
|
||||
- **'e'** - Edit (edit current item)
|
||||
- **'d'** - Delete (delete current item)
|
||||
- **'c'** - Edit Column (in table editor)
|
||||
- **'s'** - Manage Schemas (in main menu)
|
||||
- **'t'** - Manage Tables (in main menu)
|
||||
- **'q'** - Quit/Exit (in main menu)
|
||||
|
||||
## Consistency Requirements
|
||||
|
||||
1. **Layout Structure** - All screens: Title (top) → Content (middle) → Buttons (bottom)
|
||||
2. **Title Format** - Bold (`[::b]`), centered, dynamic colors enabled
|
||||
3. **Tables** - Fixed headers (row 0), borders enabled, selectable rows
|
||||
4. **Buttons** - Include keyboard shortcuts in labels (e.g., "Back [b]")
|
||||
5. **Forms** - Button order: Save, Delete (if edit), Back
|
||||
6. **Destructive Actions** - Always show confirmation dialogs
|
||||
7. **ESC Key** - All screens support ESC to go back
|
||||
8. **Action Buttons** - Positioned before Back button, in logical order
|
||||
9. **Data Refresh** - Always refresh the previous screen when returning from a form or dialog
|
||||
|
||||
## Widget Naming Conventions
|
||||
|
||||
- **Tables:** `schemaTable`, `tableTable`, `colTable`
|
||||
- **Buttons:** Prefix with `btn` (e.g., `btnBack`, `btnDelete`, `btnNewSchema`)
|
||||
- **Flex containers:** `btnFlex` for button containers, `flex` for main layout
|
||||
- **Forms:** `form`
|
||||
- **Lists:** `list`, `tableList`
|
||||
- **Text views:** `title`, `info`
|
||||
- Use camelCase for all variable names
|
||||
|
||||
## Page Naming Conventions
|
||||
|
||||
Use descriptive kebab-case names:
|
||||
|
||||
- **Main screens:** `main`, `schemas`, `tables`, `schema-editor`, `table-editor`, `column-editor`
|
||||
- **Load/Save screens:** `load-database`, `save-database`
|
||||
- **Creation dialogs:** `new-schema`, `new-table`, `new-column`, `new-table-from-list`
|
||||
- **Edit dialogs:** `edit-schema`, `edit-table`
|
||||
- **Confirmations:** `confirm-delete-schema`, `confirm-delete-table`, `confirm-delete-column`
|
||||
- **Exit confirmations:** `exit-confirm`, `exit-editor-confirm`
|
||||
- **Status dialogs:** `error-dialog`, `success-dialog`
|
||||
|
||||
## Dialog and Confirmation Rules
|
||||
|
||||
### Confirmation Dialogs
|
||||
|
||||
1. **Delete Confirmations** - Required for all destructive actions
|
||||
- Show item name in confirmation text
|
||||
- Buttons: "Cancel", "Delete"
|
||||
- ESC key dismisses dialog
|
||||
|
||||
2. **Exit Confirmations** - Required when exiting forms with potential unsaved changes
|
||||
- Text: "Exit without saving changes?"
|
||||
- Buttons: "Cancel", "No, exit without saving"
|
||||
- ESC key confirms exit
|
||||
|
||||
3. **Save Confirmations** - Optional, based on context
|
||||
- Use for critical data changes
|
||||
- Clear description of what will be saved
|
||||
|
||||
### Dialog Behavior
|
||||
|
||||
- All dialogs must capture ESC key for dismissal
|
||||
- Modal dialogs overlay current screen
|
||||
- Confirmation dialogs use `tview.NewModal()`
|
||||
- Remove dialog page after action completes
|
||||
|
||||
## Data Refresh Rules
|
||||
|
||||
When returning from any form or dialog, the previous screen must be refreshed to show updated data. If Tables exists in the screen, their data must be updated:
|
||||
|
||||
1. **After Save** - Remove and recreate the previous screen to display updated data
|
||||
2. **After Delete** - Remove and recreate the previous screen to display remaining data
|
||||
3. **After Cancel/Back** - Remove and recreate the previous screen (data may have changed)
|
||||
4. **Implementation Pattern** - Remove the current page, remove the previous page, then recreate the previous page with fresh data
|
||||
|
||||
**Why This Matters:**
|
||||
- Ensures users see their changes immediately
|
||||
- Prevents stale data from being displayed
|
||||
- Maintains data consistency across the UI
|
||||
- Avoids confusion from seeing outdated information
|
||||
|
||||
**Example Flow:**
|
||||
```
|
||||
User on Schema List → Opens Edit Schema Form → Saves Changes →
|
||||
Returns to Schema List (refreshed with updated schema data)
|
||||
```
|
||||
|
||||
## Big Loading/Saving Operations
|
||||
|
||||
When loading big changes, files or data, always give a load completed or load error dialog.
|
||||
Do the same with saving.
|
||||
This informs the user what happens.
|
||||
When data is dirty, always ask the user to save when trying to exit.
|
||||
|
||||
### Load/Save Screens
|
||||
|
||||
- **Load Screen** (`load-database`) - Shown when no source is specified via command line
|
||||
- Format dropdown (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql)
|
||||
- File path input (for file-based formats)
|
||||
- Connection string input (for database formats like pgsql)
|
||||
- Load button [l] - Loads the database
|
||||
- Create New button [n] - Creates a new empty database
|
||||
- Exit button [q] - Exits the application
|
||||
- ESC key exits the application
|
||||
|
||||
- **Save Screen** (`save-database`) - Accessible from main menu with 'w' key
|
||||
- Format dropdown (same as load screen)
|
||||
- File path input
|
||||
- Help text explaining format requirements
|
||||
- Save button [s] - Saves the database
|
||||
- Back button [b] - Returns to main menu
|
||||
- ESC key returns to main menu
|
||||
- Pre-populated with existing save configuration if available
|
||||
|
||||
### Status Dialogs
|
||||
|
||||
- **Error Dialog** (`error-dialog`) - Shows error messages with OK button and ESC to dismiss
|
||||
- **Success Dialog** (`success-dialog`) - Shows success messages with OK button, ESC to dismiss, and optional callback on close
|
||||
|
||||
## Screen Organization
|
||||
|
||||
Organize UI code into these files:
|
||||
|
||||
### UI Files (Screens and Dialogs)
|
||||
|
||||
- **editor.go** - Core `SchemaEditor` struct, constructor, `Run()` method, helper functions
|
||||
- **main_menu.go** - Main menu screen
|
||||
- **load_save_screens.go** - Database load and save screens
|
||||
- **database_screens.go** - Database edit form
|
||||
- **schema_screens.go** - Schema list, schema editor, new/edit schema dialogs
|
||||
- **table_screens.go** - Tables list, table editor, new/edit table dialogs
|
||||
- **column_screens.go** - Column editor, new column dialog
|
||||
- **domain_screens.go** - Domain list, domain editor, new/edit domain dialogs
|
||||
- **dialogs.go** - Confirmation dialogs (exit, delete)
|
||||
|
||||
### Data Operations Files (Business Logic)
|
||||
|
||||
- **schema_dataops.go** - Schema CRUD operations (Create, Read, Update, Delete)
|
||||
- **table_dataops.go** - Table CRUD operations
|
||||
- **column_dataops.go** - Column CRUD operations
|
||||
|
||||
## Code Separation Rules
|
||||
|
||||
### UI vs Business Logic
|
||||
|
||||
1. **UI Files** - Handle only presentation and user interaction
|
||||
- Display data in tables, lists, and forms
|
||||
- Capture user input
|
||||
- Navigate between screens
|
||||
- Show/hide dialogs
|
||||
- Call dataops methods for actual data changes
|
||||
|
||||
2. **Dataops Files** - Handle only business logic and data manipulation
|
||||
- Create, read, update, delete operations
|
||||
- Data validation
|
||||
- Data structure manipulation
|
||||
- Return created/updated objects or success/failure status
|
||||
- No UI code or tview references
|
||||
|
||||
### Implementation Pattern
|
||||
|
||||
#### Creating New Items
|
||||
|
||||
**Bad (Direct Data Manipulation in UI):**
|
||||
```go
|
||||
form.AddButton("Save", func() {
|
||||
schema := &models.Schema{Name: name, Description: desc, ...}
|
||||
se.db.Schemas = append(se.db.Schemas, schema)
|
||||
})
|
||||
```
|
||||
|
||||
**Good (Using Dataops Methods):**
|
||||
```go
|
||||
form.AddButton("Save", func() {
|
||||
se.CreateSchema(name, description)
|
||||
})
|
||||
```
|
||||
|
||||
#### Editing Existing Items
|
||||
|
||||
**Bad (Modifying Data in onChange Callbacks):**
|
||||
```go
|
||||
form.AddInputField("Name", column.Name, 40, nil, func(value string) {
|
||||
column.Name = value // Changes immediately as user types!
|
||||
})
|
||||
form.AddButton("Save", func() {
|
||||
// Data already changed, just refresh screen
|
||||
})
|
||||
```
|
||||
|
||||
**Good (Local Variables + Dataops on Save):**
|
||||
```go
|
||||
// Store original values
|
||||
originalName := column.Name
|
||||
newName := column.Name
|
||||
|
||||
form.AddInputField("Name", column.Name, 40, nil, func(value string) {
|
||||
newName = value // Store in local variable
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
// Apply changes only when Save is clicked
|
||||
se.UpdateColumn(schemaIndex, tableIndex, originalName, newName, ...)
|
||||
// Then refresh screen
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
// Discard changes - don't apply local variables
|
||||
// Just refresh screen
|
||||
})
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
**Edit Forms Must Use Local Variables:**
|
||||
1. **Deferred Changes** - Changes only apply when Save is clicked
|
||||
2. **Cancellable** - Back button discards changes without saving
|
||||
3. **Handles Renames** - Original name preserved to update map keys correctly
|
||||
4. **User Expectations** - Save means "commit changes", Back means "cancel"
|
||||
|
||||
This separation ensures:
|
||||
- Cleaner, more maintainable code
|
||||
- Reusable business logic
|
||||
- Easier testing
|
||||
- Clear separation of concerns
|
||||
- Proper change management (save vs cancel)
|
||||
Reference in New Issue
Block a user