package ui import ( "fmt" "sort" "strconv" "strings" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "git.warky.dev/wdevs/relspecgo/pkg/models" ) // objectLocation identifies where a new object is created: a schema, and for indexes also a table. type objectLocation struct { label string schemaIndex int tableIndex int } // objectRow is one existing object shown in an object list. type objectRow struct { cells []string schemaIndex int tableIndex int at int // position within the schema slice (views, sequences, scripts) name string // map key (indexes) } // objectKind describes how a kind of schema object is listed and edited. type objectKind struct { page string title string singular string headers []string rows func() []objectRow locations func() []objectLocation // buildForm adds the editable fields to the form for row (nil when creating) and // returns a function that validates and saves the values at the given location. buildForm func(form *tview.Form, row *objectRow) func(loc objectLocation) error remove func(row objectRow) bool } func (se *SchemaEditor) schemaLocations() []objectLocation { locs := make([]objectLocation, 0, len(se.db.Schemas)) for si, s := range se.db.Schemas { locs = append(locs, objectLocation{label: s.Name, schemaIndex: si, tableIndex: -1}) } return locs } func (se *SchemaEditor) tableLocations() []objectLocation { locs := make([]objectLocation, 0) for si, s := range se.db.Schemas { for ti, t := range s.Tables { locs = append(locs, objectLocation{label: s.Name + "." + t.Name, schemaIndex: si, tableIndex: ti}) } } return locs } func (se *SchemaEditor) indexKind() objectKind { return objectKind{ page: "indexes", title: "Manage Indexes", singular: "Index", headers: []string{"Name", "Schema", "Table", "Type", "Unique", "Columns"}, locations: se.tableLocations, rows: func() []objectRow { var rows []objectRow for si, s := range se.db.Schemas { for ti, t := range s.Tables { for _, name := range sortedKeys(t.Indexes) { idx := t.Indexes[name] rows = append(rows, objectRow{ cells: []string{idx.Name, s.Name, t.Name, idx.Type, strconv.FormatBool(idx.Unique), strings.Join(idx.Columns, ",")}, schemaIndex: si, tableIndex: ti, name: name, }) } } } return rows }, buildForm: func(form *tview.Form, row *objectRow) func(objectLocation) error { idx := models.InitIndex("", "", "") idx.Type = "btree" if row != nil { idx = se.db.Schemas[row.schemaIndex].Tables[row.tableIndex].Indexes[row.name] } name, columns, typ, where := idx.Name, strings.Join(idx.Columns, ", "), idx.Type, idx.Where unique := idx.Unique form.AddInputField("Name", name, 40, nil, func(v string) { name = v }) form.AddInputField("Columns (comma separated)", columns, 50, nil, func(v string) { columns = v }) form.AddInputField("Type", typ, 20, nil, func(v string) { typ = v }) form.AddCheckbox("Unique", unique, func(v bool) { unique = v }) form.AddInputField("Where", where, 50, nil, func(v string) { where = v }) return func(loc objectLocation) error { oldName := "" if row != nil { oldName = row.name } next := *idx next.Name, next.Columns, next.Type, next.Unique, next.Where = name, splitList(columns), typ, unique, where return se.SaveIndex(loc.schemaIndex, loc.tableIndex, oldName, &next) } }, remove: func(r objectRow) bool { return se.DeleteIndex(r.schemaIndex, r.tableIndex, r.name) }, } } func (se *SchemaEditor) viewKind() objectKind { return objectKind{ page: "views", title: "Manage Views", singular: "View", headers: []string{"Name", "Schema", "Description"}, locations: se.schemaLocations, rows: func() []objectRow { var rows []objectRow for si, s := range se.db.Schemas { for i, v := range s.Views { rows = append(rows, objectRow{cells: []string{v.Name, s.Name, v.Description}, schemaIndex: si, at: i}) } } return rows }, buildForm: func(form *tview.Form, row *objectRow) func(objectLocation) error { view := models.InitView("", "") at := -1 if row != nil { view, at = se.db.Schemas[row.schemaIndex].Views[row.at], row.at } name, desc, def := view.Name, view.Description, view.Definition form.AddInputField("Name", name, 40, nil, func(v string) { name = v }) form.AddInputField("Description", desc, 50, nil, func(v string) { desc = v }) form.AddTextArea("Definition (SQL)", def, 60, 8, 0, func(v string) { def = v }) return func(loc objectLocation) error { next := *view next.Name, next.Description, next.Definition = name, desc, def return se.SaveView(loc.schemaIndex, at, &next) } }, remove: func(r objectRow) bool { return se.DeleteView(r.schemaIndex, r.at) }, } } func (se *SchemaEditor) sequenceKind() objectKind { return objectKind{ page: "sequences", title: "Manage Sequences", singular: "Sequence", headers: []string{"Name", "Schema", "Start", "Increment", "Cycle", "Description"}, locations: se.schemaLocations, rows: func() []objectRow { var rows []objectRow for si, s := range se.db.Schemas { for i, q := range s.Sequences { rows = append(rows, objectRow{ cells: []string{q.Name, s.Name, strconv.FormatInt(q.StartValue, 10), strconv.FormatInt(q.IncrementBy, 10), strconv.FormatBool(q.Cycle), q.Description}, schemaIndex: si, at: i, }) } } return rows }, buildForm: func(form *tview.Form, row *objectRow) func(objectLocation) error { seq := models.InitSequence("", "") at := -1 if row != nil { seq, at = se.db.Schemas[row.schemaIndex].Sequences[row.at], row.at } name, desc := seq.Name, seq.Description start, incr := strconv.FormatInt(seq.StartValue, 10), strconv.FormatInt(seq.IncrementBy, 10) minV, maxV := strconv.FormatInt(seq.MinValue, 10), strconv.FormatInt(seq.MaxValue, 10) cycle := seq.Cycle form.AddInputField("Name", name, 40, nil, func(v string) { name = v }) form.AddInputField("Description", desc, 50, nil, func(v string) { desc = v }) form.AddInputField("Start", start, 20, nil, func(v string) { start = v }) form.AddInputField("Increment", incr, 20, nil, func(v string) { incr = v }) form.AddInputField("Min (0 = none)", minV, 20, nil, func(v string) { minV = v }) form.AddInputField("Max (0 = none)", maxV, 20, nil, func(v string) { maxV = v }) form.AddCheckbox("Cycle", cycle, func(v bool) { cycle = v }) return func(loc objectLocation) error { next := *seq next.Name, next.Description, next.Cycle = name, desc, cycle for _, f := range []struct { label string text string dst *int64 }{{"start", start, &next.StartValue}, {"increment", incr, &next.IncrementBy}, {"min", minV, &next.MinValue}, {"max", maxV, &next.MaxValue}} { n, err := strconv.ParseInt(strings.TrimSpace(f.text), 10, 64) if err != nil { return fmt.Errorf("%s must be an integer", f.label) } *f.dst = n } return se.SaveSequence(loc.schemaIndex, at, &next) } }, remove: func(r objectRow) bool { return se.DeleteSequence(r.schemaIndex, r.at) }, } } func (se *SchemaEditor) scriptKind() objectKind { return objectKind{ page: "scripts", title: "Manage Scripts", singular: "Script", headers: []string{"Name", "Schema", "Version", "Priority", "Description"}, locations: se.schemaLocations, rows: func() []objectRow { var rows []objectRow for si, s := range se.db.Schemas { for i, sc := range s.Scripts { rows = append(rows, objectRow{cells: []string{sc.Name, s.Name, sc.Version, strconv.Itoa(sc.Priority), sc.Description}, schemaIndex: si, at: i}) } } return rows }, buildForm: func(form *tview.Form, row *objectRow) func(objectLocation) error { script := models.InitScript("") at := -1 if row != nil { script, at = se.db.Schemas[row.schemaIndex].Scripts[row.at], row.at } name, desc, version, sql, rollback := script.Name, script.Description, script.Version, script.SQL, script.Rollback priority, runAfter := strconv.Itoa(script.Priority), strings.Join(script.RunAfter, ", ") form.AddInputField("Name", name, 40, nil, func(v string) { name = v }) form.AddInputField("Description", desc, 50, nil, func(v string) { desc = v }) form.AddInputField("Version", version, 20, nil, func(v string) { version = v }) form.AddInputField("Priority", priority, 10, nil, func(v string) { priority = v }) form.AddInputField("Run after (comma separated)", runAfter, 50, nil, func(v string) { runAfter = v }) form.AddTextArea("SQL", sql, 60, 8, 0, func(v string) { sql = v }) form.AddTextArea("Rollback SQL", rollback, 60, 4, 0, func(v string) { rollback = v }) return func(loc objectLocation) error { prio, err := strconv.Atoi(strings.TrimSpace(priority)) if err != nil { return fmt.Errorf("priority must be an integer") } next := *script next.Name, next.Description, next.Version, next.Priority = name, desc, version, prio next.RunAfter, next.SQL, next.Rollback = splitList(runAfter), sql, rollback return se.SaveScript(loc.schemaIndex, at, &next) } }, remove: func(r objectRow) bool { return se.DeleteScript(r.schemaIndex, r.at) }, } } func sortedKeys[V any](m map[string]V) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) return keys } // showObjectList displays all objects of a kind across schemas. func (se *SchemaEditor) showObjectList(k objectKind) { flex := tview.NewFlex().SetDirection(tview.FlexRow) title := tview.NewTextView().SetText("[::b]" + k.title).SetDynamicColors(true).SetTextAlign(tview.AlignCenter) table := tview.NewTable().SetBorders(true).SetSelectable(true, false).SetFixed(1, 0) for i, h := range k.headers { table.SetCell(0, i, tview.NewTableCell(h).SetTextColor(tcell.ColorYellow).SetSelectable(false).SetAlign(tview.AlignLeft)) } rows := k.rows() for r, row := range rows { for c, text := range row.cells { table.SetCell(r+1, c, tview.NewTableCell(text).SetSelectable(true)) } } table.SetTitle(" " + k.title[len("Manage "):] + " ").SetBorder(true).SetTitleAlign(tview.AlignLeft) back := func() { se.pages.SwitchToPage("main") se.pages.RemovePage(k.page) } btnNew := tview.NewButton("New " + k.singular + " [n]").SetSelectedFunc(func() { se.showObjectForm(k, nil) }) btnBack := tview.NewButton("Back [b]").SetSelectedFunc(back) btnNew.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyBacktab: se.app.SetFocus(table) return nil case tcell.KeyTab: se.app.SetFocus(btnBack) return nil } return event }) btnBack.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyBacktab: se.app.SetFocus(btnNew) return nil case tcell.KeyTab: se.app.SetFocus(table) return nil } return event }) btnFlex := tview.NewFlex().AddItem(btnNew, 0, 1, true).AddItem(btnBack, 0, 1, false) table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch { case event.Key() == tcell.KeyEscape, event.Rune() == 'b': back() return nil case event.Key() == tcell.KeyTab: se.app.SetFocus(btnNew) return nil case event.Key() == tcell.KeyEnter: if row, _ := table.GetSelection(); row > 0 && row <= len(rows) { se.showObjectForm(k, &rows[row-1]) return nil } case event.Rune() == 'n': se.showObjectForm(k, nil) return nil } return event }) flex.AddItem(title, 1, 0, false).AddItem(table, 0, 1, true).AddItem(btnFlex, 1, 0, false) se.pages.AddPage(k.page, flex, true, true) } // showObjectForm shows the create (row == nil) or edit form for an object. func (se *SchemaEditor) showObjectForm(k objectKind, row *objectRow) { formPage := k.page + "-form" form := tview.NewForm() errView := tview.NewTextView().SetDynamicColors(true) locs := k.locations() loc := objectLocation{schemaIndex: -1, tableIndex: -1} switch { case row != nil: loc = objectLocation{schemaIndex: row.schemaIndex, tableIndex: row.tableIndex} case len(locs) > 0: loc = locs[0] labels := make([]string, len(locs)) for i, l := range locs { labels[i] = l.label } form.AddDropDown("Location", labels, 0, func(_ string, i int) { loc = locs[i] }) } save := k.buildForm(form, row) closeForm := func() { se.pages.RemovePage(formPage) se.pages.RemovePage(k.page) se.showObjectList(k) } form.AddButton("Save", func() { if err := save(loc); err != nil { errView.SetText("[red]" + tview.Escape(err.Error())) return } closeForm() }) if row != nil { form.AddButton("Delete", func() { modal := tview.NewModal(). SetText(fmt.Sprintf("Delete %s '%s'? This action cannot be undone.", strings.ToLower(k.singular), row.cells[0])). AddButtons([]string{"Cancel", "Delete"}). SetDoneFunc(func(_ int, label string) { se.pages.RemovePage(formPage + "-delete") if label == "Delete" { k.remove(*row) closeForm() } }) se.pages.AddAndSwitchToPage(formPage+"-delete", modal, true) }) } form.AddButton("Back", closeForm) verb := "New" if row != nil { verb = "Edit" } form.SetBorder(true).SetTitle(" " + verb + " " + k.singular + " ").SetTitleAlign(tview.AlignLeft) form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEscape { se.showExitConfirmation(formPage, k.page) return nil } return event }) if len(locs) == 0 && row == nil { errView.SetText("[red]No schema/table available. Create one first.") } flex := tview.NewFlex().SetDirection(tview.FlexRow).AddItem(form, 0, 1, true).AddItem(errView, 1, 0, false) se.pages.AddPage(formPage, flex, true, true) } // showDomainTables lists the tables assigned to a domain and allows assigning/unassigning. func (se *SchemaEditor) showDomainTables(domainIndex int) { if domainIndex < 0 || domainIndex >= len(se.db.Domains) { return } domain := se.db.Domains[domainIndex] page := "domain-tables" list := tview.NewList().ShowSecondaryText(true) refresh := func() { se.pages.RemovePage(page) se.showDomainTables(domainIndex) } for _, dt := range domain.Tables { dt := dt list.AddItem(dt.SchemaName+"."+dt.TableName, "Enter to remove from domain", 0, func() { se.UnassignTableFromDomain(domainIndex, dt.SchemaName, dt.TableName) refresh() }) } list.AddItem("[Assign Table]", "Add a table to this domain", 'a', func() { se.showAssignDomainTable(domainIndex, refresh) }) list.AddItem("[Back]", "Return to domain", 'b', func() { se.pages.RemovePage(page) }) list.SetBorder(true).SetTitle(" Domain " + domain.Name + " - Tables ").SetTitleAlign(tview.AlignLeft) list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEscape { se.pages.RemovePage(page) return nil } return event }) se.pages.AddPage(page, list, true, true) } // showAssignDomainTable shows a form to pick a table not yet in the domain. func (se *SchemaEditor) showAssignDomainTable(domainIndex int, done func()) { page := "assign-domain-table" domain := se.db.Domains[domainIndex] var options []string var refs []models.DomainTable for _, s := range se.db.Schemas { for _, t := range s.Tables { taken := false for _, dt := range domain.Tables { taken = taken || (dt.SchemaName == s.Name && dt.TableName == t.Name) } if !taken { options = append(options, s.Name+"."+t.Name) refs = append(refs, models.DomainTable{SchemaName: s.Name, TableName: t.Name}) } } } form := tview.NewForm() selected := 0 form.AddDropDown("Table", options, 0, func(_ string, i int) { selected = i }) form.AddButton("Assign", func() { if len(refs) > 0 { _ = se.AssignTableToDomain(domainIndex, refs[selected].SchemaName, refs[selected].TableName) } se.pages.RemovePage(page) done() }) form.AddButton("Back", func() { se.pages.RemovePage(page) }) form.SetBorder(true).SetTitle(" Assign Table ").SetTitleAlign(tview.AlignLeft) form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEscape { se.pages.RemovePage(page) return nil } return event }) se.pages.AddPage(page, form, true, true) }