diff --git a/TODO.md b/TODO.md index cf18edb..33cd4c0 100644 --- a/TODO.md +++ b/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 diff --git a/cmd/relspec/edit.go b/cmd/relspec/edit.go new file mode 100644 index 0000000..3279211 --- /dev/null +++ b/cmd/relspec/edit.go @@ -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 +} diff --git a/cmd/relspec/root.go b/cmd/relspec/root.go index c2d5e97..cb33ce6 100644 --- a/cmd/relspec/root.go +++ b/cmd/relspec/root.go @@ -21,4 +21,5 @@ func init() { rootCmd.AddCommand(inspectCmd) rootCmd.AddCommand(scriptsCmd) rootCmd.AddCommand(templCmd) + rootCmd.AddCommand(editCmd) } diff --git a/pkg/models/models.go b/pkg/models/models.go index 9381d3b..56f4f40 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -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. diff --git a/pkg/ui/column_dataops.go b/pkg/ui/column_dataops.go new file mode 100644 index 0000000..eedd8f0 --- /dev/null +++ b/pkg/ui/column_dataops.go @@ -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 +} diff --git a/pkg/ui/column_screens.go b/pkg/ui/column_screens.go new file mode 100644 index 0000000..7bbf5df --- /dev/null +++ b/pkg/ui/column_screens.go @@ -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) +} diff --git a/pkg/ui/database_dataops.go b/pkg/ui/database_dataops.go new file mode 100644 index 0000000..de98ea2 --- /dev/null +++ b/pkg/ui/database_dataops.go @@ -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() +} diff --git a/pkg/ui/database_screens.go b/pkg/ui/database_screens.go new file mode 100644 index 0000000..d3c488c --- /dev/null +++ b/pkg/ui/database_screens.go @@ -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) +} diff --git a/pkg/ui/dialogs.go b/pkg/ui/dialogs.go new file mode 100644 index 0000000..499cc1c --- /dev/null +++ b/pkg/ui/dialogs.go @@ -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) +} diff --git a/pkg/ui/domain_dataops.go b/pkg/ui/domain_dataops.go new file mode 100644 index 0000000..b7a5c1b --- /dev/null +++ b/pkg/ui/domain_dataops.go @@ -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() + } +} diff --git a/pkg/ui/domain_screens.go b/pkg/ui/domain_screens.go new file mode 100644 index 0000000..8b50c1f --- /dev/null +++ b/pkg/ui/domain_screens.go @@ -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) +} diff --git a/pkg/ui/editor.go b/pkg/ui/editor.go new file mode 100644 index 0000000..7b1ae5e --- /dev/null +++ b/pkg/ui/editor.go @@ -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 +} diff --git a/pkg/ui/load_save_screens.go b/pkg/ui/load_save_screens.go new file mode 100644 index 0000000..922cfa5 --- /dev/null +++ b/pkg/ui/load_save_screens.go @@ -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/` +} diff --git a/pkg/ui/main_menu.go b/pkg/ui/main_menu.go new file mode 100644 index 0000000..0d2b0a5 --- /dev/null +++ b/pkg/ui/main_menu.go @@ -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 +} diff --git a/pkg/ui/schema_dataops.go b/pkg/ui/schema_dataops.go new file mode 100644 index 0000000..67dbb13 --- /dev/null +++ b/pkg/ui/schema_dataops.go @@ -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 +} diff --git a/pkg/ui/schema_screens.go b/pkg/ui/schema_screens.go new file mode 100644 index 0000000..c352371 --- /dev/null +++ b/pkg/ui/schema_screens.go @@ -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) +} diff --git a/pkg/ui/table_dataops.go b/pkg/ui/table_dataops.go new file mode 100644 index 0000000..ddf8a36 --- /dev/null +++ b/pkg/ui/table_dataops.go @@ -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 +} diff --git a/pkg/ui/table_screens.go b/pkg/ui/table_screens.go new file mode 100644 index 0000000..706bf52 --- /dev/null +++ b/pkg/ui/table_screens.go @@ -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) +} diff --git a/pkg/ui/ui_rules.md b/pkg/ui/ui_rules.md new file mode 100644 index 0000000..98cc3b4 --- /dev/null +++ b/pkg/ui/ui_rules.md @@ -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)