diff --git a/Makefile b/Makefile index 8f4068c..2072767 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Binary name BINARY_NAME=lazyprisma -VERSION ?= 0.3.0 +VERSION ?= 0.3.1 # Directories BUILD_DIR=build diff --git a/main.go b/main.go index bc28b21..ad7bf6e 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,7 @@ import ( ) const ( - Version = "v0.3.0" + Version = "v0.3.1" Developer = "DokaLab" ) @@ -112,6 +112,30 @@ func main() { tuiApp.RegisterPanel(output) tuiApp.RegisterPanel(statusbar) + // Create and wire controllers + gui := tuiApp.GetGui() + + migrationsController := app.NewMigrationsController( + tuiApp, gui, migrationsCtx, output, + tuiApp.OpenModal, tuiApp.CloseModal, + tuiApp.RunStreamingCommand, + ) + generateController := app.NewGenerateController( + tuiApp, gui, output, + tuiApp.OpenModal, + tuiApp.RunStreamingCommand, + ) + studioController := app.NewStudioController( + tuiApp, gui, output, + tuiApp.OpenModal, + ) + clipboardController := app.NewClipboardController( + tuiApp, gui, migrationsCtx, + tuiApp.OpenModal, tuiApp.CloseModal, + ) + + tuiApp.SetControllers(migrationsController, generateController, studioController, clipboardController) + // Register keybindings if err := tuiApp.RegisterKeybindings(); err != nil { fmt.Fprintf(os.Stderr, tr.ErrorFailedRegisterKeybindings, err) diff --git a/pkg/app/app.go b/pkg/app/app.go index c52808c..5463d0f 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -5,7 +5,6 @@ import ( "sync/atomic" "time" - "github.com/dokadev/lazyprisma/pkg/commands" "github.com/dokadev/lazyprisma/pkg/common" "github.com/dokadev/lazyprisma/pkg/gui/context" "github.com/dokadev/lazyprisma/pkg/i18n" @@ -36,9 +35,11 @@ type App struct { spinnerFrame atomic.Uint32 // Current spinner frame index (0-3) stopSpinnerCh chan struct{} // Channel to stop spinner goroutine - // Studio process management - studioCmd *commands.Command // Running studio command - studioRunning bool // True if studio is running + // Controllers + migrationsController *MigrationsController + generateController *GenerateController + studioController *StudioController + clipboardController *ClipboardController } type AppConfig struct { @@ -78,13 +79,23 @@ func NewApp(config AppConfig) (*App, error) { return app, nil } +// SetControllers wires the extracted controllers into the App. +func (a *App) SetControllers(mc *MigrationsController, gc *GenerateController, sc *StudioController, cc *ClipboardController) { + a.migrationsController = mc + a.generateController = gc + a.studioController = sc + a.clipboardController = cc +} + func (a *App) Run() error { defer a.g.Close() defer close(a.stopSpinnerCh) // Stop spinner goroutine defer func() { // Kill studio process if running - if a.studioCmd != nil { - a.studioCmd.Kill() + if a.studioController != nil { + if cmd := a.studioController.GetStudioCmd(); cmd != nil { + cmd.Kill() + } } }() @@ -119,7 +130,10 @@ func (a *App) StatusBarState() context.StatusBarState { return a.spinnerFrame.Load() }, IsStudioRunning: func() bool { - return a.studioRunning + if a.studioController != nil { + return a.studioController.IsStudioRunning() + } + return false }, GetCommandName: func() string { if val := a.runningCommandName.Load(); val != nil { @@ -173,9 +187,9 @@ func (a *App) GetCurrentPanel() Panel { return nil } -// tryStartCommand attempts to start a command execution -// Returns true if command can start, false if another command is already running -func (a *App) tryStartCommand(commandName string) bool { +// TryStartCommand attempts to start a command execution. +// Returns true if command can start, false if another command is already running. +func (a *App) TryStartCommand(commandName string) bool { // CompareAndSwap atomically: if false, set to true and return true // if already true, return false if a.commandRunning.CompareAndSwap(false, true) { @@ -185,15 +199,15 @@ func (a *App) tryStartCommand(commandName string) bool { return false } -// finishCommand marks command execution as complete -func (a *App) finishCommand() { +// FinishCommand marks command execution as complete. +func (a *App) FinishCommand() { a.runningCommandName.Store("") a.commandRunning.Store(false) a.spinnerFrame.Store(0) // Reset spinner to first frame } -// logCommandBlocked logs a message when command execution is blocked -func (a *App) logCommandBlocked(commandName string) { +// LogCommandBlocked logs a message when command execution is blocked. +func (a *App) LogCommandBlocked(commandName string) { a.g.Update(func(g *gocui.Gui) error { if outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { runningTask := "" @@ -456,16 +470,16 @@ func (a *App) registerMouseWheelBindings() { // RefreshAll refreshes all panels asynchronously func (a *App) RefreshAll(onComplete ...func()) bool { // Try to start command - if another command is running, block - if !a.tryStartCommand("Refresh All") { - a.logCommandBlocked("Refresh All") + if !a.TryStartCommand("Refresh All") { + a.LogCommandBlocked("Refresh All") return false } // Run refresh in background to avoid blocking UI go func() { - defer a.finishCommand() // Always mark command as complete + defer a.FinishCommand() // Always mark command as complete - a.refreshPanels() + a.RefreshPanels() // Update UI on main thread (thread-safe) a.g.Update(func(g *gocui.Gui) error { @@ -485,8 +499,8 @@ func (a *App) RefreshAll(onComplete ...func()) bool { return true } -// refreshPanels refreshes all panels (blocking, internal) -func (a *App) refreshPanels() { +// RefreshPanels refreshes all panels (blocking, internal). +func (a *App) RefreshPanels() { // Refresh workspace panel if workspaceCtx, ok := a.panels[ViewWorkspace].(*context.WorkspaceContext); ok { workspaceCtx.Refresh() diff --git a/pkg/app/base_modal.go b/pkg/app/base_modal.go index 4c537a8..adb0b54 100644 --- a/pkg/app/base_modal.go +++ b/pkg/app/base_modal.go @@ -104,6 +104,12 @@ func (b *BaseModal) SetupView(name string, x0, y0, x1, y1 int, zIndex byte, titl return v, isNew, nil } +// AcceptsTextInput returns false by default (most modals don't accept text input). +func (b *BaseModal) AcceptsTextInput() bool { return false } + +// ClosesOnEnter returns false by default (most modals handle Enter via HandleKey). +func (b *BaseModal) ClosesOnEnter() bool { return false } + // OnClose deletes the modal's primary view func (b *BaseModal) OnClose() { b.g.DeleteView(b.id) diff --git a/pkg/app/clipboard_controller.go b/pkg/app/clipboard_controller.go index 79a8955..735fff2 100644 --- a/pkg/app/clipboard_controller.go +++ b/pkg/app/clipboard_controller.go @@ -4,38 +4,62 @@ import ( "fmt" "github.com/dokadev/lazyprisma/pkg/gui/context" + "github.com/dokadev/lazyprisma/pkg/gui/types" + "github.com/jesseduffield/gocui" ) -// CopyMigrationInfo copies migration info to clipboard -func (a *App) CopyMigrationInfo() { - // Get migrations panel - migrationsPanel, ok := a.panels[ViewMigrations].(*context.MigrationsContext) - if !ok { - return +// ClipboardController handles clipboard-related operations. +type ClipboardController struct { + c types.IControllerHost + g *gocui.Gui + migrationsCtx *context.MigrationsContext + openModal func(Modal) + closeModal func() +} + +// NewClipboardController creates a new ClipboardController. +func NewClipboardController( + c types.IControllerHost, + g *gocui.Gui, + migrationsCtx *context.MigrationsContext, + openModal func(Modal), + closeModal func(), +) *ClipboardController { + return &ClipboardController{ + c: c, + g: g, + migrationsCtx: migrationsCtx, + openModal: openModal, + closeModal: closeModal, } +} + +// CopyMigrationInfo copies migration info to clipboard +func (cc *ClipboardController) CopyMigrationInfo() { + tr := cc.c.GetTranslationSet() // Get selected migration - selected := migrationsPanel.GetSelectedMigration() + selected := cc.migrationsCtx.GetSelectedMigration() if selected == nil { return } items := []ListModalItem{ { - Label: a.Tr.ListItemCopyName, + Label: tr.ListItemCopyName, Description: selected.Name, OnSelect: func() error { - a.CloseModal() - a.copyTextToClipboard(selected.Name, a.Tr.CopyLabelMigrationName) + cc.closeModal() + cc.copyTextToClipboard(selected.Name, tr.CopyLabelMigrationName) return nil }, }, { - Label: a.Tr.ListItemCopyPath, + Label: tr.ListItemCopyPath, Description: selected.Path, OnSelect: func() error { - a.CloseModal() - a.copyTextToClipboard(selected.Path, a.Tr.CopyLabelMigrationPath) + cc.closeModal() + cc.copyTextToClipboard(selected.Path, tr.CopyLabelMigrationPath) return nil }, }, @@ -44,39 +68,40 @@ func (a *App) CopyMigrationInfo() { // If it has a checksum, allow copying it if selected.Checksum != "" { items = append(items, ListModalItem{ - Label: a.Tr.ListItemCopyChecksum, + Label: tr.ListItemCopyChecksum, Description: selected.Checksum, OnSelect: func() error { - a.CloseModal() - a.copyTextToClipboard(selected.Checksum, a.Tr.CopyLabelChecksum) + cc.closeModal() + cc.copyTextToClipboard(selected.Checksum, tr.CopyLabelChecksum) return nil }, }) } - modal := NewListModal(a.g, a.Tr, a.Tr.ModalTitleCopyToClipboard, items, + modal := NewListModal(cc.g, tr, tr.ModalTitleCopyToClipboard, items, func() { - a.CloseModal() + cc.closeModal() }, ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}) - a.OpenModal(modal) + cc.openModal(modal) } -func (a *App) copyTextToClipboard(text, label string) { +func (cc *ClipboardController) copyTextToClipboard(text, label string) { + tr := cc.c.GetTranslationSet() + if err := CopyToClipboard(text); err != nil { - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleClipboardError, - a.Tr.ModalMsgFailedCopyClipboard, + modal := NewMessageModal(cc.g, tr, tr.ModalTitleClipboardError, + tr.ModalMsgFailedCopyClipboard, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + cc.openModal(modal) return } // Show toast/notification via modal for now - // Ideally we would have a toast system - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleCopied, - fmt.Sprintf(a.Tr.ModalMsgCopiedToClipboard, label), + modal := NewMessageModal(cc.g, tr, tr.ModalTitleCopied, + fmt.Sprintf(tr.ModalMsgCopiedToClipboard, label), ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) - a.OpenModal(modal) + cc.openModal(modal) } diff --git a/pkg/app/command_helpers.go b/pkg/app/command_helpers.go new file mode 100644 index 0000000..e3d3bce --- /dev/null +++ b/pkg/app/command_helpers.go @@ -0,0 +1,151 @@ +package app + +import ( + "os" + + "github.com/dokadev/lazyprisma/pkg/commands" + "github.com/dokadev/lazyprisma/pkg/gui/context" + "github.com/jesseduffield/gocui" +) + +// AsyncCommandOpts configures a streaming async command. +type AsyncCommandOpts struct { + Name string // for tryStartCommand / logCommandBlocked + Args []string // full command args: ["npx", "prisma", "migrate", "deploy"] + LogAction string // log action label (e.g., "Migrate Deploy") + LogDetail string // log detail text (e.g., "Running prisma migrate deploy...") + SkipTryStart bool // true if tryStartCommand was already called by the caller + + // Callbacks — each callback is responsible for calling finishCommand() at the appropriate time. + // The helper never calls finishCommand() itself. + OnSuccess func(out *context.OutputContext, cwd string) + OnFailure func(out *context.OutputContext, cwd string, exitCode int) + OnError func(out *context.OutputContext, cwd string, err error) + + // ErrorTitle and ErrorStartMsg are used for the default RunAsync failure modal. + // If empty, generic error text is used. + ErrorTitle string + ErrorStartMsg string +} + +// RunStreamingCommand handles the common boilerplate for streaming prisma commands. +// Returns false if the command could not be started (another command running or panel missing). +// The helper does NOT call FinishCommand() -- each callback is responsible for calling it. +func (a *App) RunStreamingCommand(opts AsyncCommandOpts) bool { + // Phase 1: Guard + if !opts.SkipTryStart { + if !a.TryStartCommand(opts.Name) { + a.LogCommandBlocked(opts.Name) + return false + } + } + + // Phase 2: Get output panel + outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext) + if !ok { + a.FinishCommand() + return false + } + + // Phase 3: Get cwd + cwd, err := os.Getwd() + if err != nil { + a.FinishCommand() + a.g.Update(func(g *gocui.Gui) error { + outputPanel.LogAction(opts.LogAction, a.Tr.ErrorFailedGetWorkingDir+" "+err.Error()) + modal := NewMessageModal(a.g, a.Tr, opts.ErrorTitle, + a.Tr.ErrorFailedGetWorkingDir, + err.Error(), + ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) + a.OpenModal(modal) + return nil + }) + return false + } + + // Phase 4: Log action start + a.g.Update(func(g *gocui.Gui) error { + if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { + out.LogAction(opts.LogAction, opts.LogDetail) + } + return nil + }) + + // Phase 5: Build command + builder := commands.NewCommandBuilder(commands.NewPlatform()) + + cmd := builder.New(opts.Args...). + WithWorkingDir(cwd). + StreamOutput(). + OnStdout(func(line string) { + a.g.Update(func(g *gocui.Gui) error { + if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { + out.AppendOutput(" " + line) + } + return nil + }) + }). + OnStderr(func(line string) { + a.g.Update(func(g *gocui.Gui) error { + if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { + out.AppendOutput(" " + line) + } + return nil + }) + }). + OnComplete(func(exitCode int) { + a.g.Update(func(g *gocui.Gui) error { + if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { + if exitCode == 0 { + if opts.OnSuccess != nil { + opts.OnSuccess(out, cwd) + } else { + a.FinishCommand() + } + } else { + if opts.OnFailure != nil { + opts.OnFailure(out, cwd, exitCode) + } else { + a.FinishCommand() + } + } + } else { + a.FinishCommand() + } + return nil + }) + }). + OnError(func(err error) { + a.g.Update(func(g *gocui.Gui) error { + if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { + if opts.OnError != nil { + opts.OnError(out, cwd, err) + } else { + a.FinishCommand() + } + } else { + a.FinishCommand() + } + return nil + }) + }) + + // Phase 6: RunAsync + if err := cmd.RunAsync(); err != nil { + a.FinishCommand() + errorTitle := opts.ErrorTitle + errorMsg := opts.ErrorStartMsg + a.g.Update(func(g *gocui.Gui) error { + outputPanel.LogAction(errorTitle, errorMsg+" "+err.Error()) + modal := NewMessageModal(a.g, a.Tr, errorTitle, + errorMsg, + err.Error(), + ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) + a.OpenModal(modal) + return nil + }) + return false + } + + return true +} diff --git a/pkg/app/generate_controller.go b/pkg/app/generate_controller.go index b1ef93a..24b70b2 100644 --- a/pkg/app/generate_controller.go +++ b/pkg/app/generate_controller.go @@ -2,185 +2,126 @@ package app import ( "fmt" - "os" "strings" - "github.com/dokadev/lazyprisma/pkg/commands" "github.com/dokadev/lazyprisma/pkg/gui/context" + "github.com/dokadev/lazyprisma/pkg/gui/types" "github.com/dokadev/lazyprisma/pkg/prisma" "github.com/jesseduffield/gocui" ) -// Generate runs prisma generate and shows result in modal -func (a *App) Generate() { - // Try to start command - if another command is running, block - if !a.tryStartCommand("Generate") { - a.logCommandBlocked("Generate") - return - } - - outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext) - if !ok { - a.finishCommand() // Clean up if panel not found - return - } +// GenerateController handles prisma generate operations. +type GenerateController struct { + c types.IControllerHost + g *gocui.Gui + outputCtx *context.OutputContext + openModal func(Modal) + runStreamCmd func(AsyncCommandOpts) bool +} - // Get current working directory - cwd, err := os.Getwd() - if err != nil { - a.finishCommand() - outputPanel.LogAction(a.Tr.LogActionGenerateError, a.Tr.ErrorFailedGetWorkingDir+" "+err.Error()) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleGenerateError, - a.Tr.ErrorFailedGetWorkingDir, - err.Error(), - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - return +// NewGenerateController creates a new GenerateController. +func NewGenerateController( + c types.IControllerHost, + g *gocui.Gui, + outputCtx *context.OutputContext, + openModal func(Modal), + runStreamCmd func(AsyncCommandOpts) bool, +) *GenerateController { + return &GenerateController{ + c: c, + g: g, + outputCtx: outputCtx, + openModal: openModal, + runStreamCmd: runStreamCmd, } +} - // Log action start - outputPanel.LogAction(a.Tr.LogActionGenerate, a.Tr.LogMsgRunningGenerate) - - // Create command builder - builder := commands.NewCommandBuilder(commands.NewPlatform()) - - // Build prisma generate command - generateCmd := builder.New("npx", "prisma", "generate"). - WithWorkingDir(cwd). - StreamOutput(). - OnStdout(func(line string) { - // Update UI on main thread - a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - out.AppendOutput(" " + line) - } - return nil - }) - }). - OnStderr(func(line string) { - // Update UI on main thread - a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - out.AppendOutput(" " + line) - } - return nil - }) - }). - OnComplete(func(exitCode int) { - // Update UI on main thread - a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - if exitCode == 0 { - a.finishCommand() // Finish immediately on success - out.LogAction(a.Tr.LogActionGenerateComplete, a.Tr.LogMsgPrismaClientGeneratedSuccess) - // Show success modal - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleGenerateSuccess, - a.Tr.ModalMsgPrismaClientGenerated, - ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) - a.OpenModal(modal) - } else { - // Failed - run validate to check schema (keep spinner running) - out.LogAction(a.Tr.LogActionGenerateFailed, a.Tr.LogMsgCheckingSchemaErrors) - - // Run validate in goroutine to not block UI updates - go func() { - validateResult, err := prisma.Validate(cwd) - - // Update UI on main thread after validate completes - a.g.Update(func(g *gocui.Gui) error { - a.finishCommand() // Finish after validate completes - - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - if err == nil && !validateResult.Valid { - // Schema has validation errors - show them - out.LogAction(a.Tr.LogActionSchemaValidationFailed, fmt.Sprintf(a.Tr.LogMsgFoundSchemaErrors, len(validateResult.Errors))) - - // Show validation errors in modal - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleSchemaValidationFailed, - a.Tr.ModalMsgGenerateFailedSchemaErrors, - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - } else { - // Schema is valid but generate failed for other reasons - out.LogAction(a.Tr.LogActionGenerateFailed, fmt.Sprintf(a.Tr.ModalMsgGenerateFailedWithCode, exitCode)) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleGenerateFailed, - fmt.Sprintf(a.Tr.ModalMsgGenerateFailedWithCode, exitCode), - a.Tr.ModalMsgSchemaValidCheckOutput, - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - } - } - return nil - }) - }() - } - } - return nil - }) - }). - OnError(func(err error) { - // Update UI on main thread - a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - // Check if it's an exit status error (command ran but failed) - if strings.Contains(err.Error(), "exit status") { - // Failed - run validate to check schema (keep spinner running) - out.LogAction(a.Tr.LogActionGenerateFailed, a.Tr.LogMsgCheckingSchemaErrors) - - // Run validate in goroutine to not block UI updates - go func() { - validateResult, validateErr := prisma.Validate(cwd) - - // Update UI on main thread after validate completes - a.g.Update(func(g *gocui.Gui) error { - a.finishCommand() // Finish after validate completes - - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - if validateErr == nil && !validateResult.Valid { - // Schema has validation errors - show them - out.LogAction(a.Tr.LogActionSchemaValidationFailed, fmt.Sprintf(a.Tr.LogMsgFoundSchemaErrors, len(validateResult.Errors))) - - // Show validation errors in modal - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleSchemaValidationFailed, - a.Tr.ModalMsgGenerateFailedSchemaErrors, - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - } else { - // Schema is valid but generate failed for other reasons - out.LogAction(a.Tr.LogActionGenerateFailed, err.Error()) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleGenerateFailed, - a.Tr.ModalMsgFailedRunGenerate, - a.Tr.ModalMsgSchemaValidCheckOutput, - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - } - } - return nil - }) - }() +// Generate runs prisma generate and shows result in modal +func (gc *GenerateController) Generate() { + tr := gc.c.GetTranslationSet() + + gc.runStreamCmd(AsyncCommandOpts{ + Name: "Generate", + Args: []string{"npx", "prisma", "generate"}, + LogAction: tr.LogActionGenerate, + LogDetail: tr.LogMsgRunningGenerate, + ErrorTitle: tr.ModalTitleGenerateError, + ErrorStartMsg: tr.ModalMsgFailedStartGenerate, + OnSuccess: func(out *context.OutputContext, cwd string) { + gc.c.FinishCommand() // Finish immediately on success + out.LogAction(tr.LogActionGenerateComplete, tr.LogMsgPrismaClientGeneratedSuccess) + modal := NewMessageModal(gc.g, tr, tr.ModalTitleGenerateSuccess, + tr.ModalMsgPrismaClientGenerated, + ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) + gc.openModal(modal) + }, + OnFailure: func(out *context.OutputContext, cwd string, exitCode int) { + // Don't finishCommand yet -- validate first (keep spinner running) + out.LogAction(tr.LogActionGenerateFailed, tr.LogMsgCheckingSchemaErrors) + + go func() { + validateResult, err := prisma.Validate(cwd) + + gc.c.OnUIThread(func() error { + gc.c.FinishCommand() // Finish after validate completes + + if err == nil && !validateResult.Valid { + gc.outputCtx.LogAction(tr.LogActionSchemaValidationFailed, fmt.Sprintf(tr.LogMsgFoundSchemaErrors, len(validateResult.Errors))) + modal := NewMessageModal(gc.g, tr, tr.ModalTitleSchemaValidationFailed, + tr.ModalMsgGenerateFailedSchemaErrors, + ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) + gc.openModal(modal) } else { - // Other error (command couldn't start, etc.) - a.finishCommand() // Finish immediately on startup error - out.LogAction(a.Tr.LogActionGenerateError, err.Error()) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleGenerateError, - a.Tr.ModalMsgFailedRunGenerate, - err.Error(), + gc.outputCtx.LogAction(tr.LogActionGenerateFailed, fmt.Sprintf(tr.ModalMsgGenerateFailedWithCode, exitCode)) + modal := NewMessageModal(gc.g, tr, tr.ModalTitleGenerateFailed, + fmt.Sprintf(tr.ModalMsgGenerateFailedWithCode, exitCode), + tr.ModalMsgSchemaValidCheckOutput, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + gc.openModal(modal) } - } - return nil - }) - }) - - // Run async to avoid blocking UI (spinner will show automatically) - if err := generateCmd.RunAsync(); err != nil { - a.finishCommand() // Clean up if command fails to start - outputPanel.LogAction(a.Tr.LogActionGenerateError, a.Tr.ModalMsgFailedStartGenerate+" "+err.Error()) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleGenerateError, - a.Tr.ModalMsgFailedStartGenerate, - err.Error(), - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - } + return nil + }) + }() + }, + OnError: func(out *context.OutputContext, cwd string, err error) { + // Check if it's an exit status error (command ran but failed) + if strings.Contains(err.Error(), "exit status") { + // Don't finishCommand yet -- validate first (keep spinner running) + out.LogAction(tr.LogActionGenerateFailed, tr.LogMsgCheckingSchemaErrors) + + go func() { + validateResult, validateErr := prisma.Validate(cwd) + + gc.c.OnUIThread(func() error { + gc.c.FinishCommand() // Finish after validate completes + + if validateErr == nil && !validateResult.Valid { + gc.outputCtx.LogAction(tr.LogActionSchemaValidationFailed, fmt.Sprintf(tr.LogMsgFoundSchemaErrors, len(validateResult.Errors))) + modal := NewMessageModal(gc.g, tr, tr.ModalTitleSchemaValidationFailed, + tr.ModalMsgGenerateFailedSchemaErrors, + ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) + gc.openModal(modal) + } else { + gc.outputCtx.LogAction(tr.LogActionGenerateFailed, err.Error()) + modal := NewMessageModal(gc.g, tr, tr.ModalTitleGenerateFailed, + tr.ModalMsgFailedRunGenerate, + tr.ModalMsgSchemaValidCheckOutput, + ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) + gc.openModal(modal) + } + return nil + }) + }() + } else { + // Other error (command couldn't start, etc.) + gc.c.FinishCommand() // Finish immediately on startup error + out.LogAction(tr.LogActionGenerateError, err.Error()) + modal := NewMessageModal(gc.g, tr, tr.ModalTitleGenerateError, + tr.ModalMsgFailedRunGenerate, + err.Error(), + ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) + gc.openModal(modal) + } + }, + }) } diff --git a/pkg/app/input_modal.go b/pkg/app/input_modal.go index 4a6622a..e9d4974 100644 --- a/pkg/app/input_modal.go +++ b/pkg/app/input_modal.go @@ -57,6 +57,9 @@ func (m *InputModal) OnValidationFail(callback func(string)) *InputModal { return m } +// AcceptsTextInput returns true because InputModal uses keyboard for text entry. +func (m *InputModal) AcceptsTextInput() bool { return true } + // Draw renders the input modal func (m *InputModal) Draw(dim boxlayout.Dimensions) error { // Calculate width diff --git a/pkg/app/keybinding.go b/pkg/app/keybinding.go index c561f8d..8dacb41 100644 --- a/pkg/app/keybinding.go +++ b/pkg/app/keybinding.go @@ -1,7 +1,7 @@ package app import ( - "github.com/dokadev/lazyprisma/pkg/gui/context" + "github.com/dokadev/lazyprisma/pkg/gui/types" "github.com/jesseduffield/gocui" ) @@ -9,8 +9,8 @@ func (a *App) RegisterKeybindings() error { // Quit or close modal (lowercase q) if err := a.g.SetKeybinding("", 'q', gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { if a.HasActiveModal() { - // InputModal uses 'q' for text input, not for closing - if _, ok := a.activeModal.(*InputModal); !ok { + // Modals that accept text input use 'q' for typing, not for closing + if !a.activeModal.AcceptsTextInput() { a.CloseModal() return nil } @@ -48,10 +48,8 @@ func (a *App) RegisterKeybindings() error { } // Check if current panel supports tabs if panel := a.GetCurrentPanel(); panel != nil { - if migrationsPanel, ok := panel.(*context.MigrationsContext); ok { - migrationsPanel.NextTab() - } else if detailsPanel, ok := panel.(*context.DetailsContext); ok { - detailsPanel.NextTab() + if tabbedPanel, ok := panel.(types.ITabbedContext); ok { + tabbedPanel.NextTab() } } return nil @@ -66,10 +64,8 @@ func (a *App) RegisterKeybindings() error { } // Check if current panel supports tabs if panel := a.GetCurrentPanel(); panel != nil { - if migrationsPanel, ok := panel.(*context.MigrationsContext); ok { - migrationsPanel.PrevTab() - } else if detailsPanel, ok := panel.(*context.DetailsContext); ok { - detailsPanel.PrevTab() + if tabbedPanel, ok := panel.(types.ITabbedContext); ok { + tabbedPanel.PrevTab() } } return nil @@ -103,17 +99,12 @@ func (a *App) RegisterKeybindings() error { if a.HasActiveModal() { return a.activeModal.HandleKey(gocui.KeyArrowUp, gocui.ModNone) } - // Handle different panel types + // IListContext (e.g. MigrationsContext) takes priority over IScrollableContext if panel := a.GetCurrentPanel(); panel != nil { - switch p := panel.(type) { - case *context.MigrationsContext: - p.SelectPrev() - case *context.WorkspaceContext: - p.ScrollUp() - case *context.DetailsContext: - p.ScrollUp() - case *context.OutputContext: - p.ScrollUp() + if listPanel, ok := panel.(types.IListContext); ok { + listPanel.SelectPrev() + } else if scrollPanel, ok := panel.(types.IScrollableContext); ok { + scrollPanel.ScrollUp() } } return nil @@ -125,17 +116,12 @@ func (a *App) RegisterKeybindings() error { if a.HasActiveModal() { return a.activeModal.HandleKey(gocui.KeyArrowDown, gocui.ModNone) } - // Handle different panel types + // IListContext (e.g. MigrationsContext) takes priority over IScrollableContext if panel := a.GetCurrentPanel(); panel != nil { - switch p := panel.(type) { - case *context.MigrationsContext: - p.SelectNext() - case *context.WorkspaceContext: - p.ScrollDown() - case *context.DetailsContext: - p.ScrollDown() - case *context.OutputContext: - p.ScrollDown() + if listPanel, ok := panel.(types.IListContext); ok { + listPanel.SelectNext() + } else if scrollPanel, ok := panel.(types.IScrollableContext); ok { + scrollPanel.ScrollDown() } } return nil @@ -146,8 +132,8 @@ func (a *App) RegisterKeybindings() error { // Enter key for modal if err := a.g.SetKeybinding("", gocui.KeyEnter, gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { if a.HasActiveModal() { - // MessageModal: close on Enter - if _, ok := a.activeModal.(*MessageModal); ok { + // Modals that close on Enter (e.g. MessageModal) are dismissed directly + if a.activeModal.ClosesOnEnter() { a.CloseModal() return nil } @@ -164,17 +150,9 @@ func (a *App) RegisterKeybindings() error { if a.HasActiveModal() { return a.activeModal.HandleKey(gocui.KeyHome, gocui.ModNone) } - // Handle different panel types if panel := a.GetCurrentPanel(); panel != nil { - switch p := panel.(type) { - case *context.MigrationsContext: - p.ScrollToTop() - case *context.WorkspaceContext: - p.ScrollToTop() - case *context.DetailsContext: - p.ScrollToTop() - case *context.OutputContext: - p.ScrollToTop() + if scrollPanel, ok := panel.(types.IScrollableContext); ok { + scrollPanel.ScrollToTop() } } return nil @@ -187,17 +165,9 @@ func (a *App) RegisterKeybindings() error { if a.HasActiveModal() { return a.activeModal.HandleKey(gocui.KeyEnd, gocui.ModNone) } - // Handle different panel types if panel := a.GetCurrentPanel(); panel != nil { - switch p := panel.(type) { - case *context.MigrationsContext: - p.ScrollToBottom() - case *context.WorkspaceContext: - p.ScrollToBottom() - case *context.DetailsContext: - p.ScrollToBottom() - case *context.OutputContext: - p.ScrollToBottom() + if scrollPanel, ok := panel.(types.IScrollableContext); ok { + scrollPanel.ScrollToBottom() } } return nil @@ -221,7 +191,7 @@ func (a *App) RegisterKeybindings() error { if a.HasActiveModal() { return nil } - a.MigrateDev() + a.migrationsController.MigrateDev() return nil }); err != nil { return err @@ -232,7 +202,7 @@ func (a *App) RegisterKeybindings() error { if a.HasActiveModal() { return nil } - a.MigrateDeploy() + a.migrationsController.MigrateDeploy() return nil }); err != nil { return err @@ -243,7 +213,7 @@ func (a *App) RegisterKeybindings() error { if a.HasActiveModal() { return nil } - a.Generate() + a.generateController.Generate() return nil }); err != nil { return err @@ -254,7 +224,7 @@ func (a *App) RegisterKeybindings() error { if a.HasActiveModal() { return nil } - a.MigrateResolve() + a.migrationsController.MigrateResolve() return nil }); err != nil { return err @@ -265,7 +235,7 @@ func (a *App) RegisterKeybindings() error { if a.HasActiveModal() { return nil } - a.Studio() + a.studioController.Studio() return nil }); err != nil { return err @@ -276,7 +246,7 @@ func (a *App) RegisterKeybindings() error { if a.HasActiveModal() { return nil } - a.CopyMigrationInfo() + a.clipboardController.CopyMigrationInfo() return nil }); err != nil { return err @@ -287,7 +257,7 @@ func (a *App) RegisterKeybindings() error { if a.HasActiveModal() { return nil } - a.DeleteMigration() + a.migrationsController.DeleteMigration() return nil } diff --git a/pkg/app/list_modal.go b/pkg/app/list_modal.go index d408eb4..d9330c5 100644 --- a/pkg/app/list_modal.go +++ b/pkg/app/list_modal.go @@ -3,6 +3,7 @@ package app import ( "fmt" + "github.com/dokadev/lazyprisma/pkg/gui/style" "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazycore/pkg/boxlayout" @@ -129,7 +130,7 @@ func (m *ListModal) drawListView(x0, y0, x1, y1 int) error { // Enable highlight for selection (like MigrationsPanel) v.Highlight = true - v.SelBgColor = SelectionBgColor + v.SelBgColor = style.SelectionBgColor // Render list items for _, item := range m.items { diff --git a/pkg/app/message_modal.go b/pkg/app/message_modal.go index bced1ee..b4a19f8 100644 --- a/pkg/app/message_modal.go +++ b/pkg/app/message_modal.go @@ -39,6 +39,9 @@ func (m *MessageModal) WithStyle(style MessageModalStyle) *MessageModal { return m } +// ClosesOnEnter returns true because MessageModal is dismissed with Enter. +func (m *MessageModal) ClosesOnEnter() bool { return true } + // Draw renders the modal func (m *MessageModal) Draw(dim boxlayout.Dimensions) error { // Calculate width diff --git a/pkg/app/migrations_controller.go b/pkg/app/migrations_controller.go index 9d04a37..05a28fb 100644 --- a/pkg/app/migrations_controller.go +++ b/pkg/app/migrations_controller.go @@ -6,412 +6,286 @@ import ( "strings" "time" - "github.com/dokadev/lazyprisma/pkg/commands" "github.com/dokadev/lazyprisma/pkg/gui/context" + "github.com/dokadev/lazyprisma/pkg/gui/types" "github.com/jesseduffield/gocui" ) +// MigrationsController handles migration-related operations. +type MigrationsController struct { + c types.IControllerHost + g *gocui.Gui + migrationsCtx *context.MigrationsContext + outputCtx *context.OutputContext + openModal func(Modal) + closeModal func() + runStreamCmd func(AsyncCommandOpts) bool +} + +// NewMigrationsController creates a new MigrationsController. +func NewMigrationsController( + c types.IControllerHost, + g *gocui.Gui, + migrationsCtx *context.MigrationsContext, + outputCtx *context.OutputContext, + openModal func(Modal), + closeModal func(), + runStreamCmd func(AsyncCommandOpts) bool, +) *MigrationsController { + return &MigrationsController{ + c: c, + g: g, + migrationsCtx: migrationsCtx, + outputCtx: outputCtx, + openModal: openModal, + closeModal: closeModal, + runStreamCmd: runStreamCmd, + } +} + // MigrateDeploy runs npx prisma migrate deploy -func (a *App) MigrateDeploy() { +func (mc *MigrationsController) MigrateDeploy() { + tr := mc.c.GetTranslationSet() + // Try to start command - if another command is running, block - if !a.tryStartCommand("Migrate Deploy") { - a.logCommandBlocked("Migrate Deploy") + if !mc.c.TryStartCommand("Migrate Deploy") { + mc.c.LogCommandBlocked("Migrate Deploy") return } // Run everything in background to avoid blocking UI during refresh/checks go func() { // 1. Refresh first to ensure DB connection is current - a.refreshPanels() + mc.c.RefreshPanels() // 2. Check DB connection - migrationsPanel, ok := a.panels[ViewMigrations].(*context.MigrationsContext) - if !ok { - a.finishCommand() - a.g.Update(func(g *gocui.Gui) error { - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError, - a.Tr.ErrorFailedAccessMigrationsPanel, + if !mc.migrationsCtx.IsDBConnected() { + mc.c.FinishCommand() + mc.c.OnUIThread(func() error { + modal := NewMessageModal(mc.g, tr, tr.ModalTitleDBConnectionRequired, + tr.ErrorNoDBConnectionDetected, + tr.ErrorEnsureDBAccessible, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + mc.openModal(modal) return nil }) return } - // Check if DB is connected - if !migrationsPanel.IsDBConnected() { - a.finishCommand() - a.g.Update(func(g *gocui.Gui) error { - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleDBConnectionRequired, - a.Tr.ErrorNoDBConnectionDetected, - a.Tr.ErrorEnsureDBAccessible, + // Pre-flight checks passed -- run the streaming command + mc.runStreamCmd(AsyncCommandOpts{ + Name: "Migrate Deploy", + SkipTryStart: true, // already called above + Args: []string{"npx", "prisma", "migrate", "deploy"}, + LogAction: tr.LogActionMigrateDeploy, + LogDetail: tr.LogMsgRunningMigrateDeploy, + ErrorTitle: tr.ModalTitleMigrateDeployError, + ErrorStartMsg: tr.ModalMsgFailedStartMigrateDeploy, + OnSuccess: func(out *context.OutputContext, cwd string) { + mc.c.FinishCommand() + out.LogAction(tr.LogActionMigrateDeployComplete, tr.LogMsgMigrationsAppliedSuccess) + mc.c.RefreshAll() + modal := NewMessageModal(mc.g, tr, tr.ModalTitleMigrateDeploySuccess, + tr.ModalMsgMigrationsAppliedSuccess, + ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) + mc.openModal(modal) + }, + OnFailure: func(out *context.OutputContext, cwd string, exitCode int) { + mc.c.FinishCommand() + out.LogAction(tr.LogActionMigrateDeployFailed, fmt.Sprintf(tr.LogMsgMigrateDeployFailedCode, exitCode)) + mc.c.RefreshAll() + modal := NewMessageModal(mc.g, tr, tr.ModalTitleMigrateDeployFailed, + fmt.Sprintf(tr.ModalMsgMigrateDeployFailedWithCode, exitCode), + tr.ModalMsgCheckOutputPanel, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - return nil - }) - return - } - - outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext) - if !ok { - a.finishCommand() // Clean up if panel not found - return - } - - // Get current working directory - cwd, err := os.Getwd() - if err != nil { - a.finishCommand() - a.g.Update(func(g *gocui.Gui) error { - outputPanel.LogAction(a.Tr.LogActionMigrateDeployFailed, a.Tr.ErrorFailedGetWorkingDir+" "+err.Error()) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateDeployError, - a.Tr.ErrorFailedGetWorkingDir, + mc.openModal(modal) + }, + OnError: func(out *context.OutputContext, cwd string, err error) { + mc.c.FinishCommand() + out.LogAction(tr.LogActionMigrateDeployFailed, err.Error()) + modal := NewMessageModal(mc.g, tr, tr.ModalTitleMigrateDeployError, + tr.ModalMsgFailedRunMigrateDeploy, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - return nil - }) - return - } - - // Log action start - a.g.Update(func(g *gocui.Gui) error { - outputPanel.LogAction(a.Tr.LogActionMigrateDeploy, a.Tr.LogMsgRunningMigrateDeploy) - return nil + mc.openModal(modal) + }, }) - - // Create command builder - builder := commands.NewCommandBuilder(commands.NewPlatform()) - - // Build prisma migrate deploy command - deployCmd := builder.New("npx", "prisma", "migrate", "deploy"). - WithWorkingDir(cwd). - StreamOutput(). - OnStdout(func(line string) { - // Update UI on main thread - a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - out.AppendOutput(" " + line) - } - return nil - }) - }). - OnStderr(func(line string) { - // Update UI on main thread - a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - out.AppendOutput(" " + line) - } - return nil - }) - }). - OnComplete(func(exitCode int) { - // Update UI on main thread - a.g.Update(func(g *gocui.Gui) error { - a.finishCommand() // Finish command - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - if exitCode == 0 { - out.LogAction(a.Tr.LogActionMigrateDeployComplete, a.Tr.LogMsgMigrationsAppliedSuccess) - // Refresh all panels to show updated migration status - a.RefreshAll() - // Show success modal - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateDeploySuccess, - a.Tr.ModalMsgMigrationsAppliedSuccess, - ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) - a.OpenModal(modal) - } else { - out.LogAction(a.Tr.LogActionMigrateDeployFailed, fmt.Sprintf(a.Tr.LogMsgMigrateDeployFailedCode, exitCode)) - // Refresh even on failure - DB state may have changed - a.RefreshAll() - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateDeployFailed, - fmt.Sprintf(a.Tr.ModalMsgMigrateDeployFailedWithCode, exitCode), - a.Tr.ModalMsgCheckOutputPanel, - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - } - } - return nil - }) - }). - OnError(func(err error) { - // Update UI on main thread - a.g.Update(func(g *gocui.Gui) error { - a.finishCommand() // Finish command - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - out.LogAction(a.Tr.LogActionMigrateDeployFailed, err.Error()) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateDeployError, - a.Tr.ModalMsgFailedRunMigrateDeploy, - err.Error(), - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - } - return nil - }) - }) - - // Run async to avoid blocking UI (spinner will show automatically) - if err := deployCmd.RunAsync(); err != nil { - a.finishCommand() // Clean up if command fails to start - a.g.Update(func(g *gocui.Gui) error { - outputPanel.LogAction(a.Tr.LogActionMigrateDeployFailed, a.Tr.ModalMsgFailedStartMigrateDeploy+" "+err.Error()) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateDeployError, - a.Tr.ModalMsgFailedStartMigrateDeploy, - err.Error(), - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - return nil - }) - } }() } // MigrateDev opens a list modal to choose migration type -func (a *App) MigrateDev() { +func (mc *MigrationsController) MigrateDev() { + tr := mc.c.GetTranslationSet() + items := []ListModalItem{ { - Label: a.Tr.ListItemSchemaDiffMigration, - Description: a.Tr.ListItemDescSchemaDiffMigration, + Label: tr.ListItemSchemaDiffMigration, + Description: tr.ListItemDescSchemaDiffMigration, OnSelect: func() error { - a.CloseModal() - a.SchemaDiffMigration() + mc.closeModal() + mc.SchemaDiffMigration() return nil }, }, { - Label: a.Tr.ListItemManualMigration, - Description: a.Tr.ListItemDescManualMigration, + Label: tr.ListItemManualMigration, + Description: tr.ListItemDescManualMigration, OnSelect: func() error { - a.CloseModal() - a.showManualMigrationInput() + mc.closeModal() + mc.showManualMigrationInput() return nil }, }, } - modal := NewListModal(a.g, a.Tr, a.Tr.ModalTitleMigrateDev, items, + modal := NewListModal(mc.g, tr, tr.ModalTitleMigrateDev, items, func() { - // Cancel - just close modal - a.CloseModal() + mc.closeModal() }, ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}) - a.OpenModal(modal) + mc.openModal(modal) } // executeCreateMigration runs npx prisma migrate dev --name --create-only -func (a *App) executeCreateMigration(migrationName string) { - // Try to start command - if another command is running, block - if !a.tryStartCommand("Create Migration") { - a.logCommandBlocked("Create Migration") - return - } - - outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext) - if !ok { - a.finishCommand() // Clean up if panel not found - return - } - - // Get current working directory - cwd, err := os.Getwd() - if err != nil { - a.finishCommand() - outputPanel.LogAction(a.Tr.LogActionMigrationError, a.Tr.ErrorFailedGetWorkingDir+" "+err.Error()) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrationError, - a.Tr.ErrorFailedGetWorkingDir, - err.Error(), - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - return - } - - // Log action start - outputPanel.LogAction(a.Tr.LogActionMigrateDev, fmt.Sprintf(a.Tr.LogMsgCreatingMigration, migrationName)) - - // Create command builder - builder := commands.NewCommandBuilder(commands.NewPlatform()) - - // Build prisma migrate dev --create-only command - // Note: --create-only flag creates the migration without applying it to the database - createCmd := builder.New("npx", "prisma", "migrate", "dev", "--name", migrationName, "--create-only"). - WithWorkingDir(cwd). - StreamOutput(). - OnStdout(func(line string) { - // Update UI on main thread - a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - out.AppendOutput(" " + line) - } - return nil - }) - }). - OnStderr(func(line string) { - // Update UI on main thread - a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - out.AppendOutput(" " + line) - } - return nil - }) - }). - OnComplete(func(exitCode int) { - // Update UI on main thread - a.g.Update(func(g *gocui.Gui) error { - a.finishCommand() // Finish command - // Refresh all panels to show the new migration - a.RefreshAll() - - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - if exitCode == 0 { - out.LogAction(a.Tr.LogActionMigrateComplete, a.Tr.LogMsgMigrationCreatedSuccess) - // Show success modal - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrationCreated, - fmt.Sprintf(a.Tr.ModalMsgMigrationCreatedSuccess, migrationName), - a.Tr.ModalMsgMigrationCreatedDetail, - ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) - a.OpenModal(modal) - } else { - out.LogAction(a.Tr.LogActionMigrateFailed, fmt.Sprintf(a.Tr.LogMsgMigrationCreationFailedCode, exitCode)) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrationFailed, - fmt.Sprintf(a.Tr.ModalMsgMigrationFailedWithCode, exitCode), - a.Tr.ModalMsgCheckOutputPanel, - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - } - } - return nil - }) - }). - OnError(func(err error) { - // Update UI on main thread - a.g.Update(func(g *gocui.Gui) error { - a.finishCommand() // Finish command - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - out.LogAction(a.Tr.LogActionMigrationError, err.Error()) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrationError, - a.Tr.ModalMsgFailedRunMigrateDeploy, - err.Error(), - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - } - return nil - }) - }) - - // Run async to avoid blocking UI (spinner will show automatically) - if err := createCmd.RunAsync(); err != nil { - a.finishCommand() // Clean up if command fails to start - outputPanel.LogAction(a.Tr.LogActionMigrationError, a.Tr.ModalMsgFailedStartMigrateDeploy+" "+err.Error()) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrationError, - a.Tr.ModalMsgFailedStartMigrateDeploy, - err.Error(), - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - } +func (mc *MigrationsController) executeCreateMigration(migrationName string) { + tr := mc.c.GetTranslationSet() + + mc.runStreamCmd(AsyncCommandOpts{ + Name: "Create Migration", + Args: []string{"npx", "prisma", "migrate", "dev", "--name", migrationName, "--create-only"}, + LogAction: tr.LogActionMigrateDev, + LogDetail: fmt.Sprintf(tr.LogMsgCreatingMigration, migrationName), + ErrorTitle: tr.ModalTitleMigrationError, + ErrorStartMsg: tr.ModalMsgFailedStartMigrateDeploy, + OnSuccess: func(out *context.OutputContext, cwd string) { + mc.c.FinishCommand() + mc.c.RefreshAll() + out.LogAction(tr.LogActionMigrateComplete, tr.LogMsgMigrationCreatedSuccess) + modal := NewMessageModal(mc.g, tr, tr.ModalTitleMigrationCreated, + fmt.Sprintf(tr.ModalMsgMigrationCreatedSuccess, migrationName), + tr.ModalMsgMigrationCreatedDetail, + ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) + mc.openModal(modal) + }, + OnFailure: func(out *context.OutputContext, cwd string, exitCode int) { + mc.c.FinishCommand() + mc.c.RefreshAll() + out.LogAction(tr.LogActionMigrateFailed, fmt.Sprintf(tr.LogMsgMigrationCreationFailedCode, exitCode)) + modal := NewMessageModal(mc.g, tr, tr.ModalTitleMigrationFailed, + fmt.Sprintf(tr.ModalMsgMigrationFailedWithCode, exitCode), + tr.ModalMsgCheckOutputPanel, + ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) + mc.openModal(modal) + }, + OnError: func(out *context.OutputContext, cwd string, err error) { + mc.c.FinishCommand() + out.LogAction(tr.LogActionMigrationError, err.Error()) + modal := NewMessageModal(mc.g, tr, tr.ModalTitleMigrationError, + tr.ModalMsgFailedRunMigrateDeploy, + err.Error(), + ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) + mc.openModal(modal) + }, + }) } // SchemaDiffMigration performs schema diff-based migration with validation checks -func (a *App) SchemaDiffMigration() { +func (mc *MigrationsController) SchemaDiffMigration() { + tr := mc.c.GetTranslationSet() + // 1. Refresh first (with callback to ensure data is loaded before checking) - started := a.RefreshAll(func() { + started := mc.c.RefreshAll(func() { // 2. Check DB connection - migrationsPanel, ok := a.panels[ViewMigrations].(*context.MigrationsContext) - if !ok { - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError, - a.Tr.ErrorFailedAccessMigrationsPanel, - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - return - } - - // Check if DB is connected - if !migrationsPanel.IsDBConnected() { - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleDBConnectionRequired, - a.Tr.ErrorNoDBConnectionDetected, - a.Tr.ErrorEnsureDBAccessible, + if !mc.migrationsCtx.IsDBConnected() { + modal := NewMessageModal(mc.g, tr, tr.ModalTitleDBConnectionRequired, + tr.ErrorNoDBConnectionDetected, + tr.ErrorEnsureDBAccessible, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + mc.openModal(modal) return } // 3. Check for DB-Only migrations - if len(migrationsPanel.GetCategory().DBOnly) > 0 { - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleDBOnlyMigrationsDetected, - a.Tr.ModalMsgCannotCreateWithDBOnly, - a.Tr.ModalMsgResolveDBOnlyFirst, + if len(mc.migrationsCtx.GetCategory().DBOnly) > 0 { + modal := NewMessageModal(mc.g, tr, tr.ModalTitleDBOnlyMigrationsDetected, + tr.ModalMsgCannotCreateWithDBOnly, + tr.ModalMsgResolveDBOnlyFirst, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + mc.openModal(modal) return } // 4. Check for Checksum Mismatch - for _, m := range migrationsPanel.GetCategory().Local { + for _, m := range mc.migrationsCtx.GetCategory().Local { if m.ChecksumMismatch { - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleChecksumMismatchDetected, - a.Tr.ModalMsgCannotCreateWithMismatch, - fmt.Sprintf(a.Tr.ModalMsgMigrationModifiedLocally, m.Name), + modal := NewMessageModal(mc.g, tr, tr.ModalTitleChecksumMismatchDetected, + tr.ModalMsgCannotCreateWithMismatch, + fmt.Sprintf(tr.ModalMsgMigrationModifiedLocally, m.Name), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + mc.openModal(modal) return } } // 5. Check for Pending migrations - if len(migrationsPanel.GetCategory().Pending) > 0 { + if len(mc.migrationsCtx.GetCategory().Pending) > 0 { // Check if any pending migration is empty - for _, m := range migrationsPanel.GetCategory().Pending { + for _, m := range mc.migrationsCtx.GetCategory().Pending { if m.IsEmpty { - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleEmptyPendingDetected, - a.Tr.ModalMsgCannotCreateWithEmpty, - fmt.Sprintf(a.Tr.ModalMsgMigrationPendingEmpty, m.Name), - a.Tr.ModalMsgDeleteOrAddContent, + modal := NewMessageModal(mc.g, tr, tr.ModalTitleEmptyPendingDetected, + tr.ModalMsgCannotCreateWithEmpty, + fmt.Sprintf(tr.ModalMsgMigrationPendingEmpty, m.Name), + tr.ModalMsgDeleteOrAddContent, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + mc.openModal(modal) return } } // Show confirmation modal for normal pending migrations - modal := NewConfirmModal(a.g, a.Tr, a.Tr.ModalTitlePendingMigrationsDetected, - a.Tr.ModalMsgPendingMigrationsWarning, + modal := NewConfirmModal(mc.g, tr, tr.ModalTitlePendingMigrationsDetected, + tr.ModalMsgPendingMigrationsWarning, func() { // Yes - proceed with migration name input - a.CloseModal() - a.showMigrationNameInput() + mc.closeModal() + mc.showMigrationNameInput() }, func() { // No - cancel - a.CloseModal() + mc.closeModal() }, ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow}) - a.OpenModal(modal) + mc.openModal(modal) return } // All checks passed - show migration name input - a.showMigrationNameInput() + mc.showMigrationNameInput() }) if !started { // If refresh failed to start (e.g., another command running), show error - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleOperationBlocked, - a.Tr.ModalMsgAnotherOperationRunning, - a.Tr.ModalMsgWaitComplete, + modal := NewMessageModal(mc.g, tr, tr.ModalTitleOperationBlocked, + tr.ModalMsgAnotherOperationRunning, + tr.ModalMsgWaitComplete, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + mc.openModal(modal) } } // createManualMigration creates a manual migration folder and file -func (a *App) createManualMigration(migrationName string) { +func (mc *MigrationsController) createManualMigration(migrationName string) { + tr := mc.c.GetTranslationSet() + // Get current working directory cwd, err := os.Getwd() if err != nil { - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError, - a.Tr.ErrorFailedGetWorkingDir, + modal := NewMessageModal(mc.g, tr, tr.ModalTitleError, + tr.ErrorFailedGetWorkingDir, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + mc.openModal(modal) return } @@ -425,11 +299,11 @@ func (a *App) createManualMigration(migrationName string) { // Create migration folder if err := os.MkdirAll(migrationFolder, 0755); err != nil { - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError, - a.Tr.ModalMsgFailedCreateFolder, + modal := NewMessageModal(mc.g, tr, tr.ModalTitleError, + tr.ModalMsgFailedCreateFolder, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + mc.openModal(modal) return } @@ -438,117 +312,113 @@ func (a *App) createManualMigration(migrationName string) { initialContent := "-- This migration was manually created via lazyprisma\n\n" if err := os.WriteFile(migrationFile, []byte(initialContent), 0644); err != nil { - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError, - a.Tr.ModalMsgFailedWriteMigrationFile, + modal := NewMessageModal(mc.g, tr, tr.ModalTitleError, + tr.ModalMsgFailedWriteMigrationFile, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + mc.openModal(modal) return } // Success - show result and refresh - a.RefreshAll() + mc.c.RefreshAll() - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrationCreated, - fmt.Sprintf(a.Tr.ModalMsgManualMigrationCreated, folderName), - fmt.Sprintf(a.Tr.ModalMsgManualMigrationLocation, migrationFolder), + modal := NewMessageModal(mc.g, tr, tr.ModalTitleMigrationCreated, + fmt.Sprintf(tr.ModalMsgManualMigrationCreated, folderName), + fmt.Sprintf(tr.ModalMsgManualMigrationLocation, migrationFolder), ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) - a.OpenModal(modal) + mc.openModal(modal) } // showMigrationNameInput shows input modal for migration name -func (a *App) showMigrationNameInput() { - modal := NewInputModal(a.g, a.Tr, a.Tr.ModalTitleEnterMigrationName, +func (mc *MigrationsController) showMigrationNameInput() { + tr := mc.c.GetTranslationSet() + + modal := NewInputModal(mc.g, tr, tr.ModalTitleEnterMigrationName, func(input string) { // Replace spaces with underscores migrationName := strings.ReplaceAll(strings.TrimSpace(input), " ", "_") // Close input modal - a.CloseModal() + mc.closeModal() // Execute actual migration creation - a.executeCreateMigration(migrationName) + mc.executeCreateMigration(migrationName) }, func() { // Cancel - just close modal - a.CloseModal() + mc.closeModal() }, ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}). - WithSubtitle(a.Tr.ModalMsgSpacesReplaced). + WithSubtitle(tr.ModalMsgSpacesReplaced). WithRequired(true). OnValidationFail(func(reason string) { // Validation failed - show error - a.CloseModal() - errorModal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleValidationFailed, + mc.closeModal() + errorModal := NewMessageModal(mc.g, tr, tr.ModalTitleValidationFailed, reason, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(errorModal) + mc.openModal(errorModal) }) - a.OpenModal(modal) + mc.openModal(modal) } // showManualMigrationInput shows input modal for manual migration name -func (a *App) showManualMigrationInput() { - modal := NewInputModal(a.g, a.Tr, a.Tr.ModalTitleEnterMigrationName, +func (mc *MigrationsController) showManualMigrationInput() { + tr := mc.c.GetTranslationSet() + + modal := NewInputModal(mc.g, tr, tr.ModalTitleEnterMigrationName, func(input string) { // Replace spaces with underscores migrationName := strings.ReplaceAll(strings.TrimSpace(input), " ", "_") // Close input modal - a.CloseModal() + mc.closeModal() // Create manual migration - a.createManualMigration(migrationName) + mc.createManualMigration(migrationName) }, func() { // Cancel - just close modal - a.CloseModal() + mc.closeModal() }, ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}). - WithSubtitle(a.Tr.ModalMsgSpacesReplaced). + WithSubtitle(tr.ModalMsgSpacesReplaced). WithRequired(true). OnValidationFail(func(reason string) { // Validation failed - show error - a.CloseModal() - errorModal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleValidationFailed, + mc.closeModal() + errorModal := NewMessageModal(mc.g, tr, tr.ModalTitleValidationFailed, reason, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(errorModal) + mc.openModal(errorModal) }) - a.OpenModal(modal) + mc.openModal(modal) } // MigrateResolve resolves a failed migration -func (a *App) MigrateResolve() { - // Get migrations panel - migrationsPanel, ok := a.panels[ViewMigrations].(*context.MigrationsContext) - if !ok { - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError, - a.Tr.ErrorFailedAccessMigrationsPanel, - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - return - } +func (mc *MigrationsController) MigrateResolve() { + tr := mc.c.GetTranslationSet() // Get selected migration - selectedMigration := migrationsPanel.GetSelectedMigration() + selectedMigration := mc.migrationsCtx.GetSelectedMigration() if selectedMigration == nil { - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleNoMigrationSelected, - a.Tr.ModalMsgSelectMigrationResolve, + modal := NewMessageModal(mc.g, tr, tr.ModalTitleNoMigrationSelected, + tr.ModalMsgSelectMigrationResolve, ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow}) - a.OpenModal(modal) + mc.openModal(modal) return } // Check if migration is failed (only In-Transaction migrations can be resolved) if !selectedMigration.IsFailed { - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleCannotResolveMigration, - a.Tr.ModalMsgOnlyInTransactionResolve, - fmt.Sprintf(a.Tr.ModalMsgMigrationNotFailed, selectedMigration.Name), + modal := NewMessageModal(mc.g, tr, tr.ModalTitleCannotResolveMigration, + tr.ModalMsgOnlyInTransactionResolve, + fmt.Sprintf(tr.ModalMsgMigrationNotFailed, selectedMigration.Name), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + mc.openModal(modal) return } @@ -557,225 +427,150 @@ func (a *App) MigrateResolve() { items := []ListModalItem{ { - Label: a.Tr.ListItemMarkApplied, - Description: a.Tr.ListItemDescMarkApplied, + Label: tr.ListItemMarkApplied, + Description: tr.ListItemDescMarkApplied, OnSelect: func() error { - a.CloseModal() - a.executeResolve(migrationName, "applied") + mc.closeModal() + mc.executeResolve(migrationName, "applied") return nil }, }, { - Label: a.Tr.ListItemMarkRolledBack, - Description: a.Tr.ListItemDescMarkRolledBack, + Label: tr.ListItemMarkRolledBack, + Description: tr.ListItemDescMarkRolledBack, OnSelect: func() error { - a.CloseModal() - a.executeResolve(migrationName, "rolled-back") + mc.closeModal() + mc.executeResolve(migrationName, "rolled-back") return nil }, }, } - modal := NewListModal(a.g, a.Tr, fmt.Sprintf(a.Tr.ModalTitleResolveMigration, migrationName), items, - func() { a.CloseModal() }, + modal := NewListModal(mc.g, tr, fmt.Sprintf(tr.ModalTitleResolveMigration, migrationName), items, + func() { mc.closeModal() }, ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}) - a.OpenModal(modal) + mc.openModal(modal) } // executeResolve runs npx prisma migrate resolve with the specified action -func (a *App) executeResolve(migrationName string, action string) { - // Try to start command - if another command is running, block - if !a.tryStartCommand("Migrate Resolve") { - a.logCommandBlocked("Migrate Resolve") - return - } - - outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext) - if !ok { - a.finishCommand() // Clean up if panel not found - return - } - - // Get current working directory - cwd, err := os.Getwd() - if err != nil { - a.finishCommand() - outputPanel.LogAction(a.Tr.LogActionMigrateResolveError, a.Tr.ErrorFailedGetWorkingDir+" "+err.Error()) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateResolveError, - a.Tr.ErrorFailedGetWorkingDir, - err.Error(), - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - return - } +func (mc *MigrationsController) executeResolve(migrationName string, action string) { + tr := mc.c.GetTranslationSet() - // Log action start - actionLabel := a.Tr.ActionLabelApplied + actionLabel := tr.ActionLabelApplied if action == "rolled-back" { - actionLabel = a.Tr.ActionLabelRolledBack + actionLabel = tr.ActionLabelRolledBack } - outputPanel.LogAction(a.Tr.LogActionMigrateResolve, fmt.Sprintf(a.Tr.LogMsgMarkingMigration, actionLabel, migrationName)) - - // Create command builder - builder := commands.NewCommandBuilder(commands.NewPlatform()) - - // Build prisma migrate resolve command - resolveCmd := builder.New("npx", "prisma", "migrate", "resolve", "--"+action, migrationName). - WithWorkingDir(cwd). - StreamOutput(). - OnStdout(func(line string) { - // Update UI on main thread - a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - out.AppendOutput(" " + line) - } - return nil - }) - }). - OnStderr(func(line string) { - // Update UI on main thread - a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - out.AppendOutput(" " + line) - } - return nil - }) - }). - OnComplete(func(exitCode int) { - // Update UI on main thread - a.g.Update(func(g *gocui.Gui) error { - a.finishCommand() // Finish command - // Refresh all panels to show updated migration status - a.RefreshAll() - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - if exitCode == 0 { - out.LogAction(a.Tr.LogActionMigrateResolveComplete, fmt.Sprintf(a.Tr.LogMsgMigrationMarked, actionLabel)) - // Show success modal - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateResolveSuccess, - fmt.Sprintf(a.Tr.ModalMsgMigrationMarkedSuccess, actionLabel), - ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) - a.OpenModal(modal) - } else { - out.LogAction(a.Tr.LogActionMigrateResolveFailed, fmt.Sprintf(a.Tr.LogMsgMigrateResolveFailedCode, exitCode)) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateResolveFailed, - fmt.Sprintf(a.Tr.ModalMsgMigrateResolveFailedWithCode, exitCode), - a.Tr.ModalMsgCheckOutputPanel, - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - } - } - return nil - }) - }). - OnError(func(err error) { - // Update UI on main thread - a.g.Update(func(g *gocui.Gui) error { - a.finishCommand() // Finish command - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - out.LogAction(a.Tr.LogActionMigrateResolveError, err.Error()) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateResolveError, - a.Tr.ModalMsgFailedRunMigrateResolve, - err.Error(), - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - } - return nil - }) - }) - // Run async to avoid blocking UI (spinner will show automatically) - if err := resolveCmd.RunAsync(); err != nil { - a.finishCommand() // Clean up if command fails to start - outputPanel.LogAction(a.Tr.LogActionMigrateResolveError, a.Tr.ModalMsgFailedStartMigrateResolve+" "+err.Error()) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateResolveError, - a.Tr.ModalMsgFailedStartMigrateResolve, - err.Error(), - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - } + mc.runStreamCmd(AsyncCommandOpts{ + Name: "Migrate Resolve", + Args: []string{"npx", "prisma", "migrate", "resolve", "--" + action, migrationName}, + LogAction: tr.LogActionMigrateResolve, + LogDetail: fmt.Sprintf(tr.LogMsgMarkingMigration, actionLabel, migrationName), + ErrorTitle: tr.ModalTitleMigrateResolveError, + ErrorStartMsg: tr.ModalMsgFailedStartMigrateResolve, + OnSuccess: func(out *context.OutputContext, cwd string) { + mc.c.FinishCommand() + mc.c.RefreshAll() + out.LogAction(tr.LogActionMigrateResolveComplete, fmt.Sprintf(tr.LogMsgMigrationMarked, actionLabel)) + modal := NewMessageModal(mc.g, tr, tr.ModalTitleMigrateResolveSuccess, + fmt.Sprintf(tr.ModalMsgMigrationMarkedSuccess, actionLabel), + ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) + mc.openModal(modal) + }, + OnFailure: func(out *context.OutputContext, cwd string, exitCode int) { + mc.c.FinishCommand() + mc.c.RefreshAll() + out.LogAction(tr.LogActionMigrateResolveFailed, fmt.Sprintf(tr.LogMsgMigrateResolveFailedCode, exitCode)) + modal := NewMessageModal(mc.g, tr, tr.ModalTitleMigrateResolveFailed, + fmt.Sprintf(tr.ModalMsgMigrateResolveFailedWithCode, exitCode), + tr.ModalMsgCheckOutputPanel, + ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) + mc.openModal(modal) + }, + OnError: func(out *context.OutputContext, cwd string, err error) { + mc.c.FinishCommand() + out.LogAction(tr.LogActionMigrateResolveError, err.Error()) + modal := NewMessageModal(mc.g, tr, tr.ModalTitleMigrateResolveError, + tr.ModalMsgFailedRunMigrateResolve, + err.Error(), + ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) + mc.openModal(modal) + }, + }) } // DeleteMigration deletes a pending migration -func (a *App) DeleteMigration() { - // Get migrations panel - migrationsPanel, ok := a.panels[ViewMigrations].(*context.MigrationsContext) - if !ok { - return - } +func (mc *MigrationsController) DeleteMigration() { + tr := mc.c.GetTranslationSet() // Get selected migration - selected := migrationsPanel.GetSelectedMigration() + selected := mc.migrationsCtx.GetSelectedMigration() if selected == nil { - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleNoSelection, - a.Tr.ModalMsgSelectMigrationDelete, + modal := NewMessageModal(mc.g, tr, tr.ModalTitleNoSelection, + tr.ModalMsgSelectMigrationDelete, ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow}) - a.OpenModal(modal) + mc.openModal(modal) return } // Validate: Can only delete if it exists locally if selected.Path == "" { - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleCannotDelete, - a.Tr.ModalMsgMigrationDBOnly, - a.Tr.ModalMsgCannotDeleteNoLocalFile, + modal := NewMessageModal(mc.g, tr, tr.ModalTitleCannotDelete, + tr.ModalMsgMigrationDBOnly, + tr.ModalMsgCannotDeleteNoLocalFile, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + mc.openModal(modal) return } // Validate: Can only delete pending migrations (not applied to DB) - // Exception: If DB is not connected, we assume it's safe to delete local files (user responsibility) - if migrationsPanel.IsDBConnected() && selected.AppliedAt != nil { - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleCannotDelete, - a.Tr.ModalMsgMigrationAlreadyApplied, - a.Tr.ModalMsgDeleteLocalInconsistency, + if mc.migrationsCtx.IsDBConnected() && selected.AppliedAt != nil { + modal := NewMessageModal(mc.g, tr, tr.ModalTitleCannotDelete, + tr.ModalMsgMigrationAlreadyApplied, + tr.ModalMsgDeleteLocalInconsistency, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + mc.openModal(modal) return } // Confirm deletion - modal := NewConfirmModal(a.g, a.Tr, a.Tr.ModalTitleDeleteMigration, - fmt.Sprintf(a.Tr.ModalMsgConfirmDeleteMigration, selected.Name), + modal := NewConfirmModal(mc.g, tr, tr.ModalTitleDeleteMigration, + fmt.Sprintf(tr.ModalMsgConfirmDeleteMigration, selected.Name), func() { - a.CloseModal() - a.executeDeleteMigration(selected.Path, selected.Name) + mc.closeModal() + mc.executeDeleteMigration(selected.Path, selected.Name) }, func() { - a.CloseModal() + mc.closeModal() }, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + mc.openModal(modal) } // executeDeleteMigration performs the actual deletion -func (a *App) executeDeleteMigration(path, name string) { +func (mc *MigrationsController) executeDeleteMigration(path, name string) { + tr := mc.c.GetTranslationSet() + if err := os.RemoveAll(path); err != nil { - outputPanel, _ := a.panels[ViewOutputs].(*context.OutputContext) - if outputPanel != nil { - outputPanel.LogActionRed(a.Tr.ModalTitleDeleteError, fmt.Sprintf(a.Tr.LogMsgFailedDeleteMigration, err.Error())) - } + mc.outputCtx.LogActionRed(tr.ModalTitleDeleteError, fmt.Sprintf(tr.LogMsgFailedDeleteMigration, err.Error())) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleDeleteError, - a.Tr.ModalMsgFailedDeleteFolder, + modal := NewMessageModal(mc.g, tr, tr.ModalTitleDeleteError, + tr.ModalMsgFailedDeleteFolder, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + mc.openModal(modal) return } // Success - outputPanel, _ := a.panels[ViewOutputs].(*context.OutputContext) - if outputPanel != nil { - outputPanel.LogAction(a.Tr.LogActionDeleted, fmt.Sprintf(a.Tr.LogMsgMigrationDeleted, name)) - } + mc.outputCtx.LogAction(tr.LogActionDeleted, fmt.Sprintf(tr.LogMsgMigrationDeleted, name)) // Refresh to update list - a.RefreshAll() + mc.c.RefreshAll() - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleDeleted, - a.Tr.ModalMsgMigrationDeletedSuccess, + modal := NewMessageModal(mc.g, tr, tr.ModalTitleDeleted, + tr.ModalMsgMigrationDeletedSuccess, ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) - a.OpenModal(modal) + mc.openModal(modal) } diff --git a/pkg/app/modal.go b/pkg/app/modal.go index ff91938..db7e74d 100644 --- a/pkg/app/modal.go +++ b/pkg/app/modal.go @@ -11,4 +11,11 @@ type Modal interface { Draw(dim boxlayout.Dimensions) error HandleKey(key any, mod gocui.Modifier) error OnClose() + // AcceptsTextInput reports whether the modal uses keyboard input for text entry + // (e.g. InputModal). When true, single-character keys like 'q' are not treated + // as close commands. + AcceptsTextInput() bool + // ClosesOnEnter reports whether pressing Enter should close the modal + // (e.g. MessageModal). When false, Enter is forwarded to HandleKey instead. + ClosesOnEnter() bool } diff --git a/pkg/app/panel.go b/pkg/app/panel.go index 99b1f67..53b3630 100644 --- a/pkg/app/panel.go +++ b/pkg/app/panel.go @@ -1,6 +1,7 @@ package app import ( + "github.com/dokadev/lazyprisma/pkg/gui/style" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazycore/pkg/boxlayout" ) @@ -20,29 +21,11 @@ type BasePanel struct { frameRunes []rune } -// Frame and title styling -var ( - defaultFrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} - - PrimaryFrameColor = gocui.ColorWhite - FocusedFrameColor = gocui.ColorGreen - - PrimaryTitleColor = gocui.ColorWhite | gocui.AttrNone - FocusedTitleColor = gocui.ColorGreen | gocui.AttrBold - - // Tab styling - FocusedActiveTabColor = gocui.ColorGreen | gocui.AttrBold // Active tab when panel is focused - PrimaryActiveTabColor = gocui.ColorGreen | gocui.AttrNone // Active tab when panel is not focused - - // List selection color - SelectionBgColor = gocui.ColorBlue -) - func NewBasePanel(id string, g *gocui.Gui) BasePanel { return BasePanel{ id: id, g: g, - frameRunes: defaultFrameRunes, + frameRunes: style.DefaultFrameRunes, } } @@ -53,16 +36,16 @@ func (bp *BasePanel) ID() string { func (bp *BasePanel) OnFocus() { bp.focused = true if bp.v != nil { - bp.v.FrameColor = FocusedFrameColor - bp.v.TitleColor = FocusedTitleColor + bp.v.FrameColor = style.FocusedFrameColor + bp.v.TitleColor = style.FocusedTitleColor } } func (bp *BasePanel) OnBlur() { bp.focused = false if bp.v != nil { - bp.v.FrameColor = PrimaryFrameColor - bp.v.TitleColor = PrimaryTitleColor + bp.v.FrameColor = style.PrimaryFrameColor + bp.v.TitleColor = style.PrimaryTitleColor } } @@ -75,11 +58,11 @@ func (bp *BasePanel) SetupView(v *gocui.View, title string) { v.FrameRunes = bp.frameRunes if bp.focused { - v.FrameColor = FocusedFrameColor - v.TitleColor = FocusedTitleColor + v.FrameColor = style.FocusedFrameColor + v.TitleColor = style.FocusedTitleColor } else { - v.FrameColor = PrimaryFrameColor - v.TitleColor = PrimaryTitleColor + v.FrameColor = style.PrimaryFrameColor + v.TitleColor = style.PrimaryTitleColor } } diff --git a/pkg/app/popup_handler.go b/pkg/app/popup_handler.go new file mode 100644 index 0000000..fbab7c4 --- /dev/null +++ b/pkg/app/popup_handler.go @@ -0,0 +1,136 @@ +package app + +import ( + "github.com/dokadev/lazyprisma/pkg/gui/context" + "github.com/dokadev/lazyprisma/pkg/gui/types" + "github.com/dokadev/lazyprisma/pkg/i18n" + "github.com/jesseduffield/gocui" +) + +// Compile-time interface satisfaction checks. +var _ types.IGuiCommon = (*App)(nil) +var _ types.IControllerHost = (*App)(nil) + +// --- IPopupHandler methods --- + +// Alert shows a simple notification popup (unstyled). +func (a *App) Alert(title string, message string) { + modal := NewMessageModal(a.g, a.Tr, title, message) + a.OpenModal(modal) +} + +// Confirm shows a yes/no confirmation popup. +// The ConfirmOpts callbacks return error; the underlying ConfirmModal expects func(). +// We wrap them with adapters that discard the error. +func (a *App) Confirm(opts types.ConfirmOpts) { + adaptedOnYes := func() { + if opts.HandleConfirm != nil { + _ = opts.HandleConfirm() + } + } + adaptedOnNo := func() { + if opts.HandleClose != nil { + _ = opts.HandleClose() + } + } + + modal := NewConfirmModal(a.g, a.Tr, opts.Title, opts.Prompt, adaptedOnYes, adaptedOnNo) + a.OpenModal(modal) +} + +// Prompt shows a text-input popup. +// The PromptOpts callback returns error; the underlying InputModal expects func(string). +// We wrap with an adapter that discards the error. +func (a *App) Prompt(opts types.PromptOpts) { + adaptedOnSubmit := func(input string) { + if opts.HandleConfirm != nil { + _ = opts.HandleConfirm(input) + } + } + adaptedOnCancel := func() { + a.CloseModal() + } + + modal := NewInputModal(a.g, a.Tr, opts.Title, adaptedOnSubmit, adaptedOnCancel) + + if opts.Subtitle != "" { + modal = modal.WithSubtitle(opts.Subtitle) + } + if opts.Required { + modal = modal.WithRequired(true) + } + if opts.OnValidationFail != nil { + modal = modal.OnValidationFail(opts.OnValidationFail) + } + + a.OpenModal(modal) +} + +// Menu shows a list of selectable options. +// Maps types.MenuItem to ListModalItem. +func (a *App) Menu(opts types.MenuOpts) error { + items := make([]ListModalItem, len(opts.Items)) + for i, mi := range opts.Items { + items[i] = ListModalItem{ + Label: mi.Label, + Description: mi.Description, + OnSelect: mi.OnPress, + } + } + + modal := NewListModal(a.g, a.Tr, opts.Title, items, func() { + a.CloseModal() + }) + a.OpenModal(modal) + return nil +} + +// Toast shows a brief message. Currently delegates to Alert as there is +// no auto-dismiss toast system yet. +func (a *App) Toast(message string) { + a.Alert("", message) +} + +// ErrorHandler shows an error modal with red styling. +func (a *App) ErrorHandler(err error) error { + if err == nil { + return nil + } + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError, + err.Error(), + ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) + a.OpenModal(modal) + return nil +} + +// --- IGuiCommon methods --- + +// LogAction logs a user-visible action to the output panel. +// Wrapped in g.Update() for thread safety — OutputContext.LogAction mutates +// o.content without mutex, so it must run on the UI thread. +func (a *App) LogAction(action string, detail ...string) { + a.g.Update(func(g *gocui.Gui) error { + if outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { + outputPanel.LogAction(action, detail...) + } + return nil + }) +} + +// Refresh triggers a data refresh and re-render of all contexts. +// This is the controller-facing API; it ignores the return value of RefreshAll. +func (a *App) Refresh() { + a.RefreshAll() +} + +// OnUIThread schedules a function to run on the UI thread. +func (a *App) OnUIThread(f func() error) { + a.g.Update(func(g *gocui.Gui) error { + return f() + }) +} + +// GetTranslationSet returns the current translation set. +func (a *App) GetTranslationSet() *i18n.TranslationSet { + return a.Tr +} diff --git a/pkg/app/studio_controller.go b/pkg/app/studio_controller.go index b93942a..082d235 100644 --- a/pkg/app/studio_controller.go +++ b/pkg/app/studio_controller.go @@ -2,118 +2,149 @@ package app import ( "os" + "sync/atomic" "time" "github.com/dokadev/lazyprisma/pkg/commands" "github.com/dokadev/lazyprisma/pkg/gui/context" + "github.com/dokadev/lazyprisma/pkg/gui/types" "github.com/jesseduffield/gocui" ) -// Studio toggles Prisma Studio -func (a *App) Studio() { - outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext) - if !ok { - return +// StudioController handles Prisma Studio toggle operations. +type StudioController struct { + c types.IControllerHost + g *gocui.Gui + outputCtx *context.OutputContext + openModal func(Modal) + studioCmd *commands.Command // Running studio command + studioRunning atomic.Bool // True if studio is running +} + +// NewStudioController creates a new StudioController. +func NewStudioController( + c types.IControllerHost, + g *gocui.Gui, + outputCtx *context.OutputContext, + openModal func(Modal), +) *StudioController { + return &StudioController{ + c: c, + g: g, + outputCtx: outputCtx, + openModal: openModal, } +} + +// IsStudioRunning returns whether Prisma Studio is currently running. +func (sc *StudioController) IsStudioRunning() bool { + return sc.studioRunning.Load() +} + +// GetStudioCmd returns the running studio command (for cleanup on app exit). +func (sc *StudioController) GetStudioCmd() *commands.Command { + return sc.studioCmd +} + +// Studio toggles Prisma Studio +func (sc *StudioController) Studio() { + tr := sc.c.GetTranslationSet() // Check if Studio is already running - if a.studioRunning { + if sc.studioRunning.Load() { // Stop Studio - if a.studioCmd != nil { - if err := a.studioCmd.Kill(); err != nil { - outputPanel.LogAction(a.Tr.LogActionStudio, a.Tr.ModalMsgFailedStopStudio+" "+err.Error()) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioError, - a.Tr.ModalMsgFailedStopStudio, + if sc.studioCmd != nil { + if err := sc.studioCmd.Kill(); err != nil { + sc.outputCtx.LogAction(tr.LogActionStudio, tr.ModalMsgFailedStopStudio+" "+err.Error()) + modal := NewMessageModal(sc.g, tr, tr.ModalTitleStudioError, + tr.ModalMsgFailedStopStudio, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + sc.openModal(modal) return } - a.studioCmd = nil + sc.studioCmd = nil } - a.studioRunning = false - outputPanel.LogAction(a.Tr.LogActionStudioStopped, a.Tr.LogMsgStudioHasStopped) + sc.studioRunning.Store(false) + sc.outputCtx.LogAction(tr.LogActionStudioStopped, tr.LogMsgStudioHasStopped) // Clear subtitle - outputPanel.SetSubtitle("") + sc.outputCtx.SetSubtitle("") // Update UI - a.g.Update(func(g *gocui.Gui) error { + sc.c.OnUIThread(func() error { // Trigger redraw of status bar return nil }) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioStopped, - a.Tr.ModalMsgStudioStopped, + modal := NewMessageModal(sc.g, tr, tr.ModalTitleStudioStopped, + tr.ModalMsgStudioStopped, ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow}) - a.OpenModal(modal) + sc.openModal(modal) return } // Start Studio // Try to start command - if another command is running, block - if !a.tryStartCommand("Start Studio") { - a.logCommandBlocked("Start Studio") + if !sc.c.TryStartCommand("Start Studio") { + sc.c.LogCommandBlocked("Start Studio") return } // Get current working directory cwd, err := os.Getwd() if err != nil { - a.finishCommand() - outputPanel.LogAction(a.Tr.LogActionStudio, a.Tr.ErrorFailedGetWorkingDir+" "+err.Error()) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioError, - a.Tr.ErrorFailedGetWorkingDir, + sc.c.FinishCommand() + sc.outputCtx.LogAction(tr.LogActionStudio, tr.ErrorFailedGetWorkingDir+" "+err.Error()) + modal := NewMessageModal(sc.g, tr, tr.ModalTitleStudioError, + tr.ErrorFailedGetWorkingDir, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + sc.openModal(modal) return } // Log action start - outputPanel.LogAction(a.Tr.LogActionStudio, a.Tr.LogMsgStartingStudio) + sc.outputCtx.LogAction(tr.LogActionStudio, tr.LogMsgStartingStudio) // Create command builder builder := commands.NewCommandBuilder(commands.NewPlatform()) // Build prisma studio command - // Note: We don't use StreamOutput here because Studio is a long-running process - // and we want to capture the command object to kill it later studioCmd := builder.New("npx", "prisma", "studio"). WithWorkingDir(cwd) // Start async if err := studioCmd.RunAsync(); err != nil { - a.finishCommand() - outputPanel.LogAction(a.Tr.LogActionStudio, a.Tr.ModalMsgFailedStartStudio+" "+err.Error()) - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioError, - a.Tr.ModalMsgFailedStartStudio, + sc.c.FinishCommand() + sc.outputCtx.LogAction(tr.LogActionStudio, tr.ModalMsgFailedStartStudio+" "+err.Error()) + modal := NewMessageModal(sc.g, tr, tr.ModalTitleStudioError, + tr.ModalMsgFailedStartStudio, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) + sc.openModal(modal) return } // Mark studio as running immediately to prevent double-start - a.studioRunning = true - a.studioCmd = studioCmd + sc.studioRunning.Store(true) + sc.studioCmd = studioCmd // Wait a bit to ensure it started, then finish the "starting" command - // The process continues running in background go func() { time.Sleep(2 * time.Second) - a.g.Update(func(g *gocui.Gui) error { - a.finishCommand() // Finish "starting" command + sc.c.OnUIThread(func() error { + sc.c.FinishCommand() // Finish "starting" command - outputPanel.LogAction(a.Tr.LogActionStudioStarted, a.Tr.LogMsgStudioListeningAt) - outputPanel.SetSubtitle(a.Tr.LogMsgStudioListeningAt) + sc.outputCtx.LogAction(tr.LogActionStudioStarted, tr.LogMsgStudioListeningAt) + sc.outputCtx.SetSubtitle(tr.LogMsgStudioListeningAt) // Show info modal - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioStarted, - a.Tr.ModalMsgStudioRunningAt, - a.Tr.ModalMsgPressStopStudio, + modal := NewMessageModal(sc.g, tr, tr.ModalTitleStudioStarted, + tr.ModalMsgStudioRunningAt, + tr.ModalMsgPressStopStudio, ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) - a.OpenModal(modal) + sc.openModal(modal) return nil }) }() diff --git a/pkg/app/text_style.go b/pkg/app/text_style.go index 47066e3..74fabf5 100644 --- a/pkg/app/text_style.go +++ b/pkg/app/text_style.go @@ -1,9 +1,7 @@ package app -import "fmt" - // ============================================================================ -// Text Styling Utilities - ANSI Escape Code Helpers +// Color enum — used by modal styling (MessageModalStyle, ColorToGocuiAttr) // ============================================================================ // Color represents terminal colors @@ -20,211 +18,3 @@ const ( ColorCyan ColorWhite ) - -// colorToFgCode converts Color to ANSI foreground code -func colorToFgCode(c Color) string { - codes := map[Color]string{ - ColorDefault: "", - ColorBlack: "30", - ColorRed: "31", - ColorGreen: "32", - ColorYellow: "33", - ColorBlue: "34", - ColorMagenta: "35", - ColorCyan: "36", - ColorWhite: "37", - } - return codes[c] -} - -// colorToBgCode converts Color to ANSI background code -func colorToBgCode(c Color) string { - codes := map[Color]string{ - ColorDefault: "", - ColorBlack: "40", - ColorRed: "41", - ColorGreen: "42", - ColorYellow: "43", - ColorBlue: "44", - ColorMagenta: "45", - ColorCyan: "46", - ColorWhite: "47", - } - return codes[c] -} - -// Style represents text styling options -type Style struct { - FgColor Color - BgColor Color - Bold bool - Italic bool - Underline bool - Dim bool - Blink bool - Reverse bool - StrikeThru bool -} - -// Stylize applies the given style to text using ANSI escape codes -func Stylize(text string, style Style) string { - if text == "" { - return text - } - - codes := make([]string, 0, 5) - - // Foreground color - if fgCode := colorToFgCode(style.FgColor); fgCode != "" { - codes = append(codes, fgCode) - } - - // Background color - if bgCode := colorToBgCode(style.BgColor); bgCode != "" { - codes = append(codes, bgCode) - } - - // Attributes - if style.Bold { - codes = append(codes, "1") - } - if style.Dim { - codes = append(codes, "2") - } - if style.Italic { - codes = append(codes, "3") - } - if style.Underline { - codes = append(codes, "4") - } - if style.Blink { - codes = append(codes, "5") - } - if style.Reverse { - codes = append(codes, "7") - } - if style.StrikeThru { - codes = append(codes, "9") - } - - // No styling needed - if len(codes) == 0 { - return text - } - - // Build ANSI escape sequence - var escape string - for i, code := range codes { - if i == 0 { - escape = code - } else { - escape += ";" + code - } - } - - return fmt.Sprintf("\x1b[%sm%s\x1b[0m", escape, text) -} - -// ============================================================================ -// Convenience Functions - Color Only -// ============================================================================ - -// Colorize applies a foreground color to text -func Colorize(text string, color Color) string { - return Stylize(text, Style{FgColor: color}) -} - -// Red colors text red -func Red(text string) string { - return Colorize(text, ColorRed) -} - -// Green colors text green -func Green(text string) string { - return Colorize(text, ColorGreen) -} - -// Yellow colors text yellow -func Yellow(text string) string { - return Colorize(text, ColorYellow) -} - -// Blue colors text blue -func Blue(text string) string { - return Colorize(text, ColorBlue) -} - -// Magenta colors text magenta -func Magenta(text string) string { - return Colorize(text, ColorMagenta) -} - -// Cyan colors text cyan -func Cyan(text string) string { - return Colorize(text, ColorCyan) -} - -// White colors text white -func White(text string) string { - return Colorize(text, ColorWhite) -} - -// Black colors text black -func Black(text string) string { - return Colorize(text, ColorBlack) -} - -// Orange colors text orange (using 256-color ANSI code) -func Orange(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[38;5;208m%s\x1b[0m", text) -} - -// Gray colors text gray (using 256-color ANSI code) -func Gray(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[38;5;240m%s\x1b[0m", text) -} - -// ============================================================================ -// Convenience Functions - Attributes Only -// ============================================================================ - -// Bold makes text bold -func Bold(text string) string { - return Stylize(text, Style{Bold: true}) -} - -// Italic makes text italic -func Italic(text string) string { - return Stylize(text, Style{Italic: true}) -} - -// Underline underlines text -func Underline(text string) string { - return Stylize(text, Style{Underline: true}) -} - -// Dim makes text dim/faint -func Dim(text string) string { - return Stylize(text, Style{Dim: true}) -} - -// Blink makes text blink -func Blink(text string) string { - return Stylize(text, Style{Blink: true}) -} - -// Reverse reverses foreground and background colors -func Reverse(text string) string { - return Stylize(text, Style{Reverse: true}) -} - -// StrikeThru strikes through text -func StrikeThru(text string) string { - return Stylize(text, Style{StrikeThru: true}) -} diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go index a3708aa..df35606 100644 --- a/pkg/gui/context/base_context.go +++ b/pkg/gui/context/base_context.go @@ -1,6 +1,7 @@ package context import ( + "github.com/dokadev/lazyprisma/pkg/gui/style" "github.com/dokadev/lazyprisma/pkg/gui/types" "github.com/jesseduffield/gocui" ) @@ -11,6 +12,7 @@ type BaseContext struct { viewName string view *gocui.View focusable bool + focused bool title string // Lifecycle hooks (multiple can attach) @@ -103,3 +105,39 @@ func (self *BaseContext) AddOnFocusLostFn(fn func(types.OnFocusLostOpts)) { self.onFocusLostFns = append(self.onFocusLostFns, fn) } } + +// IsFocused returns whether this context currently has focus. +func (self *BaseContext) IsFocused() bool { + return self.focused +} + +// SetFocused sets the focus state directly (without applying styles). +func (self *BaseContext) SetFocused(f bool) { + self.focused = f +} + +// ApplyFocusStyle sets the view's frame and title colours based on the +// current focus state. Safe to call when the view is nil. +func (self *BaseContext) ApplyFocusStyle() { + if v := self.view; v != nil { + if self.focused { + v.FrameColor = style.FocusedFrameColor + v.TitleColor = style.FocusedTitleColor + } else { + v.FrameColor = style.PrimaryFrameColor + v.TitleColor = style.PrimaryTitleColor + } + } +} + +// OnFocus marks this context as focused and applies the focused style. +func (self *BaseContext) OnFocus() { + self.focused = true + self.ApplyFocusStyle() +} + +// OnBlur marks this context as unfocused and applies the primary style. +func (self *BaseContext) OnBlur() { + self.focused = false + self.ApplyFocusStyle() +} diff --git a/pkg/gui/context/details_context.go b/pkg/gui/context/details_context.go index 51b39d5..8023adb 100644 --- a/pkg/gui/context/details_context.go +++ b/pkg/gui/context/details_context.go @@ -18,20 +18,6 @@ import ( "github.com/jesseduffield/lazycore/pkg/boxlayout" ) -// Frame and title styling constants (matching app.panel.go values) -var ( - detailsDefaultFrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} - - detailsPrimaryFrameColor = gocui.ColorWhite - detailsFocusedFrameColor = gocui.ColorGreen - - detailsPrimaryTitleColor = gocui.ColorWhite | gocui.AttrNone - detailsFocusedTitleColor = gocui.ColorGreen | gocui.AttrBold - - detailsFocusedActiveTabColor = gocui.ColorGreen | gocui.AttrBold - detailsPrimaryActiveTabColor = gocui.ColorGreen | gocui.AttrNone -) - // DetailsContext is the context-based replacement for DetailsPanel. // It displays migration details and action-needed information with tabbed navigation. type DetailsContext struct { @@ -50,9 +36,6 @@ type DetailsContext struct { actionNeededMigrations []prisma.Migration validationResult *prisma.ValidateResult - // UI state - focused bool - // Callback-based decoupling (replaces direct App reference) hasActiveModal func() bool onPanelClick func(viewID string) @@ -119,7 +102,7 @@ func (d *DetailsContext) Draw(dim boxlayout.Dimensions) error { v.Clear() v.Frame = true - v.FrameRunes = detailsDefaultFrameRunes + v.FrameRunes = style.DefaultFrameRunes v.Wrap = true // Enable word wrap for long lines // Set tabs from TabbedTrait @@ -128,21 +111,21 @@ func (d *DetailsContext) Draw(dim boxlayout.Dimensions) error { // Set frame and tab colors based on focus tabs := d.TabbedTrait.GetTabs() - if d.focused { - v.FrameColor = detailsFocusedFrameColor - v.TitleColor = detailsFocusedTitleColor + if d.IsFocused() { + v.FrameColor = style.FocusedFrameColor + v.TitleColor = style.FocusedTitleColor if len(tabs) == 1 { - v.SelFgColor = detailsFocusedTitleColor // Single tab: treat like title + v.SelFgColor = style.FocusedTitleColor // Single tab: treat like title } else { - v.SelFgColor = detailsFocusedActiveTabColor // Multiple tabs: use active tab color + v.SelFgColor = style.FocusedActiveTabColor // Multiple tabs: use active tab color } } else { - v.FrameColor = detailsPrimaryFrameColor - v.TitleColor = detailsPrimaryTitleColor + v.FrameColor = style.PrimaryFrameColor + v.TitleColor = style.PrimaryTitleColor if len(tabs) == 1 { - v.SelFgColor = detailsPrimaryTitleColor // Single tab: treat like title + v.SelFgColor = style.PrimaryTitleColor // Single tab: treat like title } else { - v.SelFgColor = detailsPrimaryActiveTabColor // Multiple tabs: use active tab color + v.SelFgColor = style.PrimaryActiveTabColor // Multiple tabs: use active tab color } } @@ -160,24 +143,6 @@ func (d *DetailsContext) Draw(dim boxlayout.Dimensions) error { return nil } -// OnFocus handles focus gain (implements Panel interface from app package). -func (d *DetailsContext) OnFocus() { - d.focused = true - if v := d.GetView(); v != nil { - v.FrameColor = detailsFocusedFrameColor - v.TitleColor = detailsFocusedTitleColor - } -} - -// OnBlur handles focus loss (implements Panel interface from app package). -func (d *DetailsContext) OnBlur() { - d.focused = false - if v := d.GetView(); v != nil { - v.FrameColor = detailsPrimaryFrameColor - v.TitleColor = detailsPrimaryTitleColor - } -} - // SetContent sets the content text directly. func (d *DetailsContext) SetContent(content string) { d.content = content diff --git a/pkg/gui/context/migrations_context.go b/pkg/gui/context/migrations_context.go index e385f2c..9e065b1 100644 --- a/pkg/gui/context/migrations_context.go +++ b/pkg/gui/context/migrations_context.go @@ -14,33 +14,14 @@ import ( "github.com/jesseduffield/lazycore/pkg/boxlayout" ) -// Frame and title styling constants (matching app.panel.go values) -var ( - migDefaultFrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} - - migPrimaryFrameColor = gocui.ColorWhite - migFocusedFrameColor = gocui.ColorGreen - - migPrimaryTitleColor = gocui.ColorWhite | gocui.AttrNone - migFocusedTitleColor = gocui.ColorGreen | gocui.AttrBold - - // Tab styling - migFocusedActiveTabColor = gocui.ColorGreen | gocui.AttrBold - migPrimaryActiveTabColor = gocui.ColorGreen | gocui.AttrNone - - // List selection colour - migSelectionBgColor = gocui.ColorBlue -) - // MigrationsContext manages the migrations list with tabs (Local, Pending, DB-Only). type MigrationsContext struct { *SimpleContext *ScrollableTrait *TabbedTrait - g *gocui.Gui - tr *i18n.TranslationSet - focused bool + g *gocui.Gui + tr *i18n.TranslationSet // Data category prisma.MigrationCategory // Categorised migrations @@ -153,28 +134,6 @@ func (m *MigrationsContext) IsDBConnected() bool { return m.dbConnected } -// --------------------------------------------------------------------------- -// Focus / Blur -// --------------------------------------------------------------------------- - -// OnFocus handles focus gain (Panel interface compatibility). -func (m *MigrationsContext) OnFocus() { - m.focused = true - if v := m.BaseContext.GetView(); v != nil { - v.FrameColor = migFocusedFrameColor - v.TitleColor = migFocusedTitleColor - } -} - -// OnBlur handles focus loss (Panel interface compatibility). -func (m *MigrationsContext) OnBlur() { - m.focused = false - if v := m.BaseContext.GetView(); v != nil { - v.FrameColor = migPrimaryFrameColor - v.TitleColor = migPrimaryTitleColor - } -} - // --------------------------------------------------------------------------- // Draw // --------------------------------------------------------------------------- @@ -192,7 +151,7 @@ func (m *MigrationsContext) Draw(dim boxlayout.Dimensions) error { v.Clear() v.Frame = true - v.FrameRunes = migDefaultFrameRunes + v.FrameRunes = style.DefaultFrameRunes // Set tabs tabs := m.TabbedTrait.GetTabs() @@ -205,27 +164,27 @@ func (m *MigrationsContext) Draw(dim boxlayout.Dimensions) error { v.Subtitle = "" // Frame and tab colours based on focus - if m.focused { - v.FrameColor = migFocusedFrameColor - v.TitleColor = migFocusedTitleColor + if m.IsFocused() { + v.FrameColor = style.FocusedFrameColor + v.TitleColor = style.FocusedTitleColor if len(tabs) == 1 { - v.SelFgColor = migFocusedTitleColor + v.SelFgColor = style.FocusedTitleColor } else { - v.SelFgColor = migFocusedActiveTabColor + v.SelFgColor = style.FocusedActiveTabColor } } else { - v.FrameColor = migPrimaryFrameColor - v.TitleColor = migPrimaryTitleColor + v.FrameColor = style.PrimaryFrameColor + v.TitleColor = style.PrimaryTitleColor if len(tabs) == 1 { - v.SelFgColor = migPrimaryTitleColor + v.SelFgColor = style.PrimaryTitleColor } else { - v.SelFgColor = migPrimaryActiveTabColor + v.SelFgColor = style.PrimaryActiveTabColor } } // Enable highlight for selection v.Highlight = true - v.SelBgColor = migSelectionBgColor + v.SelBgColor = style.SelectionBgColor // Render items for _, item := range m.items { diff --git a/pkg/gui/context/output_context.go b/pkg/gui/context/output_context.go index 559539d..35e2f6f 100644 --- a/pkg/gui/context/output_context.go +++ b/pkg/gui/context/output_context.go @@ -11,26 +11,14 @@ import ( "github.com/jesseduffield/lazycore/pkg/boxlayout" ) -// Frame and title styling constants (matching app.panel.go values) -var ( - outputDefaultFrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} - - outputPrimaryFrameColor = gocui.ColorWhite - outputFocusedFrameColor = gocui.ColorGreen - - outputPrimaryTitleColor = gocui.ColorWhite | gocui.AttrNone - outputFocusedTitleColor = gocui.ColorGreen | gocui.AttrBold -) - type OutputContext struct { *SimpleContext *ScrollableTrait - g *gocui.Gui - tr *i18n.TranslationSet - content string + g *gocui.Gui + tr *i18n.TranslationSet + content string subtitle string - focused bool autoScrollToBottom bool } @@ -78,9 +66,9 @@ func (o *OutputContext) Draw(dim boxlayout.Dimensions) error { } // Setup view (replicates BasePanel.SetupView) - o.setupView(v) - o.SetView(v) // BaseContext + o.SetView(v) // BaseContext (must be set before setupView for ApplyFocusStyle) o.ScrollableTrait.SetView(v) // ScrollableTrait + o.setupView(v) v.Subtitle = o.subtitle v.Wrap = true @@ -110,33 +98,8 @@ func (o *OutputContext) setupView(v *gocui.View) { v.Clear() v.Frame = true v.Title = o.tr.PanelTitleOutput - v.FrameRunes = outputDefaultFrameRunes - - if o.focused { - v.FrameColor = outputFocusedFrameColor - v.TitleColor = outputFocusedTitleColor - } else { - v.FrameColor = outputPrimaryFrameColor - v.TitleColor = outputPrimaryTitleColor - } -} - -// OnFocus handles focus gain (implements Panel interface from app package) -func (o *OutputContext) OnFocus() { - o.focused = true - if v := o.GetView(); v != nil { - v.FrameColor = outputFocusedFrameColor - v.TitleColor = outputFocusedTitleColor - } -} - -// OnBlur handles focus loss (implements Panel interface from app package) -func (o *OutputContext) OnBlur() { - o.focused = false - if v := o.GetView(); v != nil { - v.FrameColor = outputPrimaryFrameColor - v.TitleColor = outputPrimaryTitleColor - } + v.FrameRunes = style.DefaultFrameRunes + o.ApplyFocusStyle() } // AppendOutput appends text to the output buffer and flags auto-scroll diff --git a/pkg/gui/context/workspace_context.go b/pkg/gui/context/workspace_context.go index 5a31f16..efcca1f 100644 --- a/pkg/gui/context/workspace_context.go +++ b/pkg/gui/context/workspace_context.go @@ -17,24 +17,12 @@ import ( "github.com/jesseduffield/lazycore/pkg/boxlayout" ) -// Frame and title styling constants (matching app.panel.go values) -var ( - wsDefaultFrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} - - wsPrimaryFrameColor = gocui.ColorWhite - wsFocusedFrameColor = gocui.ColorGreen - - wsPrimaryTitleColor = gocui.ColorWhite | gocui.AttrNone - wsFocusedTitleColor = gocui.ColorGreen | gocui.AttrBold -) - type WorkspaceContext struct { *SimpleContext *ScrollableTrait - g *gocui.Gui - tr *i18n.TranslationSet - focused bool + g *gocui.Gui + tr *i18n.TranslationSet nodeVersion string prismaVersion string prismaGlobal bool @@ -99,9 +87,9 @@ func (w *WorkspaceContext) Draw(dim boxlayout.Dimensions) error { } // Setup view (replicates BasePanel.SetupView) - w.setupView(v) - w.SetView(v) // BaseContext + w.SetView(v) // BaseContext (must be set before setupView for ApplyFocusStyle) w.ScrollableTrait.SetView(v) // ScrollableTrait + w.setupView(v) v.Wrap = true // Enable word wrap @@ -155,33 +143,8 @@ func (w *WorkspaceContext) setupView(v *gocui.View) { v.Clear() v.Frame = true v.Title = w.tr.PanelTitleWorkspace - v.FrameRunes = wsDefaultFrameRunes - - if w.focused { - v.FrameColor = wsFocusedFrameColor - v.TitleColor = wsFocusedTitleColor - } else { - v.FrameColor = wsPrimaryFrameColor - v.TitleColor = wsPrimaryTitleColor - } -} - -// OnFocus handles focus gain (implements Panel interface from app package) -func (w *WorkspaceContext) OnFocus() { - w.focused = true - if v := w.GetView(); v != nil { - v.FrameColor = wsFocusedFrameColor - v.TitleColor = wsFocusedTitleColor - } -} - -// OnBlur handles focus loss (implements Panel interface from app package) -func (w *WorkspaceContext) OnBlur() { - w.focused = false - if v := w.GetView(); v != nil { - v.FrameColor = wsPrimaryFrameColor - v.TitleColor = wsPrimaryTitleColor - } + v.FrameRunes = style.DefaultFrameRunes + w.ApplyFocusStyle() } // Refresh reloads all workspace information diff --git a/pkg/gui/style/theme.go b/pkg/gui/style/theme.go new file mode 100644 index 0000000..a474bda --- /dev/null +++ b/pkg/gui/style/theme.go @@ -0,0 +1,23 @@ +package style + +import "github.com/jesseduffield/gocui" + +// DefaultFrameRunes defines the standard rounded-corner frame characters +// used by all panels and contexts. +var DefaultFrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} + +// Frame and title colour constants shared across all panels/contexts. +var ( + PrimaryFrameColor = gocui.ColorWhite + FocusedFrameColor = gocui.ColorGreen + + PrimaryTitleColor = gocui.ColorWhite | gocui.AttrNone + FocusedTitleColor = gocui.ColorGreen | gocui.AttrBold + + // Tab styling + FocusedActiveTabColor = gocui.ColorGreen | gocui.AttrBold + PrimaryActiveTabColor = gocui.ColorGreen | gocui.AttrNone + + // List selection colour + SelectionBgColor = gocui.ColorBlue +) diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index e74dbb5..4a4303e 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -14,9 +14,12 @@ type ConfirmOpts struct { // PromptOpts configures a text-input popup. type PromptOpts struct { - Title string - InitialContent string - HandleConfirm func(string) error + Title string + InitialContent string + HandleConfirm func(string) error + Required bool + Subtitle string + OnValidationFail func(string) } // MenuItem is a single entry in a menu popup. @@ -53,7 +56,7 @@ type IGuiCommon interface { IPopupHandler // LogAction logs a user-visible action to the output panel. - LogAction(action string) + LogAction(action string, detail ...string) // Refresh triggers a data refresh and re-render of all contexts. Refresh() // OnUIThread schedules a function to run on the UI thread. @@ -61,3 +64,18 @@ type IGuiCommon interface { // GetTranslationSet returns the current translation set. GetTranslationSet() *i18n.TranslationSet } + +// IControllerHost is the interface controllers use to interact with the application. +// It extends IGuiCommon with command lifecycle and refresh methods. +type IControllerHost interface { + IGuiCommon + + // Command lifecycle + TryStartCommand(name string) bool + LogCommandBlocked(name string) + FinishCommand() + + // Full refresh with callbacks + RefreshAll(onComplete ...func()) bool + RefreshPanels() +} diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index fe51d65..e49766a 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -17,7 +17,6 @@ type TranslationSet struct { ErrorFailedGetWorkingDirectory string ErrorLoadingLocalMigrations string ErrorNoMigrationsFound string - ErrorFailedAccessMigrationsPanel string ErrorNoDBConnectionDetected string ErrorEnsureDBAccessible string ErrorFailedGetWorkingDir string @@ -311,7 +310,6 @@ func EnglishTranslationSet() *TranslationSet { ErrorFailedGetWorkingDirectory: "Error: Failed to get working directory", ErrorLoadingLocalMigrations: "Error loading local migrations: %v", ErrorNoMigrationsFound: "No migrations found", - ErrorFailedAccessMigrationsPanel: "Failed to access migrations panel.", ErrorNoDBConnectionDetected: "No database connection detected.", ErrorEnsureDBAccessible: "Please ensure your database is running and accessible.", ErrorFailedGetWorkingDir: "Failed to get working directory:", diff --git a/pkg/i18n/translations/de.json b/pkg/i18n/translations/de.json index 710b923..56fc6c9 100644 --- a/pkg/i18n/translations/de.json +++ b/pkg/i18n/translations/de.json @@ -12,7 +12,6 @@ "ErrorFailedGetWorkingDirectory": "Fehler: Arbeitsverzeichnis konnte nicht ermittelt werden", "ErrorLoadingLocalMigrations": "Fehler beim Laden lokaler Migrationen: %v", "ErrorNoMigrationsFound": "Keine Migrationen gefunden", - "ErrorFailedAccessMigrationsPanel": "Zugriff auf das Migrationen-Panel fehlgeschlagen.", "ErrorNoDBConnectionDetected": "Keine Datenbankverbindung erkannt.", "ErrorEnsureDBAccessible": "Bitte stellen Sie sicher, dass Ihre Datenbank läuft und erreichbar ist.", "ErrorFailedGetWorkingDir": "Arbeitsverzeichnis konnte nicht ermittelt werden:",