From d28cbe1d1d8c2633df1c02ad4c26d1430f3316c8 Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 12:30:49 +0900 Subject: [PATCH 01/26] refactor for i18n --- main.go | 29 +- pkg/app/app.go | 16 +- pkg/app/command.go | 366 ++++++++++++------------- pkg/app/confirm_modal.go | 7 +- pkg/app/details.go | 188 ++++++------- pkg/app/input_modal.go | 9 +- pkg/app/list_modal.go | 7 +- pkg/app/message_modal.go | 7 +- pkg/app/migrations.go | 41 +-- pkg/app/output.go | 7 +- pkg/app/statusbar.go | 16 +- pkg/app/test.go | 30 +- pkg/app/workspace.go | 61 +++-- pkg/common/common.go | 19 ++ pkg/i18n/english.go | 579 +++++++++++++++++++++++++++++++++++++++ pkg/i18n/i18n.go | 9 + 16 files changed, 1018 insertions(+), 373 deletions(-) create mode 100644 pkg/common/common.go create mode 100644 pkg/i18n/english.go create mode 100644 pkg/i18n/i18n.go diff --git a/main.go b/main.go index 65bfbe1..d625385 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "os" "github.com/dokadev/lazyprisma/pkg/app" + "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/dokadev/lazyprisma/pkg/prisma" // Register database drivers @@ -17,10 +18,12 @@ const ( ) func main() { + tr := i18n.NewTranslationSet("en") + // Handle version flag if len(os.Args) > 1 { if os.Args[1] == "--version" || os.Args[1] == "-v" { - fmt.Printf("LazyPrisma %s (%s)\n", Version, Developer) + fmt.Printf(tr.VersionOutput, Version, Developer) os.Exit(0) } } @@ -28,15 +31,15 @@ func main() { // Check if current directory is a Prisma workspace cwd, err := os.Getwd() if err != nil { - fmt.Fprintf(os.Stderr, "Error: Failed to get current directory: %v\n", err) + fmt.Fprintf(os.Stderr, tr.ErrorFailedGetCurrentDir, err) os.Exit(1) } if !prisma.IsWorkspace(cwd) { - fmt.Fprintf(os.Stderr, "Error: Current directory is not a Prisma workspace.\n") - fmt.Fprintf(os.Stderr, "\nExpected one of:\n") - fmt.Fprintf(os.Stderr, " - prisma.config.ts (Prisma v7.0+)\n") - fmt.Fprintf(os.Stderr, " - prisma/schema.prisma (Prisma < v7.0)\n") + fmt.Fprintf(os.Stderr, tr.ErrorNotPrismaWorkspace) + fmt.Fprintf(os.Stderr, tr.ErrorExpectedOneOf) + fmt.Fprintf(os.Stderr, tr.ErrorExpectedConfigV7Plus) + fmt.Fprintf(os.Stderr, tr.ErrorExpectedSchemaV7Minus) os.Exit(1) } @@ -48,15 +51,15 @@ func main() { Developer: Developer, }) if err != nil { - fmt.Fprintf(os.Stderr, "Failed to create app: %v\n", err) + fmt.Fprintf(os.Stderr, tr.ErrorFailedCreateApp, err) os.Exit(1) } // 패널 생성 및 등록 - workspace := app.NewWorkspacePanel(tuiApp.GetGui()) - migrations := app.NewMigrationsPanel(tuiApp.GetGui()) - details := app.NewDetailsPanel(tuiApp.GetGui()) - output := app.NewOutputPanel(tuiApp.GetGui()) + workspace := app.NewWorkspacePanel(tuiApp.GetGui(), tr) + migrations := app.NewMigrationsPanel(tuiApp.GetGui(), tr) + details := app.NewDetailsPanel(tuiApp.GetGui(), tr) + output := app.NewOutputPanel(tuiApp.GetGui(), tr) statusbar := app.NewStatusBar(tuiApp.GetGui(), tuiApp) // Connect panels @@ -71,7 +74,7 @@ func main() { // 키바인딩 등록 if err := tuiApp.RegisterKeybindings(); err != nil { - fmt.Fprintf(os.Stderr, "Failed to register keybindings: %v\n", err) + fmt.Fprintf(os.Stderr, tr.ErrorFailedRegisterKeybindings, err) os.Exit(1) } @@ -80,7 +83,7 @@ func main() { // 실행 if err := tuiApp.Run(); err != nil { - fmt.Fprintf(os.Stderr, "App error: %v\n", err) + fmt.Fprintf(os.Stderr, tr.ErrorAppRuntime, err) os.Exit(1) } } diff --git a/pkg/app/app.go b/pkg/app/app.go index a094d42..7732762 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -6,6 +6,8 @@ import ( "time" "github.com/dokadev/lazyprisma/pkg/commands" + "github.com/dokadev/lazyprisma/pkg/common" + "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/jesseduffield/gocui" ) @@ -18,6 +20,8 @@ var spinnerFrames = []rune{'|', '/', '-', '\\'} type App struct { g *gocui.Gui config AppConfig + Common *common.Common + Tr *i18n.TranslationSet panels map[string]Panel focusOrder []string currentFocus int @@ -50,9 +54,13 @@ func NewApp(config AppConfig) (*App, error) { return nil, err } + cmn := common.NewCommon("en") + app := &App{ g: g, config: config, + Common: cmn, + Tr: cmn.Tr, panels: make(map[string]Panel), focusOrder: []string{ViewWorkspace, ViewMigrations, ViewDetails, ViewOutputs}, currentFocus: 0, @@ -171,12 +179,12 @@ func (a *App) logCommandBlocked(commandName string) { runningTask = val.(string) } - message := fmt.Sprintf("Cannot execute '%s'", commandName) + message := fmt.Sprintf(a.Tr.ErrorCannotExecuteCommand, commandName) if runningTask != "" { - message += fmt.Sprintf(" ('%s' is currently running)", runningTask) + message += fmt.Sprintf(a.Tr.ErrorCommandCurrentlyRunning, runningTask) } - outputPanel.LogActionRed("Operation Blocked", message) + outputPanel.LogActionRed(a.Tr.ErrorOperationBlocked, message) } return nil }) @@ -437,7 +445,7 @@ func (a *App) RefreshAll(onComplete ...func()) bool { a.g.Update(func(g *gocui.Gui) error { // Add refresh notification to output panel if outputPanel, ok := a.panels[ViewOutputs].(*OutputPanel); ok { - outputPanel.LogAction("Refresh", "All panels have been refreshed") + outputPanel.LogAction(a.Tr.ActionRefresh, a.Tr.SuccessAllPanelsRefreshed) } // Execute callbacks diff --git a/pkg/app/command.go b/pkg/app/command.go index 1b3e911..49a7be6 100644 --- a/pkg/app/command.go +++ b/pkg/app/command.go @@ -29,8 +29,8 @@ func (a *App) MigrateDeploy() { if !ok { a.finishCommand() a.g.Update(func(g *gocui.Gui) error { - modal := NewMessageModal(a.g, "Error", - "Failed to access migrations panel.", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError, + a.Tr.ErrorFailedAccessMigrationsPanel, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) return nil @@ -42,9 +42,9 @@ func (a *App) MigrateDeploy() { if !migrationsPanel.dbConnected { a.finishCommand() a.g.Update(func(g *gocui.Gui) error { - modal := NewMessageModal(a.g, "Database Connection Required", - "No database connection detected.", - "Please ensure your database is running and accessible.", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleDBConnectionRequired, + a.Tr.ErrorNoDBConnectionDetected, + a.Tr.ErrorEnsureDBAccessible, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) return nil @@ -63,9 +63,9 @@ func (a *App) MigrateDeploy() { if err != nil { a.finishCommand() a.g.Update(func(g *gocui.Gui) error { - outputPanel.LogAction("Migrate Deploy Error", "Failed to get working directory: "+err.Error()) - modal := NewMessageModal(a.g, "Migrate Deploy Error", - "Failed to get working directory:", + outputPanel.LogAction(a.Tr.LogActionMigrateDeployFailed, a.Tr.ErrorFailedGetWorkingDir+" "+err.Error()) + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateDeployError, + a.Tr.ErrorFailedGetWorkingDir, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) @@ -76,7 +76,7 @@ func (a *App) MigrateDeploy() { // Log action start a.g.Update(func(g *gocui.Gui) error { - outputPanel.LogAction("Migrate Deploy", "Running prisma migrate deploy...") + outputPanel.LogAction(a.Tr.LogActionMigrateDeploy, a.Tr.LogMsgRunningMigrateDeploy) return nil }) @@ -111,21 +111,21 @@ func (a *App) MigrateDeploy() { a.finishCommand() // Finish command if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { if exitCode == 0 { - out.LogAction("Migrate Deploy Complete", "Migrations applied successfully") + 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, "Migrate Deploy Successful", - "Migrations applied successfully!", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateDeploySuccess, + a.Tr.ModalMsgMigrationsAppliedSuccess, ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) a.OpenModal(modal) } else { - out.LogAction("Migrate Deploy Failed", fmt.Sprintf("Migrate deploy failed with exit code: %d", exitCode)) + 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, "Migrate Deploy Failed", - fmt.Sprintf("Prisma migrate deploy failed with exit code: %d", exitCode), - "Check output panel for details.", + 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) } @@ -138,9 +138,9 @@ func (a *App) MigrateDeploy() { a.g.Update(func(g *gocui.Gui) error { a.finishCommand() // Finish command if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { - out.LogAction("Migrate Deploy Error", err.Error()) - modal := NewMessageModal(a.g, "Migrate Deploy Error", - "Failed to run prisma migrate deploy:", + 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) @@ -153,9 +153,9 @@ func (a *App) MigrateDeploy() { 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("Migrate Deploy Error", "Failed to start migrate deploy: "+err.Error()) - modal := NewMessageModal(a.g, "Migrate Deploy Error", - "Failed to start migrate deploy:", + 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) @@ -169,8 +169,8 @@ func (a *App) MigrateDeploy() { func (a *App) MigrateDev() { items := []ListModalItem{ { - Label: "Schema diff-based migration", - Description: "Create a migration from changes in Prisma schema, apply it to the database, trigger generators (e.g. Prisma Client)", + Label: a.Tr.ListItemSchemaDiffMigration, + Description: a.Tr.ListItemDescSchemaDiffMigration, OnSelect: func() error { a.CloseModal() a.SchemaDiffMigration() @@ -178,8 +178,8 @@ func (a *App) MigrateDev() { }, }, { - Label: "Manual migration", - Description: "This tool creates manual migrations for database changes that cannot be expressed through Prisma schema diff. It is used to explicitly record and version control database-specific logic such as triggers, functions, and DML operations that cannot be managed at the Prisma schema level.", + Label: a.Tr.ListItemManualMigration, + Description: a.Tr.ListItemDescManualMigration, OnSelect: func() error { a.CloseModal() a.showManualMigrationInput() @@ -188,7 +188,7 @@ func (a *App) MigrateDev() { }, } - modal := NewListModal(a.g, "Migrate Dev", items, + modal := NewListModal(a.g, a.Tr, a.Tr.ModalTitleMigrateDev, items, func() { // Cancel - just close modal a.CloseModal() @@ -216,9 +216,9 @@ func (a *App) executeCreateMigration(migrationName string) { cwd, err := os.Getwd() if err != nil { a.finishCommand() - outputPanel.LogAction("Migration Error", "Failed to get working directory: "+err.Error()) - modal := NewMessageModal(a.g, "Migration Error", - "Failed to get working directory:", + 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) @@ -226,7 +226,7 @@ func (a *App) executeCreateMigration(migrationName string) { } // Log action start - outputPanel.LogAction("Migrate Dev", fmt.Sprintf("Creating migration: %s", migrationName)) + outputPanel.LogAction(a.Tr.LogActionMigrateDev, fmt.Sprintf(a.Tr.LogMsgCreatingMigration, migrationName)) // Create command builder builder := commands.NewCommandBuilder(commands.NewPlatform()) @@ -263,18 +263,18 @@ func (a *App) executeCreateMigration(migrationName string) { if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { if exitCode == 0 { - out.LogAction("Migrate Complete", "Migration created successfully") + out.LogAction(a.Tr.LogActionMigrateComplete, a.Tr.LogMsgMigrationCreatedSuccess) // Show success modal - modal := NewMessageModal(a.g, "Migration Created", - fmt.Sprintf("Migration '%s' created successfully!", migrationName), - "You can find it in the prisma/migrations directory.", + 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("Migrate Failed", fmt.Sprintf("Migration creation failed with exit code: %d", exitCode)) - modal := NewMessageModal(a.g, "Migration Failed", - fmt.Sprintf("Prisma migrate dev failed with exit code: %d", exitCode), - "Check output panel for details.", + 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) } @@ -287,9 +287,9 @@ func (a *App) executeCreateMigration(migrationName string) { a.g.Update(func(g *gocui.Gui) error { a.finishCommand() // Finish command if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { - out.LogAction("Migration Error", err.Error()) - modal := NewMessageModal(a.g, "Migration Error", - "Failed to run prisma migrate dev:", + 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) @@ -301,9 +301,9 @@ func (a *App) executeCreateMigration(migrationName string) { // 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("Migration Error", "Failed to start migrate dev: "+err.Error()) - modal := NewMessageModal(a.g, "Migration Error", - "Failed to start migrate dev:", + 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) @@ -317,8 +317,8 @@ func (a *App) SchemaDiffMigration() { // 2. Check DB connection migrationsPanel, ok := a.panels[ViewMigrations].(*MigrationsPanel) if !ok { - modal := NewMessageModal(a.g, "Error", - "Failed to access migrations panel.", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError, + a.Tr.ErrorFailedAccessMigrationsPanel, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) return @@ -326,9 +326,9 @@ func (a *App) SchemaDiffMigration() { // Check if DB is connected if !migrationsPanel.dbConnected { - modal := NewMessageModal(a.g, "Database Connection Required", - "No database connection detected.", - "Please ensure your database is running and accessible.", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleDBConnectionRequired, + a.Tr.ErrorNoDBConnectionDetected, + a.Tr.ErrorEnsureDBAccessible, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) return @@ -336,9 +336,9 @@ func (a *App) SchemaDiffMigration() { // 3. Check for DB-Only migrations if len(migrationsPanel.category.DBOnly) > 0 { - modal := NewMessageModal(a.g, "DB-Only Migrations Detected", - "Cannot create new migration whilst DB-Only migrations exist.", - "Please resolve DB-Only migrations first.", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleDBOnlyMigrationsDetected, + a.Tr.ModalMsgCannotCreateWithDBOnly, + a.Tr.ModalMsgResolveDBOnlyFirst, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) return @@ -347,9 +347,9 @@ func (a *App) SchemaDiffMigration() { // 4. Check for Checksum Mismatch for _, m := range migrationsPanel.category.Local { if m.ChecksumMismatch { - modal := NewMessageModal(a.g, "Checksum Mismatch Detected", - "Cannot create new migration whilst checksum mismatch exists.", - fmt.Sprintf("Migration '%s' has been modified locally.", m.Name), + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleChecksumMismatchDetected, + a.Tr.ModalMsgCannotCreateWithMismatch, + fmt.Sprintf(a.Tr.ModalMsgMigrationModifiedLocally, m.Name), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) return @@ -361,10 +361,10 @@ func (a *App) SchemaDiffMigration() { // Check if any pending migration is empty for _, m := range migrationsPanel.category.Pending { if m.IsEmpty { - modal := NewMessageModal(a.g, "Empty Pending Migration Detected", - "Cannot create new migration whilst empty pending migrations exist.", - fmt.Sprintf("Migration '%s' is pending and empty.", m.Name), - "Please delete it or add SQL content.", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleEmptyPendingDetected, + a.Tr.ModalMsgCannotCreateWithEmpty, + fmt.Sprintf(a.Tr.ModalMsgMigrationPendingEmpty, m.Name), + a.Tr.ModalMsgDeleteOrAddContent, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) return @@ -372,8 +372,8 @@ func (a *App) SchemaDiffMigration() { } // Show confirmation modal for normal pending migrations - modal := NewConfirmModal(a.g, "Pending Migrations Detected", - "Prisma automatically applies pending migrations before creating new ones. This may cause unintended behaviour in the future. Do you wish to continue?", + modal := NewConfirmModal(a.g, a.Tr, a.Tr.ModalTitlePendingMigrationsDetected, + a.Tr.ModalMsgPendingMigrationsWarning, func() { // Yes - proceed with migration name input a.CloseModal() @@ -394,9 +394,9 @@ func (a *App) SchemaDiffMigration() { if !started { // If refresh failed to start (e.g., another command running), show error - modal := NewMessageModal(a.g, "Operation Blocked", - "Another operation is currently running.", - "Please wait for it to complete.", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleOperationBlocked, + a.Tr.ModalMsgAnotherOperationRunning, + a.Tr.ModalMsgWaitComplete, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) } @@ -407,8 +407,8 @@ func (a *App) createManualMigration(migrationName string) { // Get current working directory cwd, err := os.Getwd() if err != nil { - modal := NewMessageModal(a.g, "Error", - "Failed to get working directory:", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError, + a.Tr.ErrorFailedGetWorkingDir, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) @@ -425,8 +425,8 @@ func (a *App) createManualMigration(migrationName string) { // Create migration folder if err := os.MkdirAll(migrationFolder, 0755); err != nil { - modal := NewMessageModal(a.g, "Error", - "Failed to create migration folder:", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError, + a.Tr.ModalMsgFailedDeleteFolder, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) @@ -438,8 +438,8 @@ 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, "Error", - "Failed to create migration.sql:", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError, + a.Tr.ErrorFailedGetWorkingDir, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) @@ -449,16 +449,16 @@ func (a *App) createManualMigration(migrationName string) { // Success - show result and refresh a.RefreshAll() - modal := NewMessageModal(a.g, "Manual Migration Created", - fmt.Sprintf("Created: %s", folderName), - fmt.Sprintf("Location: %s", migrationFolder), + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrationCreated, + fmt.Sprintf(a.Tr.ModalMsgManualMigrationCreated, folderName), + fmt.Sprintf(a.Tr.ModalMsgManualMigrationLocation, migrationFolder), ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) a.OpenModal(modal) } // showMigrationNameInput shows input modal for migration name func (a *App) showMigrationNameInput() { - modal := NewInputModal(a.g, "Enter migration name", + modal := NewInputModal(a.g, a.Tr, a.Tr.ModalTitleEnterMigrationName, func(input string) { // Replace spaces with underscores migrationName := strings.ReplaceAll(strings.TrimSpace(input), " ", "_") @@ -474,12 +474,12 @@ func (a *App) showMigrationNameInput() { a.CloseModal() }, ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}). - WithSubtitle("Spaces will be replaced with underscores"). + WithSubtitle(a.Tr.ModalMsgSpacesReplaced). WithRequired(true). OnValidationFail(func(reason string) { // Validation failed - show error a.CloseModal() - errorModal := NewMessageModal(a.g, "Validation Failed", + errorModal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleValidationFailed, reason, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(errorModal) @@ -490,7 +490,7 @@ func (a *App) showMigrationNameInput() { // showManualMigrationInput shows input modal for manual migration name func (a *App) showManualMigrationInput() { - modal := NewInputModal(a.g, "Enter migration name", + modal := NewInputModal(a.g, a.Tr, a.Tr.ModalTitleEnterMigrationName, func(input string) { // Replace spaces with underscores migrationName := strings.ReplaceAll(strings.TrimSpace(input), " ", "_") @@ -506,12 +506,12 @@ func (a *App) showManualMigrationInput() { a.CloseModal() }, ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}). - WithSubtitle("Spaces will be replaced with underscores"). + WithSubtitle(a.Tr.ModalMsgSpacesReplaced). WithRequired(true). OnValidationFail(func(reason string) { // Validation failed - show error a.CloseModal() - errorModal := NewMessageModal(a.g, "Validation Failed", + errorModal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleValidationFailed, reason, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(errorModal) @@ -538,9 +538,9 @@ func (a *App) Generate() { cwd, err := os.Getwd() if err != nil { a.finishCommand() - outputPanel.LogAction("Generate Error", "Failed to get working directory: "+err.Error()) - modal := NewMessageModal(a.g, "Generate Error", - "Failed to get working directory:", + 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) @@ -548,7 +548,7 @@ func (a *App) Generate() { } // Log action start - outputPanel.LogAction("Generate", "Running prisma generate...") + outputPanel.LogAction(a.Tr.LogActionGenerate, a.Tr.LogMsgRunningGenerate) // Create command builder builder := commands.NewCommandBuilder(commands.NewPlatform()) @@ -581,15 +581,15 @@ func (a *App) Generate() { if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { if exitCode == 0 { a.finishCommand() // Finish immediately on success - out.LogAction("Generate Complete", "Prisma Client generated successfully") + out.LogAction(a.Tr.LogActionGenerateComplete, a.Tr.LogMsgPrismaClientGeneratedSuccess) // Show success modal - modal := NewMessageModal(a.g, "Generate Successful", - "Prisma Client generated successfully!", + 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("Generate Failed", "Checking schema for errors...") + out.LogAction(a.Tr.LogActionGenerateFailed, a.Tr.LogMsgCheckingSchemaErrors) // Run validate in goroutine to not block UI updates go func() { @@ -602,19 +602,19 @@ func (a *App) Generate() { if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { if err == nil && !validateResult.Valid { // Schema has validation errors - show them - out.LogAction("Schema Validation Failed", fmt.Sprintf("Found %d schema errors", len(validateResult.Errors))) + out.LogAction(a.Tr.LogActionSchemaValidationFailed, fmt.Sprintf(a.Tr.LogMsgFoundSchemaErrors, len(validateResult.Errors))) // Show validation errors in modal - modal := NewMessageModal(a.g, "Schema Validation Failed", - "Generate failed due to schema errors.", + 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("Generate Failed", fmt.Sprintf("Generate failed with exit code: %d", exitCode)) - modal := NewMessageModal(a.g, "Generate Failed", - fmt.Sprintf("Prisma generate failed with exit code: %d", exitCode), - "Schema is valid. Check output panel for details.", + out.LogAction(a.Tr.LogActionGenerateFailed, fmt.Sprintf(a.Tr.LogMsgFoundSchemaErrors, 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) } @@ -634,7 +634,7 @@ func (a *App) Generate() { // 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("Generate Failed", "Checking schema for errors...") + out.LogAction(a.Tr.LogActionGenerateFailed, a.Tr.LogMsgCheckingSchemaErrors) // Run validate in goroutine to not block UI updates go func() { @@ -647,19 +647,19 @@ func (a *App) Generate() { if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { if validateErr == nil && !validateResult.Valid { // Schema has validation errors - show them - out.LogAction("Schema Validation Failed", fmt.Sprintf("Found %d schema errors", len(validateResult.Errors))) + out.LogAction(a.Tr.LogActionSchemaValidationFailed, fmt.Sprintf(a.Tr.LogMsgFoundSchemaErrors, len(validateResult.Errors))) // Show validation errors in modal - modal := NewMessageModal(a.g, "Schema Validation Failed", - "Generate failed due to schema errors.", + 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("Generate Failed", err.Error()) - modal := NewMessageModal(a.g, "Generate Failed", - "Prisma generate failed:", - "Schema is valid. Check output panel for details.", + 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) } @@ -670,9 +670,9 @@ func (a *App) Generate() { } else { // Other error (command couldn't start, etc.) a.finishCommand() // Finish immediately on startup error - out.LogAction("Generate Error", err.Error()) - modal := NewMessageModal(a.g, "Generate Error", - "Failed to run prisma generate:", + out.LogAction(a.Tr.LogActionGenerateError, err.Error()) + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleGenerateError, + a.Tr.ModalMsgFailedRunGenerate, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) @@ -685,9 +685,9 @@ func (a *App) Generate() { // 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("Generate Error", "Failed to start generate: "+err.Error()) - modal := NewMessageModal(a.g, "Generate Error", - "Failed to start generate:", + 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) @@ -699,8 +699,8 @@ func (a *App) MigrateResolve() { // Get migrations panel migrationsPanel, ok := a.panels[ViewMigrations].(*MigrationsPanel) if !ok { - modal := NewMessageModal(a.g, "Error", - "Failed to access migrations panel.", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError, + a.Tr.ErrorFailedAccessMigrationsPanel, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) return @@ -709,8 +709,8 @@ func (a *App) MigrateResolve() { // Get selected migration selectedMigration := migrationsPanel.GetSelectedMigration() if selectedMigration == nil { - modal := NewMessageModal(a.g, "No Migration Selected", - "Please select a migration to resolve.", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleNoMigrationSelected, + a.Tr.ModalMsgSelectMigrationResolve, ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow}) a.OpenModal(modal) return @@ -718,9 +718,9 @@ func (a *App) MigrateResolve() { // Check if migration is failed (only In-Transaction migrations can be resolved) if !selectedMigration.IsFailed { - modal := NewMessageModal(a.g, "Cannot Resolve Migration", - "Only migrations in 'In-Transaction' state can be resolved.", - fmt.Sprintf("Migration '%s' is not in a failed state.", selectedMigration.Name), + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleCannotResolveMigration, + a.Tr.ModalMsgOnlyInTransactionResolve, + fmt.Sprintf(a.Tr.ModalMsgMigrationNotFailed, selectedMigration.Name), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) return @@ -731,8 +731,8 @@ func (a *App) MigrateResolve() { items := []ListModalItem{ { - Label: "Mark as applied", - Description: "Mark this migration as successfully applied to the database. Use this if you have manually fixed the issue and the migration changes are now present in the database.", + Label: a.Tr.ListItemMarkApplied, + Description: a.Tr.ListItemDescMarkApplied, OnSelect: func() error { a.CloseModal() a.executeResolve(migrationName, "applied") @@ -740,8 +740,8 @@ func (a *App) MigrateResolve() { }, }, { - Label: "Mark as rolled back", - Description: "Mark this migration as rolled back (reverted from the database). Use this if you have manually reverted the changes and the migration is no longer applied to the database.", + Label: a.Tr.ListItemMarkRolledBack, + Description: a.Tr.ListItemDescMarkRolledBack, OnSelect: func() error { a.CloseModal() a.executeResolve(migrationName, "rolled-back") @@ -750,7 +750,7 @@ func (a *App) MigrateResolve() { }, } - modal := NewListModal(a.g, "Resolve Migration: "+migrationName, items, + modal := NewListModal(a.g, a.Tr, fmt.Sprintf(a.Tr.ModalTitleResolveMigration, migrationName), items, func() { a.CloseModal() }, ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}) @@ -775,9 +775,9 @@ func (a *App) executeResolve(migrationName string, action string) { cwd, err := os.Getwd() if err != nil { a.finishCommand() - outputPanel.LogAction("Migrate Resolve Error", "Failed to get working directory: "+err.Error()) - modal := NewMessageModal(a.g, "Migrate Resolve Error", - "Failed to get working directory:", + 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) @@ -789,7 +789,7 @@ func (a *App) executeResolve(migrationName string, action string) { if action == "rolled-back" { actionLabel = "rolled back" } - outputPanel.LogAction("Migrate Resolve", fmt.Sprintf("Marking migration as %s: %s", actionLabel, migrationName)) + outputPanel.LogAction(a.Tr.LogActionMigrateResolve, fmt.Sprintf(a.Tr.LogMsgMarkingMigration, actionLabel, migrationName)) // Create command builder builder := commands.NewCommandBuilder(commands.NewPlatform()) @@ -824,17 +824,17 @@ func (a *App) executeResolve(migrationName string, action string) { a.RefreshAll() if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { if exitCode == 0 { - out.LogAction("Migrate Resolve Complete", fmt.Sprintf("Migration marked as %s successfully", actionLabel)) + out.LogAction(a.Tr.LogActionMigrateResolveComplete, fmt.Sprintf(a.Tr.LogMsgMigrationMarked, actionLabel)) // Show success modal - modal := NewMessageModal(a.g, "Migrate Resolve Successful", - fmt.Sprintf("Migration marked as %s successfully!", actionLabel), + 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("Migrate Resolve Failed", fmt.Sprintf("Migrate resolve failed with exit code: %d", exitCode)) - modal := NewMessageModal(a.g, "Migrate Resolve Failed", - fmt.Sprintf("Prisma migrate resolve failed with exit code: %d", exitCode), - "Check output panel for details.", + 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) } @@ -847,9 +847,9 @@ func (a *App) executeResolve(migrationName string, action string) { a.g.Update(func(g *gocui.Gui) error { a.finishCommand() // Finish command if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { - out.LogAction("Migrate Resolve Error", err.Error()) - modal := NewMessageModal(a.g, "Migrate Resolve Error", - "Failed to run prisma migrate resolve:", + 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) @@ -861,9 +861,9 @@ func (a *App) executeResolve(migrationName string, action string) { // 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("Migrate Resolve Error", "Failed to start migrate resolve: "+err.Error()) - modal := NewMessageModal(a.g, "Migrate Resolve Error", - "Failed to start migrate resolve:", + 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) @@ -882,9 +882,9 @@ func (a *App) Studio() { // Stop Studio if a.studioCmd != nil { if err := a.studioCmd.Kill(); err != nil { - outputPanel.LogAction("Studio Error", "Failed to stop Prisma Studio: "+err.Error()) - modal := NewMessageModal(a.g, "Studio Error", - "Failed to stop Prisma Studio:", + outputPanel.LogAction(a.Tr.LogActionStudio, a.Tr.ModalMsgFailedStopStudio+" "+err.Error()) + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioError, + a.Tr.ModalMsgFailedStopStudio, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) @@ -893,7 +893,7 @@ func (a *App) Studio() { a.studioCmd = nil } a.studioRunning = false - outputPanel.LogAction("Studio Stopped", "Prisma Studio has been stopped") + outputPanel.LogAction(a.Tr.LogActionStudioStopped, a.Tr.LogMsgStudioHasStopped) // Clear subtitle outputPanel.SetSubtitle("") @@ -904,8 +904,8 @@ func (a *App) Studio() { return nil }) - modal := NewMessageModal(a.g, "Studio Stopped", - "Prisma Studio has been stopped.", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioStopped, + a.Tr.ModalMsgStudioStopped, ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow}) a.OpenModal(modal) return @@ -922,9 +922,9 @@ func (a *App) Studio() { cwd, err := os.Getwd() if err != nil { a.finishCommand() - outputPanel.LogAction("Studio Error", "Failed to get working directory: "+err.Error()) - modal := NewMessageModal(a.g, "Studio Error", - "Failed to get working directory:", + outputPanel.LogAction(a.Tr.LogActionStudio, a.Tr.ErrorFailedGetWorkingDir+" "+err.Error()) + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioError, + a.Tr.ErrorFailedGetWorkingDir, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) @@ -932,7 +932,7 @@ func (a *App) Studio() { } // Log action start - outputPanel.LogAction("Studio", "Starting Prisma Studio...") + outputPanel.LogAction(a.Tr.LogActionStudio, a.Tr.LogMsgStartingStudio) // Create command builder builder := commands.NewCommandBuilder(commands.NewPlatform()) @@ -946,9 +946,9 @@ func (a *App) Studio() { // Start async if err := studioCmd.RunAsync(); err != nil { a.finishCommand() - outputPanel.LogAction("Studio Error", "Failed to start Prisma Studio: "+err.Error()) - modal := NewMessageModal(a.g, "Studio Error", - "Failed to start Prisma Studio:", + outputPanel.LogAction(a.Tr.LogActionStudio, a.Tr.ModalMsgFailedStartStudio+" "+err.Error()) + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioError, + a.Tr.ModalMsgFailedStartStudio, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) @@ -964,13 +964,13 @@ func (a *App) Studio() { a.studioRunning = true a.studioCmd = studioCmd // Save Command object - outputPanel.LogAction("Studio Started", "Prisma Studio is running at http://localhost:5555") - outputPanel.SetSubtitle("Prisma Studio listening on http://localhost:5555") + outputPanel.LogAction(a.Tr.LogActionStudioStarted, a.Tr.LogMsgStudioListeningAt) + outputPanel.SetSubtitle(a.Tr.LogMsgStudioListeningAt) // Show info modal - modal := NewMessageModal(a.g, "Prisma Studio Started", - "Prisma Studio is running at http://localhost:5555", - "Press 'S' again to stop it.", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioStarted, + a.Tr.ModalMsgStudioRunningAt, + a.Tr.ModalMsgPressStopStudio, ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) a.OpenModal(modal) return nil @@ -989,8 +989,8 @@ func (a *App) DeleteMigration() { // Get selected migration selected := migrationsPanel.GetSelectedMigration() if selected == nil { - modal := NewMessageModal(a.g, "No Selection", - "Please select a migration to delete.", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleNoSelection, + a.Tr.ModalMsgSelectMigrationDelete, ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow}) a.OpenModal(modal) return @@ -998,9 +998,9 @@ func (a *App) DeleteMigration() { // Validate: Can only delete if it exists locally if selected.Path == "" { - modal := NewMessageModal(a.g, "Cannot Delete", - "This migration exists only in the database (DB-Only).", - "Cannot delete a migration that has no local file.", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleCannotDelete, + a.Tr.ModalMsgMigrationDBOnly, + a.Tr.ModalMsgCannotDeleteNoLocalFile, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) return @@ -1009,17 +1009,17 @@ func (a *App) DeleteMigration() { // 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.dbConnected && selected.AppliedAt != nil { - modal := NewMessageModal(a.g, "Cannot Delete", - "This migration has already been applied to the database.", - "Deleting it locally will cause inconsistency.", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleCannotDelete, + a.Tr.ModalMsgMigrationAlreadyApplied, + a.Tr.ModalMsgDeleteLocalInconsistency, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) return } // Confirm deletion - modal := NewConfirmModal(a.g, "Delete Migration", - fmt.Sprintf("Are you sure you want to delete this migration?\n\n%s\n\nThis action cannot be undone.", selected.Name), + modal := NewConfirmModal(a.g, a.Tr, a.Tr.ModalTitleDeleteMigration, + fmt.Sprintf(a.Tr.ModalMsgConfirmDeleteMigration, selected.Name), func() { a.CloseModal() a.executeDeleteMigration(selected.Path, selected.Name) @@ -1036,11 +1036,11 @@ func (a *App) executeDeleteMigration(path, name string) { if err := os.RemoveAll(path); err != nil { outputPanel, _ := a.panels[ViewOutputs].(*OutputPanel) if outputPanel != nil { - outputPanel.LogActionRed("Delete Error", "Failed to delete migration: "+err.Error()) + outputPanel.LogActionRed(a.Tr.ModalTitleDeleteError, fmt.Sprintf(a.Tr.LogMsgFailedDeleteMigration, err.Error())) } - modal := NewMessageModal(a.g, "Delete Error", - "Failed to delete migration folder:", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleDeleteError, + a.Tr.ModalMsgFailedDeleteFolder, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) @@ -1050,14 +1050,14 @@ func (a *App) executeDeleteMigration(path, name string) { // Success outputPanel, _ := a.panels[ViewOutputs].(*OutputPanel) if outputPanel != nil { - outputPanel.LogAction("Deleted", fmt.Sprintf("Migration '%s' deleted", name)) + outputPanel.LogAction(a.Tr.LogActionDeleted, fmt.Sprintf(a.Tr.LogMsgMigrationDeleted, name)) } // Refresh to update list a.RefreshAll() - modal := NewMessageModal(a.g, "Deleted", - "Migration deleted successfully.", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleDeleted, + a.Tr.ModalMsgMigrationDeletedSuccess, ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) a.OpenModal(modal) } @@ -1078,20 +1078,20 @@ func (a *App) CopyMigrationInfo() { items := []ListModalItem{ { - Label: "Copy Name", + Label: a.Tr.ListItemCopyName, Description: selected.Name, OnSelect: func() error { a.CloseModal() - a.copyTextToClipboard(selected.Name, "Migration Name") + a.copyTextToClipboard(selected.Name, a.Tr.CopyLabelMigrationName) return nil }, }, { - Label: "Copy Path", + Label: a.Tr.ListItemCopyPath, Description: selected.Path, OnSelect: func() error { a.CloseModal() - a.copyTextToClipboard(selected.Path, "Migration Path") + a.copyTextToClipboard(selected.Path, a.Tr.CopyLabelMigrationPath) return nil }, }, @@ -1100,17 +1100,17 @@ func (a *App) CopyMigrationInfo() { // If it has a checksum, allow copying it if selected.Checksum != "" { items = append(items, ListModalItem{ - Label: "Copy Checksum", + Label: a.Tr.ListItemCopyChecksum, Description: selected.Checksum, OnSelect: func() error { a.CloseModal() - a.copyTextToClipboard(selected.Checksum, "Checksum") + a.copyTextToClipboard(selected.Checksum, a.Tr.CopyLabelChecksum) return nil }, }) } - modal := NewListModal(a.g, "Copy to Clipboard", items, + modal := NewListModal(a.g, a.Tr, a.Tr.ModalTitleCopyToClipboard, items, func() { a.CloseModal() }, @@ -1121,8 +1121,8 @@ func (a *App) CopyMigrationInfo() { func (a *App) copyTextToClipboard(text, label string) { if err := CopyToClipboard(text); err != nil { - modal := NewMessageModal(a.g, "Clipboard Error", - "Failed to copy to clipboard:", + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleClipboardError, + a.Tr.ModalMsgFailedCopyClipboard, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) @@ -1131,8 +1131,8 @@ func (a *App) copyTextToClipboard(text, label string) { // Show toast/notification via modal for now // Ideally we would have a toast system - modal := NewMessageModal(a.g, "Copied", - fmt.Sprintf("%s copied to clipboard!", label), + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleCopied, + fmt.Sprintf(a.Tr.ModalMsgCopiedToClipboard, label), ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) a.OpenModal(modal) } diff --git a/pkg/app/confirm_modal.go b/pkg/app/confirm_modal.go index 2c579cb..799d0bf 100644 --- a/pkg/app/confirm_modal.go +++ b/pkg/app/confirm_modal.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazycore/pkg/boxlayout" ) @@ -11,6 +12,7 @@ import ( // ConfirmModal displays a confirmation dialog with Yes/No options type ConfirmModal struct { g *gocui.Gui + tr *i18n.TranslationSet title string message string onYes func() @@ -21,9 +23,10 @@ type ConfirmModal struct { } // NewConfirmModal creates a new confirmation modal -func NewConfirmModal(g *gocui.Gui, title string, message string, onYes func(), onNo func()) *ConfirmModal { +func NewConfirmModal(g *gocui.Gui, tr *i18n.TranslationSet, title string, message string, onYes func(), onNo func()) *ConfirmModal { return &ConfirmModal{ g: g, + tr: tr, title: title, message: message, onYes: onYes, @@ -89,7 +92,7 @@ func (m *ConfirmModal) Draw(dim boxlayout.Dimensions) error { v.Frame = true v.FrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} v.Title = " " + m.title + " " - v.Footer = " [Y] Yes [N] No [ESC] Cancel " + v.Footer = m.tr.ModalFooterConfirmYesNo // Apply frame color (border) if set if m.style.BorderColor != ColorDefault { diff --git a/pkg/app/details.go b/pkg/app/details.go index 04747bc..8999132 100644 --- a/pkg/app/details.go +++ b/pkg/app/details.go @@ -10,6 +10,7 @@ import ( "github.com/alecthomas/chroma/v2/formatters" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" + "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/dokadev/lazyprisma/pkg/prisma" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazycore/pkg/boxlayout" @@ -29,15 +30,19 @@ type DetailsPanel struct { validationResult *prisma.ValidateResult // Schema validation result tabOriginY map[string]int // Scroll position per tab + // Translation set (available from construction time) + tr *i18n.TranslationSet + // App reference for modal check (tab click events) app *App } -func NewDetailsPanel(g *gocui.Gui) *DetailsPanel { +func NewDetailsPanel(g *gocui.Gui, tr *i18n.TranslationSet) *DetailsPanel { return &DetailsPanel{ BasePanel: NewBasePanel(ViewDetails, g), - content: "Details\n\nSelect a migration to view details...", - tabs: []string{"Details"}, // Start with Details tab only + tr: tr, + content: tr.DetailsPanelInitialPlaceholder, + tabs: []string{tr.TabDetails}, tabIndex: 0, actionNeededMigrations: []prisma.Migration{}, tabOriginY: make(map[string]int), @@ -83,7 +88,7 @@ func (d *DetailsPanel) Draw(dim boxlayout.Dimensions) error { // Render content based on current tab if d.tabIndex < len(d.tabs) { tabName := d.tabs[d.tabIndex] - if tabName == "Action-Needed" { + if d.app != nil && tabName == d.tr.TabActionNeeded { fmt.Fprint(v, d.buildActionNeededContent()) } else { fmt.Fprint(v, d.content) @@ -131,75 +136,73 @@ func (d *DetailsPanel) buildActionNeededContent() string { totalCount := emptyCount + mismatchCount + validationErrorCount if totalCount == 0 { - return "No action required\n\nAll migrations are in good state and schema is valid." + return d.tr.ActionNeededNoIssuesMessage } var content strings.Builder // Header - content.WriteString(fmt.Sprintf("%s (%d issue", Yellow("⚠ Action Needed"), totalCount)) + content.WriteString(fmt.Sprintf("%s (%d%s", Yellow(d.tr.ActionNeededHeader), totalCount, d.tr.ActionNeededIssueSingular)) if totalCount > 1 { - content.WriteString("s") + content.WriteString(d.tr.ActionNeededIssuePlural) } content.WriteString(")\n\n") // Empty Migrations Section if emptyCount > 0 { content.WriteString(strings.Repeat("━", 40) + "\n") - content.WriteString(fmt.Sprintf("%s (%d)\n", Red("Empty Migrations"), emptyCount)) + content.WriteString(fmt.Sprintf("%s (%d)\n", Red(d.tr.ActionNeededEmptyMigrationsHeader), emptyCount)) content.WriteString(strings.Repeat("━", 40) + "\n\n") - content.WriteString("These migrations have no SQL content.\n\n") + content.WriteString(d.tr.ActionNeededEmptyDescription) - content.WriteString("Affected:\n") + content.WriteString(d.tr.ActionNeededAffectedLabel) for _, mig := range emptyMigrations { _, name := parseMigrationName(mig.Name) content.WriteString(fmt.Sprintf(" • %s\n", Red(name))) } - content.WriteString("\nRecommended Actions:\n") - content.WriteString(" → Add migration.sql manually\n") - content.WriteString(" → Delete empty migration folders\n") - content.WriteString(" → Mark as baseline migration\n\n") + content.WriteString("\n" + d.tr.ActionNeededRecommendedLabel) + content.WriteString(d.tr.ActionNeededAddMigrationSQL) + content.WriteString(d.tr.ActionNeededDeleteEmptyFolders) + content.WriteString(d.tr.ActionNeededMarkAsBaseline) } // Checksum Mismatch Section if mismatchCount > 0 { content.WriteString(strings.Repeat("━", 40) + "\n") - content.WriteString(fmt.Sprintf("%s (%d)\n", Orange("Checksum Mismatch"), mismatchCount)) + content.WriteString(fmt.Sprintf("%s (%d)\n", Orange(d.tr.ActionNeededChecksumMismatchHeader), mismatchCount)) content.WriteString(strings.Repeat("━", 40) + "\n\n") - content.WriteString("Migration content was modified after\n") - content.WriteString("being applied to database.\n\n") + content.WriteString(d.tr.ActionNeededChecksumModifiedDescription) - content.WriteString(Yellow("⚠ WARNING: ")) - content.WriteString("Editing applied migrations\n") - content.WriteString("can cause inconsistencies.\n\n") + content.WriteString(Yellow(d.tr.ActionNeededWarningPrefix)) + content.WriteString(d.tr.ActionNeededEditingInconsistenciesWarning) - content.WriteString("Affected:\n") + content.WriteString(d.tr.ActionNeededAffectedLabel) for _, mig := range mismatchMigrations { _, name := parseMigrationName(mig.Name) content.WriteString(fmt.Sprintf(" • %s\n", Orange(name))) } - content.WriteString("\nRecommended Actions:\n") - content.WriteString(" → Revert local changes\n") - content.WriteString(" → Create new migration instead\n") - content.WriteString(" → Contact team if needed\n\n") + content.WriteString("\n" + d.tr.ActionNeededRecommendedLabel) + content.WriteString(d.tr.ActionNeededRevertLocalChanges) + content.WriteString(d.tr.ActionNeededCreateNewInstead) + content.WriteString(d.tr.ActionNeededContactTeamIfNeeded) } // Schema Validation Section if validationErrorCount > 0 { content.WriteString(strings.Repeat("━", 40) + "\n") - content.WriteString(fmt.Sprintf("%s (%d)\n", Red("Schema Validation Errors"), validationErrorCount)) + content.WriteString(fmt.Sprintf("%s (%d)\n", Red(d.tr.ActionNeededSchemaValidationErrorsHeader), validationErrorCount)) content.WriteString(strings.Repeat("━", 40) + "\n\n") - content.WriteString("Schema validation failed.\n") - content.WriteString("Fix these issues before running migrations.\n\n") + content.WriteString(d.tr.ActionNeededSchemaValidationFailedDesc) + content.WriteString(d.tr.ActionNeededFixBeforeMigration) // Show full validation output (contains detailed error info) if d.validationResult.Output != "" { - content.WriteString(Stylize("Validation Output:", Style{FgColor: ColorYellow, Bold: true}) + "\n") + content.WriteString(Stylize(d.tr.ActionNeededValidationOutputLabel, Style{FgColor: ColorYellow, Bold: true}) + "\n") // Display the full output with proper formatting (preserve all line breaks) outputLines := strings.Split(d.validationResult.Output, "\n") for _, line := range outputLines { @@ -216,10 +219,10 @@ func (d *DetailsPanel) buildActionNeededContent() string { content.WriteString("\n") } - content.WriteString(Stylize("Recommended Actions:", Style{FgColor: ColorYellow, Bold: true}) + "\n") - content.WriteString(" → Fix schema.prisma errors\n") - content.WriteString(" → Check line numbers in output above\n") - content.WriteString(" → Refer to Prisma documentation\n") + content.WriteString(Stylize(d.tr.ActionNeededRecommendedActionsLabel, Style{FgColor: ColorYellow, Bold: true}) + "\n") + content.WriteString(d.tr.ActionNeededFixSchemaErrors) + content.WriteString(d.tr.ActionNeededCheckLineNumbers) + content.WriteString(d.tr.ActionNeededReferPrismaDocumentation) } return content.String() @@ -352,24 +355,24 @@ func (d *DetailsPanel) UpdateFromMigration(migration *prisma.Migration, tabName // Only reset scroll position for Details tab if viewing a different migration if migration != nil && d.currentMigrationName != migration.Name { // Reset Details tab scroll position only - d.tabOriginY["Details"] = 0 + d.tabOriginY[d.tr.TabDetails] = 0 // If currently on Details tab, also update originY - if d.tabIndex < len(d.tabs) && d.tabs[d.tabIndex] == "Details" { + if d.tabIndex < len(d.tabs) && d.tabs[d.tabIndex] == d.tr.TabDetails { d.originY = 0 } d.currentMigrationName = migration.Name } else if migration == nil { // Reset Details tab scroll position only - d.tabOriginY["Details"] = 0 + d.tabOriginY[d.tr.TabDetails] = 0 // If currently on Details tab, also update originY - if d.tabIndex < len(d.tabs) && d.tabs[d.tabIndex] == "Details" { + if d.tabIndex < len(d.tabs) && d.tabs[d.tabIndex] == d.tr.TabDetails { d.originY = 0 } d.currentMigrationName = "" } if migration == nil { - d.content = "Details\n\nSelect a migration to view details..." + d.content = d.tr.DetailsPanelInitialPlaceholder return } @@ -378,32 +381,32 @@ func (d *DetailsPanel) UpdateFromMigration(migration *prisma.Migration, tabName // In-Transaction migrations (highest priority) if migration.IsFailed { timestamp, name := parseMigrationName(migration.Name) - header := fmt.Sprintf("Name: %s\n", Cyan(name)) - header += fmt.Sprintf("Timestamp: %s\n", timestamp) + header := fmt.Sprintf(d.tr.DetailsNameLabel, Cyan(name)) + header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) if migration.Path != "" { - header += fmt.Sprintf("Path: %s\n", getRelativePath(migration.Path)) + header += fmt.Sprintf(d.tr.DetailsPathLabel, getRelativePath(migration.Path)) } - header += fmt.Sprintf("Status: %s\n", Cyan("⚠ In-Transaction")) + header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n", Cyan(d.tr.MigrationStatusInTransaction)) // Show down migration availability if migration.HasDownSQL { - header += fmt.Sprintf("Down Migration: %s\n", Green("✓ Available")) + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", Green(d.tr.DetailsDownMigrationAvailable)) } else { - header += fmt.Sprintf("Down Migration: %s\n", Red("✗ Not available")) + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", Red(d.tr.DetailsDownMigrationNotAvailable)) } // Show started_at if available if migration.StartedAt != nil { - header += fmt.Sprintf("Started At: %s\n", migration.StartedAt.Format("2006-01-02 15:04:05")) + header += fmt.Sprintf(d.tr.DetailsStartedAtLabel+"%s\n", migration.StartedAt.Format("2006-01-02 15:04:05")) } - header += "\n" + Yellow("⚠ WARNING: This migration is stuck in an incomplete state.") - header += "\n" + Yellow("No additional migrations can be applied until this is resolved.") - header += "\n\nPlease resolve this migration manually before proceeding.\n" + header += "\n" + Yellow(d.tr.DetailsInTransactionWarning) + header += "\n" + Yellow(d.tr.DetailsNoAdditionalMigrationsWarning) + header += "\n\n" + d.tr.DetailsResolveManuallyInstruction // Show logs if available if migration.Logs != nil && *migration.Logs != "" { - header += "\nError Logs:\n" + Red(*migration.Logs) + header += "\n" + d.tr.DetailsErrorLogsLabel + "\n" + Red(*migration.Logs) } // Read and show migration.sql content (if Path is available - not DB-Only) @@ -420,7 +423,7 @@ func (d *DetailsPanel) UpdateFromMigration(migration *prisma.Migration, tabName downContent, err := os.ReadFile(downSQLPath) if err == nil { highlightedDownSQL := highlightSQL(string(downContent)) - d.content += "\n\n" + Yellow("Down Migration SQL:") + "\n\n" + highlightedDownSQL + d.content += "\n\n" + Yellow(d.tr.DetailsDownMigrationSQLLabel) + "\n\n" + highlightedDownSQL } } } else { @@ -434,10 +437,10 @@ func (d *DetailsPanel) UpdateFromMigration(migration *prisma.Migration, tabName if tabName == "DB-Only" { timestamp, name := parseMigrationName(migration.Name) - header := fmt.Sprintf("Name: %s\n", Yellow(name)) - header += fmt.Sprintf("Timestamp: %s\n", timestamp) - header += fmt.Sprintf("Status: %s\n\n", Red("✗ DB Only")) - header += "This migration exists in the database but not in local files." + header := fmt.Sprintf(d.tr.DetailsNameLabel, Yellow(name)) + header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) + header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n\n", Red(d.tr.MigrationStatusDBOnly)) + header += d.tr.DetailsDBOnlyDescription d.content = header return } @@ -445,32 +448,32 @@ func (d *DetailsPanel) UpdateFromMigration(migration *prisma.Migration, tabName // Checksum mismatch if migration.ChecksumMismatch { timestamp, name := parseMigrationName(migration.Name) - header := fmt.Sprintf("Name: %s\n", Orange(name)) - header += fmt.Sprintf("Timestamp: %s\n", timestamp) + header := fmt.Sprintf(d.tr.DetailsNameLabel, Orange(name)) + header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) if migration.Path != "" { - header += fmt.Sprintf("Path: %s\n", getRelativePath(migration.Path)) + header += fmt.Sprintf(d.tr.DetailsPathLabel, getRelativePath(migration.Path)) } // Show Applied status with Checksum Mismatch warning - statusLine := fmt.Sprintf("Status: %s", Green("✓ Applied")) + statusLine := fmt.Sprintf(d.tr.DetailsStatusLabel+"%s", Green(d.tr.MigrationStatusApplied)) if migration.AppliedAt != nil { - statusLine += fmt.Sprintf(" (Applied at: %s)", migration.AppliedAt.Format("2006-01-02 15:04:05")) + statusLine += fmt.Sprintf(" (%s)", fmt.Sprintf(d.tr.DetailsAppliedAtLabel, migration.AppliedAt.Format("2006-01-02 15:04:05"))) } - statusLine += fmt.Sprintf(" - %s\n", Orange("⚠ Checksum Mismatch")) + statusLine += fmt.Sprintf(" - %s\n", Orange(d.tr.MigrationStatusChecksumMismatch)) header += statusLine // Show down migration availability if migration.HasDownSQL { - header += fmt.Sprintf("Down Migration: %s\n", Green("✓ Available")) + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", Green(d.tr.DetailsDownMigrationAvailable)) } else { - header += fmt.Sprintf("Down Migration: %s\n", Red("✗ Not available")) + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", Red(d.tr.DetailsDownMigrationNotAvailable)) } - header += "\nThe local migration file has been modified after being applied to the database.\n" - header += "This can cause issues during deployment.\n\n" + header += "\n" + d.tr.DetailsChecksumModifiedDescription + header += d.tr.DetailsChecksumIssuesWarning // Show checksum values (in orange for emphasis) - header += fmt.Sprintf("Local Checksum: %s\n", Orange(migration.Checksum)) - header += fmt.Sprintf("History Checksum: %s\n", Orange(migration.DBChecksum)) + header += fmt.Sprintf(d.tr.DetailsLocalChecksumLabel+"%s\n", Orange(migration.Checksum)) + header += fmt.Sprintf(d.tr.DetailsHistoryChecksumLabel+"%s\n", Orange(migration.DBChecksum)) // Read and show migration.sql content sqlPath := filepath.Join(migration.Path, "migration.sql") @@ -485,7 +488,7 @@ func (d *DetailsPanel) UpdateFromMigration(migration *prisma.Migration, tabName downContent, err := os.ReadFile(downSQLPath) if err == nil { highlightedDownSQL := highlightSQL(string(downContent)) - d.content += "\n\n" + Yellow("Down Migration SQL:") + "\n\n" + highlightedDownSQL + d.content += "\n\n" + Yellow(d.tr.DetailsDownMigrationSQLLabel) + "\n\n" + highlightedDownSQL } } } else { @@ -496,22 +499,22 @@ func (d *DetailsPanel) UpdateFromMigration(migration *prisma.Migration, tabName if migration.IsEmpty { timestamp, name := parseMigrationName(migration.Name) - header := fmt.Sprintf("Name: %s\n", Magenta(name)) - header += fmt.Sprintf("Timestamp: %s\n", timestamp) + header := fmt.Sprintf(d.tr.DetailsNameLabel, Magenta(name)) + header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) if migration.Path != "" { - header += fmt.Sprintf("Path: %s\n", getRelativePath(migration.Path)) + header += fmt.Sprintf(d.tr.DetailsPathLabel, getRelativePath(migration.Path)) } - header += fmt.Sprintf("Status: %s\n", Red("⚠ Empty Migration")) + header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n", Red(d.tr.MigrationStatusEmptyMigration)) // Show down migration availability (even for empty migrations) if migration.HasDownSQL { - header += fmt.Sprintf("Down Migration: %s\n", Green("✓ Available")) + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", Green(d.tr.DetailsDownMigrationAvailable)) } else { - header += fmt.Sprintf("Down Migration: %s\n", Red("✗ Not available")) + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", Red(d.tr.DetailsDownMigrationNotAvailable)) } - header += "\nThis migration folder is empty or missing migration.sql.\n" - header += "This may cause issues during deployment." + header += "\n" + d.tr.DetailsEmptyMigrationDescription + header += d.tr.DetailsEmptyMigrationWarning d.content = header return } @@ -521,8 +524,9 @@ func (d *DetailsPanel) UpdateFromMigration(migration *prisma.Migration, tabName content, err := os.ReadFile(sqlPath) if err != nil { timestamp, name := parseMigrationName(migration.Name) - d.content = fmt.Sprintf("Name: %s\nTimestamp: %s\n\nError reading migration.sql:\n%v", - name, timestamp, err) + d.content = fmt.Sprintf(d.tr.DetailsNameLabel, name) + + fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) + + "\n" + fmt.Sprintf(d.tr.ErrorReadingMigrationSQL, err) return } @@ -530,28 +534,28 @@ func (d *DetailsPanel) UpdateFromMigration(migration *prisma.Migration, tabName timestamp, name := parseMigrationName(migration.Name) var header string if migration.AppliedAt != nil { - header = fmt.Sprintf("Name: %s\n", Green(name)) - header += fmt.Sprintf("Timestamp: %s\n", timestamp) + header = fmt.Sprintf(d.tr.DetailsNameLabel, Green(name)) + header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) if migration.Path != "" { - header += fmt.Sprintf("Path: %s\n", getRelativePath(migration.Path)) + header += fmt.Sprintf(d.tr.DetailsPathLabel, getRelativePath(migration.Path)) } - header += fmt.Sprintf("Status: %s (Applied at: %s)\n", - Green("✓ Applied"), - migration.AppliedAt.Format("2006-01-02 15:04:05")) + header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s (%s)\n", + Green(d.tr.MigrationStatusApplied), + fmt.Sprintf(d.tr.DetailsAppliedAtLabel, migration.AppliedAt.Format("2006-01-02 15:04:05"))) } else { - header = fmt.Sprintf("Name: %s\n", Yellow(name)) - header += fmt.Sprintf("Timestamp: %s\n", timestamp) + header = fmt.Sprintf(d.tr.DetailsNameLabel, Yellow(name)) + header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) if migration.Path != "" { - header += fmt.Sprintf("Path: %s\n", getRelativePath(migration.Path)) + header += fmt.Sprintf(d.tr.DetailsPathLabel, getRelativePath(migration.Path)) } - header += fmt.Sprintf("Status: %s\n", Yellow("⚠ Pending")) + header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n", Yellow(d.tr.MigrationStatusPending)) } // Show down migration availability if migration.HasDownSQL { - header += fmt.Sprintf("Down Migration: %s\n", Green("✓ Available")) + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", Green(d.tr.DetailsDownMigrationAvailable)) } else { - header += fmt.Sprintf("Down Migration: %s\n", Red("✗ Not available")) + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", Red(d.tr.DetailsDownMigrationNotAvailable)) } // Apply syntax highlighting to SQL content @@ -565,7 +569,7 @@ func (d *DetailsPanel) UpdateFromMigration(migration *prisma.Migration, tabName downContent, err := os.ReadFile(downSQLPath) if err == nil { highlightedDownSQL := highlightSQL(string(downContent)) - d.content += "\n\n" + Yellow("Down Migration SQL:") + "\n\n" + highlightedDownSQL + d.content += "\n\n" + Yellow(d.tr.DetailsDownMigrationSQLLabel) + "\n\n" + highlightedDownSQL } } } @@ -653,14 +657,14 @@ func (d *DetailsPanel) LoadActionNeededData() { // updateTabs rebuilds the tabs list based on available data func (d *DetailsPanel) updateTabs() { // Always have Details tab - d.tabs = []string{"Details"} + d.tabs = []string{d.tr.TabDetails} // Add Action-Needed tab if there are migration issues or validation errors hasIssues := len(d.actionNeededMigrations) > 0 hasValidationErrors := d.validationResult != nil && !d.validationResult.Valid if hasIssues || hasValidationErrors { - d.tabs = append(d.tabs, "Action-Needed") + d.tabs = append(d.tabs, d.tr.TabActionNeeded) } // Reset tab index if current tab no longer exists diff --git a/pkg/app/input_modal.go b/pkg/app/input_modal.go index 9edf429..a6a08da 100644 --- a/pkg/app/input_modal.go +++ b/pkg/app/input_modal.go @@ -3,6 +3,7 @@ package app import ( "strings" + "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazycore/pkg/boxlayout" ) @@ -10,6 +11,7 @@ import ( // InputModal displays an input field for user text entry type InputModal struct { g *gocui.Gui + tr *i18n.TranslationSet title string // Used as placeholder subtitle string // Optional subtitle footer string // Key bindings description @@ -23,11 +25,12 @@ type InputModal struct { } // NewInputModal creates a new input modal -func NewInputModal(g *gocui.Gui, title string, onSubmit func(string), onCancel func()) *InputModal { +func NewInputModal(g *gocui.Gui, tr *i18n.TranslationSet, title string, onSubmit func(string), onCancel func()) *InputModal { return &InputModal{ g: g, + tr: tr, title: title, - footer: " [Enter] Submit [ESC] Cancel ", + footer: tr.ModalFooterInputSubmitCancel, style: MessageModalStyle{}, // Default style onSubmit: onSubmit, onCancel: onCancel, @@ -156,7 +159,7 @@ func (m *InputModal) HandleKey(key any, mod gocui.Modifier) error { // Validate if required if m.required && input == "" { if m.onValidationFail != nil { - m.onValidationFail("Input is required") + m.onValidationFail(m.tr.ModalMsgInputRequired) } return nil // Don't submit } diff --git a/pkg/app/list_modal.go b/pkg/app/list_modal.go index 900972e..1bfa962 100644 --- a/pkg/app/list_modal.go +++ b/pkg/app/list_modal.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazycore/pkg/boxlayout" ) @@ -18,6 +19,7 @@ type ListModalItem struct { // ListModal displays a list of items with descriptions type ListModal struct { g *gocui.Gui + tr *i18n.TranslationSet title string items []ListModalItem selectedIdx int @@ -29,9 +31,10 @@ type ListModal struct { } // NewListModal creates a new list modal -func NewListModal(g *gocui.Gui, title string, items []ListModalItem, onCancel func()) *ListModal { +func NewListModal(g *gocui.Gui, tr *i18n.TranslationSet, title string, items []ListModalItem, onCancel func()) *ListModal { return &ListModal{ g: g, + tr: tr, title: title, items: items, selectedIdx: 0, @@ -189,7 +192,7 @@ func (m *ListModal) drawDescView(x0, y0, x1, y1 int) error { v.Frame = true v.FrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} v.Title = "" - v.Footer = " [↑/↓] Navigate [Enter] Select [ESC] Cancel " + v.Footer = m.tr.ModalFooterListNavigate // Apply frame color (border) if set if m.style.BorderColor != ColorDefault { diff --git a/pkg/app/message_modal.go b/pkg/app/message_modal.go index 971c01c..9dc9bfc 100644 --- a/pkg/app/message_modal.go +++ b/pkg/app/message_modal.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazycore/pkg/boxlayout" ) @@ -17,6 +18,7 @@ type MessageModalStyle struct { // MessageModal displays a message with title and content type MessageModal struct { g *gocui.Gui + tr *i18n.TranslationSet title string contentLines []string // Original content lines lines []string // Wrapped content lines @@ -26,9 +28,10 @@ type MessageModal struct { } // NewMessageModal creates a new message modal -func NewMessageModal(g *gocui.Gui, title string, lines ...string) *MessageModal { +func NewMessageModal(g *gocui.Gui, tr *i18n.TranslationSet, title string, lines ...string) *MessageModal { return &MessageModal{ g: g, + tr: tr, title: title, contentLines: lines, style: MessageModalStyle{}, // Default style @@ -93,7 +96,7 @@ func (m *MessageModal) Draw(dim boxlayout.Dimensions) error { v.Frame = true v.FrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} v.Title = " " + m.title + " " - v.Footer = " [Enter/q/ESC] Close " + v.Footer = m.tr.ModalFooterMessageClose // Apply frame color (border) if set if m.style.BorderColor != ColorDefault { diff --git a/pkg/app/migrations.go b/pkg/app/migrations.go index e6fdbcb..67b2fa6 100644 --- a/pkg/app/migrations.go +++ b/pkg/app/migrations.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/dokadev/lazyprisma/pkg/database" + "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/dokadev/lazyprisma/pkg/prisma" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazycore/pkg/boxlayout" @@ -30,17 +31,21 @@ type MigrationsPanel struct { // Details panel reference detailsPanel *DetailsPanel + // Translation set (available from construction time) + tr *i18n.TranslationSet + // App reference for modal check app *App } -func NewMigrationsPanel(g *gocui.Gui) *MigrationsPanel { +func NewMigrationsPanel(g *gocui.Gui, tr *i18n.TranslationSet) *MigrationsPanel { panel := &MigrationsPanel{ BasePanel: NewBasePanel(ViewMigrations, g), items: []string{}, tabs: []string{}, tabIndex: 0, selected: 0, + tr: tr, tabSelected: make(map[string]int), tabOriginY: make(map[string]int), } @@ -51,16 +56,16 @@ func NewMigrationsPanel(g *gocui.Gui) *MigrationsPanel { func (m *MigrationsPanel) loadMigrations() { cwd, err := os.Getwd() if err != nil { - m.items = []string{"Error: Failed to get working directory"} - m.tabs = []string{"Local"} + m.items = []string{m.tr.ErrorFailedGetWorkingDirectory} + m.tabs = []string{m.tr.TabLocal} return } // Get local migrations localMigrations, err := prisma.GetLocalMigrations(cwd) if err != nil { - m.items = []string{fmt.Sprintf("Error loading local migrations: %v", err)} - m.tabs = []string{"Local"} + m.items = []string{fmt.Sprintf(m.tr.ErrorLoadingLocalMigrations, err)} + m.tabs = []string{m.tr.TabLocal} return } @@ -96,12 +101,12 @@ func (m *MigrationsPanel) loadMigrations() { m.category = prisma.CompareMigrations(localMigrations, dbMigrations) // Build tabs based on available data - m.tabs = []string{"Local"} + m.tabs = []string{m.tr.TabLocal} if len(m.category.Pending) > 0 { - m.tabs = append(m.tabs, "Pending") + m.tabs = append(m.tabs, m.tr.TabPending) } if len(m.category.DBOnly) > 0 { - m.tabs = append(m.tabs, "DB-Only") + m.tabs = append(m.tabs, m.tr.TabDBOnly) } // Store table existence info for display @@ -113,7 +118,7 @@ func (m *MigrationsPanel) loadMigrations() { Pending: []prisma.Migration{}, DBOnly: []prisma.Migration{}, } - m.tabs = []string{"Local"} + m.tabs = []string{m.tr.TabLocal} m.tableExists = false } @@ -132,16 +137,16 @@ func (m *MigrationsPanel) loadItemsForCurrentTab() { var migrations []prisma.Migration switch tabName { - case "Local": + case m.tr.TabLocal: migrations = m.category.Local - case "Pending": + case m.tr.TabPending: migrations = m.category.Pending - case "DB-Only": + case m.tr.TabDBOnly: migrations = m.category.DBOnly } if len(migrations) == 0 { - m.items = []string{"No migrations found"} + m.items = []string{m.tr.ErrorNoMigrationsFound} return } @@ -275,12 +280,12 @@ func (m *MigrationsPanel) Draw(dim boxlayout.Dimensions) error { // buildFooter builds the footer text (selection info in "n of n" format) func (m *MigrationsPanel) buildFooter() string { // Don't show footer if no valid items - if len(m.items) == 0 || (len(m.items) == 1 && m.items[0] == "No migrations found") { + if len(m.items) == 0 || (len(m.items) == 1 && m.items[0] == m.tr.ErrorNoMigrationsFound) { return "" } // Show selection info: "2 of 5" - return fmt.Sprintf("%d of %d", m.selected+1, len(m.items)) + return fmt.Sprintf(m.tr.MigrationsFooterFormat, m.selected+1, len(m.items)) } func (m *MigrationsPanel) SelectNext() { @@ -439,11 +444,11 @@ func (m *MigrationsPanel) GetSelectedMigration() *prisma.Migration { var migrations []prisma.Migration switch tabName { - case "Local": + case m.tr.TabLocal: migrations = m.category.Local - case "Pending": + case m.tr.TabPending: migrations = m.category.Pending - case "DB-Only": + case m.tr.TabDBOnly: migrations = m.category.DBOnly } diff --git a/pkg/app/output.go b/pkg/app/output.go index 60484d3..7aa512f 100644 --- a/pkg/app/output.go +++ b/pkg/app/output.go @@ -4,21 +4,24 @@ import ( "fmt" "time" + "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazycore/pkg/boxlayout" ) type OutputPanel struct { BasePanel + tr *i18n.TranslationSet content string subtitle string // Custom subtitle originY int // Scroll position autoScrollToBottom bool // Auto-scroll to bottom on next draw } -func NewOutputPanel(g *gocui.Gui) *OutputPanel { +func NewOutputPanel(g *gocui.Gui, tr *i18n.TranslationSet) *OutputPanel { return &OutputPanel{ BasePanel: NewBasePanel(ViewOutputs, g), + tr: tr, content: "", // Start with empty output } } @@ -29,7 +32,7 @@ func (o *OutputPanel) Draw(dim boxlayout.Dimensions) error { return err } - o.SetupView(v, "Output") + o.SetupView(v, o.tr.PanelTitleOutput) o.v = v v.Subtitle = o.subtitle // Set subtitle v.Wrap = true // Enable word wrap diff --git a/pkg/app/statusbar.go b/pkg/app/statusbar.go index 13b11fe..6033ae9 100644 --- a/pkg/app/statusbar.go +++ b/pkg/app/statusbar.go @@ -60,7 +60,7 @@ func (s *StatusBar) Draw(dim boxlayout.Dimensions) error { // Show Studio status if running if s.app.studioRunning { - studioMsg := "[Studio: ON]" + studioMsg := s.app.Tr.StatusStudioOn leftContent += fmt.Sprintf("%s ", Green(studioMsg)) visibleLen += len(studioMsg) + 1 } @@ -77,13 +77,13 @@ func (s *StatusBar) Draw(dim boxlayout.Dimensions) error { visibleLen += vLen + 1 } - appendKey("r", "efresh") - appendKey("d", "ev") - appendKey("D", "eploy") - appendKey("g", "enerate") - appendKey("s", "resolve") - appendKey("S", "tudio") - appendKey("c", "opy") + appendKey("r", s.app.Tr.KeyHintRefresh) + appendKey("d", s.app.Tr.KeyHintDev) + appendKey("D", s.app.Tr.KeyHintDeploy) + appendKey("g", s.app.Tr.KeyHintGenerate) + appendKey("s", s.app.Tr.KeyHintResolve) + appendKey("S", s.app.Tr.KeyHintStudio) + appendKey("c", s.app.Tr.KeyHintCopy) // Right content (Metadata) // Style right content (e.g., in blue or default) diff --git a/pkg/app/test.go b/pkg/app/test.go index be65f78..b2f0f7d 100644 --- a/pkg/app/test.go +++ b/pkg/app/test.go @@ -14,7 +14,7 @@ func (a *App) TestModal() { // Get current working directory cwd, err := os.Getwd() if err != nil { - modal := NewMessageModal(a.g, "Error", + modal := NewMessageModal(a.g, a.Tr,"Error", "Failed to get working directory:", err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) @@ -25,7 +25,7 @@ func (a *App) TestModal() { // Run validation result, err := prisma.Validate(cwd) if err != nil { - modal := NewMessageModal(a.g, "Validation Error", + modal := NewMessageModal(a.g, a.Tr,"Validation Error", "Failed to run validation:", err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) @@ -36,7 +36,7 @@ func (a *App) TestModal() { // Show result if result.Valid { // Validation passed - modal := NewMessageModal(a.g, "Schema Validation Passed", + modal := NewMessageModal(a.g, a.Tr,"Schema Validation Passed", "Your Prisma schema is valid!", ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}) a.OpenModal(modal) @@ -53,7 +53,7 @@ func (a *App) TestModal() { lines = append(lines, styledOutput) } - modal := NewMessageModal(a.g, "Schema Validation Failed", lines...). + modal := NewMessageModal(a.g, a.Tr,"Schema Validation Failed", lines...). WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) } @@ -61,13 +61,13 @@ func (a *App) TestModal() { // TestInputModal opens a test input modal (temporary for testing) func (a *App) TestInputModal() { - modal := NewInputModal(a.g, "Enter migration name", + modal := NewInputModal(a.g, a.Tr,"Enter migration name", func(input string) { // Close input modal a.CloseModal() // Show result in message modal - resultModal := NewMessageModal(a.g, "Input Received", + resultModal := NewMessageModal(a.g, a.Tr,"Input Received", "You entered:", input, ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) @@ -83,7 +83,7 @@ func (a *App) TestInputModal() { // Close input modal and show error modal a.CloseModal() - errorModal := NewMessageModal(a.g, "Validation Failed", + errorModal := NewMessageModal(a.g, a.Tr,"Validation Failed", reason, ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(errorModal) @@ -100,7 +100,7 @@ func (a *App) TestListModal() { Description: "Create a new migration file.\n\nThis will:\n• Generate a new migration file in prisma/migrations\n• Include timestamp in the filename\n• Prompt for migration name", OnSelect: func() error { a.CloseModal() - resultModal := NewMessageModal(a.g, "Action Selected", + resultModal := NewMessageModal(a.g, a.Tr,"Action Selected", "You selected: Create Migration", ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) a.OpenModal(resultModal) @@ -112,7 +112,7 @@ func (a *App) TestListModal() { Description: "Apply pending migrations to the database.\n\nThis will:\n• Execute all pending migrations in order\n• Update _prisma_migrations table\n• May modify database schema", OnSelect: func() error { a.CloseModal() - resultModal := NewMessageModal(a.g, "Action Selected", + resultModal := NewMessageModal(a.g, a.Tr,"Action Selected", "You selected: Run Migrations", ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) a.OpenModal(resultModal) @@ -124,7 +124,7 @@ func (a *App) TestListModal() { Description: "Reset the database to a clean state.\n\nWARNING: This will:\n• Drop all tables and data\n• Re-run all migrations from scratch\n• Cannot be undone", OnSelect: func() error { a.CloseModal() - resultModal := NewMessageModal(a.g, "Action Selected", + resultModal := NewMessageModal(a.g, a.Tr,"Action Selected", "You selected: Reset Database", ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(resultModal) @@ -136,7 +136,7 @@ func (a *App) TestListModal() { Description: "Validate the Prisma schema file.\n\nThis will:\n• Check for syntax errors\n• Verify model relationships\n• Validate field types\n• Report any issues", OnSelect: func() error { a.CloseModal() - resultModal := NewMessageModal(a.g, "Action Selected", + resultModal := NewMessageModal(a.g, a.Tr,"Action Selected", "You selected: Validate Schema", ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) a.OpenModal(resultModal) @@ -145,7 +145,7 @@ func (a *App) TestListModal() { }, } - modal := NewListModal(a.g, "Select Action", items, + modal := NewListModal(a.g, a.Tr,"Select Action", items, func() { // Cancel - just close modal a.CloseModal() @@ -157,12 +157,12 @@ func (a *App) TestListModal() { // TestConfirmModal opens a test confirm modal (temporary for testing) func (a *App) TestConfirmModal() { - modal := NewConfirmModal(a.g, "Confirm Action", + modal := NewConfirmModal(a.g, a.Tr,"Confirm Action", "Are you sure you want to proceed with this action? This cannot be undone.", func() { // Yes callback - close confirm modal and show result a.CloseModal() - resultModal := NewMessageModal(a.g, "Confirmed", + resultModal := NewMessageModal(a.g, a.Tr,"Confirmed", "You clicked Yes!", ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) a.OpenModal(resultModal) @@ -170,7 +170,7 @@ func (a *App) TestConfirmModal() { func() { // No callback - close confirm modal and show result a.CloseModal() - resultModal := NewMessageModal(a.g, "Cancelled", + resultModal := NewMessageModal(a.g, a.Tr,"Cancelled", "You clicked No!", ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(resultModal) diff --git a/pkg/app/workspace.go b/pkg/app/workspace.go index 0b5a7a2..9248a68 100644 --- a/pkg/app/workspace.go +++ b/pkg/app/workspace.go @@ -9,6 +9,7 @@ import ( "github.com/dokadev/lazyprisma/pkg/database" _ "github.com/dokadev/lazyprisma/pkg/database/drivers" // Register database drivers "github.com/dokadev/lazyprisma/pkg/git" + "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/dokadev/lazyprisma/pkg/node" "github.com/dokadev/lazyprisma/pkg/prisma" "github.com/jesseduffield/gocui" @@ -17,6 +18,7 @@ import ( type WorkspacePanel struct { BasePanel + tr *i18n.TranslationSet nodeVersion string prismaVersion string prismaGlobal bool @@ -35,9 +37,10 @@ type WorkspacePanel struct { originY int // Scroll position } -func NewWorkspacePanel(g *gocui.Gui) *WorkspacePanel { +func NewWorkspacePanel(g *gocui.Gui, tr *i18n.TranslationSet) *WorkspacePanel { wp := &WorkspacePanel{ BasePanel: NewBasePanel(ViewWorkspace, g), + tr: tr, showMasked: true, // Default to masked } wp.loadVersionInfo() @@ -51,7 +54,7 @@ func (w *WorkspacePanel) loadVersionInfo() { if nodeVer, err := node.GetVersion(); err == nil { w.nodeVersion = nodeVer.Version } else { - w.nodeVersion = "Not found" + w.nodeVersion = w.tr.WorkspaceVersionNotFound } // Prisma version @@ -59,7 +62,7 @@ func (w *WorkspacePanel) loadVersionInfo() { w.prismaVersion = prismaVer.Version w.prismaGlobal = prismaVer.IsGlobal } else { - w.prismaVersion = "Not found" + w.prismaVersion = w.tr.WorkspaceVersionNotFound w.prismaGlobal = false } @@ -95,19 +98,19 @@ func (w *WorkspacePanel) buildDatabaseLines() []string { // Build provider line with status var providerLine string if w.dbConnected { - statusStyled := Stylize("✓ Connected", Style{FgColor: ColorGreen, Bold: true}) - providerLine = fmt.Sprintf("Provider: %s %s", providerName, statusStyled) + statusStyled := Stylize(w.tr.WorkspaceConnected, Style{FgColor: ColorGreen, Bold: true}) + providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled) } else if w.dbError != "" { if w.isConfigurationError() { - statusStyled := Stylize("✗ Not configured", Style{FgColor: ColorRed, Bold: true}) - providerLine = fmt.Sprintf("Provider: %s %s", providerName, statusStyled) + statusStyled := Stylize(w.tr.WorkspaceNotConfigured, Style{FgColor: ColorRed, Bold: true}) + providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled) } else { - statusStyled := Stylize("✗ Disconnected", Style{FgColor: ColorRed, Bold: true}) - providerLine = fmt.Sprintf("Provider: %s %s", providerName, statusStyled) + statusStyled := Stylize(w.tr.WorkspaceDisconnected, Style{FgColor: ColorRed, Bold: true}) + providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled) } } else { - statusStyled := Stylize("✗ Disconnected", Style{FgColor: ColorRed, Bold: true}) - providerLine = fmt.Sprintf("Provider: %s %s", providerName, statusStyled) + statusStyled := Stylize(w.tr.WorkspaceDisconnected, Style{FgColor: ColorRed, Bold: true}) + providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled) } lines = append(lines, providerLine) @@ -120,26 +123,26 @@ func (w *WorkspacePanel) buildDatabaseLines() []string { // Add hardcoded warning if applicable if w.isHardcoded { - lines = append(lines, fmt.Sprintf("%s %s", displayURL, Red("(Hard coded)"))) + lines = append(lines, fmt.Sprintf("%s %s", displayURL, Red(w.tr.WorkspaceHardcodedIndicator))) } else { lines = append(lines, displayURL) } } else if w.dbError != "" && w.isConfigurationError() { // Only show error in URL field if it's a configuration issue // Apply styling: bold+red env var name, red "not configured" - if w.envVarName != "" && strings.Contains(w.dbError, " not configured") { - styledError := Stylize(w.envVarName, Style{FgColor: ColorRed, Bold: true}) + Red(" not configured") + if w.envVarName != "" && strings.Contains(w.dbError, w.tr.WorkspaceNotConfiguredSuffix) { + styledError := Stylize(w.envVarName, Style{FgColor: ColorRed, Bold: true}) + Red(w.tr.WorkspaceNotConfiguredSuffix) lines = append(lines, styledError) } else { lines = append(lines, Red(w.dbError)) } } else { - lines = append(lines, "Not set") + lines = append(lines, w.tr.WorkspaceNotSet) } // Show detailed error message if disconnected (not configuration error) if !w.dbConnected && w.dbError != "" && !w.isConfigurationError() { - lines = append(lines, Red(fmt.Sprintf("Error: %s", w.dbError))) + lines = append(lines, Red(fmt.Sprintf(w.tr.WorkspaceErrorFormat, w.dbError))) } return lines @@ -180,7 +183,7 @@ func (w *WorkspacePanel) loadDatabaseInfo() { cwd, err := os.Getwd() if err != nil { - w.dbError = "Error getting working directory" + w.dbError = w.tr.WorkspaceErrorGetWorkingDirectory return } @@ -200,13 +203,13 @@ func (w *WorkspacePanel) loadDatabaseInfo() { // Categorize error message for better user understanding errMsg := err.Error() if strings.Contains(errMsg, "not found") { - w.dbError = "Schema file not found" + w.dbError = w.tr.WorkspaceErrorSchemaNotFound } else if strings.Contains(errMsg, "incomplete") { // Store plain text, styling will be applied in buildDatabaseLines() if w.envVarName != "" { - w.dbError = w.envVarName + " not configured" + w.dbError = w.envVarName + w.tr.WorkspaceNotConfiguredSuffix } else { - w.dbError = "DATABASE_URL not configured" + w.dbError = w.tr.WorkspaceDatabaseURLNotConfigured } } else { w.dbError = errMsg @@ -224,9 +227,9 @@ func (w *WorkspacePanel) loadDatabaseInfo() { // Try to connect to database if ds.URL == "" { if w.envVarName != "" { - w.dbError = w.envVarName + " not configured" + w.dbError = w.envVarName + w.tr.WorkspaceNotConfiguredSuffix } else { - w.dbError = "No DATABASE_URL" + w.dbError = w.tr.WorkspaceNoDatabaseURL } return } @@ -255,7 +258,7 @@ func (w *WorkspacePanel) Draw(dim boxlayout.Dimensions) error { return err } - w.SetupView(v, "Workspace") + w.SetupView(v, w.tr.PanelTitleWorkspace) w.v = v v.Wrap = true // Enable word wrap @@ -265,9 +268,9 @@ func (w *WorkspacePanel) Draw(dim boxlayout.Dimensions) error { // Node and Prisma version on one line nodeVersionStyled := Stylize(w.nodeVersion, Style{FgColor: ColorYellow, Bold: true}) prismaVersionStyled := Stylize(w.prismaVersion, Style{FgColor: ColorYellow, Bold: true}) - versionLine := fmt.Sprintf("Node: %s | Prisma: %s", nodeVersionStyled, prismaVersionStyled) + versionLine := fmt.Sprintf(w.tr.WorkspaceVersionLine, nodeVersionStyled, prismaVersionStyled) if w.prismaGlobal { - versionLine += " " + Orange("(Global)") + versionLine += " " + Orange(w.tr.WorkspacePrismaGlobalIndicator) } lines = append(lines, versionLine) @@ -275,17 +278,17 @@ func (w *WorkspacePanel) Draw(dim boxlayout.Dimensions) error { lines = append(lines, "") if w.isGitRepo { // Git line with optional schema modified indicator - gitLine := fmt.Sprintf("Git: %s", w.gitRepoName) + gitLine := fmt.Sprintf(w.tr.WorkspaceGitLine, w.gitRepoName) if w.schemaModified { - gitLine += " " + Orange("(schema modified)") + gitLine += " " + Orange(w.tr.WorkspaceSchemaModifiedIndicator) } lines = append(lines, gitLine) // Branch on separate line branchStyled := Stylize(w.gitBranch, Style{FgColor: ColorYellow, Bold: true}) - lines = append(lines, fmt.Sprintf("(%s)", branchStyled)) + lines = append(lines, fmt.Sprintf(w.tr.WorkspaceBranchFormat, branchStyled)) } else { - lines = append(lines, "Git: Not a git repository") + lines = append(lines, w.tr.WorkspaceNotGitRepository) } lines = append(lines, "") diff --git a/pkg/common/common.go b/pkg/common/common.go new file mode 100644 index 0000000..f85d530 --- /dev/null +++ b/pkg/common/common.go @@ -0,0 +1,19 @@ +package common + +import ( + "github.com/dokadev/lazyprisma/pkg/i18n" +) + +// Common provides shared dependencies used across the application. +// All components that need access to translations or configuration +// should receive a *Common reference. +type Common struct { + Tr *i18n.TranslationSet +} + +// NewCommon creates a new Common instance with the given language. +func NewCommon(language string) *Common { + return &Common{ + Tr: i18n.NewTranslationSet(language), + } +} diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go new file mode 100644 index 0000000..3c4bf37 --- /dev/null +++ b/pkg/i18n/english.go @@ -0,0 +1,579 @@ +package i18n + +type TranslationSet struct { + // Panel Titles + PanelTitleOutput string + PanelTitleWorkspace string + PanelTitleDetails string + + // Tab Labels + TabLocal string + TabPending string + TabDBOnly string + TabDetails string + TabActionNeeded string + + // Error Messages (general) + ErrorFailedGetWorkingDirectory string + ErrorLoadingLocalMigrations string + ErrorNoMigrationsFound string + ErrorFailedAccessMigrationsPanel string + ErrorNoDBConnectionDetected string + ErrorEnsureDBAccessible string + ErrorFailedGetWorkingDir string + ErrorCannotExecuteCommand string + ErrorCommandCurrentlyRunning string + ErrorOperationBlocked string + + // Modal Titles + ModalTitleError string + ModalTitleDBConnectionRequired string + ModalTitleMigrationError string + ModalTitleMigrationCreated string + ModalTitleMigrationFailed string + ModalTitleMigrateDeploySuccess string + ModalTitleMigrateDeployFailed string + ModalTitleMigrateDeployError string + ModalTitleGenerateSuccess string + ModalTitleGenerateFailed string + ModalTitleGenerateError string + ModalTitleSchemaValidationFailed string + ModalTitleNoMigrationSelected string + ModalTitleCannotResolveMigration string + ModalTitleMigrateResolveSuccess string + ModalTitleMigrateResolveFailed string + ModalTitleMigrateResolveError string + ModalTitleStudioError string + ModalTitleStudioStopped string + ModalTitleStudioStarted string + ModalTitleNoSelection string + ModalTitleCannotDelete string + ModalTitleDeleteError string + ModalTitleDeleted string + ModalTitleClipboardError string + ModalTitleCopied string + ModalTitlePendingMigrationsDetected string + ModalTitleDBOnlyMigrationsDetected string + ModalTitleChecksumMismatchDetected string + ModalTitleEmptyPendingDetected string + ModalTitleOperationBlocked string + ModalTitleDeleteMigration string + ModalTitleValidationFailed string + ModalTitleMigrateDev string + ModalTitleResolveMigration string + ModalTitleCopyToClipboard string + ModalTitleEnterMigrationName string + + // Modal Messages + ModalMsgMigrationCreatedSuccess string + ModalMsgMigrationCreatedDetail string + ModalMsgMigrationFailedWithCode string + ModalMsgCheckOutputPanel string + ModalMsgMigrationsAppliedSuccess string + ModalMsgMigrateDeployFailedWithCode string + ModalMsgFailedRunMigrateDeploy string + ModalMsgFailedStartMigrateDeploy string + ModalMsgPrismaClientGenerated string + ModalMsgGenerateFailedSchemaErrors string + ModalMsgGenerateFailedWithCode string + ModalMsgSchemaValidCheckOutput string + ModalMsgFailedRunGenerate string + ModalMsgFailedStartGenerate string + ModalMsgSelectMigrationResolve string + ModalMsgOnlyInTransactionResolve string + ModalMsgMigrationNotFailed string + ModalMsgMigrationMarkedSuccess string + ModalMsgMigrateResolveFailedWithCode string + ModalMsgFailedRunMigrateResolve string + ModalMsgFailedStartMigrateResolve string + ModalMsgFailedStopStudio string + ModalMsgStudioStopped string + ModalMsgFailedStartStudio string + ModalMsgStudioRunningAt string + ModalMsgPressStopStudio string + ModalMsgSelectMigrationDelete string + ModalMsgMigrationDBOnly string + ModalMsgCannotDeleteNoLocalFile string + ModalMsgMigrationAlreadyApplied string + ModalMsgDeleteLocalInconsistency string + ModalMsgFailedDeleteFolder string + ModalMsgMigrationDeletedSuccess string + ModalMsgFailedCopyClipboard string + ModalMsgCopiedToClipboard string + ModalMsgPendingMigrationsWarning string + ModalMsgCannotCreateWithDBOnly string + ModalMsgResolveDBOnlyFirst string + ModalMsgCannotCreateWithMismatch string + ModalMsgMigrationModifiedLocally string + ModalMsgCannotCreateWithEmpty string + ModalMsgMigrationPendingEmpty string + ModalMsgDeleteOrAddContent string + ModalMsgAnotherOperationRunning string + ModalMsgWaitComplete string + ModalMsgConfirmDeleteMigration string + ModalMsgSpacesReplaced string + ModalMsgInputRequired string + ModalMsgManualMigrationCreated string + ModalMsgManualMigrationLocation string + CopyLabelMigrationName string + CopyLabelMigrationPath string + CopyLabelChecksum string + + // Modal Footers + ModalFooterInputSubmitCancel string + ModalFooterListNavigate string + ModalFooterMessageClose string + ModalFooterConfirmYesNo string + + // Status Bar + StatusStudioOn string + KeyHintRefresh string + KeyHintDev string + KeyHintDeploy string + KeyHintGenerate string + KeyHintResolve string + KeyHintStudio string + KeyHintCopy string + + // Log Actions + LogActionMigrateDeploy string + LogMsgRunningMigrateDeploy string + LogActionMigrateDeployComplete string + LogMsgMigrationsAppliedSuccess string + LogActionMigrateDeployFailed string + LogMsgMigrateDeployFailedCode string + LogActionMigrateResolve string + LogMsgMarkingMigration string + LogActionMigrateResolveComplete string + LogMsgMigrationMarked string + LogActionMigrateResolveFailed string + LogMsgMigrateResolveFailedCode string + LogActionMigrateResolveError string + LogActionGenerate string + LogMsgRunningGenerate string + LogActionGenerateComplete string + LogMsgPrismaClientGeneratedSuccess string + LogActionGenerateFailed string + LogMsgCheckingSchemaErrors string + LogActionSchemaValidationFailed string + LogMsgFoundSchemaErrors string + LogActionGenerateError string + LogActionStudio string + LogMsgStartingStudio string + LogActionStudioStarted string + LogMsgStudioListeningAt string + LogActionStudioStopped string + LogMsgStudioHasStopped string + LogActionMigrateDev string + LogMsgCreatingMigration string + LogActionMigrateComplete string + LogMsgMigrationCreatedSuccess string + LogActionMigrateFailed string + LogMsgMigrationCreationFailedCode string + LogActionMigrationError string + LogMsgFailedDeleteMigration string + LogActionDeleted string + LogMsgMigrationDeleted string + SuccessAllPanelsRefreshed string + ActionRefresh string + + // List Modal Items + ListItemSchemaDiffMigration string + ListItemDescSchemaDiffMigration string + ListItemManualMigration string + ListItemDescManualMigration string + ListItemMarkApplied string + ListItemDescMarkApplied string + ListItemMarkRolledBack string + ListItemDescMarkRolledBack string + ListItemCopyName string + ListItemCopyPath string + ListItemCopyChecksum string + + // Details Panel - Migration Status + MigrationStatusInTransaction string + MigrationStatusDBOnly string + MigrationStatusChecksumMismatch string + MigrationStatusApplied string + MigrationStatusEmptyMigration string + MigrationStatusPending string + + // Details Panel - Labels & Descriptions + DetailsPanelInitialPlaceholder string + DetailsNameLabel string + DetailsTimestampLabel string + DetailsPathLabel string + DetailsStatusLabel string + DetailsAppliedAtLabel string + DetailsDownMigrationLabel string + DetailsDownMigrationAvailable string + DetailsDownMigrationNotAvailable string + DetailsStartedAtLabel string + DetailsInTransactionWarning string + DetailsNoAdditionalMigrationsWarning string + DetailsResolveManuallyInstruction string + DetailsErrorLogsLabel string + DetailsDBOnlyDescription string + DetailsChecksumModifiedDescription string + DetailsChecksumIssuesWarning string + DetailsLocalChecksumLabel string + DetailsHistoryChecksumLabel string + DetailsEmptyMigrationDescription string + DetailsEmptyMigrationWarning string + DetailsDownMigrationSQLLabel string + DetailsTimestampNA string + ErrorReadingMigrationSQL string + + // Details Panel - Action Needed + ActionNeededNoIssuesMessage string + ActionNeededHeader string + ActionNeededIssueSingular string + ActionNeededIssuePlural string + ActionNeededEmptyMigrationsHeader string + ActionNeededEmptyDescription string + ActionNeededAffectedLabel string + ActionNeededRecommendedLabel string + ActionNeededAddMigrationSQL string + ActionNeededDeleteEmptyFolders string + ActionNeededMarkAsBaseline string + ActionNeededChecksumMismatchHeader string + ActionNeededChecksumModifiedDescription string + ActionNeededWarningPrefix string + ActionNeededEditingInconsistenciesWarning string + ActionNeededRevertLocalChanges string + ActionNeededCreateNewInstead string + ActionNeededContactTeamIfNeeded string + ActionNeededSchemaValidationErrorsHeader string + ActionNeededSchemaValidationFailedDesc string + ActionNeededFixBeforeMigration string + ActionNeededValidationOutputLabel string + ActionNeededRecommendedActionsLabel string + ActionNeededFixSchemaErrors string + ActionNeededCheckLineNumbers string + ActionNeededReferPrismaDocumentation string + + // Workspace Panel + WorkspaceVersionLine string + WorkspacePrismaGlobalIndicator string + WorkspaceGitLine string + WorkspaceSchemaModifiedIndicator string + WorkspaceBranchFormat string + WorkspaceNotGitRepository string + WorkspaceConnected string + WorkspaceNotConfigured string + WorkspaceDisconnected string + WorkspaceProviderLine string + WorkspaceHardcodedIndicator string + WorkspaceNotSet string + WorkspaceErrorFormat string + WorkspaceErrorGetWorkingDirectory string + WorkspaceErrorSchemaNotFound string + WorkspaceNotConfiguredSuffix string + WorkspaceDatabaseURLNotConfigured string + WorkspaceNoDatabaseURL string + WorkspaceVersionNotFound string + + // Migrations Panel + MigrationsFooterFormat string + + // main.go strings + VersionOutput string + ErrorFailedGetCurrentDir string + ErrorNotPrismaWorkspace string + ErrorExpectedOneOf string + ErrorExpectedConfigV7Plus string + ErrorExpectedSchemaV7Minus string + ErrorFailedCreateApp string + ErrorFailedRegisterKeybindings string + ErrorAppRuntime string +} + +func EnglishTranslationSet() *TranslationSet { + return &TranslationSet{ + // Panel Titles + PanelTitleOutput: "Output", + PanelTitleWorkspace: "Workspace", + PanelTitleDetails: "Details", + + // Tab Labels + TabLocal: "Local", + TabPending: "Pending", + TabDBOnly: "DB-Only", + TabDetails: "Details", + TabActionNeeded: "Action-Needed", + + // Error Messages (general) + 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:", + ErrorCannotExecuteCommand: "Cannot execute '%s'", + ErrorCommandCurrentlyRunning: "' is currently running'", + ErrorOperationBlocked: "Operation Blocked", + + // Modal Titles + ModalTitleError: "Error", + ModalTitleDBConnectionRequired: "Database Connection Required", + ModalTitleMigrationError: "Migration Error", + ModalTitleMigrationCreated: "Migration Created", + ModalTitleMigrationFailed: "Migration Failed", + ModalTitleMigrateDeploySuccess: "Migrate Deploy Successful", + ModalTitleMigrateDeployFailed: "Migrate Deploy Failed", + ModalTitleMigrateDeployError: "Migrate Deploy Error", + ModalTitleGenerateSuccess: "Generate Successful", + ModalTitleGenerateFailed: "Generate Failed", + ModalTitleGenerateError: "Generate Error", + ModalTitleSchemaValidationFailed: "Schema Validation Failed", + ModalTitleNoMigrationSelected: "No Migration Selected", + ModalTitleCannotResolveMigration: "Cannot Resolve Migration", + ModalTitleMigrateResolveSuccess: "Migrate Resolve Successful", + ModalTitleMigrateResolveFailed: "Migrate Resolve Failed", + ModalTitleMigrateResolveError: "Migrate Resolve Error", + ModalTitleStudioError: "Studio Error", + ModalTitleStudioStopped: "Studio Stopped", + ModalTitleStudioStarted: "Prisma Studio Started", + ModalTitleNoSelection: "No Selection", + ModalTitleCannotDelete: "Cannot Delete", + ModalTitleDeleteError: "Delete Error", + ModalTitleDeleted: "Deleted", + ModalTitleClipboardError: "Clipboard Error", + ModalTitleCopied: "Copied", + ModalTitlePendingMigrationsDetected: "Pending Migrations Detected", + ModalTitleDBOnlyMigrationsDetected: "DB-Only Migrations Detected", + ModalTitleChecksumMismatchDetected: "Checksum Mismatch Detected", + ModalTitleEmptyPendingDetected: "Empty Pending Migration Detected", + ModalTitleOperationBlocked: "Operation Blocked", + ModalTitleDeleteMigration: "Delete Migration", + ModalTitleValidationFailed: "Validation Failed", + ModalTitleMigrateDev: "Migrate Dev", + ModalTitleResolveMigration: "Resolve Migration: %s", + ModalTitleCopyToClipboard: "Copy to Clipboard", + ModalTitleEnterMigrationName: "Enter migration name", + + // Modal Messages + ModalMsgMigrationCreatedSuccess: "Migration '%s' created successfully!", + ModalMsgMigrationCreatedDetail: "You can find it in the prisma/migrations directory.", + ModalMsgMigrationFailedWithCode: "Prisma migrate dev failed with exit code: %d", + ModalMsgCheckOutputPanel: "Check output panel for details.", + ModalMsgMigrationsAppliedSuccess: "Migrations applied successfully!", + ModalMsgMigrateDeployFailedWithCode: "Prisma migrate deploy failed with exit code: %d", + ModalMsgFailedRunMigrateDeploy: "Failed to run prisma migrate deploy:", + ModalMsgFailedStartMigrateDeploy: "Failed to start migrate deploy:", + ModalMsgPrismaClientGenerated: "Prisma Client generated successfully!", + ModalMsgGenerateFailedSchemaErrors: "Generate failed due to schema errors.", + ModalMsgGenerateFailedWithCode: "Prisma generate failed with exit code: %d", + ModalMsgSchemaValidCheckOutput: "Schema is valid. Check output panel for details.", + ModalMsgFailedRunGenerate: "Failed to run prisma generate:", + ModalMsgFailedStartGenerate: "Failed to start generate:", + ModalMsgSelectMigrationResolve: "Please select a migration to resolve.", + ModalMsgOnlyInTransactionResolve: "Only migrations in 'In-Transaction' state can be resolved.", + ModalMsgMigrationNotFailed: "Migration '%s' is not in a failed state.", + ModalMsgMigrationMarkedSuccess: "Migration marked as %s successfully!", + ModalMsgMigrateResolveFailedWithCode: "Prisma migrate resolve failed with exit code: %d", + ModalMsgFailedRunMigrateResolve: "Failed to run prisma migrate resolve:", + ModalMsgFailedStartMigrateResolve: "Failed to start migrate resolve:", + ModalMsgFailedStopStudio: "Failed to stop Prisma Studio:", + ModalMsgStudioStopped: "Prisma Studio has been stopped.", + ModalMsgFailedStartStudio: "Failed to start Prisma Studio:", + ModalMsgStudioRunningAt: "Prisma Studio is running at http://localhost:5555", + ModalMsgPressStopStudio: "Press 'S' again to stop it.", + ModalMsgSelectMigrationDelete: "Please select a migration to delete.", + ModalMsgMigrationDBOnly: "This migration exists only in the database (DB-Only).", + ModalMsgCannotDeleteNoLocalFile: "Cannot delete a migration that has no local file.", + ModalMsgMigrationAlreadyApplied: "This migration has already been applied to the database.", + ModalMsgDeleteLocalInconsistency: "Deleting it locally will cause inconsistency.", + ModalMsgFailedDeleteFolder: "Failed to delete migration folder:", + ModalMsgMigrationDeletedSuccess: "Migration deleted successfully.", + ModalMsgFailedCopyClipboard: "Failed to copy to clipboard:", + ModalMsgCopiedToClipboard: "%s copied to clipboard!", + ModalMsgPendingMigrationsWarning: "Prisma automatically applies pending migrations before creating new ones. This may cause unintended behaviour in the future. Do you wish to continue?", + ModalMsgCannotCreateWithDBOnly: "Cannot create new migration whilst DB-Only migrations exist.", + ModalMsgResolveDBOnlyFirst: "Please resolve DB-Only migrations first.", + ModalMsgCannotCreateWithMismatch: "Cannot create new migration whilst checksum mismatch exists.", + ModalMsgMigrationModifiedLocally: "Migration '%s' has been modified locally.", + ModalMsgCannotCreateWithEmpty: "Cannot create new migration whilst empty pending migrations exist.", + ModalMsgMigrationPendingEmpty: "Migration '%s' is pending and empty.", + ModalMsgDeleteOrAddContent: "Please delete it or add SQL content.", + ModalMsgAnotherOperationRunning: "Another operation is currently running.", + ModalMsgWaitComplete: "Please wait for it to complete.", + ModalMsgConfirmDeleteMigration: "Are you sure you want to delete this migration?\n\n%s\n\nThis action cannot be undone.", + ModalMsgSpacesReplaced: "Spaces will be replaced with underscores", + ModalMsgInputRequired: "Input is required", + ModalMsgManualMigrationCreated: "Created: %s", + ModalMsgManualMigrationLocation: "Location: %s", + CopyLabelMigrationName: "Migration Name", + CopyLabelMigrationPath: "Migration Path", + CopyLabelChecksum: "Checksum", + + // Modal Footers + ModalFooterInputSubmitCancel: "[Enter] Submit [ESC] Cancel", + ModalFooterListNavigate: "[↑/↓] Navigate [Enter] Select [ESC] Cancel", + ModalFooterMessageClose: " [Enter/q/ESC] Close ", + ModalFooterConfirmYesNo: " [Y] Yes [N] No [ESC] Cancel ", + + // Status Bar + StatusStudioOn: "[Studio: ON]", + KeyHintRefresh: "efresh", + KeyHintDev: "ev", + KeyHintDeploy: "eploy", + KeyHintGenerate: "enerate", + KeyHintResolve: "resolve", + KeyHintStudio: "tudio", + KeyHintCopy: "opy", + + // Log Actions + LogActionMigrateDeploy: "Migrate Deploy", + LogMsgRunningMigrateDeploy: "Running prisma migrate deploy...", + LogActionMigrateDeployComplete: "Migrate Deploy Complete", + LogMsgMigrationsAppliedSuccess: "Migrations applied successfully", + LogActionMigrateDeployFailed: "Migrate Deploy Failed", + LogMsgMigrateDeployFailedCode: "Migrate deploy failed with exit code: %d", + LogActionMigrateResolve: "Migrate Resolve", + LogMsgMarkingMigration: "Marking migration as %s: %s", + LogActionMigrateResolveComplete: "Migrate Resolve Complete", + LogMsgMigrationMarked: "Migration marked as %s successfully", + LogActionMigrateResolveFailed: "Migrate Resolve Failed", + LogMsgMigrateResolveFailedCode: "Migrate resolve failed with exit code: %d", + LogActionMigrateResolveError: "Migrate Resolve Error", + LogActionGenerate: "Generate", + LogMsgRunningGenerate: "Running prisma generate...", + LogActionGenerateComplete: "Generate Complete", + LogMsgPrismaClientGeneratedSuccess: "Prisma Client generated successfully", + LogActionGenerateFailed: "Generate Failed", + LogMsgCheckingSchemaErrors: "Checking schema for errors...", + LogActionSchemaValidationFailed: "Schema Validation Failed", + LogMsgFoundSchemaErrors: "Found %d schema errors", + LogActionGenerateError: "Generate Error", + LogActionStudio: "Studio", + LogMsgStartingStudio: "Starting Prisma Studio...", + LogActionStudioStarted: "Studio Started", + LogMsgStudioListeningAt: "Prisma Studio is running at http://localhost:5555", + LogActionStudioStopped: "Studio Stopped", + LogMsgStudioHasStopped: "Prisma Studio has been stopped", + LogActionMigrateDev: "Migrate Dev", + LogMsgCreatingMigration: "Creating migration: %s", + LogActionMigrateComplete: "Migrate Complete", + LogMsgMigrationCreatedSuccess: "Migration created successfully", + LogActionMigrateFailed: "Migrate Failed", + LogMsgMigrationCreationFailedCode: "Migration creation failed with exit code: %d", + LogActionMigrationError: "Migration Error", + LogMsgFailedDeleteMigration: "Failed to delete migration: %s", + LogActionDeleted: "Deleted", + LogMsgMigrationDeleted: "Migration '%s' deleted", + SuccessAllPanelsRefreshed: "All panels have been refreshed", + ActionRefresh: "Refresh", + + // List Modal Items + ListItemSchemaDiffMigration: "Schema diff-based migration", + ListItemDescSchemaDiffMigration: "Create a migration from changes in Prisma schema, apply it to the database, trigger generators (e.g. Prisma Client)", + ListItemManualMigration: "Manual migration", + ListItemDescManualMigration: "This tool creates manual migrations for database changes that cannot be expressed through Prisma schema diff. It is used to explicitly record and version control database-specific logic such as triggers, functions, and DML operations that cannot be managed at the Prisma schema level.", + ListItemMarkApplied: "Mark as applied", + ListItemDescMarkApplied: "Mark this migration as successfully applied to the database. Use this if you have manually fixed the issue and the migration changes are now present in the database.", + ListItemMarkRolledBack: "Mark as rolled back", + ListItemDescMarkRolledBack: "Mark this migration as rolled back (reverted from the database). Use this if you have manually reverted the changes and the migration is no longer applied to the database.", + ListItemCopyName: "Copy Name", + ListItemCopyPath: "Copy Path", + ListItemCopyChecksum: "Copy Checksum", + + // Details Panel - Migration Status + MigrationStatusInTransaction: "⚠ In-Transaction", + MigrationStatusDBOnly: "✗ DB Only", + MigrationStatusChecksumMismatch: "⚠ Checksum Mismatch", + MigrationStatusApplied: "✓ Applied", + MigrationStatusEmptyMigration: "⚠ Empty Migration", + MigrationStatusPending: "⚠ Pending", + + // Details Panel - Labels & Descriptions + DetailsPanelInitialPlaceholder: "Details\n\nSelect a migration to view details...", + DetailsNameLabel: "Name: %s\n", + DetailsTimestampLabel: "Timestamp: %s\n", + DetailsPathLabel: "Path: %s\n", + DetailsStatusLabel: "Status: ", + DetailsAppliedAtLabel: "Applied at: %s", + DetailsDownMigrationLabel: "Down Migration: ", + DetailsDownMigrationAvailable: "✓ Available", + DetailsDownMigrationNotAvailable: "✗ Not available", + DetailsStartedAtLabel: "Started At: ", + DetailsInTransactionWarning: "⚠ WARNING: This migration is stuck in an incomplete state.", + DetailsNoAdditionalMigrationsWarning: "No additional migrations can be applied until this is resolved.", + DetailsResolveManuallyInstruction: "Please resolve this migration manually before proceeding.\n", + DetailsErrorLogsLabel: "Error Logs:", + DetailsDBOnlyDescription: "This migration exists in the database but not in local files.", + DetailsChecksumModifiedDescription: "The local migration file has been modified after being applied to the database.\n", + DetailsChecksumIssuesWarning: "This can cause issues during deployment.\n\n", + DetailsLocalChecksumLabel: "Local Checksum: ", + DetailsHistoryChecksumLabel: "History Checksum: ", + DetailsEmptyMigrationDescription: "This migration folder is empty or missing migration.sql.\n", + DetailsEmptyMigrationWarning: "This may cause issues during deployment.", + DetailsDownMigrationSQLLabel: "Down Migration SQL:", + DetailsTimestampNA: "N/A", + ErrorReadingMigrationSQL: "Error reading migration.sql:\n%v", + + // Details Panel - Action Needed + ActionNeededNoIssuesMessage: "No action required\n\nAll migrations are in good state and schema is valid.", + ActionNeededHeader: "⚠ Action Needed", + ActionNeededIssueSingular: " issue", + ActionNeededIssuePlural: "s", + ActionNeededEmptyMigrationsHeader: "Empty Migrations", + ActionNeededEmptyDescription: "These migrations have no SQL content.\n\n", + ActionNeededAffectedLabel: "Affected:\n", + ActionNeededRecommendedLabel: "Recommended Actions:\n", + ActionNeededAddMigrationSQL: " → Add migration.sql manually\n", + ActionNeededDeleteEmptyFolders: " → Delete empty migration folders\n", + ActionNeededMarkAsBaseline: " → Mark as baseline migration\n\n", + ActionNeededChecksumMismatchHeader: "Checksum Mismatch", + ActionNeededChecksumModifiedDescription: "Migration content was modified after\nbeing applied to database.\n\n", + ActionNeededWarningPrefix: "⚠ WARNING: ", + ActionNeededEditingInconsistenciesWarning: "Editing applied migrations\ncan cause inconsistencies.\n\n", + ActionNeededRevertLocalChanges: " → Revert local changes\n", + ActionNeededCreateNewInstead: " → Create new migration instead\n", + ActionNeededContactTeamIfNeeded: " → Contact team if needed\n\n", + ActionNeededSchemaValidationErrorsHeader: "Schema Validation Errors", + ActionNeededSchemaValidationFailedDesc: "Schema validation failed.\n", + ActionNeededFixBeforeMigration: "Fix these issues before running migrations.\n\n", + ActionNeededValidationOutputLabel: "Validation Output:", + ActionNeededRecommendedActionsLabel: "Recommended Actions:", + ActionNeededFixSchemaErrors: " → Fix schema.prisma errors\n", + ActionNeededCheckLineNumbers: " → Check line numbers in output above\n", + ActionNeededReferPrismaDocumentation: " → Refer to Prisma documentation\n", + + // Workspace Panel + WorkspaceVersionLine: "Node: %s | Prisma: %s", + WorkspacePrismaGlobalIndicator: " (Global)", + WorkspaceGitLine: "Git: %s", + WorkspaceSchemaModifiedIndicator: " (schema modified)", + WorkspaceBranchFormat: "(%s)", + WorkspaceNotGitRepository: "Git: Not a git repository", + WorkspaceConnected: "✓ Connected", + WorkspaceNotConfigured: "✗ Not configured", + WorkspaceDisconnected: "✗ Disconnected", + WorkspaceProviderLine: "Provider: %s %s", + WorkspaceHardcodedIndicator: " (Hard coded)", + WorkspaceNotSet: "Not set", + WorkspaceErrorFormat: "Error: %s", + WorkspaceErrorGetWorkingDirectory: "Error getting working directory", + WorkspaceErrorSchemaNotFound: "Schema file not found", + WorkspaceNotConfiguredSuffix: " not configured", + WorkspaceDatabaseURLNotConfigured: "DATABASE_URL not configured", + WorkspaceNoDatabaseURL: "No DATABASE_URL", + WorkspaceVersionNotFound: "Not found", + + // Migrations Panel + MigrationsFooterFormat: "%d of %d", + + // main.go strings + VersionOutput: "LazyPrisma %s (%s)\n", + ErrorFailedGetCurrentDir: "Error: Failed to get current directory: %v\n", + ErrorNotPrismaWorkspace: "Error: Current directory is not a Prisma workspace.\n", + ErrorExpectedOneOf: "\nExpected one of:\n", + ErrorExpectedConfigV7Plus: " - prisma.config.ts (Prisma v7.0+)\n", + ErrorExpectedSchemaV7Minus: " - prisma/schema.prisma (Prisma < v7.0)\n", + ErrorFailedCreateApp: "Failed to create app: %v\n", + ErrorFailedRegisterKeybindings: "Failed to register keybindings: %v\n", + ErrorAppRuntime: "App error: %v\n", + } +} diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go new file mode 100644 index 0000000..2891b6f --- /dev/null +++ b/pkg/i18n/i18n.go @@ -0,0 +1,9 @@ +package i18n + +// NewTranslationSet returns a TranslationSet for the given language. +// Currently only English is supported; other languages will be added later. +func NewTranslationSet(language string) *TranslationSet { + // For now, always return English. + // Future: load JSON translations and merge with English defaults. + return EnglishTranslationSet() +} From de9f788518bace3c847491815092202b9be51ea5 Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 12:48:42 +0900 Subject: [PATCH 02/26] add automatic language detection from system locale --- main.go | 5 ++++- pkg/app/app.go | 3 ++- pkg/config/config.go | 7 ++++++- pkg/i18n/i18n.go | 50 +++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/main.go b/main.go index d625385..9847a4e 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "os" "github.com/dokadev/lazyprisma/pkg/app" + "github.com/dokadev/lazyprisma/pkg/config" "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/dokadev/lazyprisma/pkg/prisma" @@ -18,7 +19,8 @@ const ( ) func main() { - tr := i18n.NewTranslationSet("en") + cfg, _ := config.Load() + tr := i18n.NewTranslationSet(cfg.Language) // Handle version flag if len(os.Args) > 1 { @@ -49,6 +51,7 @@ func main() { AppName: "LazyPrisma", Version: Version, Developer: Developer, + Language: cfg.Language, }) if err != nil { fmt.Fprintf(os.Stderr, tr.ErrorFailedCreateApp, err) diff --git a/pkg/app/app.go b/pkg/app/app.go index 7732762..2a97278 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -46,6 +46,7 @@ type AppConfig struct { AppName string Version string Developer string + Language string } func NewApp(config AppConfig) (*App, error) { @@ -54,7 +55,7 @@ func NewApp(config AppConfig) (*App, error) { return nil, err } - cmn := common.NewCommon("en") + cmn := common.NewCommon(config.Language) app := &App{ g: g, diff --git a/pkg/config/config.go b/pkg/config/config.go index 2c52e73..a7a8022 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -15,7 +15,8 @@ const ( // Config holds application configuration type Config struct { - Scan ScanConfig `yaml:"scan"` + Scan ScanConfig `yaml:"scan"` + Language string `yaml:"language"` } // ScanConfig holds project scanning settings @@ -31,6 +32,7 @@ func Default() *Config { MaxDepth: 10, ExcludeDirs: []string{}, // Additional excludes (defaults are in prisma.DefaultExcludeDirs) }, + Language: "auto", } } @@ -127,6 +129,9 @@ scan: excludeDirs: # - /full/path/to/exclude # - dirname-to-exclude + +# Language setting ("auto" for system detection, "en" for English, "ko" for Korean) +language: auto ` return os.WriteFile(path, []byte(defaultConfig), 0644) } diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go index 2891b6f..305c7bf 100644 --- a/pkg/i18n/i18n.go +++ b/pkg/i18n/i18n.go @@ -1,9 +1,53 @@ package i18n +import ( + "os" + "strings" +) + +// Supported language codes +var supportedLanguages = map[string]func() *TranslationSet{ + "en": EnglishTranslationSet, +} + // NewTranslationSet returns a TranslationSet for the given language. -// Currently only English is supported; other languages will be added later. +// If language is "auto", it detects the system language. +// Falls back to English if the language is not supported. func NewTranslationSet(language string) *TranslationSet { - // For now, always return English. - // Future: load JSON translations and merge with English defaults. + if language == "auto" || language == "" { + language = detectSystemLanguage() + } + + if factory, ok := supportedLanguages[language]; ok { + return factory() + } + return EnglishTranslationSet() } + +// detectSystemLanguage checks LANG, LC_ALL, LC_MESSAGES environment variables. +func detectSystemLanguage() string { + for _, envVar := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} { + if val := os.Getenv(envVar); val != "" { + return parseLanguageCode(val) + } + } + return "en" +} + +// parseLanguageCode extracts the language code from locale strings like "ko_KR.UTF-8". +func parseLanguageCode(locale string) string { + // Remove encoding (e.g., ".UTF-8") + if idx := strings.Index(locale, "."); idx != -1 { + locale = locale[:idx] + } + // Remove country (e.g., "_KR") + if idx := strings.Index(locale, "_"); idx != -1 { + locale = locale[:idx] + } + // Remove region variant (e.g., "-KR") + if idx := strings.Index(locale, "-"); idx != -1 { + locale = locale[:idx] + } + return strings.ToLower(locale) +} From 49d4e8c2163592b7c17e5ade34d61d66d391b1f2 Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 14:10:29 +0900 Subject: [PATCH 03/26] migrate panel architecture to context+trait pattern lazygit style --- go.mod | 21 +- go.sum | 73 +- main.go | 69 +- pkg/app/app.go | 103 ++- pkg/app/command.go | 75 +- pkg/app/details.go | 765 ---------------- pkg/app/keybinding.go | 45 +- pkg/app/migrations.go | 637 ------------- pkg/app/output.go | 192 ---- pkg/app/statusbar.go | 113 --- pkg/app/test.go | 11 +- pkg/common/common.go | 6 +- pkg/gui/context/base_context.go | 105 +++ pkg/gui/context/details_context.go | 837 ++++++++++++++++++ pkg/gui/context/migrations_context.go | 752 ++++++++++++++++ pkg/gui/context/output_context.go | 227 +++++ pkg/gui/context/scrollable_trait.go | 115 +++ pkg/gui/context/simple_context.go | 46 + pkg/gui/context/statusbar_context.go | 187 ++++ pkg/gui/context/tabbed_trait.go | 88 ++ .../context/workspace_context.go} | 502 ++++++----- pkg/gui/types/common.go | 63 ++ pkg/gui/types/context.go | 78 ++ pkg/gui/types/keybindings.go | 28 + 24 files changed, 3040 insertions(+), 2098 deletions(-) delete mode 100644 pkg/app/details.go delete mode 100644 pkg/app/migrations.go delete mode 100644 pkg/app/output.go delete mode 100644 pkg/app/statusbar.go create mode 100644 pkg/gui/context/base_context.go create mode 100644 pkg/gui/context/details_context.go create mode 100644 pkg/gui/context/migrations_context.go create mode 100644 pkg/gui/context/output_context.go create mode 100644 pkg/gui/context/scrollable_trait.go create mode 100644 pkg/gui/context/simple_context.go create mode 100644 pkg/gui/context/statusbar_context.go create mode 100644 pkg/gui/context/tabbed_trait.go rename pkg/{app/workspace.go => gui/context/workspace_context.go} (57%) create mode 100644 pkg/gui/types/common.go create mode 100644 pkg/gui/types/context.go create mode 100644 pkg/gui/types/keybindings.go diff --git a/go.mod b/go.mod index c528d00..4715e72 100644 --- a/go.mod +++ b/go.mod @@ -3,26 +3,25 @@ module github.com/dokadev/lazyprisma go 1.25.5 require ( - github.com/jesseduffield/gocui v0.3.1-0.20251002151855-67e0e55ff42a + github.com/alecthomas/chroma/v2 v2.21.1 + github.com/go-sql-driver/mysql v1.9.3 + github.com/jesseduffield/gocui v0.3.1-0.20260128194906-9d8c3cdfac18 github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 + github.com/lib/pq v1.10.9 + gopkg.in/yaml.v3 v3.0.1 ) require ( filippo.io/edwards25519 v1.1.0 // indirect - github.com/alecthomas/chroma/v2 v2.21.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/gdamore/encoding v1.0.1 // indirect - github.com/gdamore/tcell/v2 v2.8.0 // indirect + github.com/gdamore/tcell/v2 v2.13.5 // indirect github.com/go-errors/errors v1.0.2 // indirect - github.com/go-sql-driver/mysql v1.9.3 // indirect - github.com/lib/pq v1.10.9 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/samber/lo v1.31.0 // indirect golang.org/x/exp v0.0.0-20220317015231-48e79f11773a // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/term v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect ) diff --git a/go.sum b/go.sum index e4ee263..f2321cb 100644 --- a/go.sum +++ b/go.sum @@ -1,41 +1,43 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA= github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v2 v2.8.0 h1:IDclow1j6kKpU/gOhjmc+7Pj5Dxnukb74pfKN4Cxrfg= -github.com/gdamore/tcell/v2 v2.8.0/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= +github.com/gdamore/tcell/v2 v2.13.5 h1:YvWYCSr6gr2Ovs84dXbZLjDuOfQchhj8buOEqY52rpA= +github.com/gdamore/tcell/v2 v2.13.5/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/go-errors/errors v1.0.2 h1:xMxH9j2fNg/L4hLn/4y3M0IUsn0M6Wbu/Uh9QlOfBh4= github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/jesseduffield/gocui v0.3.1-0.20251002151855-67e0e55ff42a h1:z3NQvFXAWGT4B/MwQBZc+1ej8WJ/Nv35xngQRvwzPuI= -github.com/jesseduffield/gocui v0.3.1-0.20251002151855-67e0e55ff42a/go.mod h1:sLIyZ2J42R6idGdtemZzsiR3xY5EF0KsvYEGh3dQv3s= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/jesseduffield/gocui v0.3.1-0.20260128194906-9d8c3cdfac18 h1:+Q17GqvNaGzuvIR1JCdIS0khVjMzdwrhXBBDxnsdN8Y= +github.com/jesseduffield/gocui v0.3.1-0.20260128194906-9d8c3cdfac18/go.mod h1:lQCd2TvvNXVKFBowy4A7xxZbUp+1KEiGs4j0Q5Zt9gQ= github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 h1:CDuQmfOjAtb1Gms6a1p5L2P8RhbLUq5t8aL7PiQd2uY= github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5/go.mod h1:qxN4mHOAyeIDLP7IK7defgPClM/z1Kze8VVQiaEjzsQ= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/samber/lo v1.31.0 h1:Sfa+/064Tdo4SvlohQUQzBhgSer9v/coGvKQI/XLWAM= github.com/samber/lo v1.31.0/go.mod h1:HLeWcJRRyLKp3+/XBJvOrerCQn9mhdKMHyd7IRlgeQ8= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M= @@ -43,71 +45,44 @@ github.com/thoas/go-funk v0.9.1/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xz github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/exp v0.0.0-20220317015231-48e79f11773a h1:DAzrdbxsb5tXNOhMCSwF7ZdfMbW46hE9fSVO6BsmUZM= golang.org/x/exp v0.0.0-20220317015231-48e79f11773a/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= -golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 9847a4e..cc40df9 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "github.com/dokadev/lazyprisma/pkg/app" "github.com/dokadev/lazyprisma/pkg/config" + "github.com/dokadev/lazyprisma/pkg/gui/context" "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/dokadev/lazyprisma/pkg/prisma" @@ -59,19 +60,55 @@ func main() { } // 패널 생성 및 등록 - workspace := app.NewWorkspacePanel(tuiApp.GetGui(), tr) - migrations := app.NewMigrationsPanel(tuiApp.GetGui(), tr) - details := app.NewDetailsPanel(tuiApp.GetGui(), tr) - output := app.NewOutputPanel(tuiApp.GetGui(), tr) - statusbar := app.NewStatusBar(tuiApp.GetGui(), tuiApp) + workspace := context.NewWorkspaceContext(context.WorkspaceContextOpts{ + Gui: tuiApp.GetGui(), + Tr: tr, + ViewName: "workspace", + }) + migrationsCtx := context.NewMigrationsContext(context.MigrationsContextOpts{ + Gui: tuiApp.GetGui(), + Tr: tr, + ViewName: "migrations", + }) + detailsCtx := context.NewDetailsContext(context.DetailsContextOpts{ + Gui: tuiApp.GetGui(), + Tr: tr, + ViewName: "details", + }) + output := context.NewOutputContext(context.OutputContextOpts{ + Gui: tuiApp.GetGui(), + Tr: tr, + ViewName: "outputs", + }) + statusbar := context.NewStatusBarContext(context.StatusBarContextOpts{ + Gui: tuiApp.GetGui(), + Tr: tr, + ViewName: "statusbar", + State: tuiApp.StatusBarState(), + Config: context.StatusBarConfig{ + Developer: Developer, + Version: Version, + }, + }) - // Connect panels - migrations.SetDetailsPanel(details) - details.SetApp(tuiApp) + // Wire callbacks (replaces old bidirectional coupling) + migrationsCtx.SetOnSelectionChanged(func(mig *prisma.Migration, tab string) { + detailsCtx.UpdateFromMigration(mig, tab) + }) + migrationsCtx.SetModalCallbacks(tuiApp.HasActiveModal, func(viewID string) { + tuiApp.HandlePanelClick(viewID) + }) + detailsCtx.SetModalCallbacks(tuiApp.HasActiveModal, func(viewID string) { + tuiApp.HandlePanelClick(viewID) + }) + + // Load action-needed data for details context + detailsCtx.SetActionNeededMigrations(collectActionNeededMigrations(migrationsCtx.GetCategory())) + detailsCtx.LoadActionNeededData() tuiApp.RegisterPanel(workspace) - tuiApp.RegisterPanel(migrations) - tuiApp.RegisterPanel(details) + tuiApp.RegisterPanel(migrationsCtx) + tuiApp.RegisterPanel(detailsCtx) tuiApp.RegisterPanel(output) tuiApp.RegisterPanel(statusbar) @@ -90,3 +127,15 @@ func main() { os.Exit(1) } } + +// collectActionNeededMigrations extracts migrations that need action (Empty or Checksum Mismatch) +// from the Local category. +func collectActionNeededMigrations(cat prisma.MigrationCategory) []prisma.Migration { + var result []prisma.Migration + for _, mig := range cat.Local { + if mig.IsEmpty || mig.ChecksumMismatch { + result = append(result, mig) + } + } + return result +} diff --git a/pkg/app/app.go b/pkg/app/app.go index 2a97278..bbffcab 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -7,7 +7,9 @@ import ( "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" + "github.com/dokadev/lazyprisma/pkg/prisma" "github.com/jesseduffield/gocui" ) @@ -55,7 +57,7 @@ func NewApp(config AppConfig) (*App, error) { return nil, err } - cmn := common.NewCommon(config.Language) + cmn := common.NewCommon(i18n.NewTranslationSet(config.Language)) app := &App{ g: g, @@ -109,6 +111,27 @@ func (a *App) GetGui() *gocui.Gui { return a.g } +// StatusBarState returns callbacks for the StatusBarContext to access App state. +func (a *App) StatusBarState() context.StatusBarState { + return context.StatusBarState{ + IsCommandRunning: func() bool { + return a.commandRunning.Load() + }, + GetSpinnerFrame: func() uint32 { + return a.spinnerFrame.Load() + }, + IsStudioRunning: func() bool { + return a.studioRunning + }, + GetCommandName: func() string { + if val := a.runningCommandName.Load(); val != nil { + return val.(string) + } + return "" + }, + } +} + // OpenModal opens a modal and saves current focus state func (a *App) OpenModal(modal Modal) { // Save current focus @@ -174,7 +197,7 @@ func (a *App) finishCommand() { // 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].(*OutputPanel); ok { + if outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { runningTask := "" if val := a.runningCommandName.Load(); val != nil { runningTask = val.(string) @@ -220,6 +243,12 @@ func (a *App) startSpinnerUpdater() { }() } +// HandlePanelClick is the public wrapper for panel-click focus switching. +// It is used as a callback by contexts that manage their own mouse events. +func (a *App) HandlePanelClick(viewID string) { + a.handlePanelClick(viewID) +} + // handlePanelClick handles mouse click on a panel to switch focus func (a *App) handlePanelClick(viewID string) error { // Ignore if modal is active @@ -278,13 +307,11 @@ func (a *App) RegisterMouseBindings() { } } - // Register special bindings for MigrationsPanel - if migrationsPanel, ok := a.panels[ViewMigrations].(*MigrationsPanel); ok { - migrationsPanel.SetApp(a) - + // Register special bindings for MigrationsContext + if migrationsCtx, ok := a.panels[ViewMigrations].(*context.MigrationsContext); ok { // Tab click binding a.g.SetTabClickBinding(ViewMigrations, func(tabIndex int) error { - return migrationsPanel.handleTabClick(tabIndex) + return migrationsCtx.HandleTabClick(tabIndex) }) // List item click binding @@ -293,16 +320,16 @@ func (a *App) RegisterMouseBindings() { Key: gocui.MouseLeft, Modifier: gocui.ModNone, Handler: func(opts gocui.ViewMouseBindingOpts) error { - return migrationsPanel.handleListClick(opts.Y) + return migrationsCtx.HandleListClick(opts.Y) }, }) } - // Register special bindings for DetailsPanel - if detailsPanel, ok := a.panels[ViewDetails].(*DetailsPanel); ok { + // Register special bindings for DetailsContext + if detailsCtx, ok := a.panels[ViewDetails].(*context.DetailsContext); ok { // Tab click binding a.g.SetTabClickBinding(ViewDetails, func(tabIndex int) error { - return detailsPanel.handleTabClick(tabIndex) + return detailsCtx.HandleTabClick(tabIndex) }) // Panel focus click binding (for content area) @@ -316,7 +343,7 @@ func (a *App) RegisterMouseBindings() { // registerMouseWheelBindings registers mouse wheel handlers for all panels func (a *App) registerMouseWheelBindings() { // Workspace panel - if workspacePanel, ok := a.panels[ViewWorkspace].(*WorkspacePanel); ok { + if workspaceCtx, ok := a.panels[ViewWorkspace].(*context.WorkspaceContext); ok { a.g.SetViewClickBinding(&gocui.ViewMouseBinding{ ViewName: ViewWorkspace, Key: gocui.MouseWheelUp, @@ -325,7 +352,7 @@ func (a *App) registerMouseWheelBindings() { if a.HasActiveModal() { return nil } - workspacePanel.ScrollUpByWheel() + workspaceCtx.ScrollUpByWheel() return nil }, }) @@ -337,14 +364,14 @@ func (a *App) registerMouseWheelBindings() { if a.HasActiveModal() { return nil } - workspacePanel.ScrollDownByWheel() + workspaceCtx.ScrollDownByWheel() return nil }, }) } - // Migrations panel - if migrationsPanel, ok := a.panels[ViewMigrations].(*MigrationsPanel); ok { + // Migrations context + if migrationsCtx, ok := a.panels[ViewMigrations].(*context.MigrationsContext); ok { a.g.SetViewClickBinding(&gocui.ViewMouseBinding{ ViewName: ViewMigrations, Key: gocui.MouseWheelUp, @@ -353,7 +380,7 @@ func (a *App) registerMouseWheelBindings() { if a.HasActiveModal() { return nil } - migrationsPanel.ScrollUpByWheel() + migrationsCtx.ScrollUpByWheel() return nil }, }) @@ -365,14 +392,14 @@ func (a *App) registerMouseWheelBindings() { if a.HasActiveModal() { return nil } - migrationsPanel.ScrollDownByWheel() + migrationsCtx.ScrollDownByWheel() return nil }, }) } - // Details panel - if detailsPanel, ok := a.panels[ViewDetails].(*DetailsPanel); ok { + // Details context + if detailsCtx, ok := a.panels[ViewDetails].(*context.DetailsContext); ok { a.g.SetViewClickBinding(&gocui.ViewMouseBinding{ ViewName: ViewDetails, Key: gocui.MouseWheelUp, @@ -381,7 +408,7 @@ func (a *App) registerMouseWheelBindings() { if a.HasActiveModal() { return nil } - detailsPanel.ScrollUpByWheel() + detailsCtx.ScrollUpByWheel() return nil }, }) @@ -393,14 +420,14 @@ func (a *App) registerMouseWheelBindings() { if a.HasActiveModal() { return nil } - detailsPanel.ScrollDownByWheel() + detailsCtx.ScrollDownByWheel() return nil }, }) } // Output panel - if outputPanel, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + if outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { a.g.SetViewClickBinding(&gocui.ViewMouseBinding{ ViewName: ViewOutputs, Key: gocui.MouseWheelUp, @@ -445,7 +472,7 @@ func (a *App) RefreshAll(onComplete ...func()) bool { // Update UI on main thread (thread-safe) a.g.Update(func(g *gocui.Gui) error { // Add refresh notification to output panel - if outputPanel, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + if outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { outputPanel.LogAction(a.Tr.ActionRefresh, a.Tr.SuccessAllPanelsRefreshed) } @@ -463,17 +490,25 @@ func (a *App) RefreshAll(onComplete ...func()) bool { // refreshPanels refreshes all panels (blocking, internal) func (a *App) refreshPanels() { // Refresh workspace panel - if workspacePanel, ok := a.panels[ViewWorkspace].(*WorkspacePanel); ok { - workspacePanel.Refresh() - } - - // Refresh migrations panel - if migrationsPanel, ok := a.panels[ViewMigrations].(*MigrationsPanel); ok { - migrationsPanel.Refresh() + if workspaceCtx, ok := a.panels[ViewWorkspace].(*context.WorkspaceContext); ok { + workspaceCtx.Refresh() } - // Refresh Details panel Action-Needed data - if detailsPanel, ok := a.panels[ViewDetails].(*DetailsPanel); ok { - detailsPanel.LoadActionNeededData() + // Refresh migrations context + if migrationsCtx, ok := a.panels[ViewMigrations].(*context.MigrationsContext); ok { + migrationsCtx.Refresh() + + // Wire action-needed data from migrations to details + if detailsCtx, ok := a.panels[ViewDetails].(*context.DetailsContext); ok { + // Collect action-needed migrations from Local category + var actionNeeded []prisma.Migration + for _, mig := range migrationsCtx.GetCategory().Local { + if mig.IsEmpty || mig.ChecksumMismatch { + actionNeeded = append(actionNeeded, mig) + } + } + detailsCtx.SetActionNeededMigrations(actionNeeded) + detailsCtx.LoadActionNeededData() + } } } diff --git a/pkg/app/command.go b/pkg/app/command.go index 49a7be6..c3ee0ff 100644 --- a/pkg/app/command.go +++ b/pkg/app/command.go @@ -7,6 +7,7 @@ import ( "time" "github.com/dokadev/lazyprisma/pkg/commands" + "github.com/dokadev/lazyprisma/pkg/gui/context" "github.com/dokadev/lazyprisma/pkg/prisma" "github.com/jesseduffield/gocui" ) @@ -25,7 +26,7 @@ func (a *App) MigrateDeploy() { a.refreshPanels() // 2. Check DB connection - migrationsPanel, ok := a.panels[ViewMigrations].(*MigrationsPanel) + migrationsPanel, ok := a.panels[ViewMigrations].(*context.MigrationsContext) if !ok { a.finishCommand() a.g.Update(func(g *gocui.Gui) error { @@ -39,7 +40,7 @@ func (a *App) MigrateDeploy() { } // Check if DB is connected - if !migrationsPanel.dbConnected { + if !migrationsPanel.IsDBConnected() { a.finishCommand() a.g.Update(func(g *gocui.Gui) error { modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleDBConnectionRequired, @@ -52,7 +53,7 @@ func (a *App) MigrateDeploy() { return } - outputPanel, ok := a.panels[ViewOutputs].(*OutputPanel) + outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext) if !ok { a.finishCommand() // Clean up if panel not found return @@ -90,7 +91,7 @@ func (a *App) MigrateDeploy() { OnStdout(func(line string) { // Update UI on main thread a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { out.AppendOutput(" " + line) } return nil @@ -99,7 +100,7 @@ func (a *App) MigrateDeploy() { OnStderr(func(line string) { // Update UI on main thread a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { out.AppendOutput(" " + line) } return nil @@ -109,7 +110,7 @@ func (a *App) MigrateDeploy() { // Update UI on main thread a.g.Update(func(g *gocui.Gui) error { a.finishCommand() // Finish command - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + 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 @@ -137,7 +138,7 @@ func (a *App) MigrateDeploy() { // Update UI on main thread a.g.Update(func(g *gocui.Gui) error { a.finishCommand() // Finish command - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + 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, @@ -206,7 +207,7 @@ func (a *App) executeCreateMigration(migrationName string) { return } - outputPanel, ok := a.panels[ViewOutputs].(*OutputPanel) + outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext) if !ok { a.finishCommand() // Clean up if panel not found return @@ -239,7 +240,7 @@ func (a *App) executeCreateMigration(migrationName string) { OnStdout(func(line string) { // Update UI on main thread a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { out.AppendOutput(" " + line) } return nil @@ -248,7 +249,7 @@ func (a *App) executeCreateMigration(migrationName string) { OnStderr(func(line string) { // Update UI on main thread a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { out.AppendOutput(" " + line) } return nil @@ -261,7 +262,7 @@ func (a *App) executeCreateMigration(migrationName string) { // Refresh all panels to show the new migration a.RefreshAll() - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { if exitCode == 0 { out.LogAction(a.Tr.LogActionMigrateComplete, a.Tr.LogMsgMigrationCreatedSuccess) // Show success modal @@ -286,7 +287,7 @@ func (a *App) executeCreateMigration(migrationName string) { // Update UI on main thread a.g.Update(func(g *gocui.Gui) error { a.finishCommand() // Finish command - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + 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, @@ -315,7 +316,7 @@ func (a *App) SchemaDiffMigration() { // 1. Refresh first (with callback to ensure data is loaded before checking) started := a.RefreshAll(func() { // 2. Check DB connection - migrationsPanel, ok := a.panels[ViewMigrations].(*MigrationsPanel) + migrationsPanel, ok := a.panels[ViewMigrations].(*context.MigrationsContext) if !ok { modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError, a.Tr.ErrorFailedAccessMigrationsPanel, @@ -325,7 +326,7 @@ func (a *App) SchemaDiffMigration() { } // Check if DB is connected - if !migrationsPanel.dbConnected { + if !migrationsPanel.IsDBConnected() { modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleDBConnectionRequired, a.Tr.ErrorNoDBConnectionDetected, a.Tr.ErrorEnsureDBAccessible, @@ -335,7 +336,7 @@ func (a *App) SchemaDiffMigration() { } // 3. Check for DB-Only migrations - if len(migrationsPanel.category.DBOnly) > 0 { + if len(migrationsPanel.GetCategory().DBOnly) > 0 { modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleDBOnlyMigrationsDetected, a.Tr.ModalMsgCannotCreateWithDBOnly, a.Tr.ModalMsgResolveDBOnlyFirst, @@ -345,7 +346,7 @@ func (a *App) SchemaDiffMigration() { } // 4. Check for Checksum Mismatch - for _, m := range migrationsPanel.category.Local { + for _, m := range migrationsPanel.GetCategory().Local { if m.ChecksumMismatch { modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleChecksumMismatchDetected, a.Tr.ModalMsgCannotCreateWithMismatch, @@ -357,9 +358,9 @@ func (a *App) SchemaDiffMigration() { } // 5. Check for Pending migrations - if len(migrationsPanel.category.Pending) > 0 { + if len(migrationsPanel.GetCategory().Pending) > 0 { // Check if any pending migration is empty - for _, m := range migrationsPanel.category.Pending { + for _, m := range migrationsPanel.GetCategory().Pending { if m.IsEmpty { modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleEmptyPendingDetected, a.Tr.ModalMsgCannotCreateWithEmpty, @@ -528,7 +529,7 @@ func (a *App) Generate() { return } - outputPanel, ok := a.panels[ViewOutputs].(*OutputPanel) + outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext) if !ok { a.finishCommand() // Clean up if panel not found return @@ -560,7 +561,7 @@ func (a *App) Generate() { OnStdout(func(line string) { // Update UI on main thread a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { out.AppendOutput(" " + line) } return nil @@ -569,7 +570,7 @@ func (a *App) Generate() { OnStderr(func(line string) { // Update UI on main thread a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { out.AppendOutput(" " + line) } return nil @@ -578,7 +579,7 @@ func (a *App) Generate() { OnComplete(func(exitCode int) { // Update UI on main thread a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + 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) @@ -599,7 +600,7 @@ func (a *App) Generate() { a.g.Update(func(g *gocui.Gui) error { a.finishCommand() // Finish after validate completes - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + 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))) @@ -630,7 +631,7 @@ func (a *App) Generate() { OnError(func(err error) { // Update UI on main thread a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + 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) @@ -644,7 +645,7 @@ func (a *App) Generate() { a.g.Update(func(g *gocui.Gui) error { a.finishCommand() // Finish after validate completes - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + 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))) @@ -697,7 +698,7 @@ func (a *App) Generate() { // MigrateResolve resolves a failed migration func (a *App) MigrateResolve() { // Get migrations panel - migrationsPanel, ok := a.panels[ViewMigrations].(*MigrationsPanel) + migrationsPanel, ok := a.panels[ViewMigrations].(*context.MigrationsContext) if !ok { modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError, a.Tr.ErrorFailedAccessMigrationsPanel, @@ -765,7 +766,7 @@ func (a *App) executeResolve(migrationName string, action string) { return } - outputPanel, ok := a.panels[ViewOutputs].(*OutputPanel) + outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext) if !ok { a.finishCommand() // Clean up if panel not found return @@ -801,7 +802,7 @@ func (a *App) executeResolve(migrationName string, action string) { OnStdout(func(line string) { // Update UI on main thread a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { out.AppendOutput(" " + line) } return nil @@ -810,7 +811,7 @@ func (a *App) executeResolve(migrationName string, action string) { OnStderr(func(line string) { // Update UI on main thread a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { out.AppendOutput(" " + line) } return nil @@ -822,7 +823,7 @@ func (a *App) executeResolve(migrationName string, action string) { a.finishCommand() // Finish command // Refresh all panels to show updated migration status a.RefreshAll() - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + 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 @@ -846,7 +847,7 @@ func (a *App) executeResolve(migrationName string, action string) { // Update UI on main thread a.g.Update(func(g *gocui.Gui) error { a.finishCommand() // Finish command - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + 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, @@ -872,7 +873,7 @@ func (a *App) executeResolve(migrationName string, action string) { // Studio toggles Prisma Studio func (a *App) Studio() { - outputPanel, ok := a.panels[ViewOutputs].(*OutputPanel) + outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext) if !ok { return } @@ -981,7 +982,7 @@ func (a *App) Studio() { // DeleteMigration deletes a pending migration func (a *App) DeleteMigration() { // Get migrations panel - migrationsPanel, ok := a.panels[ViewMigrations].(*MigrationsPanel) + migrationsPanel, ok := a.panels[ViewMigrations].(*context.MigrationsContext) if !ok { return } @@ -1008,7 +1009,7 @@ func (a *App) DeleteMigration() { // 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.dbConnected && selected.AppliedAt != nil { + if migrationsPanel.IsDBConnected() && selected.AppliedAt != nil { modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleCannotDelete, a.Tr.ModalMsgMigrationAlreadyApplied, a.Tr.ModalMsgDeleteLocalInconsistency, @@ -1034,7 +1035,7 @@ func (a *App) DeleteMigration() { // executeDeleteMigration performs the actual deletion func (a *App) executeDeleteMigration(path, name string) { if err := os.RemoveAll(path); err != nil { - outputPanel, _ := a.panels[ViewOutputs].(*OutputPanel) + outputPanel, _ := a.panels[ViewOutputs].(*context.OutputContext) if outputPanel != nil { outputPanel.LogActionRed(a.Tr.ModalTitleDeleteError, fmt.Sprintf(a.Tr.LogMsgFailedDeleteMigration, err.Error())) } @@ -1048,7 +1049,7 @@ func (a *App) executeDeleteMigration(path, name string) { } // Success - outputPanel, _ := a.panels[ViewOutputs].(*OutputPanel) + outputPanel, _ := a.panels[ViewOutputs].(*context.OutputContext) if outputPanel != nil { outputPanel.LogAction(a.Tr.LogActionDeleted, fmt.Sprintf(a.Tr.LogMsgMigrationDeleted, name)) } @@ -1065,7 +1066,7 @@ func (a *App) executeDeleteMigration(path, name string) { // CopyMigrationInfo copies migration info to clipboard func (a *App) CopyMigrationInfo() { // Get migrations panel - migrationsPanel, ok := a.panels[ViewMigrations].(*MigrationsPanel) + migrationsPanel, ok := a.panels[ViewMigrations].(*context.MigrationsContext) if !ok { return } diff --git a/pkg/app/details.go b/pkg/app/details.go deleted file mode 100644 index 8999132..0000000 --- a/pkg/app/details.go +++ /dev/null @@ -1,765 +0,0 @@ -package app - -import ( - "bytes" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/alecthomas/chroma/v2/formatters" - "github.com/alecthomas/chroma/v2/lexers" - "github.com/alecthomas/chroma/v2/styles" - "github.com/dokadev/lazyprisma/pkg/i18n" - "github.com/dokadev/lazyprisma/pkg/prisma" - "github.com/jesseduffield/gocui" - "github.com/jesseduffield/lazycore/pkg/boxlayout" -) - -type DetailsPanel struct { - BasePanel - content string - originY int // Scroll position - currentMigrationName string // Currently displayed migration name - migrationsPanel *MigrationsPanel - - // Tab management - tabs []string // Tab names (Details, Action-Needed) - tabIndex int // Current tab index - actionNeededMigrations []prisma.Migration // Migrations requiring action (Empty + Mismatch) - validationResult *prisma.ValidateResult // Schema validation result - tabOriginY map[string]int // Scroll position per tab - - // Translation set (available from construction time) - tr *i18n.TranslationSet - - // App reference for modal check (tab click events) - app *App -} - -func NewDetailsPanel(g *gocui.Gui, tr *i18n.TranslationSet) *DetailsPanel { - return &DetailsPanel{ - BasePanel: NewBasePanel(ViewDetails, g), - tr: tr, - content: tr.DetailsPanelInitialPlaceholder, - tabs: []string{tr.TabDetails}, - tabIndex: 0, - actionNeededMigrations: []prisma.Migration{}, - tabOriginY: make(map[string]int), - } -} - -func (d *DetailsPanel) Draw(dim boxlayout.Dimensions) error { - v, err := d.g.SetView(d.id, dim.X0, dim.Y0, dim.X1, dim.Y1, 0) - if err != nil && err.Error() != "unknown view" { - return err - } - - // Setup view WITHOUT title (tabs replace title) - d.v = v - v.Clear() - v.Frame = true - v.FrameRunes = d.frameRunes - v.Wrap = true // Enable word wrap for long lines - - // Set tabs - v.Tabs = d.tabs - v.TabIndex = d.tabIndex - - // Set frame and tab colors based on focus - if d.focused { - v.FrameColor = FocusedFrameColor - v.TitleColor = FocusedTitleColor - if len(d.tabs) == 1 { - v.SelFgColor = FocusedTitleColor // Single tab: treat like title - } else { - v.SelFgColor = FocusedActiveTabColor // Multiple tabs: use active tab color - } - } else { - v.FrameColor = PrimaryFrameColor - v.TitleColor = PrimaryTitleColor - if len(d.tabs) == 1 { - v.SelFgColor = PrimaryTitleColor // Single tab: treat like title - } else { - v.SelFgColor = PrimaryActiveTabColor // Multiple tabs: use active tab color - } - } - - // Render content based on current tab - if d.tabIndex < len(d.tabs) { - tabName := d.tabs[d.tabIndex] - if d.app != nil && tabName == d.tr.TabActionNeeded { - fmt.Fprint(v, d.buildActionNeededContent()) - } else { - fmt.Fprint(v, d.content) - } - } - - // Adjust origin to ensure it's within valid bounds - AdjustOrigin(v, &d.originY) - v.SetOrigin(0, d.originY) - - return nil -} - -func (d *DetailsPanel) SetContent(content string) { - d.content = content -} - -// buildActionNeededContent builds the content for the Action-Needed tab -func (d *DetailsPanel) buildActionNeededContent() string { - // Count all issues - emptyCount := 0 - mismatchCount := 0 - var emptyMigrations []prisma.Migration - var mismatchMigrations []prisma.Migration - - for _, mig := range d.actionNeededMigrations { - if mig.IsEmpty { - emptyCount++ - emptyMigrations = append(emptyMigrations, mig) - } - if mig.ChecksumMismatch { - mismatchCount++ - mismatchMigrations = append(mismatchMigrations, mig) - } - } - - validationErrorCount := 0 - if d.validationResult != nil && !d.validationResult.Valid { - validationErrorCount = len(d.validationResult.Errors) - if validationErrorCount == 0 { - validationErrorCount = 1 // At least one error if validation failed - } - } - - totalCount := emptyCount + mismatchCount + validationErrorCount - - if totalCount == 0 { - return d.tr.ActionNeededNoIssuesMessage - } - - var content strings.Builder - - // Header - content.WriteString(fmt.Sprintf("%s (%d%s", Yellow(d.tr.ActionNeededHeader), totalCount, d.tr.ActionNeededIssueSingular)) - if totalCount > 1 { - content.WriteString(d.tr.ActionNeededIssuePlural) - } - content.WriteString(")\n\n") - - // Empty Migrations Section - if emptyCount > 0 { - content.WriteString(strings.Repeat("━", 40) + "\n") - content.WriteString(fmt.Sprintf("%s (%d)\n", Red(d.tr.ActionNeededEmptyMigrationsHeader), emptyCount)) - content.WriteString(strings.Repeat("━", 40) + "\n\n") - - content.WriteString(d.tr.ActionNeededEmptyDescription) - - content.WriteString(d.tr.ActionNeededAffectedLabel) - for _, mig := range emptyMigrations { - _, name := parseMigrationName(mig.Name) - content.WriteString(fmt.Sprintf(" • %s\n", Red(name))) - } - - content.WriteString("\n" + d.tr.ActionNeededRecommendedLabel) - content.WriteString(d.tr.ActionNeededAddMigrationSQL) - content.WriteString(d.tr.ActionNeededDeleteEmptyFolders) - content.WriteString(d.tr.ActionNeededMarkAsBaseline) - } - - // Checksum Mismatch Section - if mismatchCount > 0 { - content.WriteString(strings.Repeat("━", 40) + "\n") - content.WriteString(fmt.Sprintf("%s (%d)\n", Orange(d.tr.ActionNeededChecksumMismatchHeader), mismatchCount)) - content.WriteString(strings.Repeat("━", 40) + "\n\n") - - content.WriteString(d.tr.ActionNeededChecksumModifiedDescription) - - content.WriteString(Yellow(d.tr.ActionNeededWarningPrefix)) - content.WriteString(d.tr.ActionNeededEditingInconsistenciesWarning) - - content.WriteString(d.tr.ActionNeededAffectedLabel) - for _, mig := range mismatchMigrations { - _, name := parseMigrationName(mig.Name) - content.WriteString(fmt.Sprintf(" • %s\n", Orange(name))) - } - - content.WriteString("\n" + d.tr.ActionNeededRecommendedLabel) - content.WriteString(d.tr.ActionNeededRevertLocalChanges) - content.WriteString(d.tr.ActionNeededCreateNewInstead) - content.WriteString(d.tr.ActionNeededContactTeamIfNeeded) - } - - // Schema Validation Section - if validationErrorCount > 0 { - content.WriteString(strings.Repeat("━", 40) + "\n") - content.WriteString(fmt.Sprintf("%s (%d)\n", Red(d.tr.ActionNeededSchemaValidationErrorsHeader), validationErrorCount)) - content.WriteString(strings.Repeat("━", 40) + "\n\n") - - content.WriteString(d.tr.ActionNeededSchemaValidationFailedDesc) - content.WriteString(d.tr.ActionNeededFixBeforeMigration) - - // Show full validation output (contains detailed error info) - if d.validationResult.Output != "" { - content.WriteString(Stylize(d.tr.ActionNeededValidationOutputLabel, Style{FgColor: ColorYellow, Bold: true}) + "\n") - // Display the full output with proper formatting (preserve all line breaks) - outputLines := strings.Split(d.validationResult.Output, "\n") - for _, line := range outputLines { - // Highlight error lines - if strings.Contains(line, "Error:") || strings.Contains(line, "error:") { - content.WriteString(Red(line) + "\n") - } else if strings.Contains(line, "-->") { - content.WriteString(Yellow(line) + "\n") - } else { - // Preserve empty lines and all other content as-is - content.WriteString(line + "\n") - } - } - content.WriteString("\n") - } - - content.WriteString(Stylize(d.tr.ActionNeededRecommendedActionsLabel, Style{FgColor: ColorYellow, Bold: true}) + "\n") - content.WriteString(d.tr.ActionNeededFixSchemaErrors) - content.WriteString(d.tr.ActionNeededCheckLineNumbers) - content.WriteString(d.tr.ActionNeededReferPrismaDocumentation) - } - - return content.String() -} - -// highlightSQL applies syntax highlighting to SQL code with line numbers -func highlightSQL(code string) string { - // Get SQL lexer - lexer := lexers.Get("sql") - if lexer == nil { - lexer = lexers.Fallback - } - - // Get style (monokai is a popular dark theme) - style := styles.Get("dracula") - if style == nil { - style = styles.Fallback - } - - // Get terminal formatter with 256 colors - formatter := formatters.Get("terminal256") - if formatter == nil { - formatter = formatters.Fallback - } - - // Tokenize and format - var buf bytes.Buffer - iterator, err := lexer.Tokenise(nil, code) - if err != nil { - return code // Return original if highlighting fails - } - - err = formatter.Format(&buf, style, iterator) - if err != nil { - return code // Return original if highlighting fails - } - - // Add line numbers - highlighted := buf.String() - lines := strings.Split(highlighted, "\n") - var result strings.Builder - - for i, line := range lines { - if i > 0 { - result.WriteString("\n") - } - // Line number in gray color, right-aligned to 4 digits - result.WriteString(fmt.Sprintf("\033[90m%4d │\033[0m %s", i+1, line)) - } - - return result.String() -} - -// ScrollUp scrolls the details panel up -func (d *DetailsPanel) ScrollUp() { - if d.originY > 0 { - d.originY-- - } -} - -// ScrollDown scrolls the details panel down -func (d *DetailsPanel) ScrollDown() { - d.originY++ - // AdjustOrigin will be called in Draw() to ensure bounds -} - -// ScrollUpByWheel scrolls the details panel up by 2 lines (mouse wheel) -func (d *DetailsPanel) ScrollUpByWheel() { - if d.originY > 0 { - d.originY -= 2 - if d.originY < 0 { - d.originY = 0 - } - } -} - -// ScrollDownByWheel scrolls the details panel down by 2 lines (mouse wheel) -func (d *DetailsPanel) ScrollDownByWheel() { - if d.v == nil { - return - } - - // Get actual content lines from the rendered view buffer - contentLines := len(d.v.ViewBufferLines()) - _, viewHeight := d.v.Size() - innerHeight := viewHeight - 2 // Exclude frame (top + bottom) - - // Calculate maxOrigin - maxOrigin := contentLines - innerHeight - if maxOrigin < 0 { - maxOrigin = 0 - } - - // Only scroll if we haven't reached the bottom - if d.originY < maxOrigin { - d.originY += 2 - if d.originY > maxOrigin { - d.originY = maxOrigin - } - } -} - -// ScrollToTop scrolls to the top of the details panel -func (d *DetailsPanel) ScrollToTop() { - d.originY = 0 -} - -// ScrollToBottom scrolls to the bottom of the details panel -func (d *DetailsPanel) ScrollToBottom() { - if d.v == nil { - return - } - - // Get actual content lines from the rendered view buffer - contentLines := len(d.v.ViewBufferLines()) - _, viewHeight := d.v.Size() - innerHeight := viewHeight - 2 // Exclude frame (top + bottom) - - // Calculate maxOrigin - maxOrigin := contentLines - innerHeight - if maxOrigin < 0 { - maxOrigin = 0 - } - - d.originY = maxOrigin -} - -// UpdateFromMigration updates the details panel with migration information -func (d *DetailsPanel) UpdateFromMigration(migration *prisma.Migration, tabName string) { - // Only reset scroll position for Details tab if viewing a different migration - if migration != nil && d.currentMigrationName != migration.Name { - // Reset Details tab scroll position only - d.tabOriginY[d.tr.TabDetails] = 0 - // If currently on Details tab, also update originY - if d.tabIndex < len(d.tabs) && d.tabs[d.tabIndex] == d.tr.TabDetails { - d.originY = 0 - } - d.currentMigrationName = migration.Name - } else if migration == nil { - // Reset Details tab scroll position only - d.tabOriginY[d.tr.TabDetails] = 0 - // If currently on Details tab, also update originY - if d.tabIndex < len(d.tabs) && d.tabs[d.tabIndex] == d.tr.TabDetails { - d.originY = 0 - } - d.currentMigrationName = "" - } - - if migration == nil { - d.content = d.tr.DetailsPanelInitialPlaceholder - return - } - - // Handle different cases (priority: Failed > DB-Only > Checksum Mismatch > Empty) - - // In-Transaction migrations (highest priority) - if migration.IsFailed { - timestamp, name := parseMigrationName(migration.Name) - header := fmt.Sprintf(d.tr.DetailsNameLabel, Cyan(name)) - header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) - if migration.Path != "" { - header += fmt.Sprintf(d.tr.DetailsPathLabel, getRelativePath(migration.Path)) - } - header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n", Cyan(d.tr.MigrationStatusInTransaction)) - - // Show down migration availability - if migration.HasDownSQL { - header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", Green(d.tr.DetailsDownMigrationAvailable)) - } else { - header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", Red(d.tr.DetailsDownMigrationNotAvailable)) - } - - // Show started_at if available - if migration.StartedAt != nil { - header += fmt.Sprintf(d.tr.DetailsStartedAtLabel+"%s\n", migration.StartedAt.Format("2006-01-02 15:04:05")) - } - - header += "\n" + Yellow(d.tr.DetailsInTransactionWarning) - header += "\n" + Yellow(d.tr.DetailsNoAdditionalMigrationsWarning) - header += "\n\n" + d.tr.DetailsResolveManuallyInstruction - - // Show logs if available - if migration.Logs != nil && *migration.Logs != "" { - header += "\n" + d.tr.DetailsErrorLogsLabel + "\n" + Red(*migration.Logs) - } - - // Read and show migration.sql content (if Path is available - not DB-Only) - if migration.Path != "" { - sqlPath := filepath.Join(migration.Path, "migration.sql") - content, err := os.ReadFile(sqlPath) - if err == nil { - highlightedSQL := highlightSQL(string(content)) - d.content = header + "\n\n" + highlightedSQL - - // Show down.sql if available - if migration.HasDownSQL { - downSQLPath := filepath.Join(migration.Path, "down.sql") - downContent, err := os.ReadFile(downSQLPath) - if err == nil { - highlightedDownSQL := highlightSQL(string(downContent)) - d.content += "\n\n" + Yellow(d.tr.DetailsDownMigrationSQLLabel) + "\n\n" + highlightedDownSQL - } - } - } else { - d.content = header - } - } else { - d.content = header - } - return - } - - if tabName == "DB-Only" { - timestamp, name := parseMigrationName(migration.Name) - header := fmt.Sprintf(d.tr.DetailsNameLabel, Yellow(name)) - header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) - header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n\n", Red(d.tr.MigrationStatusDBOnly)) - header += d.tr.DetailsDBOnlyDescription - d.content = header - return - } - - // Checksum mismatch - if migration.ChecksumMismatch { - timestamp, name := parseMigrationName(migration.Name) - header := fmt.Sprintf(d.tr.DetailsNameLabel, Orange(name)) - header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) - if migration.Path != "" { - header += fmt.Sprintf(d.tr.DetailsPathLabel, getRelativePath(migration.Path)) - } - // Show Applied status with Checksum Mismatch warning - statusLine := fmt.Sprintf(d.tr.DetailsStatusLabel+"%s", Green(d.tr.MigrationStatusApplied)) - if migration.AppliedAt != nil { - statusLine += fmt.Sprintf(" (%s)", fmt.Sprintf(d.tr.DetailsAppliedAtLabel, migration.AppliedAt.Format("2006-01-02 15:04:05"))) - } - statusLine += fmt.Sprintf(" - %s\n", Orange(d.tr.MigrationStatusChecksumMismatch)) - header += statusLine - - // Show down migration availability - if migration.HasDownSQL { - header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", Green(d.tr.DetailsDownMigrationAvailable)) - } else { - header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", Red(d.tr.DetailsDownMigrationNotAvailable)) - } - - header += "\n" + d.tr.DetailsChecksumModifiedDescription - header += d.tr.DetailsChecksumIssuesWarning - - // Show checksum values (in orange for emphasis) - header += fmt.Sprintf(d.tr.DetailsLocalChecksumLabel+"%s\n", Orange(migration.Checksum)) - header += fmt.Sprintf(d.tr.DetailsHistoryChecksumLabel+"%s\n", Orange(migration.DBChecksum)) - - // Read and show migration.sql content - sqlPath := filepath.Join(migration.Path, "migration.sql") - content, err := os.ReadFile(sqlPath) - if err == nil { - highlightedSQL := highlightSQL(string(content)) - d.content = header + "\n" + highlightedSQL - - // Show down.sql if available - if migration.HasDownSQL { - downSQLPath := filepath.Join(migration.Path, "down.sql") - downContent, err := os.ReadFile(downSQLPath) - if err == nil { - highlightedDownSQL := highlightSQL(string(downContent)) - d.content += "\n\n" + Yellow(d.tr.DetailsDownMigrationSQLLabel) + "\n\n" + highlightedDownSQL - } - } - } else { - d.content = header - } - return - } - - if migration.IsEmpty { - timestamp, name := parseMigrationName(migration.Name) - header := fmt.Sprintf(d.tr.DetailsNameLabel, Magenta(name)) - header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) - if migration.Path != "" { - header += fmt.Sprintf(d.tr.DetailsPathLabel, getRelativePath(migration.Path)) - } - header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n", Red(d.tr.MigrationStatusEmptyMigration)) - - // Show down migration availability (even for empty migrations) - if migration.HasDownSQL { - header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", Green(d.tr.DetailsDownMigrationAvailable)) - } else { - header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", Red(d.tr.DetailsDownMigrationNotAvailable)) - } - - header += "\n" + d.tr.DetailsEmptyMigrationDescription - header += d.tr.DetailsEmptyMigrationWarning - d.content = header - return - } - - // Read migration.sql content - sqlPath := filepath.Join(migration.Path, "migration.sql") - content, err := os.ReadFile(sqlPath) - if err != nil { - timestamp, name := parseMigrationName(migration.Name) - d.content = fmt.Sprintf(d.tr.DetailsNameLabel, name) + - fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) + - "\n" + fmt.Sprintf(d.tr.ErrorReadingMigrationSQL, err) - return - } - - // Build header with status - timestamp, name := parseMigrationName(migration.Name) - var header string - if migration.AppliedAt != nil { - header = fmt.Sprintf(d.tr.DetailsNameLabel, Green(name)) - header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) - if migration.Path != "" { - header += fmt.Sprintf(d.tr.DetailsPathLabel, getRelativePath(migration.Path)) - } - header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s (%s)\n", - Green(d.tr.MigrationStatusApplied), - fmt.Sprintf(d.tr.DetailsAppliedAtLabel, migration.AppliedAt.Format("2006-01-02 15:04:05"))) - } else { - header = fmt.Sprintf(d.tr.DetailsNameLabel, Yellow(name)) - header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) - if migration.Path != "" { - header += fmt.Sprintf(d.tr.DetailsPathLabel, getRelativePath(migration.Path)) - } - header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n", Yellow(d.tr.MigrationStatusPending)) - } - - // Show down migration availability - if migration.HasDownSQL { - header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", Green(d.tr.DetailsDownMigrationAvailable)) - } else { - header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", Red(d.tr.DetailsDownMigrationNotAvailable)) - } - - // Apply syntax highlighting to SQL content - highlightedSQL := highlightSQL(string(content)) - - d.content = header + "\n" + highlightedSQL - - // Show down.sql if available - if migration.HasDownSQL { - downSQLPath := filepath.Join(migration.Path, "down.sql") - downContent, err := os.ReadFile(downSQLPath) - if err == nil { - highlightedDownSQL := highlightSQL(string(downContent)) - d.content += "\n\n" + Yellow(d.tr.DetailsDownMigrationSQLLabel) + "\n\n" + highlightedDownSQL - } - } -} - -// parseMigrationName parses a Prisma migration name into timestamp and description -// Expected format: YYYYMMDDHHMMSS_description -// Example: 20231123052950_create_career_table -> "2023-11-23 05:29:50", "create_career_table" -func parseMigrationName(fullName string) (timestamp, name string) { - // Check if name matches expected format (at least 15 chars with underscore at position 14) - if len(fullName) > 15 && fullName[14] == '_' { - timestampStr := fullName[:14] // "20231123052950" - name = fullName[15:] // "create_career_table" - - // Parse timestamp: YYYYMMDDHHMMSS -> YYYY-MM-DD HH:MM:SS - if len(timestampStr) == 14 { - timestamp = fmt.Sprintf("%s-%s-%s %s:%s:%s", - timestampStr[0:4], // YYYY - timestampStr[4:6], // MM - timestampStr[6:8], // DD - timestampStr[8:10], // HH - timestampStr[10:12], // mm - timestampStr[12:14]) // ss - return timestamp, name - } - } - - // Fallback: couldn't parse, return as-is - return "N/A", fullName -} - -// getRelativePath converts absolute path to relative path from current working directory -func getRelativePath(absPath string) string { - if absPath == "" { - return "" - } - - cwd, err := os.Getwd() - if err != nil { - return absPath // Fallback to absolute path - } - - relPath, err := filepath.Rel(cwd, absPath) - if err != nil { - return absPath // Fallback to absolute path - } - - return relPath -} - -// LoadActionNeededData loads migrations that require action (Empty + Mismatch) and validates schema -func (d *DetailsPanel) LoadActionNeededData() { - if d.migrationsPanel == nil { - d.actionNeededMigrations = []prisma.Migration{} - d.validationResult = nil - d.updateTabs() - return - } - - // Collect Empty and Mismatch migrations from Local category - var actionNeeded []prisma.Migration - for _, mig := range d.migrationsPanel.category.Local { - if mig.IsEmpty || mig.ChecksumMismatch { - actionNeeded = append(actionNeeded, mig) - } - } - - d.actionNeededMigrations = actionNeeded - - // Run schema validation - cwd, err := os.Getwd() - if err == nil { - validateResult, err := prisma.Validate(cwd) - if err == nil { - d.validationResult = validateResult - } else { - d.validationResult = nil - } - } else { - d.validationResult = nil - } - - d.updateTabs() -} - -// updateTabs rebuilds the tabs list based on available data -func (d *DetailsPanel) updateTabs() { - // Always have Details tab - d.tabs = []string{d.tr.TabDetails} - - // Add Action-Needed tab if there are migration issues or validation errors - hasIssues := len(d.actionNeededMigrations) > 0 - hasValidationErrors := d.validationResult != nil && !d.validationResult.Valid - - if hasIssues || hasValidationErrors { - d.tabs = append(d.tabs, d.tr.TabActionNeeded) - } - - // Reset tab index if current tab no longer exists - if d.tabIndex >= len(d.tabs) { - d.tabIndex = 0 - } -} - -// saveCurrentTabState saves the current scroll position -func (d *DetailsPanel) saveCurrentTabState() { - if d.tabIndex >= len(d.tabs) { - return - } - tabName := d.tabs[d.tabIndex] - d.tabOriginY[tabName] = d.originY -} - -// restoreTabState restores the scroll position for the current tab -func (d *DetailsPanel) restoreTabState() { - if d.tabIndex >= len(d.tabs) { - return - } - tabName := d.tabs[d.tabIndex] - if prevOriginY, exists := d.tabOriginY[tabName]; exists { - d.originY = prevOriginY - } else { - d.originY = 0 - } -} - -// NextTab switches to the next tab -func (d *DetailsPanel) NextTab() { - if len(d.tabs) == 0 { - return - } - // Save current tab state before switching - d.saveCurrentTabState() - - d.tabIndex = (d.tabIndex + 1) % len(d.tabs) - - // Restore scroll position for new tab - d.restoreTabState() -} - -// PrevTab switches to the previous tab -func (d *DetailsPanel) PrevTab() { - if len(d.tabs) == 0 { - return - } - // Save current tab state before switching - d.saveCurrentTabState() - - d.tabIndex = (d.tabIndex - 1 + len(d.tabs)) % len(d.tabs) - - // Restore scroll position for new tab - d.restoreTabState() -} - -// SetApp sets the app reference for modal checking -func (d *DetailsPanel) SetApp(app *App) { - d.app = app -} - -// handleTabClick handles mouse click on tab bar -func (d *DetailsPanel) handleTabClick(tabIndex int) error { - // Ignore if modal is active - if d.app != nil && d.app.HasActiveModal() { - return nil - } - - // First, switch focus to this panel if not already focused - if d.app != nil { - if err := d.app.handlePanelClick(ViewDetails); err != nil { - return err - } - } - - // Ignore if same tab is clicked - if tabIndex == d.tabIndex { - return nil - } - - // Ignore if tab index is out of bounds - if tabIndex < 0 || tabIndex >= len(d.tabs) { - return nil - } - - // Save current tab state - d.saveCurrentTabState() - - // Switch to clicked tab - d.tabIndex = tabIndex - - // Restore scroll position for new tab - d.restoreTabState() - - return nil -} diff --git a/pkg/app/keybinding.go b/pkg/app/keybinding.go index 8b04573..499ff43 100644 --- a/pkg/app/keybinding.go +++ b/pkg/app/keybinding.go @@ -1,6 +1,9 @@ package app -import "github.com/jesseduffield/gocui" +import ( + "github.com/dokadev/lazyprisma/pkg/gui/context" + "github.com/jesseduffield/gocui" +) func (a *App) RegisterKeybindings() error { // Quit or close modal (lowercase q) @@ -56,9 +59,9 @@ func (a *App) RegisterKeybindings() error { } // Check if current panel supports tabs if panel := a.GetCurrentPanel(); panel != nil { - if migrationsPanel, ok := panel.(*MigrationsPanel); ok { + if migrationsPanel, ok := panel.(*context.MigrationsContext); ok { migrationsPanel.NextTab() - } else if detailsPanel, ok := panel.(*DetailsPanel); ok { + } else if detailsPanel, ok := panel.(*context.DetailsContext); ok { detailsPanel.NextTab() } } @@ -74,9 +77,9 @@ func (a *App) RegisterKeybindings() error { } // Check if current panel supports tabs if panel := a.GetCurrentPanel(); panel != nil { - if migrationsPanel, ok := panel.(*MigrationsPanel); ok { + if migrationsPanel, ok := panel.(*context.MigrationsContext); ok { migrationsPanel.PrevTab() - } else if detailsPanel, ok := panel.(*DetailsPanel); ok { + } else if detailsPanel, ok := panel.(*context.DetailsContext); ok { detailsPanel.PrevTab() } } @@ -114,13 +117,13 @@ func (a *App) RegisterKeybindings() error { // Handle different panel types if panel := a.GetCurrentPanel(); panel != nil { switch p := panel.(type) { - case *MigrationsPanel: + case *context.MigrationsContext: p.SelectPrev() - case *WorkspacePanel: + case *context.WorkspaceContext: p.ScrollUp() - case *DetailsPanel: + case *context.DetailsContext: p.ScrollUp() - case *OutputPanel: + case *context.OutputContext: p.ScrollUp() } } @@ -136,13 +139,13 @@ func (a *App) RegisterKeybindings() error { // Handle different panel types if panel := a.GetCurrentPanel(); panel != nil { switch p := panel.(type) { - case *MigrationsPanel: + case *context.MigrationsContext: p.SelectNext() - case *WorkspacePanel: + case *context.WorkspaceContext: p.ScrollDown() - case *DetailsPanel: + case *context.DetailsContext: p.ScrollDown() - case *OutputPanel: + case *context.OutputContext: p.ScrollDown() } } @@ -175,13 +178,13 @@ func (a *App) RegisterKeybindings() error { // Handle different panel types if panel := a.GetCurrentPanel(); panel != nil { switch p := panel.(type) { - case *MigrationsPanel: + case *context.MigrationsContext: p.ScrollToTop() - case *WorkspacePanel: + case *context.WorkspaceContext: p.ScrollToTop() - case *DetailsPanel: + case *context.DetailsContext: p.ScrollToTop() - case *OutputPanel: + case *context.OutputContext: p.ScrollToTop() } } @@ -198,13 +201,13 @@ func (a *App) RegisterKeybindings() error { // Handle different panel types if panel := a.GetCurrentPanel(); panel != nil { switch p := panel.(type) { - case *MigrationsPanel: + case *context.MigrationsContext: p.ScrollToBottom() - case *WorkspacePanel: + case *context.WorkspaceContext: p.ScrollToBottom() - case *DetailsPanel: + case *context.DetailsContext: p.ScrollToBottom() - case *OutputPanel: + case *context.OutputContext: p.ScrollToBottom() } } diff --git a/pkg/app/migrations.go b/pkg/app/migrations.go deleted file mode 100644 index 67b2fa6..0000000 --- a/pkg/app/migrations.go +++ /dev/null @@ -1,637 +0,0 @@ -package app - -import ( - "fmt" - "os" - "strings" - - "github.com/dokadev/lazyprisma/pkg/database" - "github.com/dokadev/lazyprisma/pkg/i18n" - "github.com/dokadev/lazyprisma/pkg/prisma" - "github.com/jesseduffield/gocui" - "github.com/jesseduffield/lazycore/pkg/boxlayout" -) - -type MigrationsPanel struct { - BasePanel - category prisma.MigrationCategory // Categorized migrations - items []string // Current tab's migration names - tabs []string // Tab names (conditional) - tabIndex int // Current tab index - selected int // Selected item in current tab - originY int // Scroll position - dbClient *database.Client // Database connection - dbConnected bool // True if connected to database - tableExists bool // True if _prisma_migrations table exists - - // Tab state preservation - tabSelected map[string]int // Last selected index per tab - tabOriginY map[string]int // Last scroll position per tab - - // Details panel reference - detailsPanel *DetailsPanel - - // Translation set (available from construction time) - tr *i18n.TranslationSet - - // App reference for modal check - app *App -} - -func NewMigrationsPanel(g *gocui.Gui, tr *i18n.TranslationSet) *MigrationsPanel { - panel := &MigrationsPanel{ - BasePanel: NewBasePanel(ViewMigrations, g), - items: []string{}, - tabs: []string{}, - tabIndex: 0, - selected: 0, - tr: tr, - tabSelected: make(map[string]int), - tabOriginY: make(map[string]int), - } - panel.loadMigrations() - return panel -} - -func (m *MigrationsPanel) loadMigrations() { - cwd, err := os.Getwd() - if err != nil { - m.items = []string{m.tr.ErrorFailedGetWorkingDirectory} - m.tabs = []string{m.tr.TabLocal} - return - } - - // Get local migrations - localMigrations, err := prisma.GetLocalMigrations(cwd) - if err != nil { - m.items = []string{fmt.Sprintf(m.tr.ErrorLoadingLocalMigrations, err)} - m.tabs = []string{m.tr.TabLocal} - return - } - - // Try to connect to database - ds, err := prisma.GetDatasource(cwd) - var dbMigrations []prisma.DBMigration - m.dbConnected = false - tableExists := false - - if err == nil && ds.URL != "" { - client, err := database.NewClientFromDSN(ds.Provider, ds.URL) - if err == nil { - m.dbClient = client - dbMigrations, err = prisma.GetDBMigrations(client.DB()) - if err == nil { - m.dbConnected = true - tableExists = true - } else { - // Check if error is due to missing table - if isMissingTableError(err) { - // Table doesn't exist - treat as empty DB (all migrations are pending) - m.dbConnected = true - tableExists = false - dbMigrations = []prisma.DBMigration{} // Empty list - } - // Other errors: keep m.dbConnected = false - } - } - } - - // If DB is connected (or table doesn't exist), categorize migrations - if m.dbConnected { - m.category = prisma.CompareMigrations(localMigrations, dbMigrations) - - // Build tabs based on available data - m.tabs = []string{m.tr.TabLocal} - if len(m.category.Pending) > 0 { - m.tabs = append(m.tabs, m.tr.TabPending) - } - if len(m.category.DBOnly) > 0 { - m.tabs = append(m.tabs, m.tr.TabDBOnly) - } - - // Store table existence info for display - m.tableExists = tableExists - } else { - // DB connection failed completely - m.category = prisma.MigrationCategory{ - Local: localMigrations, - Pending: []prisma.Migration{}, - DBOnly: []prisma.Migration{}, - } - m.tabs = []string{m.tr.TabLocal} - m.tableExists = false - } - - // Load items for current tab (default: Local) - m.tabIndex = 0 - m.loadItemsForCurrentTab() -} - -func (m *MigrationsPanel) loadItemsForCurrentTab() { - if m.tabIndex >= len(m.tabs) { - m.items = []string{} - return - } - - tabName := m.tabs[m.tabIndex] - var migrations []prisma.Migration - - switch tabName { - case m.tr.TabLocal: - migrations = m.category.Local - case m.tr.TabPending: - migrations = m.category.Pending - case m.tr.TabDBOnly: - migrations = m.category.DBOnly - } - - if len(migrations) == 0 { - m.items = []string{m.tr.ErrorNoMigrationsFound} - return - } - - m.items = make([]string, len(migrations)) - for i, mig := range migrations { - // Parse migration name to show only description (without timestamp) - displayName := mig.Name - if len(mig.Name) > 15 && mig.Name[14] == '_' { - displayName = mig.Name[15:] // Skip YYYYMMDDHHMMSS_ prefix - } - - // Add index number with color based on migration status - var indexPrefix string - if mig.IsEmpty { - indexPrefix = fmt.Sprintf("\033[31m%4d │\033[0m ", i+1) // Red for empty migrations (no migration.sql) - } else if mig.HasDownSQL { - indexPrefix = fmt.Sprintf("\033[32m%4d │\033[0m ", i+1) // Green for migrations with down.sql - } else { - indexPrefix = fmt.Sprintf("\033[90m%4d │\033[0m ", i+1) // Gray for normal migrations - } - - // Color priority: Failed > Checksum Mismatch > Empty > Pending > Normal - if mig.IsFailed { - // In-Transaction migrations (finished_at IS NULL AND rolled_back_at IS NULL) are shown in cyan - m.items[i] = indexPrefix + Cyan(displayName) - } else if mig.ChecksumMismatch { - // Checksum mismatch migrations are shown in orange - m.items[i] = indexPrefix + Orange(displayName) - } else if mig.IsEmpty { - // Empty migrations (no migration.sql) are shown in red - m.items[i] = indexPrefix + Red(displayName) - } else if m.dbConnected && mig.AppliedAt == nil { - // Pending migrations (not applied to DB) are shown in yellow - // Only when DB is connected (otherwise we can't determine pending status) - m.items[i] = indexPrefix + Yellow(displayName) - } else { - m.items[i] = indexPrefix + displayName - } - } - - // Restore previous selection and scroll position for this tab - if prevSelected, exists := m.tabSelected[tabName]; exists { - m.selected = prevSelected - // Ensure selection is within bounds - if m.selected >= len(m.items) { - m.selected = len(m.items) - 1 - } - if m.selected < 0 { - m.selected = 0 - } - } else { - m.selected = 0 - } - - if prevOriginY, exists := m.tabOriginY[tabName]; exists { - m.originY = prevOriginY - } else { - m.originY = 0 - } - - // Update details panel - m.updateDetails() -} - -func (m *MigrationsPanel) Draw(dim boxlayout.Dimensions) error { - v, err := m.g.SetView(m.id, dim.X0, dim.Y0, dim.X1, dim.Y1, 0) - if err != nil && err.Error() != "unknown view" { - return err - } - - // Setup view WITHOUT title (tabs replace title) - m.v = v - v.Clear() - v.Frame = true - v.FrameRunes = m.frameRunes - - // Set tabs - v.Tabs = m.tabs - v.TabIndex = m.tabIndex - - // Set footer based on current tab (moved from subtitle) - if m.tabIndex < len(m.tabs) { - // Footer (n of n) for all tabs - footer := m.buildFooter() - v.Footer = footer - } else { - v.Footer = "" - } - - // No subtitle - v.Subtitle = "" - - // Set frame and tab colors based on focus - if m.focused { - v.FrameColor = FocusedFrameColor - v.TitleColor = FocusedTitleColor - if len(m.tabs) == 1 { - v.SelFgColor = FocusedTitleColor // Single tab: treat like title - } else { - v.SelFgColor = FocusedActiveTabColor // Multiple tabs: use active tab color - } - } else { - v.FrameColor = PrimaryFrameColor - v.TitleColor = PrimaryTitleColor - if len(m.tabs) == 1 { - v.SelFgColor = PrimaryTitleColor // Single tab: treat like title - } else { - v.SelFgColor = PrimaryActiveTabColor // Multiple tabs: use active tab color - } - } - - // Enable highlight for selection - v.Highlight = true - v.SelBgColor = SelectionBgColor - - // Render items - for _, item := range m.items { - fmt.Fprintln(v, item) - } - - // Adjust origin to ensure it's within valid bounds - AdjustOrigin(v, &m.originY) - - // Set cursor position to selected item - v.SetCursor(0, m.selected-m.originY) - v.SetOrigin(0, m.originY) - - return nil -} - -// buildFooter builds the footer text (selection info in "n of n" format) -func (m *MigrationsPanel) buildFooter() string { - // Don't show footer if no valid items - if len(m.items) == 0 || (len(m.items) == 1 && m.items[0] == m.tr.ErrorNoMigrationsFound) { - return "" - } - - // Show selection info: "2 of 5" - return fmt.Sprintf(m.tr.MigrationsFooterFormat, m.selected+1, len(m.items)) -} - -func (m *MigrationsPanel) SelectNext() { - if len(m.items) == 0 { - return - } - - if m.selected < len(m.items)-1 { - m.selected++ - - // Auto-scroll if needed - if m.v != nil { - _, h := m.v.Size() - innerHeight := h - 2 // Subtract frame borders - if m.selected-m.originY >= innerHeight { - m.originY++ - } - } - - // Update details panel - m.updateDetails() - } -} - -func (m *MigrationsPanel) SelectPrev() { - if len(m.items) == 0 { - return - } - - if m.selected > 0 { - m.selected-- - - // Auto-scroll if needed - if m.selected < m.originY { - m.originY-- - } - - // Update details panel - m.updateDetails() - } -} - -// ScrollUpByWheel scrolls the migrations list up by 2 lines (mouse wheel) -func (m *MigrationsPanel) ScrollUpByWheel() { - if m.originY > 0 { - m.originY -= 2 - if m.originY < 0 { - m.originY = 0 - } - } -} - -// ScrollDownByWheel scrolls the migrations list down by 2 lines (mouse wheel) -func (m *MigrationsPanel) ScrollDownByWheel() { - if m.v == nil || len(m.items) == 0 { - return - } - - // Get actual content lines - contentLines := len(m.items) - _, viewHeight := m.v.Size() - innerHeight := viewHeight - 2 // Exclude frame (top + bottom) - - // Calculate maxOrigin - maxOrigin := contentLines - innerHeight - if maxOrigin < 0 { - maxOrigin = 0 - } - - // Only scroll if we haven't reached the bottom - if m.originY < maxOrigin { - m.originY += 2 - if m.originY > maxOrigin { - m.originY = maxOrigin - } - } -} - -// ScrollToTop scrolls to the top of the migrations list -func (m *MigrationsPanel) ScrollToTop() { - if len(m.items) == 0 { - return - } - - m.selected = 0 - m.originY = 0 - m.updateDetails() -} - -// ScrollToBottom scrolls to the bottom of the migrations list -func (m *MigrationsPanel) ScrollToBottom() { - if len(m.items) == 0 { - return - } - - maxIndex := len(m.items) - 1 - m.selected = maxIndex - - // Adjust origin to show the last item - if m.v != nil { - _, h := m.v.Size() - innerHeight := h - 2 // Subtract frame borders - m.originY = maxIndex - innerHeight + 1 - if m.originY < 0 { - m.originY = 0 - } - } - - m.updateDetails() -} - -// NextTab switches to the next tab -func (m *MigrationsPanel) NextTab() { - if len(m.tabs) == 0 { - return - } - - // Save current tab state before switching - m.saveCurrentTabState() - - m.tabIndex = (m.tabIndex + 1) % len(m.tabs) - m.loadItemsForCurrentTab() -} - -// PrevTab switches to the previous tab -func (m *MigrationsPanel) PrevTab() { - if len(m.tabs) == 0 { - return - } - - // Save current tab state before switching - m.saveCurrentTabState() - - m.tabIndex = (m.tabIndex - 1 + len(m.tabs)) % len(m.tabs) - m.loadItemsForCurrentTab() -} - -// saveCurrentTabState saves the current selection and scroll position -func (m *MigrationsPanel) saveCurrentTabState() { - if m.tabIndex >= len(m.tabs) { - return - } - - tabName := m.tabs[m.tabIndex] - m.tabSelected[tabName] = m.selected - m.tabOriginY[tabName] = m.originY -} - -// GetSelectedMigration returns the currently selected migration -func (m *MigrationsPanel) GetSelectedMigration() *prisma.Migration { - if m.tabIndex >= len(m.tabs) { - return nil - } - - tabName := m.tabs[m.tabIndex] - var migrations []prisma.Migration - - switch tabName { - case m.tr.TabLocal: - migrations = m.category.Local - case m.tr.TabPending: - migrations = m.category.Pending - case m.tr.TabDBOnly: - migrations = m.category.DBOnly - } - - if m.selected >= 0 && m.selected < len(migrations) { - return &migrations[m.selected] - } - - return nil -} - -// GetCurrentTab returns the name of the current tab -func (m *MigrationsPanel) GetCurrentTab() string { - if m.tabIndex >= len(m.tabs) { - return "" - } - return m.tabs[m.tabIndex] -} - -// SetDetailsPanel sets the details panel reference and performs initial update -func (m *MigrationsPanel) SetDetailsPanel(details *DetailsPanel) { - m.detailsPanel = details - // Set bidirectional reference - details.migrationsPanel = m - // Load Action-Needed data - details.LoadActionNeededData() - // Update details with initial selection (index 0) - m.updateDetails() -} - -// updateDetails updates the details panel with current selection -func (m *MigrationsPanel) updateDetails() { - if m.detailsPanel == nil { - return - } - - migration := m.GetSelectedMigration() - tabName := m.GetCurrentTab() - m.detailsPanel.UpdateFromMigration(migration, tabName) -} - -// isMissingTableError checks if error is due to missing _prisma_migrations table -func isMissingTableError(err error) bool { - if err == nil { - return false - } - - errMsg := strings.ToLower(err.Error()) - - // Common error patterns across different databases - missingTablePatterns := []string{ - "does not exist", // PostgreSQL - "doesn't exist", // MySQL - "no such table", // SQLite - "invalid object name", // SQL Server - "table or view does not exist", // Oracle - } - - for _, pattern := range missingTablePatterns { - if strings.Contains(errMsg, pattern) { - return true - } - } - - return false -} - -// SetApp sets the app reference for modal checking -func (m *MigrationsPanel) SetApp(app *App) { - m.app = app -} - -// handleTabClick handles mouse click on tab bar -func (m *MigrationsPanel) handleTabClick(tabIndex int) error { - // Ignore if modal is active - if m.app != nil && m.app.HasActiveModal() { - return nil - } - - // First, switch focus to this panel if not already focused - if m.app != nil { - if err := m.app.handlePanelClick(ViewMigrations); err != nil { - return err - } - } - - // Ignore if same tab is clicked - if tabIndex == m.tabIndex { - return nil - } - - // Ignore if tab index is out of bounds - if tabIndex < 0 || tabIndex >= len(m.tabs) { - return nil - } - - // Save current tab state - m.saveCurrentTabState() - - // Switch to clicked tab - m.tabIndex = tabIndex - m.loadItemsForCurrentTab() - - return nil -} - -// handleListClick handles mouse click on list item -func (m *MigrationsPanel) handleListClick(y int) error { - // Ignore if modal is active - if m.app != nil && m.app.HasActiveModal() { - return nil - } - - // Ignore if no items - if len(m.items) == 0 { - return nil - } - - // opts.Y is already content-relative index (including origin) - clickedIndex := y - - // Validate index - if clickedIndex < 0 || clickedIndex >= len(m.items) { - return nil - } - - // Update selected index - m.selected = clickedIndex - - // Update details panel - m.updateDetails() - - // Switch focus to this panel if not already focused - if m.app != nil { - if err := m.app.handlePanelClick(ViewMigrations); err != nil { - return err - } - } - - return nil -} - -// Refresh reloads all migration data -func (m *MigrationsPanel) Refresh() { - // Save current state to restore after refresh - currentTabIndex := m.tabIndex - currentSelected := m.selected - currentOriginY := m.originY - - // Save current tab state before refresh (to prevent loadItemsForCurrentTab from resetting selection) - if currentTabIndex < len(m.tabs) { - currentTabName := m.tabs[currentTabIndex] - m.tabSelected[currentTabName] = currentSelected - m.tabOriginY[currentTabName] = currentOriginY - } - - // Reload migrations - m.loadMigrations() - - // Restore tab index if still valid - if currentTabIndex < len(m.tabs) { - m.tabIndex = currentTabIndex - } else { - // Reset to first tab if current tab no longer exists - m.tabIndex = 0 - } - - // Reload items for current tab - m.loadItemsForCurrentTab() - - // Restore selection if still valid - if currentSelected < len(m.items) { - m.selected = currentSelected - m.originY = currentOriginY - // Only update details if selection changed - m.updateDetails() - } else if len(m.items) > 0 { - // If old selection is invalid, select last valid item - m.selected = len(m.items) - 1 - m.originY = 0 - m.updateDetails() - } else { - // No items, reset - m.selected = 0 - m.originY = 0 - } -} diff --git a/pkg/app/output.go b/pkg/app/output.go deleted file mode 100644 index 7aa512f..0000000 --- a/pkg/app/output.go +++ /dev/null @@ -1,192 +0,0 @@ -package app - -import ( - "fmt" - "time" - - "github.com/dokadev/lazyprisma/pkg/i18n" - "github.com/jesseduffield/gocui" - "github.com/jesseduffield/lazycore/pkg/boxlayout" -) - -type OutputPanel struct { - BasePanel - tr *i18n.TranslationSet - content string - subtitle string // Custom subtitle - originY int // Scroll position - autoScrollToBottom bool // Auto-scroll to bottom on next draw -} - -func NewOutputPanel(g *gocui.Gui, tr *i18n.TranslationSet) *OutputPanel { - return &OutputPanel{ - BasePanel: NewBasePanel(ViewOutputs, g), - tr: tr, - content: "", // Start with empty output - } -} - -func (o *OutputPanel) Draw(dim boxlayout.Dimensions) error { - v, err := o.g.SetView(o.id, dim.X0, dim.Y0, dim.X1, dim.Y1, 0) - if err != nil && err.Error() != "unknown view" { - return err - } - - o.SetupView(v, o.tr.PanelTitleOutput) - o.v = v - v.Subtitle = o.subtitle // Set subtitle - v.Wrap = true // Enable word wrap - fmt.Fprint(v, o.content) - - // Auto-scroll to bottom if flagged - if o.autoScrollToBottom { - // Calculate maxOrigin - contentLines := len(v.ViewBufferLines()) - _, viewHeight := v.Size() - innerHeight := viewHeight - 2 // Exclude frame - maxOrigin := contentLines - innerHeight - if maxOrigin < 0 { - maxOrigin = 0 - } - o.originY = maxOrigin - o.autoScrollToBottom = false // Reset flag - } - - // Adjust origin to ensure it's within valid bounds - AdjustOrigin(v, &o.originY) - v.SetOrigin(0, o.originY) - - return nil -} - -func (o *OutputPanel) AppendOutput(text string) { - o.content += text + "\n" - // Flag to auto-scroll on next draw - o.autoScrollToBottom = true -} - -// LogAction logs an action with timestamp and optional details -func (o *OutputPanel) LogAction(action string, details ...string) { - // Get current timestamp - timestamp := time.Now().Format("15:04:05") - - // Add separator if there's already content - if o.content != "" { - o.content += "\n" - } - - // Format: [Timestamp] Action (in cyan bold) - header := fmt.Sprintf("%s %s", Gray(timestamp), Stylize(action, Style{FgColor: ColorCyan, Bold: true})) - o.content += header + "\n" - - // Add details with indentation - for _, detail := range details { - o.content += " " + detail + "\n" - } - - // Flag to auto-scroll on next draw - o.autoScrollToBottom = true -} - -// SetSubtitle sets the custom subtitle for the panel -func (o *OutputPanel) SetSubtitle(subtitle string) { - o.subtitle = subtitle -} - -// LogActionRed logs an action in red (for errors/warnings) -func (o *OutputPanel) LogActionRed(action string, details ...string) { - // Get current timestamp - timestamp := time.Now().Format("15:04:05") - - // Add separator if there's already content - if o.content != "" { - o.content += "\n" - } - - // Format: [Timestamp] Action in RED - header := fmt.Sprintf("%s %s", Gray(timestamp), - Stylize(action, Style{FgColor: ColorRed, Bold: true})) - o.content += header + "\n" - - // Add details with indentation in red - for _, detail := range details { - o.content += " " + Red(detail) + "\n" - } - - // Flag to auto-scroll on next draw - o.autoScrollToBottom = true -} - -// ScrollUp scrolls the output panel up -func (o *OutputPanel) ScrollUp() { - if o.originY > 0 { - o.originY-- - } -} - -// ScrollDown scrolls the output panel down -func (o *OutputPanel) ScrollDown() { - o.originY++ - // AdjustOrigin will be called in Draw() to ensure bounds -} - -// ScrollUpByWheel scrolls the output panel up by 2 lines (mouse wheel) -func (o *OutputPanel) ScrollUpByWheel() { - if o.originY > 0 { - o.originY -= 2 - if o.originY < 0 { - o.originY = 0 - } - } -} - -// ScrollDownByWheel scrolls the output panel down by 2 lines (mouse wheel) -func (o *OutputPanel) ScrollDownByWheel() { - if o.v == nil { - return - } - - // Get actual content lines from the rendered view buffer - contentLines := len(o.v.ViewBufferLines()) - _, viewHeight := o.v.Size() - innerHeight := viewHeight - 2 // Exclude frame (top + bottom) - - // Calculate maxOrigin - maxOrigin := contentLines - innerHeight - if maxOrigin < 0 { - maxOrigin = 0 - } - - // Only scroll if we haven't reached the bottom - if o.originY < maxOrigin { - o.originY += 2 - if o.originY > maxOrigin { - o.originY = maxOrigin - } - } -} - -// ScrollToTop scrolls to the top of the output panel -func (o *OutputPanel) ScrollToTop() { - o.originY = 0 -} - -// ScrollToBottom scrolls to the bottom of the output panel -func (o *OutputPanel) ScrollToBottom() { - if o.v == nil { - return - } - - // Get actual content lines from the rendered view buffer - contentLines := len(o.v.ViewBufferLines()) - _, viewHeight := o.v.Size() - innerHeight := viewHeight - 2 // Exclude frame (top + bottom) - - // Calculate maxOrigin - maxOrigin := contentLines - innerHeight - if maxOrigin < 0 { - maxOrigin = 0 - } - - o.originY = maxOrigin -} diff --git a/pkg/app/statusbar.go b/pkg/app/statusbar.go deleted file mode 100644 index 6033ae9..0000000 --- a/pkg/app/statusbar.go +++ /dev/null @@ -1,113 +0,0 @@ -package app - -import ( - "fmt" - - "github.com/jesseduffield/gocui" - "github.com/jesseduffield/lazycore/pkg/boxlayout" -) - -type StatusBar struct { - BasePanel - app *App // Reference to App for accessing command state -} - -func NewStatusBar(g *gocui.Gui, app *App) *StatusBar { - return &StatusBar{ - BasePanel: NewBasePanel(ViewStatusbar, g), - app: app, - } -} - -func (s *StatusBar) Draw(dim boxlayout.Dimensions) error { - // StatusBar has no frame, so adjust dimensions - frameOffset := 1 - x0 := dim.X0 - frameOffset - y0 := dim.Y0 - frameOffset - x1 := dim.X1 + frameOffset - y1 := dim.Y1 + frameOffset - - v, err := s.g.SetView(s.id, x0, y0, x1, y1, 0) - if err != nil && err.Error() != "unknown view" { - return err - } - - s.v = v - v.Clear() - v.Frame = false - - // Build status bar content - var leftContent string - var visibleLen int - - // Show spinner if command is running - if s.app.commandRunning.Load() { - frameIndex := s.app.spinnerFrame.Load() - spinner := string(spinnerFrames[frameIndex]) - - // Get running task name - taskName := "" - if val := s.app.runningCommandName.Load(); val != nil { - taskName = val.(string) - } - - leftContent = fmt.Sprintf(" %s %s ", Cyan(spinner), Gray(taskName)) - visibleLen += 1 + 1 + 1 + len(taskName) + 1 // " " + spinner + " " + taskName + " " - } else { - leftContent = " " // Single space when not running - visibleLen += 1 - } - - // Show Studio status if running - if s.app.studioRunning { - studioMsg := s.app.Tr.StatusStudioOn - leftContent += fmt.Sprintf("%s ", Green(studioMsg)) - visibleLen += len(studioMsg) + 1 - } - - // Helper to format key binding: [k]ey -> [Cyan(k)]Gray(ey) - // Returns styled string and its visible length - appendKey := func(key, desc string) { - // Style: [key]desc - styled := fmt.Sprintf("[%s]%s", Cyan(key), Gray(desc)) - // Visible: [key]desc - vLen := 1 + len(key) + 1 + len(desc) - - leftContent += styled + " " - visibleLen += vLen + 1 - } - - appendKey("r", s.app.Tr.KeyHintRefresh) - appendKey("d", s.app.Tr.KeyHintDev) - appendKey("D", s.app.Tr.KeyHintDeploy) - appendKey("g", s.app.Tr.KeyHintGenerate) - appendKey("s", s.app.Tr.KeyHintResolve) - appendKey("S", s.app.Tr.KeyHintStudio) - appendKey("c", s.app.Tr.KeyHintCopy) - - // Right content (Metadata) - // Style right content (e.g., in blue or default) - styledRight := fmt.Sprintf("%s %s", Blue(s.app.config.Developer), Gray(s.app.config.Version)) - rightLen := len(s.app.config.Developer) + 1 + len(s.app.config.Version) - - // Calculate padding - viewWidth, _ := v.Size() - paddingLen := viewWidth - visibleLen - rightLen - 2 // -2 for extra safety buffer - - if paddingLen < 1 { - paddingLen = 1 - } - - padding := "" - for i := 0; i < paddingLen; i++ { - padding += " " - } - - fmt.Fprint(v, leftContent + padding + styledRight) - - return nil -} - -// 상태바는 포커스를 받지 않음 -func (s *StatusBar) OnFocus() {} -func (s *StatusBar) OnBlur() {} diff --git a/pkg/app/test.go b/pkg/app/test.go index b2f0f7d..dc22291 100644 --- a/pkg/app/test.go +++ b/pkg/app/test.go @@ -5,6 +5,7 @@ import ( "os" "github.com/dokadev/lazyprisma/pkg/commands" + "github.com/dokadev/lazyprisma/pkg/gui/context" "github.com/dokadev/lazyprisma/pkg/prisma" "github.com/jesseduffield/gocui" ) @@ -188,7 +189,7 @@ func (a *App) TestPing() { return } - outputPanel, ok := a.panels[ViewOutputs].(*OutputPanel) + outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext) if !ok { a.finishCommand() // Clean up if panel not found return @@ -206,7 +207,7 @@ func (a *App) TestPing() { OnStdout(func(line string) { // Update UI on main thread a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { out.AppendOutput(" " + line) } return nil @@ -215,7 +216,7 @@ func (a *App) TestPing() { OnStderr(func(line string) { // Update UI on main thread a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { out.AppendOutput(" [ERROR] " + line) } return nil @@ -226,7 +227,7 @@ func (a *App) TestPing() { // Update UI on main thread a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { if exitCode == 0 { out.LogAction("Network Test Complete", "Ping successful") } else { @@ -241,7 +242,7 @@ func (a *App) TestPing() { // Update UI on main thread a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok { + if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { out.LogAction("Network Test Error", err.Error()) } return nil diff --git a/pkg/common/common.go b/pkg/common/common.go index f85d530..47800cd 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -11,9 +11,9 @@ type Common struct { Tr *i18n.TranslationSet } -// NewCommon creates a new Common instance with the given language. -func NewCommon(language string) *Common { +// NewCommon creates a new Common instance with the given TranslationSet. +func NewCommon(tr *i18n.TranslationSet) *Common { return &Common{ - Tr: i18n.NewTranslationSet(language), + Tr: tr, } } diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go new file mode 100644 index 0000000..a3708aa --- /dev/null +++ b/pkg/gui/context/base_context.go @@ -0,0 +1,105 @@ +package context + +import ( + "github.com/dokadev/lazyprisma/pkg/gui/types" + "github.com/jesseduffield/gocui" +) + +type BaseContext struct { + key types.ContextKey + kind types.ContextKind + viewName string + view *gocui.View + focusable bool + title string + + // Lifecycle hooks (multiple can attach) + onFocusFns []func(types.OnFocusOpts) + onFocusLostFns []func(types.OnFocusLostOpts) + + // Keybinding attachment + keybindingsFns []types.KeybindingsFn +} + +var _ types.IBaseContext = &BaseContext{} + +type BaseContextOpts struct { + Key types.ContextKey + Kind types.ContextKind + ViewName string + View *gocui.View + Focusable bool + Title string +} + +func NewBaseContext(opts BaseContextOpts) *BaseContext { + return &BaseContext{ + key: opts.Key, + kind: opts.Kind, + viewName: opts.ViewName, + view: opts.View, + focusable: opts.Focusable, + title: opts.Title, + } +} + +func (self *BaseContext) GetKey() types.ContextKey { + return self.key +} + +func (self *BaseContext) GetKind() types.ContextKind { + return self.kind +} + +func (self *BaseContext) GetViewName() string { + if self.view != nil { + return self.view.Name() + } + return self.viewName +} + +func (self *BaseContext) GetView() *gocui.View { + return self.view +} + +func (self *BaseContext) SetView(v *gocui.View) { + self.view = v +} + +func (self *BaseContext) IsFocusable() bool { + return self.focusable +} + +func (self *BaseContext) Title() string { + return self.title +} + +// AddKeybindingsFn registers a function that provides keybindings for this context. +// Controllers call this to attach their bindings. +func (self *BaseContext) AddKeybindingsFn(fn types.KeybindingsFn) { + self.keybindingsFns = append(self.keybindingsFns, fn) +} + +// GetKeybindings collects all registered keybindings. +// Later-registered functions take precedence (appended in reverse order). +func (self *BaseContext) GetKeybindings() []*types.Binding { + bindings := []*types.Binding{} + for i := range self.keybindingsFns { + bindings = append(bindings, self.keybindingsFns[len(self.keybindingsFns)-1-i]()...) + } + return bindings +} + +// AddOnFocusFn registers a lifecycle hook called when this context gains focus. +func (self *BaseContext) AddOnFocusFn(fn func(types.OnFocusOpts)) { + if fn != nil { + self.onFocusFns = append(self.onFocusFns, fn) + } +} + +// AddOnFocusLostFn registers a lifecycle hook called when this context loses focus. +func (self *BaseContext) AddOnFocusLostFn(fn func(types.OnFocusLostOpts)) { + if fn != nil { + self.onFocusLostFns = append(self.onFocusLostFns, fn) + } +} diff --git a/pkg/gui/context/details_context.go b/pkg/gui/context/details_context.go new file mode 100644 index 0000000..4fb151c --- /dev/null +++ b/pkg/gui/context/details_context.go @@ -0,0 +1,837 @@ +package context + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/alecthomas/chroma/v2/formatters" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + "github.com/dokadev/lazyprisma/pkg/gui/types" + "github.com/dokadev/lazyprisma/pkg/i18n" + "github.com/dokadev/lazyprisma/pkg/prisma" + "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazycore/pkg/boxlayout" +) + +// ============================================================================ +// Self-contained ANSI styling helpers (avoid importing pkg/app for colours) +// ============================================================================ + +func detailsRed(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[31m%s\x1b[0m", text) +} + +func detailsGreen(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[32m%s\x1b[0m", text) +} + +func detailsYellow(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[33m%s\x1b[0m", text) +} + +func detailsMagenta(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[35m%s\x1b[0m", text) +} + +func detailsCyan(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[36m%s\x1b[0m", text) +} + +func detailsOrange(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[38;5;208m%s\x1b[0m", text) +} + +// detailsStylize applies combined ANSI styling (fg colour + bold). +func detailsStylize(text string, fgCode string, bold bool) string { + if text == "" { + return text + } + codes := make([]string, 0, 2) + if fgCode != "" { + codes = append(codes, fgCode) + } + if bold { + codes = append(codes, "1") + } + if len(codes) == 0 { + return text + } + return fmt.Sprintf("\x1b[%sm%s\x1b[0m", strings.Join(codes, ";"), text) +} + +// 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 { + *SimpleContext + *ScrollableTrait + *TabbedTrait + + g *gocui.Gui + tr *i18n.TranslationSet + + // Content fields + content string + currentMigrationName string + + // Action-needed data + actionNeededMigrations []prisma.Migration + validationResult *prisma.ValidateResult + + // UI state + focused bool + + // Callback-based decoupling (replaces direct App reference) + hasActiveModal func() bool + onPanelClick func(viewID string) +} + +var _ types.Context = &DetailsContext{} +var _ types.IScrollableContext = &DetailsContext{} + +// DetailsContextOpts holds the options for creating a DetailsContext. +type DetailsContextOpts struct { + Gui *gocui.Gui + Tr *i18n.TranslationSet + ViewName string +} + +// NewDetailsContext creates a new DetailsContext. +func NewDetailsContext(opts DetailsContextOpts) *DetailsContext { + baseCtx := NewBaseContext(BaseContextOpts{ + Key: types.ContextKey(opts.ViewName), + Kind: types.MAIN_CONTEXT, + ViewName: opts.ViewName, + Focusable: true, + Title: opts.Tr.PanelTitleDetails, + }) + + simpleCtx := NewSimpleContext(baseCtx) + + tabbedTrait := NewTabbedTrait([]string{opts.Tr.TabDetails}) + + dc := &DetailsContext{ + SimpleContext: simpleCtx, + ScrollableTrait: &ScrollableTrait{}, + TabbedTrait: &tabbedTrait, + g: opts.Gui, + tr: opts.Tr, + content: opts.Tr.DetailsPanelInitialPlaceholder, + actionNeededMigrations: []prisma.Migration{}, + } + + return dc +} + +// ID returns the view identifier (implements Panel interface from app package). +func (d *DetailsContext) ID() string { + return d.GetViewName() +} + +// SetModalCallbacks sets callbacks that replace the direct App reference. +func (d *DetailsContext) SetModalCallbacks(hasActiveModal func() bool, onPanelClick func(string)) { + d.hasActiveModal = hasActiveModal + d.onPanelClick = onPanelClick +} + +// Draw renders the details panel (implements Panel interface from app package). +func (d *DetailsContext) Draw(dim boxlayout.Dimensions) error { + v, err := d.g.SetView(d.GetViewName(), dim.X0, dim.Y0, dim.X1, dim.Y1, 0) + if err != nil && err.Error() != "unknown view" { + return err + } + + // Setup view WITHOUT title (tabs replace title) + d.SetView(v) // BaseContext + d.ScrollableTrait.SetView(v) // ScrollableTrait + + v.Clear() + v.Frame = true + v.FrameRunes = detailsDefaultFrameRunes + v.Wrap = true // Enable word wrap for long lines + + // Set tabs from TabbedTrait + v.Tabs = d.TabbedTrait.GetTabs() + v.TabIndex = d.TabbedTrait.GetCurrentTabIdx() + + // Set frame and tab colors based on focus + tabs := d.TabbedTrait.GetTabs() + if d.focused { + v.FrameColor = detailsFocusedFrameColor + v.TitleColor = detailsFocusedTitleColor + if len(tabs) == 1 { + v.SelFgColor = detailsFocusedTitleColor // Single tab: treat like title + } else { + v.SelFgColor = detailsFocusedActiveTabColor // Multiple tabs: use active tab color + } + } else { + v.FrameColor = detailsPrimaryFrameColor + v.TitleColor = detailsPrimaryTitleColor + if len(tabs) == 1 { + v.SelFgColor = detailsPrimaryTitleColor // Single tab: treat like title + } else { + v.SelFgColor = detailsPrimaryActiveTabColor // Multiple tabs: use active tab color + } + } + + // Render content based on current tab + currentTab := d.TabbedTrait.GetCurrentTab() + if currentTab == d.tr.TabActionNeeded { + fmt.Fprint(v, d.buildActionNeededContent()) + } else { + fmt.Fprint(v, d.content) + } + + // Adjust scroll and apply origin + d.ScrollableTrait.AdjustScroll() + + 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 +} + +// UpdateFromMigration updates the details panel with migration information. +func (d *DetailsContext) UpdateFromMigration(migration *prisma.Migration, tabName string) { + // Only reset scroll position for Details tab if viewing a different migration + if migration != nil && d.currentMigrationName != migration.Name { + // Reset Details tab scroll position only + d.TabbedTrait.tabOriginY[d.tabIdxByName(d.tr.TabDetails)] = 0 + // If currently on Details tab, also update originY + if d.TabbedTrait.GetCurrentTab() == d.tr.TabDetails { + d.ScrollableTrait.SetOriginY(0) + } + d.currentMigrationName = migration.Name + } else if migration == nil { + // Reset Details tab scroll position only + d.TabbedTrait.tabOriginY[d.tabIdxByName(d.tr.TabDetails)] = 0 + // If currently on Details tab, also update originY + if d.TabbedTrait.GetCurrentTab() == d.tr.TabDetails { + d.ScrollableTrait.SetOriginY(0) + } + d.currentMigrationName = "" + } + + if migration == nil { + d.content = d.tr.DetailsPanelInitialPlaceholder + return + } + + d.content = d.buildMigrationDetailContent(migration, tabName) +} + +// buildMigrationDetailContent builds the detail content for a given migration. +func (d *DetailsContext) buildMigrationDetailContent(migration *prisma.Migration, tabName string) string { + // Handle different cases (priority: Failed > DB-Only > Checksum Mismatch > Empty) + + // In-Transaction migrations (highest priority) + if migration.IsFailed { + return d.buildFailedMigrationContent(migration) + } + + if tabName == "DB-Only" { + return d.buildDBOnlyContent(migration) + } + + // Checksum mismatch + if migration.ChecksumMismatch { + return d.buildChecksumMismatchContent(migration) + } + + if migration.IsEmpty { + return d.buildEmptyMigrationContent(migration) + } + + // Normal migration + return d.buildNormalMigrationContent(migration) +} + +// buildFailedMigrationContent builds content for failed/in-transaction migrations. +func (d *DetailsContext) buildFailedMigrationContent(migration *prisma.Migration) string { + timestamp, name := detailsParseMigrationName(migration.Name) + header := fmt.Sprintf(d.tr.DetailsNameLabel, detailsCyan(name)) + header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) + if migration.Path != "" { + header += fmt.Sprintf(d.tr.DetailsPathLabel, detailsGetRelativePath(migration.Path)) + } + header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n", detailsCyan(d.tr.MigrationStatusInTransaction)) + + // Show down migration availability + if migration.HasDownSQL { + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", detailsGreen(d.tr.DetailsDownMigrationAvailable)) + } else { + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", detailsRed(d.tr.DetailsDownMigrationNotAvailable)) + } + + // Show started_at if available + if migration.StartedAt != nil { + header += fmt.Sprintf(d.tr.DetailsStartedAtLabel+"%s\n", migration.StartedAt.Format("2006-01-02 15:04:05")) + } + + header += "\n" + detailsYellow(d.tr.DetailsInTransactionWarning) + header += "\n" + detailsYellow(d.tr.DetailsNoAdditionalMigrationsWarning) + header += "\n\n" + d.tr.DetailsResolveManuallyInstruction + + // Show logs if available + if migration.Logs != nil && *migration.Logs != "" { + header += "\n" + d.tr.DetailsErrorLogsLabel + "\n" + detailsRed(*migration.Logs) + } + + // Read and show migration.sql content (if Path is available - not DB-Only) + if migration.Path != "" { + sqlPath := filepath.Join(migration.Path, "migration.sql") + content, err := os.ReadFile(sqlPath) + if err == nil { + highlightedSQL := detailsHighlightSQL(string(content)) + result := header + "\n\n" + highlightedSQL + + // Show down.sql if available + if migration.HasDownSQL { + downSQLPath := filepath.Join(migration.Path, "down.sql") + downContent, err := os.ReadFile(downSQLPath) + if err == nil { + highlightedDownSQL := detailsHighlightSQL(string(downContent)) + result += "\n\n" + detailsYellow(d.tr.DetailsDownMigrationSQLLabel) + "\n\n" + highlightedDownSQL + } + } + return result + } + } + + return header +} + +// buildDBOnlyContent builds content for DB-only migrations. +func (d *DetailsContext) buildDBOnlyContent(migration *prisma.Migration) string { + timestamp, name := detailsParseMigrationName(migration.Name) + header := fmt.Sprintf(d.tr.DetailsNameLabel, detailsYellow(name)) + header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) + header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n\n", detailsRed(d.tr.MigrationStatusDBOnly)) + header += d.tr.DetailsDBOnlyDescription + return header +} + +// buildChecksumMismatchContent builds content for checksum mismatch migrations. +func (d *DetailsContext) buildChecksumMismatchContent(migration *prisma.Migration) string { + timestamp, name := detailsParseMigrationName(migration.Name) + header := fmt.Sprintf(d.tr.DetailsNameLabel, detailsOrange(name)) + header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) + if migration.Path != "" { + header += fmt.Sprintf(d.tr.DetailsPathLabel, detailsGetRelativePath(migration.Path)) + } + // Show Applied status with Checksum Mismatch warning + statusLine := fmt.Sprintf(d.tr.DetailsStatusLabel+"%s", detailsGreen(d.tr.MigrationStatusApplied)) + if migration.AppliedAt != nil { + statusLine += fmt.Sprintf(" (%s)", fmt.Sprintf(d.tr.DetailsAppliedAtLabel, migration.AppliedAt.Format("2006-01-02 15:04:05"))) + } + statusLine += fmt.Sprintf(" - %s\n", detailsOrange(d.tr.MigrationStatusChecksumMismatch)) + header += statusLine + + // Show down migration availability + if migration.HasDownSQL { + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", detailsGreen(d.tr.DetailsDownMigrationAvailable)) + } else { + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", detailsRed(d.tr.DetailsDownMigrationNotAvailable)) + } + + header += "\n" + d.tr.DetailsChecksumModifiedDescription + header += d.tr.DetailsChecksumIssuesWarning + + // Show checksum values (in orange for emphasis) + header += fmt.Sprintf(d.tr.DetailsLocalChecksumLabel+"%s\n", detailsOrange(migration.Checksum)) + header += fmt.Sprintf(d.tr.DetailsHistoryChecksumLabel+"%s\n", detailsOrange(migration.DBChecksum)) + + // Read and show migration.sql content + sqlPath := filepath.Join(migration.Path, "migration.sql") + content, err := os.ReadFile(sqlPath) + if err == nil { + highlightedSQL := detailsHighlightSQL(string(content)) + result := header + "\n" + highlightedSQL + + // Show down.sql if available + if migration.HasDownSQL { + downSQLPath := filepath.Join(migration.Path, "down.sql") + downContent, err := os.ReadFile(downSQLPath) + if err == nil { + highlightedDownSQL := detailsHighlightSQL(string(downContent)) + result += "\n\n" + detailsYellow(d.tr.DetailsDownMigrationSQLLabel) + "\n\n" + highlightedDownSQL + } + } + return result + } + + return header +} + +// buildEmptyMigrationContent builds content for empty migrations. +func (d *DetailsContext) buildEmptyMigrationContent(migration *prisma.Migration) string { + timestamp, name := detailsParseMigrationName(migration.Name) + header := fmt.Sprintf(d.tr.DetailsNameLabel, detailsMagenta(name)) + header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) + if migration.Path != "" { + header += fmt.Sprintf(d.tr.DetailsPathLabel, detailsGetRelativePath(migration.Path)) + } + header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n", detailsRed(d.tr.MigrationStatusEmptyMigration)) + + // Show down migration availability (even for empty migrations) + if migration.HasDownSQL { + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", detailsGreen(d.tr.DetailsDownMigrationAvailable)) + } else { + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", detailsRed(d.tr.DetailsDownMigrationNotAvailable)) + } + + header += "\n" + d.tr.DetailsEmptyMigrationDescription + header += d.tr.DetailsEmptyMigrationWarning + return header +} + +// buildNormalMigrationContent builds content for normal (applied/pending) migrations. +func (d *DetailsContext) buildNormalMigrationContent(migration *prisma.Migration) string { + // Read migration.sql content + sqlPath := filepath.Join(migration.Path, "migration.sql") + content, err := os.ReadFile(sqlPath) + if err != nil { + timestamp, name := detailsParseMigrationName(migration.Name) + return fmt.Sprintf(d.tr.DetailsNameLabel, name) + + fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) + + "\n" + fmt.Sprintf(d.tr.ErrorReadingMigrationSQL, err) + } + + // Build header with status + timestamp, name := detailsParseMigrationName(migration.Name) + var header string + if migration.AppliedAt != nil { + header = fmt.Sprintf(d.tr.DetailsNameLabel, detailsGreen(name)) + header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) + if migration.Path != "" { + header += fmt.Sprintf(d.tr.DetailsPathLabel, detailsGetRelativePath(migration.Path)) + } + header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s (%s)\n", + detailsGreen(d.tr.MigrationStatusApplied), + fmt.Sprintf(d.tr.DetailsAppliedAtLabel, migration.AppliedAt.Format("2006-01-02 15:04:05"))) + } else { + header = fmt.Sprintf(d.tr.DetailsNameLabel, detailsYellow(name)) + header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) + if migration.Path != "" { + header += fmt.Sprintf(d.tr.DetailsPathLabel, detailsGetRelativePath(migration.Path)) + } + header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n", detailsYellow(d.tr.MigrationStatusPending)) + } + + // Show down migration availability + if migration.HasDownSQL { + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", detailsGreen(d.tr.DetailsDownMigrationAvailable)) + } else { + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", detailsRed(d.tr.DetailsDownMigrationNotAvailable)) + } + + // Apply syntax highlighting to SQL content + highlightedSQL := detailsHighlightSQL(string(content)) + + result := header + "\n" + highlightedSQL + + // Show down.sql if available + if migration.HasDownSQL { + downSQLPath := filepath.Join(migration.Path, "down.sql") + downContent, err := os.ReadFile(downSQLPath) + if err == nil { + highlightedDownSQL := detailsHighlightSQL(string(downContent)) + result += "\n\n" + detailsYellow(d.tr.DetailsDownMigrationSQLLabel) + "\n\n" + highlightedDownSQL + } + } + + return result +} + +// SetActionNeededMigrations receives migration data from outside (App will call this). +// This replaces the old pattern of directly accessing MigrationsPanel. +func (d *DetailsContext) SetActionNeededMigrations(migrations []prisma.Migration) { + d.actionNeededMigrations = migrations +} + +// LoadActionNeededData loads action-needed data using the internal migrations list and validates schema. +func (d *DetailsContext) LoadActionNeededData() { + // Run schema validation + cwd, err := os.Getwd() + if err == nil { + validateResult, err := prisma.Validate(cwd) + if err == nil { + d.validationResult = validateResult + } else { + d.validationResult = nil + } + } else { + d.validationResult = nil + } + + d.updateTabs() +} + +// updateTabs rebuilds the tabs list based on available data. +func (d *DetailsContext) updateTabs() { + // Always have Details tab + newTabs := []string{d.tr.TabDetails} + + // Add Action-Needed tab if there are migration issues or validation errors + hasIssues := len(d.actionNeededMigrations) > 0 + hasValidationErrors := d.validationResult != nil && !d.validationResult.Valid + + if hasIssues || hasValidationErrors { + newTabs = append(newTabs, d.tr.TabActionNeeded) + } + + d.TabbedTrait.SetTabs(newTabs) +} + +// buildActionNeededContent builds the content for the Action-Needed tab. +func (d *DetailsContext) buildActionNeededContent() string { + // Count all issues + emptyCount := 0 + mismatchCount := 0 + var emptyMigrations []prisma.Migration + var mismatchMigrations []prisma.Migration + + for _, mig := range d.actionNeededMigrations { + if mig.IsEmpty { + emptyCount++ + emptyMigrations = append(emptyMigrations, mig) + } + if mig.ChecksumMismatch { + mismatchCount++ + mismatchMigrations = append(mismatchMigrations, mig) + } + } + + validationErrorCount := 0 + if d.validationResult != nil && !d.validationResult.Valid { + validationErrorCount = len(d.validationResult.Errors) + if validationErrorCount == 0 { + validationErrorCount = 1 // At least one error if validation failed + } + } + + totalCount := emptyCount + mismatchCount + validationErrorCount + + if totalCount == 0 { + return d.tr.ActionNeededNoIssuesMessage + } + + var content strings.Builder + + // Header + content.WriteString(fmt.Sprintf("%s (%d%s", detailsYellow(d.tr.ActionNeededHeader), totalCount, d.tr.ActionNeededIssueSingular)) + if totalCount > 1 { + content.WriteString(d.tr.ActionNeededIssuePlural) + } + content.WriteString(")\n\n") + + // Empty Migrations Section + if emptyCount > 0 { + content.WriteString(strings.Repeat("━", 40) + "\n") + content.WriteString(fmt.Sprintf("%s (%d)\n", detailsRed(d.tr.ActionNeededEmptyMigrationsHeader), emptyCount)) + content.WriteString(strings.Repeat("━", 40) + "\n\n") + + content.WriteString(d.tr.ActionNeededEmptyDescription) + + content.WriteString(d.tr.ActionNeededAffectedLabel) + for _, mig := range emptyMigrations { + _, name := detailsParseMigrationName(mig.Name) + content.WriteString(fmt.Sprintf(" • %s\n", detailsRed(name))) + } + + content.WriteString("\n" + d.tr.ActionNeededRecommendedLabel) + content.WriteString(d.tr.ActionNeededAddMigrationSQL) + content.WriteString(d.tr.ActionNeededDeleteEmptyFolders) + content.WriteString(d.tr.ActionNeededMarkAsBaseline) + } + + // Checksum Mismatch Section + if mismatchCount > 0 { + content.WriteString(strings.Repeat("━", 40) + "\n") + content.WriteString(fmt.Sprintf("%s (%d)\n", detailsOrange(d.tr.ActionNeededChecksumMismatchHeader), mismatchCount)) + content.WriteString(strings.Repeat("━", 40) + "\n\n") + + content.WriteString(d.tr.ActionNeededChecksumModifiedDescription) + + content.WriteString(detailsYellow(d.tr.ActionNeededWarningPrefix)) + content.WriteString(d.tr.ActionNeededEditingInconsistenciesWarning) + + content.WriteString(d.tr.ActionNeededAffectedLabel) + for _, mig := range mismatchMigrations { + _, name := detailsParseMigrationName(mig.Name) + content.WriteString(fmt.Sprintf(" • %s\n", detailsOrange(name))) + } + + content.WriteString("\n" + d.tr.ActionNeededRecommendedLabel) + content.WriteString(d.tr.ActionNeededRevertLocalChanges) + content.WriteString(d.tr.ActionNeededCreateNewInstead) + content.WriteString(d.tr.ActionNeededContactTeamIfNeeded) + } + + // Schema Validation Section + if validationErrorCount > 0 { + content.WriteString(strings.Repeat("━", 40) + "\n") + content.WriteString(fmt.Sprintf("%s (%d)\n", detailsRed(d.tr.ActionNeededSchemaValidationErrorsHeader), validationErrorCount)) + content.WriteString(strings.Repeat("━", 40) + "\n\n") + + content.WriteString(d.tr.ActionNeededSchemaValidationFailedDesc) + content.WriteString(d.tr.ActionNeededFixBeforeMigration) + + // Show full validation output (contains detailed error info) + if d.validationResult.Output != "" { + content.WriteString(detailsStylize(d.tr.ActionNeededValidationOutputLabel, "33", true) + "\n") + // Display the full output with proper formatting (preserve all line breaks) + outputLines := strings.Split(d.validationResult.Output, "\n") + for _, line := range outputLines { + // Highlight error lines + if strings.Contains(line, "Error:") || strings.Contains(line, "error:") { + content.WriteString(detailsRed(line) + "\n") + } else if strings.Contains(line, "-->") { + content.WriteString(detailsYellow(line) + "\n") + } else { + // Preserve empty lines and all other content as-is + content.WriteString(line + "\n") + } + } + content.WriteString("\n") + } + + content.WriteString(detailsStylize(d.tr.ActionNeededRecommendedActionsLabel, "33", true) + "\n") + content.WriteString(d.tr.ActionNeededFixSchemaErrors) + content.WriteString(d.tr.ActionNeededCheckLineNumbers) + content.WriteString(d.tr.ActionNeededReferPrismaDocumentation) + } + + return content.String() +} + +// NextTab switches to the next tab with scroll state save/restore. +func (d *DetailsContext) NextTab() { + if len(d.TabbedTrait.GetTabs()) == 0 { + return + } + // Save current scroll position before switching + d.TabbedTrait.SaveTabOriginY(d.ScrollableTrait.GetOriginY()) + d.TabbedTrait.NextTab() + // Restore scroll position for new tab + d.ScrollableTrait.SetOriginY(d.TabbedTrait.RestoreTabOriginY()) +} + +// PrevTab switches to the previous tab with scroll state save/restore. +func (d *DetailsContext) PrevTab() { + if len(d.TabbedTrait.GetTabs()) == 0 { + return + } + // Save current scroll position before switching + d.TabbedTrait.SaveTabOriginY(d.ScrollableTrait.GetOriginY()) + d.TabbedTrait.PrevTab() + // Restore scroll position for new tab + d.ScrollableTrait.SetOriginY(d.TabbedTrait.RestoreTabOriginY()) +} + +// handleTabClick handles mouse click on tab bar. +func (d *DetailsContext) HandleTabClick(tabIndex int) error { + // Ignore if modal is active + if d.hasActiveModal != nil && d.hasActiveModal() { + return nil + } + + // First, switch focus to this panel if not already focused + if d.onPanelClick != nil { + d.onPanelClick(d.GetViewName()) + } + + // Ignore if same tab is clicked + if tabIndex == d.TabbedTrait.GetCurrentTabIdx() { + return nil + } + + // Ignore if tab index is out of bounds + tabs := d.TabbedTrait.GetTabs() + if tabIndex < 0 || tabIndex >= len(tabs) { + return nil + } + + // Save current tab state + d.TabbedTrait.SaveTabOriginY(d.ScrollableTrait.GetOriginY()) + + // Switch to clicked tab + d.TabbedTrait.SetCurrentTabIdx(tabIndex) + + // Restore scroll position for new tab + d.ScrollableTrait.SetOriginY(d.TabbedTrait.RestoreTabOriginY()) + + return nil +} + +// ScrollUpByWheel scrolls up by wheel increment (delegates to ScrollableTrait). +func (d *DetailsContext) ScrollUpByWheel() { + d.ScrollableTrait.ScrollUpByWheel() +} + +// ScrollDownByWheel scrolls down by wheel increment (delegates to ScrollableTrait). +func (d *DetailsContext) ScrollDownByWheel() { + d.ScrollableTrait.ScrollDownByWheel() +} + +// ============================================================================ +// Private helpers +// ============================================================================ + +// tabIdxByName returns the index of the tab with the given name, or 0 if not found. +func (d *DetailsContext) tabIdxByName(name string) int { + for i, t := range d.TabbedTrait.GetTabs() { + if t == name { + return i + } + } + return 0 +} + +// detailsHighlightSQL applies syntax highlighting to SQL code with line numbers. +func detailsHighlightSQL(code string) string { + // Get SQL lexer + lexer := lexers.Get("sql") + if lexer == nil { + lexer = lexers.Fallback + } + + // Get style (dracula is a popular dark theme) + style := styles.Get("dracula") + if style == nil { + style = styles.Fallback + } + + // Get terminal formatter with 256 colors + formatter := formatters.Get("terminal256") + if formatter == nil { + formatter = formatters.Fallback + } + + // Tokenize and format + var buf bytes.Buffer + iterator, err := lexer.Tokenise(nil, code) + if err != nil { + return code // Return original if highlighting fails + } + + err = formatter.Format(&buf, style, iterator) + if err != nil { + return code // Return original if highlighting fails + } + + // Add line numbers + highlighted := buf.String() + lines := strings.Split(highlighted, "\n") + var result strings.Builder + + for i, line := range lines { + if i > 0 { + result.WriteString("\n") + } + // Line number in gray color, right-aligned to 4 digits + result.WriteString(fmt.Sprintf("\033[90m%4d │\033[0m %s", i+1, line)) + } + + return result.String() +} + +// detailsParseMigrationName parses a Prisma migration name into timestamp and description. +// Expected format: YYYYMMDDHHMMSS_description +// Example: 20231123052950_create_career_table -> "2023-11-23 05:29:50", "create_career_table" +func detailsParseMigrationName(fullName string) (timestamp, name string) { + // Check if name matches expected format (at least 15 chars with underscore at position 14) + if len(fullName) > 15 && fullName[14] == '_' { + timestampStr := fullName[:14] // "20231123052950" + name = fullName[15:] // "create_career_table" + + // Parse timestamp: YYYYMMDDHHMMSS -> YYYY-MM-DD HH:MM:SS + if len(timestampStr) == 14 { + timestamp = fmt.Sprintf("%s-%s-%s %s:%s:%s", + timestampStr[0:4], // YYYY + timestampStr[4:6], // MM + timestampStr[6:8], // DD + timestampStr[8:10], // HH + timestampStr[10:12], // mm + timestampStr[12:14]) // ss + return timestamp, name + } + } + + // Fallback: couldn't parse, return as-is + return "N/A", fullName +} + +// detailsGetRelativePath converts absolute path to relative path from current working directory. +func detailsGetRelativePath(absPath string) string { + if absPath == "" { + return "" + } + + cwd, err := os.Getwd() + if err != nil { + return absPath // Fallback to absolute path + } + + relPath, err := filepath.Rel(cwd, absPath) + if err != nil { + return absPath // Fallback to absolute path + } + + return relPath +} diff --git a/pkg/gui/context/migrations_context.go b/pkg/gui/context/migrations_context.go new file mode 100644 index 0000000..ed34434 --- /dev/null +++ b/pkg/gui/context/migrations_context.go @@ -0,0 +1,752 @@ +package context + +import ( + "fmt" + "os" + "strings" + + "github.com/dokadev/lazyprisma/pkg/database" + "github.com/dokadev/lazyprisma/pkg/gui/types" + "github.com/dokadev/lazyprisma/pkg/i18n" + "github.com/dokadev/lazyprisma/pkg/prisma" + "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazycore/pkg/boxlayout" +) + +// Self-contained ANSI colour helpers (avoid importing pkg/app) +func migRed(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[31m%s\x1b[0m", text) +} + +func migYellow(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[33m%s\x1b[0m", text) +} + +func migCyan(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[36m%s\x1b[0m", text) +} + +func migOrange(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[38;5;208m%s\x1b[0m", text) +} + +// 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 + + // Data + category prisma.MigrationCategory // Categorised migrations + items []string // Current tab's rendered display strings + selected int // Selected item index in current tab + dbClient *database.Client // Database connection + dbConnected bool // True if connected to database + tableExists bool // True if _prisma_migrations table exists + + // Per-tab state preservation + tabSelectedMap map[string]int // Last selected index per tab (keyed by tab name) + tabOriginYMap map[string]int // Last scroll position per tab (keyed by tab name) + + // Callbacks (replace direct panel/app references) + onSelectionChanged func(migration *prisma.Migration, tabName string) + hasActiveModal func() bool + onPanelClick func(viewID string) +} + +var _ types.Context = &MigrationsContext{} + +type MigrationsContextOpts struct { + Gui *gocui.Gui + Tr *i18n.TranslationSet + ViewName string +} + +func NewMigrationsContext(opts MigrationsContextOpts) *MigrationsContext { + baseCtx := NewBaseContext(BaseContextOpts{ + Key: types.ContextKey(opts.ViewName), + Kind: types.SIDE_CONTEXT, + ViewName: opts.ViewName, + Focusable: true, + }) + + simpleCtx := NewSimpleContext(baseCtx) + + mc := &MigrationsContext{ + SimpleContext: simpleCtx, + ScrollableTrait: &ScrollableTrait{}, + TabbedTrait: &TabbedTrait{}, + g: opts.Gui, + tr: opts.Tr, + items: []string{}, + selected: 0, + tabSelectedMap: make(map[string]int), + tabOriginYMap: make(map[string]int), + } + + // Initialise TabbedTrait with empty tabs (loadMigrations will populate) + tt := NewTabbedTrait([]string{}) + mc.TabbedTrait = &tt + + mc.loadMigrations() + return mc +} + +// --------------------------------------------------------------------------- +// Callback setters +// --------------------------------------------------------------------------- + +// SetOnSelectionChanged registers a callback invoked whenever the selected +// migration changes (replaces the old SetDetailsPanel coupling). +func (m *MigrationsContext) SetOnSelectionChanged(cb func(*prisma.Migration, string)) { + m.onSelectionChanged = cb +} + +// SetModalCallbacks registers callbacks for modal and panel-click checks +// (replaces the old SetApp coupling). +func (m *MigrationsContext) SetModalCallbacks(hasActiveModal func() bool, onPanelClick func(string)) { + m.hasActiveModal = hasActiveModal + m.onPanelClick = onPanelClick +} + +// --------------------------------------------------------------------------- +// Public accessors +// --------------------------------------------------------------------------- + +// ID returns the view identifier (Panel interface compatibility). +func (m *MigrationsContext) ID() string { + return m.GetViewName() +} + +// GetSelectedMigration returns the currently selected migration, or nil. +func (m *MigrationsContext) GetSelectedMigration() *prisma.Migration { + tabName := m.TabbedTrait.GetCurrentTab() + if tabName == "" { + return nil + } + + migrations := m.migrationsForTab(tabName) + + if m.selected >= 0 && m.selected < len(migrations) { + return &migrations[m.selected] + } + return nil +} + +// GetCurrentTabName returns the name of the active tab. +func (m *MigrationsContext) GetCurrentTabName() string { + return m.TabbedTrait.GetCurrentTab() +} + +// GetCategory exposes the full migration category for external use. +func (m *MigrationsContext) GetCategory() prisma.MigrationCategory { + return m.category +} + +// IsDBConnected returns whether the database connection is active. +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 +// --------------------------------------------------------------------------- + +// Draw renders the migrations panel (Panel interface compatibility). +func (m *MigrationsContext) Draw(dim boxlayout.Dimensions) error { + v, err := m.g.SetView(m.GetViewName(), dim.X0, dim.Y0, dim.X1, dim.Y1, 0) + if err != nil && err.Error() != "unknown view" { + return err + } + + // Store view references + m.BaseContext.SetView(v) + m.ScrollableTrait.SetView(v) + + v.Clear() + v.Frame = true + v.FrameRunes = migDefaultFrameRunes + + // Set tabs + tabs := m.TabbedTrait.GetTabs() + v.Tabs = tabs + v.TabIndex = m.TabbedTrait.GetCurrentTabIdx() + + // Footer + footer := m.buildFooter() + v.Footer = footer + v.Subtitle = "" + + // Frame and tab colours based on focus + if m.focused { + v.FrameColor = migFocusedFrameColor + v.TitleColor = migFocusedTitleColor + if len(tabs) == 1 { + v.SelFgColor = migFocusedTitleColor + } else { + v.SelFgColor = migFocusedActiveTabColor + } + } else { + v.FrameColor = migPrimaryFrameColor + v.TitleColor = migPrimaryTitleColor + if len(tabs) == 1 { + v.SelFgColor = migPrimaryTitleColor + } else { + v.SelFgColor = migPrimaryActiveTabColor + } + } + + // Enable highlight for selection + v.Highlight = true + v.SelBgColor = migSelectionBgColor + + // Render items + for _, item := range m.items { + fmt.Fprintln(v, item) + } + + // Adjust origin to ensure it's within valid bounds + m.adjustOrigin(v) + + // Set cursor position to selected item + v.SetCursor(0, m.selected-m.ScrollableTrait.GetOriginY()) + v.SetOrigin(0, m.ScrollableTrait.GetOriginY()) + + return nil +} + +// adjustOrigin clamps the scroll origin within valid bounds. +func (m *MigrationsContext) adjustOrigin(v *gocui.View) { + if v == nil { + return + } + + contentLines := len(v.ViewBufferLines()) + _, viewHeight := v.Size() + innerHeight := viewHeight - 2 + + maxOrigin := contentLines - innerHeight + if maxOrigin < 0 { + maxOrigin = 0 + } + + originY := m.ScrollableTrait.GetOriginY() + if originY > maxOrigin { + m.ScrollableTrait.SetOriginY(maxOrigin) + } +} + +// buildFooter builds the footer text (selection info in "n of n" format). +func (m *MigrationsContext) buildFooter() string { + if len(m.items) == 0 || (len(m.items) == 1 && m.items[0] == m.tr.ErrorNoMigrationsFound) { + return "" + } + return fmt.Sprintf(m.tr.MigrationsFooterFormat, m.selected+1, len(m.items)) +} + +// --------------------------------------------------------------------------- +// Selection +// --------------------------------------------------------------------------- + +// SelectNext moves the selection down by one. +func (m *MigrationsContext) SelectNext() { + if len(m.items) == 0 { + return + } + + if m.selected < len(m.items)-1 { + m.selected++ + + // Auto-scroll if needed + if v := m.BaseContext.GetView(); v != nil { + _, h := v.Size() + innerHeight := h - 2 + originY := m.ScrollableTrait.GetOriginY() + if m.selected-originY >= innerHeight { + m.ScrollableTrait.SetOriginY(originY + 1) + } + } + + m.notifySelectionChanged() + } +} + +// SelectPrev moves the selection up by one. +func (m *MigrationsContext) SelectPrev() { + if len(m.items) == 0 { + return + } + + if m.selected > 0 { + m.selected-- + + // Auto-scroll if needed + originY := m.ScrollableTrait.GetOriginY() + if m.selected < originY { + m.ScrollableTrait.SetOriginY(originY - 1) + } + + m.notifySelectionChanged() + } +} + +// --------------------------------------------------------------------------- +// Scroll overrides (list-aware: also update selection) +// --------------------------------------------------------------------------- + +// ScrollToTop scrolls to the top of the list and selects the first item. +func (m *MigrationsContext) ScrollToTop() { + if len(m.items) == 0 { + return + } + m.selected = 0 + m.ScrollableTrait.SetOriginY(0) + m.notifySelectionChanged() +} + +// ScrollToBottom scrolls to the bottom of the list and selects the last item. +func (m *MigrationsContext) ScrollToBottom() { + if len(m.items) == 0 { + return + } + + maxIndex := len(m.items) - 1 + m.selected = maxIndex + + if v := m.BaseContext.GetView(); v != nil { + _, h := v.Size() + innerHeight := h - 2 + newOriginY := maxIndex - innerHeight + 1 + if newOriginY < 0 { + newOriginY = 0 + } + m.ScrollableTrait.SetOriginY(newOriginY) + } + + m.notifySelectionChanged() +} + +// ScrollUpByWheel scrolls the view up by 2 lines (mouse wheel). +func (m *MigrationsContext) ScrollUpByWheel() { + m.ScrollableTrait.ScrollUpByWheel() +} + +// ScrollDownByWheel scrolls the view down by 2 lines (mouse wheel). +func (m *MigrationsContext) ScrollDownByWheel() { + if m.BaseContext.GetView() == nil || len(m.items) == 0 { + return + } + + contentLines := len(m.items) + v := m.BaseContext.GetView() + _, viewHeight := v.Size() + innerHeight := viewHeight - 2 + + maxOrigin := contentLines - innerHeight + if maxOrigin < 0 { + maxOrigin = 0 + } + + originY := m.ScrollableTrait.GetOriginY() + if originY < maxOrigin { + newOriginY := originY + 2 + if newOriginY > maxOrigin { + newOriginY = maxOrigin + } + m.ScrollableTrait.SetOriginY(newOriginY) + } +} + +// --------------------------------------------------------------------------- +// Tab switching (custom logic wrapping TabbedTrait) +// --------------------------------------------------------------------------- + +// NextTab switches to the next tab, saving and restoring per-tab state. +func (m *MigrationsContext) NextTab() { + tabs := m.TabbedTrait.GetTabs() + if len(tabs) == 0 { + return + } + + m.saveCurrentTabState() + m.TabbedTrait.NextTab() + m.loadItemsForCurrentTab() +} + +// PrevTab switches to the previous tab, saving and restoring per-tab state. +func (m *MigrationsContext) PrevTab() { + tabs := m.TabbedTrait.GetTabs() + if len(tabs) == 0 { + return + } + + m.saveCurrentTabState() + m.TabbedTrait.PrevTab() + m.loadItemsForCurrentTab() +} + +// --------------------------------------------------------------------------- +// Mouse handlers +// --------------------------------------------------------------------------- + +// HandleTabClick handles mouse click on a tab. +func (m *MigrationsContext) HandleTabClick(tabIndex int) error { + if m.hasActiveModal != nil && m.hasActiveModal() { + return nil + } + + // Switch focus to this panel if not already focused + if m.onPanelClick != nil { + m.onPanelClick(m.GetViewName()) + } + + // Ignore if same tab or out of bounds + if tabIndex == m.TabbedTrait.GetCurrentTabIdx() { + return nil + } + tabs := m.TabbedTrait.GetTabs() + if tabIndex < 0 || tabIndex >= len(tabs) { + return nil + } + + m.saveCurrentTabState() + m.TabbedTrait.SetCurrentTabIdx(tabIndex) + m.loadItemsForCurrentTab() + + return nil +} + +// HandleListClick handles mouse click on a list item. +func (m *MigrationsContext) HandleListClick(y int) error { + if m.hasActiveModal != nil && m.hasActiveModal() { + return nil + } + + if len(m.items) == 0 { + return nil + } + + clickedIndex := y + if clickedIndex < 0 || clickedIndex >= len(m.items) { + return nil + } + + m.selected = clickedIndex + m.notifySelectionChanged() + + // Switch focus to this panel if not already focused + if m.onPanelClick != nil { + m.onPanelClick(m.GetViewName()) + } + + return nil +} + +// --------------------------------------------------------------------------- +// Refresh +// --------------------------------------------------------------------------- + +// Refresh reloads all migration data, preserving current tab and selection +// where possible. +func (m *MigrationsContext) Refresh() { + currentTabIdx := m.TabbedTrait.GetCurrentTabIdx() + currentSelected := m.selected + currentOriginY := m.ScrollableTrait.GetOriginY() + + // Save current tab state before refresh + tabs := m.TabbedTrait.GetTabs() + if currentTabIdx < len(tabs) { + tabName := tabs[currentTabIdx] + m.tabSelectedMap[tabName] = currentSelected + m.tabOriginYMap[tabName] = currentOriginY + } + + // Reload migrations + m.loadMigrations() + + // Restore tab index if still valid + newTabs := m.TabbedTrait.GetTabs() + if currentTabIdx < len(newTabs) { + m.TabbedTrait.SetCurrentTabIdx(currentTabIdx) + } else { + m.TabbedTrait.SetCurrentTabIdx(0) + } + + // Reload items for current tab + m.loadItemsForCurrentTab() + + // Restore selection if still valid + if currentSelected < len(m.items) { + m.selected = currentSelected + m.ScrollableTrait.SetOriginY(currentOriginY) + m.notifySelectionChanged() + } else if len(m.items) > 0 { + m.selected = len(m.items) - 1 + m.ScrollableTrait.SetOriginY(0) + m.notifySelectionChanged() + } else { + m.selected = 0 + m.ScrollableTrait.SetOriginY(0) + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +// notifySelectionChanged invokes the onSelectionChanged callback if set. +func (m *MigrationsContext) notifySelectionChanged() { + if m.onSelectionChanged == nil { + return + } + migration := m.GetSelectedMigration() + tabName := m.GetCurrentTabName() + m.onSelectionChanged(migration, tabName) +} + +// migrationsForTab returns the migration slice for the given tab name. +func (m *MigrationsContext) migrationsForTab(tabName string) []prisma.Migration { + switch tabName { + case m.tr.TabLocal: + return m.category.Local + case m.tr.TabPending: + return m.category.Pending + case m.tr.TabDBOnly: + return m.category.DBOnly + } + return nil +} + +// saveCurrentTabState saves the current selection and scroll position for the +// active tab (keyed by tab name). +func (m *MigrationsContext) saveCurrentTabState() { + tabName := m.TabbedTrait.GetCurrentTab() + if tabName == "" { + return + } + m.tabSelectedMap[tabName] = m.selected + m.tabOriginYMap[tabName] = m.ScrollableTrait.GetOriginY() +} + +// loadMigrations loads local and (optionally) DB migrations and sets up tabs. +func (m *MigrationsContext) loadMigrations() { + cwd, err := os.Getwd() + if err != nil { + m.items = []string{m.tr.ErrorFailedGetWorkingDirectory} + m.TabbedTrait.SetTabs([]string{m.tr.TabLocal}) + return + } + + // Get local migrations + localMigrations, err := prisma.GetLocalMigrations(cwd) + if err != nil { + m.items = []string{fmt.Sprintf(m.tr.ErrorLoadingLocalMigrations, err)} + m.TabbedTrait.SetTabs([]string{m.tr.TabLocal}) + return + } + + // Try to connect to database + ds, err := prisma.GetDatasource(cwd) + var dbMigrations []prisma.DBMigration + m.dbConnected = false + tableExists := false + + if err == nil && ds.URL != "" { + client, err := database.NewClientFromDSN(ds.Provider, ds.URL) + if err == nil { + m.dbClient = client + dbMigrations, err = prisma.GetDBMigrations(client.DB()) + if err == nil { + m.dbConnected = true + tableExists = true + } else { + // Check if error is due to missing table + if isMigMissingTableError(err) { + m.dbConnected = true + tableExists = false + dbMigrations = []prisma.DBMigration{} + } + } + } + } + + if m.dbConnected { + m.category = prisma.CompareMigrations(localMigrations, dbMigrations) + + tabs := []string{m.tr.TabLocal} + if len(m.category.Pending) > 0 { + tabs = append(tabs, m.tr.TabPending) + } + if len(m.category.DBOnly) > 0 { + tabs = append(tabs, m.tr.TabDBOnly) + } + m.TabbedTrait.SetTabs(tabs) + + m.tableExists = tableExists + } else { + m.category = prisma.MigrationCategory{ + Local: localMigrations, + Pending: []prisma.Migration{}, + DBOnly: []prisma.Migration{}, + } + m.TabbedTrait.SetTabs([]string{m.tr.TabLocal}) + m.tableExists = false + } + + // Default to first tab + m.TabbedTrait.SetCurrentTabIdx(0) + m.loadItemsForCurrentTab() +} + +// loadItemsForCurrentTab rebuilds the display items for the active tab and +// restores any previously saved selection / scroll position. +func (m *MigrationsContext) loadItemsForCurrentTab() { + tabName := m.TabbedTrait.GetCurrentTab() + if tabName == "" { + m.items = []string{} + return + } + + migrations := m.migrationsForTab(tabName) + + if len(migrations) == 0 { + m.items = []string{m.tr.ErrorNoMigrationsFound} + return + } + + m.items = make([]string, len(migrations)) + for i, mig := range migrations { + // Parse migration name to show only description (without timestamp) + displayName := mig.Name + if len(mig.Name) > 15 && mig.Name[14] == '_' { + displayName = mig.Name[15:] // Skip YYYYMMDDHHMMSS_ prefix + } + + // Add index number with colour based on migration status + var indexPrefix string + if mig.IsEmpty { + indexPrefix = fmt.Sprintf("\033[31m%4d │\033[0m ", i+1) // Red for empty + } else if mig.HasDownSQL { + indexPrefix = fmt.Sprintf("\033[32m%4d │\033[0m ", i+1) // Green for down.sql + } else { + indexPrefix = fmt.Sprintf("\033[90m%4d │\033[0m ", i+1) // Gray for normal + } + + // Colour priority: Failed > Checksum Mismatch > Empty > Pending > Normal + if mig.IsFailed { + m.items[i] = indexPrefix + migCyan(displayName) + } else if mig.ChecksumMismatch { + m.items[i] = indexPrefix + migOrange(displayName) + } else if mig.IsEmpty { + m.items[i] = indexPrefix + migRed(displayName) + } else if m.dbConnected && mig.AppliedAt == nil { + m.items[i] = indexPrefix + migYellow(displayName) + } else { + m.items[i] = indexPrefix + displayName + } + } + + // Restore previous selection and scroll position for this tab + if prevSelected, exists := m.tabSelectedMap[tabName]; exists { + m.selected = prevSelected + if m.selected >= len(m.items) { + m.selected = len(m.items) - 1 + } + if m.selected < 0 { + m.selected = 0 + } + } else { + m.selected = 0 + } + + if prevOriginY, exists := m.tabOriginYMap[tabName]; exists { + m.ScrollableTrait.SetOriginY(prevOriginY) + } else { + m.ScrollableTrait.SetOriginY(0) + } + + // Notify selection changed + m.notifySelectionChanged() +} + +// isMigMissingTableError checks if an error is due to a missing +// _prisma_migrations table. +func isMigMissingTableError(err error) bool { + if err == nil { + return false + } + + errMsg := strings.ToLower(err.Error()) + + missingTablePatterns := []string{ + "does not exist", // PostgreSQL + "doesn't exist", // MySQL + "no such table", // SQLite + "invalid object name", // SQL Server + "table or view does not exist", // Oracle + } + + for _, pattern := range missingTablePatterns { + if strings.Contains(errMsg, pattern) { + return true + } + } + + return false +} diff --git a/pkg/gui/context/output_context.go b/pkg/gui/context/output_context.go new file mode 100644 index 0000000..887c41a --- /dev/null +++ b/pkg/gui/context/output_context.go @@ -0,0 +1,227 @@ +package context + +import ( + "fmt" + "time" + + "github.com/dokadev/lazyprisma/pkg/gui/types" + "github.com/dokadev/lazyprisma/pkg/i18n" + "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazycore/pkg/boxlayout" +) + +// ANSI styling helpers (self-contained to avoid circular import with app) +func gray(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[38;5;240m%s\x1b[0m", text) +} + +func red(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[31m%s\x1b[0m", text) +} + +func cyanBold(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[36;1m%s\x1b[0m", text) +} + +func redBold(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[31;1m%s\x1b[0m", text) +} + +// 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 + subtitle string + focused bool + autoScrollToBottom bool +} + +var _ types.Context = &OutputContext{} +var _ types.IScrollableContext = &OutputContext{} + +type OutputContextOpts struct { + Gui *gocui.Gui + Tr *i18n.TranslationSet + ViewName string +} + +func NewOutputContext(opts OutputContextOpts) *OutputContext { + baseCtx := NewBaseContext(BaseContextOpts{ + Key: types.ContextKey(opts.ViewName), + Kind: types.MAIN_CONTEXT, + ViewName: opts.ViewName, + Focusable: true, + Title: opts.Tr.PanelTitleOutput, + }) + + simpleCtx := NewSimpleContext(baseCtx) + + oc := &OutputContext{ + SimpleContext: simpleCtx, + ScrollableTrait: &ScrollableTrait{}, + g: opts.Gui, + tr: opts.Tr, + content: "", + } + + return oc +} + +// ID returns the view identifier (implements Panel interface from app package) +func (o *OutputContext) ID() string { + return o.GetViewName() +} + +// Draw renders the output panel (implements Panel interface from app package) +func (o *OutputContext) Draw(dim boxlayout.Dimensions) error { + v, err := o.g.SetView(o.GetViewName(), dim.X0, dim.Y0, dim.X1, dim.Y1, 0) + if err != nil && err.Error() != "unknown view" { + return err + } + + // Setup view (replicates BasePanel.SetupView) + o.setupView(v) + o.SetView(v) // BaseContext + o.ScrollableTrait.SetView(v) // ScrollableTrait + + v.Subtitle = o.subtitle + v.Wrap = true + fmt.Fprint(v, o.content) + + // Auto-scroll to bottom if flagged + if o.autoScrollToBottom { + contentLines := len(v.ViewBufferLines()) + _, viewHeight := v.Size() + innerHeight := viewHeight - 2 + maxOrigin := contentLines - innerHeight + if maxOrigin < 0 { + maxOrigin = 0 + } + o.ScrollableTrait.SetOriginY(maxOrigin) + o.autoScrollToBottom = false + } + + // Adjust scroll and apply origin + o.ScrollableTrait.AdjustScroll() + + return nil +} + +// setupView configures the view with common settings (replaces BasePanel.SetupView) +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 + } +} + +// AppendOutput appends text to the output buffer and flags auto-scroll +func (o *OutputContext) AppendOutput(text string) { + o.content += text + "\n" + o.autoScrollToBottom = true +} + +// LogAction logs an action with timestamp and optional details +func (o *OutputContext) LogAction(action string, details ...string) { + timestamp := time.Now().Format("15:04:05") + + if o.content != "" { + o.content += "\n" + } + + header := fmt.Sprintf("%s %s", gray(timestamp), cyanBold(action)) + o.content += header + "\n" + + for _, detail := range details { + o.content += " " + detail + "\n" + } + + o.autoScrollToBottom = true +} + +// LogActionRed logs an action in red (for errors/warnings) +func (o *OutputContext) LogActionRed(action string, details ...string) { + timestamp := time.Now().Format("15:04:05") + + if o.content != "" { + o.content += "\n" + } + + header := fmt.Sprintf("%s %s", gray(timestamp), redBold(action)) + o.content += header + "\n" + + for _, detail := range details { + o.content += " " + red(detail) + "\n" + } + + o.autoScrollToBottom = true +} + +// SetSubtitle sets the custom subtitle for the panel +func (o *OutputContext) SetSubtitle(subtitle string) { + o.subtitle = subtitle +} + +// ScrollUpByWheel scrolls up by wheel increment (delegates to ScrollableTrait) +// This method is provided for backward compatibility with existing callers +// that pass no arguments (the old OutputPanel signature). +func (o *OutputContext) ScrollUpByWheel() { + o.ScrollableTrait.ScrollUpByWheel() +} + +// ScrollDownByWheel scrolls down by wheel increment (delegates to ScrollableTrait) +func (o *OutputContext) ScrollDownByWheel() { + o.ScrollableTrait.ScrollDownByWheel() +} diff --git a/pkg/gui/context/scrollable_trait.go b/pkg/gui/context/scrollable_trait.go new file mode 100644 index 0000000..1d84fc9 --- /dev/null +++ b/pkg/gui/context/scrollable_trait.go @@ -0,0 +1,115 @@ +package context + +import ( + "github.com/jesseduffield/gocui" +) + +const wheelScrollLines = 2 + +// ScrollableTrait provides shared vertical scroll logic. +// It tracks originY manually and applies it to the gocui view, +// replicating the exact behaviour used across all existing panels. +type ScrollableTrait struct { + view *gocui.View + originY int +} + +// SetView assigns (or reassigns) the underlying gocui view. +func (self *ScrollableTrait) SetView(v *gocui.View) { + self.view = v +} + +// GetOriginY returns the current scroll offset. +func (self *ScrollableTrait) GetOriginY() int { + return self.originY +} + +// SetOriginY sets the scroll offset directly (e.g. when restoring tab state). +func (self *ScrollableTrait) SetOriginY(y int) { + self.originY = y +} + +// ScrollUp scrolls the view up by 1 line. +func (self *ScrollableTrait) ScrollUp() { + if self.originY > 0 { + self.originY-- + } +} + +// ScrollDown scrolls the view down by 1 line. +// AdjustScroll should be called during render to clamp within bounds. +func (self *ScrollableTrait) ScrollDown() { + self.originY++ +} + +// ScrollUpByWheel scrolls the view up by the wheel increment. +func (self *ScrollableTrait) ScrollUpByWheel() { + if self.originY > 0 { + self.originY -= wheelScrollLines + if self.originY < 0 { + self.originY = 0 + } + } +} + +// ScrollDownByWheel scrolls the view down by the wheel increment, +// clamping to the maximum scrollable position. +func (self *ScrollableTrait) ScrollDownByWheel() { + if self.view == nil { + return + } + + maxOrigin := self.maxOrigin() + if self.originY < maxOrigin { + self.originY += wheelScrollLines + if self.originY > maxOrigin { + self.originY = maxOrigin + } + } +} + +// ScrollToTop scrolls to the very top. +func (self *ScrollableTrait) ScrollToTop() { + self.originY = 0 +} + +// ScrollToBottom scrolls to the very bottom. +func (self *ScrollableTrait) ScrollToBottom() { + if self.view == nil { + return + } + + maxOrigin := self.maxOrigin() + self.originY = maxOrigin +} + +// AdjustScroll clamps originY to valid bounds and applies it to the view. +// Call this during render after content has been written to the view. +func (self *ScrollableTrait) AdjustScroll() { + if self.view == nil { + return + } + + maxOrigin := self.maxOrigin() + if self.originY > maxOrigin { + self.originY = maxOrigin + } + if self.originY < 0 { + self.originY = 0 + } + + self.view.SetOrigin(0, self.originY) +} + +// maxOrigin calculates the maximum valid originY based on content and view size. +func (self *ScrollableTrait) maxOrigin() int { + contentLines := len(self.view.ViewBufferLines()) + _, viewHeight := self.view.Size() + innerHeight := viewHeight - 2 // Exclude frame (top + bottom) + + max := contentLines - innerHeight + if max < 0 { + max = 0 + } + return max +} diff --git a/pkg/gui/context/simple_context.go b/pkg/gui/context/simple_context.go new file mode 100644 index 0000000..631955f --- /dev/null +++ b/pkg/gui/context/simple_context.go @@ -0,0 +1,46 @@ +package context + +import ( + "github.com/dokadev/lazyprisma/pkg/gui/types" +) + +type SimpleContext struct { + *BaseContext + onRenderFn func() +} + +var _ types.Context = &SimpleContext{} + +func NewSimpleContext(baseContext *BaseContext) *SimpleContext { + return &SimpleContext{ + BaseContext: baseContext, + } +} + +// SetOnRenderFn sets the function called during HandleRender. +func (self *SimpleContext) SetOnRenderFn(fn func()) { + self.onRenderFn = fn +} + +// HandleFocus is called when this context gains focus. +// It invokes all registered onFocusFns in order. +func (self *SimpleContext) HandleFocus(opts types.OnFocusOpts) { + for _, fn := range self.onFocusFns { + fn(opts) + } +} + +// HandleFocusLost is called when this context loses focus. +// It invokes all registered onFocusLostFns in order. +func (self *SimpleContext) HandleFocusLost(opts types.OnFocusLostOpts) { + for _, fn := range self.onFocusLostFns { + fn(opts) + } +} + +// HandleRender is called when the context needs to re-render its content. +func (self *SimpleContext) HandleRender() { + if self.onRenderFn != nil { + self.onRenderFn() + } +} diff --git a/pkg/gui/context/statusbar_context.go b/pkg/gui/context/statusbar_context.go new file mode 100644 index 0000000..1bffad0 --- /dev/null +++ b/pkg/gui/context/statusbar_context.go @@ -0,0 +1,187 @@ +package context + +import ( + "fmt" + + "github.com/dokadev/lazyprisma/pkg/gui/types" + "github.com/dokadev/lazyprisma/pkg/i18n" + "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazycore/pkg/boxlayout" +) + +// ANSI styling helpers for status bar (self-contained to avoid circular import with app) +func statusCyan(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[36m%s\x1b[0m", text) +} + +func statusGray(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[38;5;240m%s\x1b[0m", text) +} + +func statusGreen(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[32m%s\x1b[0m", text) +} + +func statusBlue(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[34m%s\x1b[0m", text) +} + +// StatusBarState provides callbacks for accessing App state without direct dependency. +type StatusBarState struct { + IsCommandRunning func() bool + GetSpinnerFrame func() uint32 + IsStudioRunning func() bool + GetCommandName func() string +} + +// StatusBarConfig holds static configuration for the status bar display. +type StatusBarConfig struct { + Developer string + Version string +} + +var spinnerFrames = []rune{'|', '/', '-', '\\'} + +type StatusBarContext struct { + *BaseContext + + g *gocui.Gui + tr *i18n.TranslationSet + state StatusBarState + config StatusBarConfig +} + +type StatusBarContextOpts struct { + Gui *gocui.Gui + Tr *i18n.TranslationSet + ViewName string + State StatusBarState + Config StatusBarConfig +} + +func NewStatusBarContext(opts StatusBarContextOpts) *StatusBarContext { + baseCtx := NewBaseContext(BaseContextOpts{ + Key: types.ContextKey(opts.ViewName), + Kind: types.MAIN_CONTEXT, + ViewName: opts.ViewName, + Focusable: false, + Title: "", + }) + + return &StatusBarContext{ + BaseContext: baseCtx, + g: opts.Gui, + tr: opts.Tr, + state: opts.State, + config: opts.Config, + } +} + +// ID returns the view identifier (implements Panel interface from app package) +func (s *StatusBarContext) ID() string { + return s.GetViewName() +} + +// Draw renders the status bar (implements Panel interface from app package) +func (s *StatusBarContext) Draw(dim boxlayout.Dimensions) error { + // StatusBar has no frame, so adjust dimensions + frameOffset := 1 + x0 := dim.X0 - frameOffset + y0 := dim.Y0 - frameOffset + x1 := dim.X1 + frameOffset + y1 := dim.Y1 + frameOffset + + v, err := s.g.SetView(s.GetViewName(), x0, y0, x1, y1, 0) + if err != nil && err.Error() != "unknown view" { + return err + } + + s.SetView(v) + v.Clear() + v.Frame = false + + // Build status bar content + var leftContent string + var visibleLen int + + // Show spinner if command is running + if s.state.IsCommandRunning() { + frameIndex := s.state.GetSpinnerFrame() + spinner := string(spinnerFrames[frameIndex]) + + // Get running task name + taskName := s.state.GetCommandName() + + leftContent = fmt.Sprintf(" %s %s ", statusCyan(spinner), statusGray(taskName)) + visibleLen += 1 + 1 + 1 + len(taskName) + 1 // " " + spinner + " " + taskName + " " + } else { + leftContent = " " // Single space when not running + visibleLen += 1 + } + + // Show Studio status if running + if s.state.IsStudioRunning() { + studioMsg := s.tr.StatusStudioOn + leftContent += fmt.Sprintf("%s ", statusGreen(studioMsg)) + visibleLen += len(studioMsg) + 1 + } + + // Helper to format key binding: [k]ey -> [Cyan(k)]Gray(ey) + // Returns styled string and its visible length + appendKey := func(key, desc string) { + // Style: [key]desc + styled := fmt.Sprintf("[%s]%s", statusCyan(key), statusGray(desc)) + // Visible: [key]desc + vLen := 1 + len(key) + 1 + len(desc) + + leftContent += styled + " " + visibleLen += vLen + 1 + } + + appendKey("r", s.tr.KeyHintRefresh) + appendKey("d", s.tr.KeyHintDev) + appendKey("D", s.tr.KeyHintDeploy) + appendKey("g", s.tr.KeyHintGenerate) + appendKey("s", s.tr.KeyHintResolve) + appendKey("S", s.tr.KeyHintStudio) + appendKey("c", s.tr.KeyHintCopy) + + // Right content (Metadata) + styledRight := fmt.Sprintf("%s %s", statusBlue(s.config.Developer), statusGray(s.config.Version)) + rightLen := len(s.config.Developer) + 1 + len(s.config.Version) + + // Calculate padding + viewWidth, _ := v.Size() + paddingLen := viewWidth - visibleLen - rightLen - 2 // -2 for extra safety buffer + + if paddingLen < 1 { + paddingLen = 1 + } + + padding := "" + for i := 0; i < paddingLen; i++ { + padding += " " + } + + fmt.Fprint(v, leftContent+padding+styledRight) + + return nil +} + +// OnFocus is a no-op for the status bar (not focusable) +func (s *StatusBarContext) OnFocus() {} + +// OnBlur is a no-op for the status bar (not focusable) +func (s *StatusBarContext) OnBlur() {} diff --git a/pkg/gui/context/tabbed_trait.go b/pkg/gui/context/tabbed_trait.go new file mode 100644 index 0000000..74782e0 --- /dev/null +++ b/pkg/gui/context/tabbed_trait.go @@ -0,0 +1,88 @@ +package context + +// TabbedTrait provides shared tab management with per-tab scroll position saving. +// It replicates the exact tab origin save/restore pattern used in +// MigrationsPanel and DetailsPanel. +type TabbedTrait struct { + tabs []string + currentTabIdx int + tabOriginY map[int]int // scroll position keyed by tab index +} + +// NewTabbedTrait creates a TabbedTrait with the given initial tabs. +func NewTabbedTrait(tabs []string) TabbedTrait { + return TabbedTrait{ + tabs: tabs, + currentTabIdx: 0, + tabOriginY: make(map[int]int), + } +} + +// SetTabs replaces the tab list. If the current index is out of bounds +// after the change, it resets to 0. +func (self *TabbedTrait) SetTabs(tabs []string) { + self.tabs = tabs + if self.currentTabIdx >= len(self.tabs) { + self.currentTabIdx = 0 + } +} + +// GetTabs returns the current tab names. +func (self *TabbedTrait) GetTabs() []string { + return self.tabs +} + +// GetCurrentTab returns the name of the active tab, +// or an empty string if there are no tabs. +func (self *TabbedTrait) GetCurrentTab() string { + if self.currentTabIdx >= len(self.tabs) { + return "" + } + return self.tabs[self.currentTabIdx] +} + +// GetCurrentTabIdx returns the zero-based index of the active tab. +func (self *TabbedTrait) GetCurrentTabIdx() int { + return self.currentTabIdx +} + +// SetCurrentTabIdx sets the active tab index directly. +func (self *TabbedTrait) SetCurrentTabIdx(idx int) { + if idx >= 0 && idx < len(self.tabs) { + self.currentTabIdx = idx + } +} + +// NextTab advances to the next tab, wrapping around. +// The caller must call SaveTabOriginY before and RestoreTabOriginY after. +func (self *TabbedTrait) NextTab() { + if len(self.tabs) == 0 { + return + } + self.currentTabIdx = (self.currentTabIdx + 1) % len(self.tabs) +} + +// PrevTab moves to the previous tab, wrapping around. +// The caller must call SaveTabOriginY before and RestoreTabOriginY after. +func (self *TabbedTrait) PrevTab() { + if len(self.tabs) == 0 { + return + } + self.currentTabIdx = (self.currentTabIdx - 1 + len(self.tabs)) % len(self.tabs) +} + +// SaveTabOriginY saves the given scroll position for the current tab. +// Call this before switching tabs. +func (self *TabbedTrait) SaveTabOriginY(originY int) { + self.tabOriginY[self.currentTabIdx] = originY +} + +// RestoreTabOriginY returns the saved scroll position for the current tab. +// Returns 0 if no position was previously saved. +// Call this after switching tabs. +func (self *TabbedTrait) RestoreTabOriginY() int { + if y, exists := self.tabOriginY[self.currentTabIdx]; exists { + return y + } + return 0 +} diff --git a/pkg/app/workspace.go b/pkg/gui/context/workspace_context.go similarity index 57% rename from pkg/app/workspace.go rename to pkg/gui/context/workspace_context.go index 9248a68..7f9e6cb 100644 --- a/pkg/app/workspace.go +++ b/pkg/gui/context/workspace_context.go @@ -1,4 +1,4 @@ -package app +package context import ( "fmt" @@ -9,6 +9,7 @@ import ( "github.com/dokadev/lazyprisma/pkg/database" _ "github.com/dokadev/lazyprisma/pkg/database/drivers" // Register database drivers "github.com/dokadev/lazyprisma/pkg/git" + "github.com/dokadev/lazyprisma/pkg/gui/types" "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/dokadev/lazyprisma/pkg/node" "github.com/dokadev/lazyprisma/pkg/prisma" @@ -16,9 +17,72 @@ import ( "github.com/jesseduffield/lazycore/pkg/boxlayout" ) -type WorkspacePanel struct { - BasePanel +// ANSI styling helpers (self-contained to avoid circular import with app) +func stylize(text string, fg string, bold bool) string { + if text == "" { + return text + } + codes := "" + if fg != "" { + codes = fg + } + if bold { + if codes != "" { + codes += ";1" + } else { + codes = "1" + } + } + if codes == "" { + return text + } + return fmt.Sprintf("\x1b[%sm%s\x1b[0m", codes, text) +} + +func yellowBold(text string) string { + return stylize(text, "33", true) +} + +func greenBold(text string) string { + return stylize(text, "32", true) +} + +func wsRed(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[31m%s\x1b[0m", text) +} + +func wsRedBold(text string) string { + return stylize(text, "31", true) +} + +func orange(text string) string { + if text == "" { + return text + } + return fmt.Sprintf("\x1b[38;5;208m%s\x1b[0m", text) +} + +// 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 nodeVersion string prismaVersion string prismaGlobal bool @@ -34,20 +98,163 @@ type WorkspacePanel struct { dbError string envVarName string // Environment variable name (e.g., "DATABASE_URL") isHardcoded bool // True if URL is hardcoded in schema/config - originY int // Scroll position } -func NewWorkspacePanel(g *gocui.Gui, tr *i18n.TranslationSet) *WorkspacePanel { - wp := &WorkspacePanel{ - BasePanel: NewBasePanel(ViewWorkspace, g), - tr: tr, - showMasked: true, // Default to masked +var _ types.Context = &WorkspaceContext{} +var _ types.IScrollableContext = &WorkspaceContext{} + +type WorkspaceContextOpts struct { + Gui *gocui.Gui + Tr *i18n.TranslationSet + ViewName string +} + +func NewWorkspaceContext(opts WorkspaceContextOpts) *WorkspaceContext { + baseCtx := NewBaseContext(BaseContextOpts{ + Key: types.ContextKey(opts.ViewName), + Kind: types.SIDE_CONTEXT, + ViewName: opts.ViewName, + Focusable: true, + Title: opts.Tr.PanelTitleWorkspace, + }) + + simpleCtx := NewSimpleContext(baseCtx) + + wc := &WorkspaceContext{ + SimpleContext: simpleCtx, + ScrollableTrait: &ScrollableTrait{}, + g: opts.Gui, + tr: opts.Tr, + showMasked: true, // Default to masked } - wp.loadVersionInfo() - return wp + + wc.loadVersionInfo() + + return wc +} + +// ID returns the view identifier (implements Panel interface from app package) +func (w *WorkspaceContext) ID() string { + return w.GetViewName() } -func (w *WorkspacePanel) loadVersionInfo() { +// Draw renders the workspace panel (implements Panel interface from app package) +func (w *WorkspaceContext) Draw(dim boxlayout.Dimensions) error { + v, err := w.g.SetView(w.GetViewName(), dim.X0, dim.Y0, dim.X1, dim.Y1, 0) + if err != nil && err.Error() != "unknown view" { + return err + } + + // Setup view (replicates BasePanel.SetupView) + w.setupView(v) + w.SetView(v) // BaseContext + w.ScrollableTrait.SetView(v) // ScrollableTrait + + v.Wrap = true // Enable word wrap + + // Build content from fields + var lines []string + + // Node and Prisma version on one line + nodeVersionStyled := yellowBold(w.nodeVersion) + prismaVersionStyled := yellowBold(w.prismaVersion) + versionLine := fmt.Sprintf(w.tr.WorkspaceVersionLine, nodeVersionStyled, prismaVersionStyled) + if w.prismaGlobal { + versionLine += " " + orange(w.tr.WorkspacePrismaGlobalIndicator) + } + lines = append(lines, versionLine) + + // Git info + lines = append(lines, "") + if w.isGitRepo { + // Git line with optional schema modified indicator + gitLine := fmt.Sprintf(w.tr.WorkspaceGitLine, w.gitRepoName) + if w.schemaModified { + gitLine += " " + orange(w.tr.WorkspaceSchemaModifiedIndicator) + } + lines = append(lines, gitLine) + + // Branch on separate line + branchStyled := yellowBold(w.gitBranch) + lines = append(lines, fmt.Sprintf(w.tr.WorkspaceBranchFormat, branchStyled)) + } else { + lines = append(lines, w.tr.WorkspaceNotGitRepository) + } + + lines = append(lines, "") + lines = append(lines, w.buildDatabaseLines()...) + + content := "" + for _, line := range lines { + content += line + "\n" + } + + fmt.Fprint(v, content) + + // Adjust scroll and apply origin + w.ScrollableTrait.AdjustScroll() + + return nil +} + +// setupView configures the view with common settings (replaces BasePanel.SetupView) +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 + } +} + +// Refresh reloads all workspace information +func (w *WorkspaceContext) Refresh() { + // Save current scroll position + currentOriginY := w.ScrollableTrait.GetOriginY() + + // Reload information + w.loadVersionInfo() + w.loadDatabaseInfo() + + // Restore scroll position (will be adjusted by AdjustScroll in Draw if needed) + w.ScrollableTrait.SetOriginY(currentOriginY) +} + +// ScrollUpByWheel scrolls up by wheel increment (delegates to ScrollableTrait) +func (w *WorkspaceContext) ScrollUpByWheel() { + w.ScrollableTrait.ScrollUpByWheel() +} + +// ScrollDownByWheel scrolls down by wheel increment (delegates to ScrollableTrait) +func (w *WorkspaceContext) ScrollDownByWheel() { + w.ScrollableTrait.ScrollDownByWheel() +} + +func (w *WorkspaceContext) loadVersionInfo() { cwd, _ := os.Getwd() // Node version @@ -84,94 +291,7 @@ func (w *WorkspacePanel) loadVersionInfo() { w.loadDatabaseInfo() } -func (w *WorkspacePanel) buildDatabaseLines() []string { - var lines []string - - // Display provider with status on the same line - providerName := database.GetProviderDisplayName(w.dbProvider) - if providerName == "Unknown" { - providerName = Stylize(providerName, Style{FgColor: ColorYellow, Bold: true}) - } else { - providerName = Stylize(providerName, Style{FgColor: ColorYellow, Bold: true}) - } - - // Build provider line with status - var providerLine string - if w.dbConnected { - statusStyled := Stylize(w.tr.WorkspaceConnected, Style{FgColor: ColorGreen, Bold: true}) - providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled) - } else if w.dbError != "" { - if w.isConfigurationError() { - statusStyled := Stylize(w.tr.WorkspaceNotConfigured, Style{FgColor: ColorRed, Bold: true}) - providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled) - } else { - statusStyled := Stylize(w.tr.WorkspaceDisconnected, Style{FgColor: ColorRed, Bold: true}) - providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled) - } - } else { - statusStyled := Stylize(w.tr.WorkspaceDisconnected, Style{FgColor: ColorRed, Bold: true}) - providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled) - } - lines = append(lines, providerLine) - - // Display URL (always show if available) - if w.unmaskedURL != "" { - displayURL := w.maskedURL - if !w.showMasked { - displayURL = w.unmaskedURL - } - - // Add hardcoded warning if applicable - if w.isHardcoded { - lines = append(lines, fmt.Sprintf("%s %s", displayURL, Red(w.tr.WorkspaceHardcodedIndicator))) - } else { - lines = append(lines, displayURL) - } - } else if w.dbError != "" && w.isConfigurationError() { - // Only show error in URL field if it's a configuration issue - // Apply styling: bold+red env var name, red "not configured" - if w.envVarName != "" && strings.Contains(w.dbError, w.tr.WorkspaceNotConfiguredSuffix) { - styledError := Stylize(w.envVarName, Style{FgColor: ColorRed, Bold: true}) + Red(w.tr.WorkspaceNotConfiguredSuffix) - lines = append(lines, styledError) - } else { - lines = append(lines, Red(w.dbError)) - } - } else { - lines = append(lines, w.tr.WorkspaceNotSet) - } - - // Show detailed error message if disconnected (not configuration error) - if !w.dbConnected && w.dbError != "" && !w.isConfigurationError() { - lines = append(lines, Red(fmt.Sprintf(w.tr.WorkspaceErrorFormat, w.dbError))) - } - - return lines -} - -// isConfigurationError checks if the error is a configuration issue -func (w *WorkspacePanel) isConfigurationError() bool { - if w.dbError == "" { - return false - } - - configErrors := []string{ - "not found", - "not configured", - "not set", - "incomplete", - "no database_url", - } - - errLower := strings.ToLower(w.dbError) - for _, substr := range configErrors { - if strings.Contains(errLower, substr) { - return true - } - } - return false -} - -func (w *WorkspacePanel) loadDatabaseInfo() { +func (w *WorkspaceContext) loadDatabaseInfo() { // Reset fields w.dbProvider = "" w.unmaskedURL = "" @@ -252,145 +372,85 @@ func (w *WorkspacePanel) loadDatabaseInfo() { w.dbConnected = true } -func (w *WorkspacePanel) Draw(dim boxlayout.Dimensions) error { - v, err := w.g.SetView(w.id, dim.X0, dim.Y0, dim.X1, dim.Y1, 0) - if err != nil && err.Error() != "unknown view" { - return err - } - - w.SetupView(v, w.tr.PanelTitleWorkspace) - w.v = v - v.Wrap = true // Enable word wrap - - // Build content from fields +func (w *WorkspaceContext) buildDatabaseLines() []string { var lines []string - // Node and Prisma version on one line - nodeVersionStyled := Stylize(w.nodeVersion, Style{FgColor: ColorYellow, Bold: true}) - prismaVersionStyled := Stylize(w.prismaVersion, Style{FgColor: ColorYellow, Bold: true}) - versionLine := fmt.Sprintf(w.tr.WorkspaceVersionLine, nodeVersionStyled, prismaVersionStyled) - if w.prismaGlobal { - versionLine += " " + Orange(w.tr.WorkspacePrismaGlobalIndicator) - } - lines = append(lines, versionLine) + // Display provider with status on the same line + providerName := database.GetProviderDisplayName(w.dbProvider) + providerName = yellowBold(providerName) - // Git info - lines = append(lines, "") - if w.isGitRepo { - // Git line with optional schema modified indicator - gitLine := fmt.Sprintf(w.tr.WorkspaceGitLine, w.gitRepoName) - if w.schemaModified { - gitLine += " " + Orange(w.tr.WorkspaceSchemaModifiedIndicator) + // Build provider line with status + var providerLine string + if w.dbConnected { + statusStyled := greenBold(w.tr.WorkspaceConnected) + providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled) + } else if w.dbError != "" { + if w.isConfigurationError() { + statusStyled := wsRedBold(w.tr.WorkspaceNotConfigured) + providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled) + } else { + statusStyled := wsRedBold(w.tr.WorkspaceDisconnected) + providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled) } - lines = append(lines, gitLine) - - // Branch on separate line - branchStyled := Stylize(w.gitBranch, Style{FgColor: ColorYellow, Bold: true}) - lines = append(lines, fmt.Sprintf(w.tr.WorkspaceBranchFormat, branchStyled)) } else { - lines = append(lines, w.tr.WorkspaceNotGitRepository) + statusStyled := wsRedBold(w.tr.WorkspaceDisconnected) + providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled) } + lines = append(lines, providerLine) - lines = append(lines, "") - lines = append(lines, w.buildDatabaseLines()...) + // Display URL (always show if available) + if w.unmaskedURL != "" { + displayURL := w.maskedURL + if !w.showMasked { + displayURL = w.unmaskedURL + } - content := "" - for _, line := range lines { - content += line + "\n" + // Add hardcoded warning if applicable + if w.isHardcoded { + lines = append(lines, fmt.Sprintf("%s %s", displayURL, wsRed(w.tr.WorkspaceHardcodedIndicator))) + } else { + lines = append(lines, displayURL) + } + } else if w.dbError != "" && w.isConfigurationError() { + // Only show error in URL field if it's a configuration issue + // Apply styling: bold+red env var name, red "not configured" + if w.envVarName != "" && strings.Contains(w.dbError, w.tr.WorkspaceNotConfiguredSuffix) { + styledError := wsRedBold(w.envVarName) + wsRed(w.tr.WorkspaceNotConfiguredSuffix) + lines = append(lines, styledError) + } else { + lines = append(lines, wsRed(w.dbError)) + } + } else { + lines = append(lines, w.tr.WorkspaceNotSet) } - fmt.Fprint(v, content) - - // Adjust origin to ensure it's within valid bounds - AdjustOrigin(v, &w.originY) - v.SetOrigin(0, w.originY) - - return nil -} - -// ScrollUp scrolls the workspace panel up -func (w *WorkspacePanel) ScrollUp() { - if w.originY > 0 { - w.originY-- + // Show detailed error message if disconnected (not configuration error) + if !w.dbConnected && w.dbError != "" && !w.isConfigurationError() { + lines = append(lines, wsRed(fmt.Sprintf(w.tr.WorkspaceErrorFormat, w.dbError))) } -} -// ScrollDown scrolls the workspace panel down -func (w *WorkspacePanel) ScrollDown() { - w.originY++ - // AdjustOrigin will be called in Draw() to ensure bounds -} - -// ScrollUpByWheel scrolls the workspace panel up by 2 lines (mouse wheel) -func (w *WorkspacePanel) ScrollUpByWheel() { - if w.originY > 0 { - w.originY -= 2 - if w.originY < 0 { - w.originY = 0 - } - } + return lines } -// ScrollDownByWheel scrolls the workspace panel down by 2 lines (mouse wheel) -func (w *WorkspacePanel) ScrollDownByWheel() { - if w.v == nil { - return +// isConfigurationError checks if the error is a configuration issue +func (w *WorkspaceContext) isConfigurationError() bool { + if w.dbError == "" { + return false } - // Get actual content lines from the rendered view buffer - contentLines := len(w.v.ViewBufferLines()) - _, viewHeight := w.v.Size() - innerHeight := viewHeight - 2 // Exclude frame (top + bottom) - - // Calculate maxOrigin - maxOrigin := contentLines - innerHeight - if maxOrigin < 0 { - maxOrigin = 0 + configErrors := []string{ + "not found", + "not configured", + "not set", + "incomplete", + "no database_url", } - // Only scroll if we haven't reached the bottom - if w.originY < maxOrigin { - w.originY += 2 - if w.originY > maxOrigin { - w.originY = maxOrigin + errLower := strings.ToLower(w.dbError) + for _, substr := range configErrors { + if strings.Contains(errLower, substr) { + return true } } -} - -// ScrollToTop scrolls to the top of the workspace panel -func (w *WorkspacePanel) ScrollToTop() { - w.originY = 0 -} - -// ScrollToBottom scrolls to the bottom of the workspace panel -func (w *WorkspacePanel) ScrollToBottom() { - if w.v == nil { - return - } - - // Get actual content lines from the rendered view buffer - contentLines := len(w.v.ViewBufferLines()) - _, viewHeight := w.v.Size() - innerHeight := viewHeight - 2 // Exclude frame (top + bottom) - - // Calculate maxOrigin - maxOrigin := contentLines - innerHeight - if maxOrigin < 0 { - maxOrigin = 0 - } - - w.originY = maxOrigin -} - -// Refresh reloads all workspace information -func (w *WorkspacePanel) Refresh() { - // Save current scroll position - currentOriginY := w.originY - - // Reload information - w.loadVersionInfo() - w.loadDatabaseInfo() - - // Restore scroll position (will be adjusted by AdjustOrigin in Draw if needed) - w.originY = currentOriginY + return false } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go new file mode 100644 index 0000000..e74dbb5 --- /dev/null +++ b/pkg/gui/types/common.go @@ -0,0 +1,63 @@ +package types + +import ( + "github.com/dokadev/lazyprisma/pkg/i18n" +) + +// ConfirmOpts configures a confirmation popup. +type ConfirmOpts struct { + Title string + Prompt string + HandleConfirm func() error + HandleClose func() error +} + +// PromptOpts configures a text-input popup. +type PromptOpts struct { + Title string + InitialContent string + HandleConfirm func(string) error +} + +// MenuItem is a single entry in a menu popup. +type MenuItem struct { + Label string + OnPress func() error + Description string +} + +// MenuOpts configures a menu popup. +type MenuOpts struct { + Title string + Items []*MenuItem +} + +// IPopupHandler provides methods for displaying popups to the user. +type IPopupHandler interface { + // Alert shows a simple notification popup. + Alert(title string, message string) + // Confirm shows a yes/no confirmation popup. + Confirm(opts ConfirmOpts) + // Prompt shows a text-input popup. + Prompt(opts PromptOpts) + // Menu shows a list of selectable options. + Menu(opts MenuOpts) error + // Toast shows a brief, non-blocking message. + Toast(message string) + // ErrorHandler is the global error handler for gocui. + ErrorHandler(err error) error +} + +// IGuiCommon is the common interface available to controllers via dependency injection. +type IGuiCommon interface { + IPopupHandler + + // LogAction logs a user-visible action to the output panel. + LogAction(action string) + // Refresh triggers a data refresh and re-render of all contexts. + Refresh() + // OnUIThread schedules a function to run on the UI thread. + OnUIThread(f func() error) + // GetTranslationSet returns the current translation set. + GetTranslationSet() *i18n.TranslationSet +} diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go new file mode 100644 index 0000000..ead497e --- /dev/null +++ b/pkg/gui/types/context.go @@ -0,0 +1,78 @@ +package types + +import ( + "github.com/jesseduffield/gocui" +) + +// ContextKey uniquely identifies a context. +type ContextKey string + +// ContextKind categorises contexts by their role in the layout. +type ContextKind int + +const ( + // SIDE_CONTEXT is a panel on the left-hand side (workspace, migrations). + SIDE_CONTEXT ContextKind = iota + // MAIN_CONTEXT is the main content area (details, output). + MAIN_CONTEXT + // TEMPORARY_POPUP is a transient popup (confirm, prompt, menu, message). + TEMPORARY_POPUP +) + +// OnFocusOpts carries information when a context gains focus. +type OnFocusOpts struct { + ClickedViewLineIdx int +} + +// OnFocusLostOpts carries information when a context loses focus. +type OnFocusLostOpts struct { + NewContextKey ContextKey +} + +// IBaseContext defines the minimal identity and metadata for a context. +type IBaseContext interface { + GetKey() ContextKey + GetKind() ContextKind + GetViewName() string + GetView() *gocui.View + IsFocusable() bool + Title() string +} + +// Context extends IBaseContext with lifecycle hooks. +type Context interface { + IBaseContext + + HandleFocus(opts OnFocusOpts) + HandleFocusLost(opts OnFocusLostOpts) + HandleRender() +} + +// IListContext is a context that presents a selectable list of items. +type IListContext interface { + Context + + GetSelectedIdx() int + GetItemCount() int + SelectNext() + SelectPrev() +} + +// ITabbedContext is a context that supports tabbed sub-views. +type ITabbedContext interface { + Context + + NextTab() + PrevTab() + GetCurrentTab() int +} + +// IScrollableContext is a context that supports vertical scrolling. +type IScrollableContext interface { + Context + + ScrollUp() + ScrollDown() + ScrollToTop() + ScrollToBottom() +} diff --git a/pkg/gui/types/keybindings.go b/pkg/gui/types/keybindings.go new file mode 100644 index 0000000..91f4d05 --- /dev/null +++ b/pkg/gui/types/keybindings.go @@ -0,0 +1,28 @@ +package types + +import ( + "github.com/jesseduffield/gocui" +) + +// Key is an alias for any type that can represent a key (gocui.Key or rune). +type Key = any + +// Binding maps a key press to a handler within a specific context. +type Binding struct { + Key Key + Modifier gocui.Modifier + Handler func() error + Description string + Tag string // e.g. "navigation", used for grouping in help views +} + +// KeybindingsFn is a function that returns a slice of key bindings. +type KeybindingsFn func() []*Binding + +// IController is the interface that all controllers must implement. +// Each controller is associated with exactly one context and provides +// the keybindings for that context. +type IController interface { + GetKeybindings() []*Binding + Context() Context +} From d038ca751a8cf7afbc8d755e93dfa0a0ef7d4727 Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 14:20:47 +0900 Subject: [PATCH 04/26] resolve runtime panic risks and encapsulation issues --- pkg/app/app.go | 6 ++---- pkg/gui/context/details_context.go | 4 ++-- pkg/gui/context/migrations_context.go | 1 - pkg/gui/context/statusbar_context.go | 7 ++++++- pkg/gui/context/tabbed_trait.go | 5 +++++ pkg/gui/context/workspace_context.go | 1 - 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/pkg/app/app.go b/pkg/app/app.go index bbffcab..2be210b 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -17,8 +17,6 @@ const ( spinnerTickInterval = 50 * time.Millisecond ) -var spinnerFrames = []rune{'|', '/', '-', '\\'} - type App struct { g *gocui.Gui config AppConfig @@ -227,7 +225,7 @@ func (a *App) startSpinnerUpdater() { if a.commandRunning.Load() { // Increment frame index (0-3, wrapping around) currentFrame := a.spinnerFrame.Load() - nextFrame := (currentFrame + 1) % uint32(len(spinnerFrames)) + nextFrame := (currentFrame + 1) % context.SpinnerFrameCount() a.spinnerFrame.Store(nextFrame) // Trigger UI update (thread-safe) @@ -246,7 +244,7 @@ func (a *App) startSpinnerUpdater() { // HandlePanelClick is the public wrapper for panel-click focus switching. // It is used as a callback by contexts that manage their own mouse events. func (a *App) HandlePanelClick(viewID string) { - a.handlePanelClick(viewID) + _ = a.handlePanelClick(viewID) // error intentionally ignored: click handler fallback } // handlePanelClick handles mouse click on a panel to switch focus diff --git a/pkg/gui/context/details_context.go b/pkg/gui/context/details_context.go index 4fb151c..37b376b 100644 --- a/pkg/gui/context/details_context.go +++ b/pkg/gui/context/details_context.go @@ -251,7 +251,7 @@ func (d *DetailsContext) UpdateFromMigration(migration *prisma.Migration, tabNam // Only reset scroll position for Details tab if viewing a different migration if migration != nil && d.currentMigrationName != migration.Name { // Reset Details tab scroll position only - d.TabbedTrait.tabOriginY[d.tabIdxByName(d.tr.TabDetails)] = 0 + d.TabbedTrait.ResetTabOriginYAt(d.tabIdxByName(d.tr.TabDetails)) // If currently on Details tab, also update originY if d.TabbedTrait.GetCurrentTab() == d.tr.TabDetails { d.ScrollableTrait.SetOriginY(0) @@ -259,7 +259,7 @@ func (d *DetailsContext) UpdateFromMigration(migration *prisma.Migration, tabNam d.currentMigrationName = migration.Name } else if migration == nil { // Reset Details tab scroll position only - d.TabbedTrait.tabOriginY[d.tabIdxByName(d.tr.TabDetails)] = 0 + d.TabbedTrait.ResetTabOriginYAt(d.tabIdxByName(d.tr.TabDetails)) // If currently on Details tab, also update originY if d.TabbedTrait.GetCurrentTab() == d.tr.TabDetails { d.ScrollableTrait.SetOriginY(0) diff --git a/pkg/gui/context/migrations_context.go b/pkg/gui/context/migrations_context.go index ed34434..476ee54 100644 --- a/pkg/gui/context/migrations_context.go +++ b/pkg/gui/context/migrations_context.go @@ -109,7 +109,6 @@ func NewMigrationsContext(opts MigrationsContextOpts) *MigrationsContext { mc := &MigrationsContext{ SimpleContext: simpleCtx, ScrollableTrait: &ScrollableTrait{}, - TabbedTrait: &TabbedTrait{}, g: opts.Gui, tr: opts.Tr, items: []string{}, diff --git a/pkg/gui/context/statusbar_context.go b/pkg/gui/context/statusbar_context.go index 1bffad0..1f9628d 100644 --- a/pkg/gui/context/statusbar_context.go +++ b/pkg/gui/context/statusbar_context.go @@ -54,6 +54,11 @@ type StatusBarConfig struct { var spinnerFrames = []rune{'|', '/', '-', '\\'} +// SpinnerFrameCount returns the number of spinner animation frames. +func SpinnerFrameCount() uint32 { + return uint32(len(spinnerFrames)) +} + type StatusBarContext struct { *BaseContext @@ -118,7 +123,7 @@ func (s *StatusBarContext) Draw(dim boxlayout.Dimensions) error { // Show spinner if command is running if s.state.IsCommandRunning() { - frameIndex := s.state.GetSpinnerFrame() + frameIndex := s.state.GetSpinnerFrame() % uint32(len(spinnerFrames)) spinner := string(spinnerFrames[frameIndex]) // Get running task name diff --git a/pkg/gui/context/tabbed_trait.go b/pkg/gui/context/tabbed_trait.go index 74782e0..232ca2b 100644 --- a/pkg/gui/context/tabbed_trait.go +++ b/pkg/gui/context/tabbed_trait.go @@ -77,6 +77,11 @@ func (self *TabbedTrait) SaveTabOriginY(originY int) { self.tabOriginY[self.currentTabIdx] = originY } +// ResetTabOriginYAt resets the saved scroll position for the tab at the given index. +func (self *TabbedTrait) ResetTabOriginYAt(idx int) { + self.tabOriginY[idx] = 0 +} + // RestoreTabOriginY returns the saved scroll position for the current tab. // Returns 0 if no position was previously saved. // Call this after switching tabs. diff --git a/pkg/gui/context/workspace_context.go b/pkg/gui/context/workspace_context.go index 7f9e6cb..d88e7e8 100644 --- a/pkg/gui/context/workspace_context.go +++ b/pkg/gui/context/workspace_context.go @@ -7,7 +7,6 @@ import ( "strings" "github.com/dokadev/lazyprisma/pkg/database" - _ "github.com/dokadev/lazyprisma/pkg/database/drivers" // Register database drivers "github.com/dokadev/lazyprisma/pkg/git" "github.com/dokadev/lazyprisma/pkg/gui/types" "github.com/dokadev/lazyprisma/pkg/i18n" From 849ec3507c0e2af6f659f312df336897a00ab8c0 Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 14:53:27 +0900 Subject: [PATCH 05/26] split command into domain specific controller module --- pkg/app/generate_controller.go | 186 +++++++++ pkg/app/global_controller.go | 82 ++++ .../{command.go => migrations_controller.go} | 358 ------------------ pkg/app/studio_controller.go | 118 ++++++ 4 files changed, 386 insertions(+), 358 deletions(-) create mode 100644 pkg/app/generate_controller.go create mode 100644 pkg/app/global_controller.go rename pkg/app/{command.go => migrations_controller.go} (68%) create mode 100644 pkg/app/studio_controller.go diff --git a/pkg/app/generate_controller.go b/pkg/app/generate_controller.go new file mode 100644 index 0000000..d6cb1ac --- /dev/null +++ b/pkg/app/generate_controller.go @@ -0,0 +1,186 @@ +package app + +import ( + "fmt" + "os" + "strings" + + "github.com/dokadev/lazyprisma/pkg/commands" + "github.com/dokadev/lazyprisma/pkg/gui/context" + "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 + } + + // 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 + } + + // 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.LogMsgFoundSchemaErrors, 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 + }) + }() + } 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(), + ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) + a.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) + } +} diff --git a/pkg/app/global_controller.go b/pkg/app/global_controller.go new file mode 100644 index 0000000..79a8955 --- /dev/null +++ b/pkg/app/global_controller.go @@ -0,0 +1,82 @@ +package app + +import ( + "fmt" + + "github.com/dokadev/lazyprisma/pkg/gui/context" +) + +// CopyMigrationInfo copies migration info to clipboard +func (a *App) CopyMigrationInfo() { + // Get migrations panel + migrationsPanel, ok := a.panels[ViewMigrations].(*context.MigrationsContext) + if !ok { + return + } + + // Get selected migration + selected := migrationsPanel.GetSelectedMigration() + if selected == nil { + return + } + + items := []ListModalItem{ + { + Label: a.Tr.ListItemCopyName, + Description: selected.Name, + OnSelect: func() error { + a.CloseModal() + a.copyTextToClipboard(selected.Name, a.Tr.CopyLabelMigrationName) + return nil + }, + }, + { + Label: a.Tr.ListItemCopyPath, + Description: selected.Path, + OnSelect: func() error { + a.CloseModal() + a.copyTextToClipboard(selected.Path, a.Tr.CopyLabelMigrationPath) + return nil + }, + }, + } + + // If it has a checksum, allow copying it + if selected.Checksum != "" { + items = append(items, ListModalItem{ + Label: a.Tr.ListItemCopyChecksum, + Description: selected.Checksum, + OnSelect: func() error { + a.CloseModal() + a.copyTextToClipboard(selected.Checksum, a.Tr.CopyLabelChecksum) + return nil + }, + }) + } + + modal := NewListModal(a.g, a.Tr, a.Tr.ModalTitleCopyToClipboard, items, + func() { + a.CloseModal() + }, + ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}) + + a.OpenModal(modal) +} + +func (a *App) copyTextToClipboard(text, label string) { + if err := CopyToClipboard(text); err != nil { + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleClipboardError, + a.Tr.ModalMsgFailedCopyClipboard, + err.Error(), + ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) + a.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), + ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) + a.OpenModal(modal) +} diff --git a/pkg/app/command.go b/pkg/app/migrations_controller.go similarity index 68% rename from pkg/app/command.go rename to pkg/app/migrations_controller.go index c3ee0ff..0d496c3 100644 --- a/pkg/app/command.go +++ b/pkg/app/migrations_controller.go @@ -8,7 +8,6 @@ import ( "github.com/dokadev/lazyprisma/pkg/commands" "github.com/dokadev/lazyprisma/pkg/gui/context" - "github.com/dokadev/lazyprisma/pkg/prisma" "github.com/jesseduffield/gocui" ) @@ -521,180 +520,6 @@ func (a *App) showManualMigrationInput() { a.OpenModal(modal) } -// 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 - } - - // 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 - } - - // 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.LogMsgFoundSchemaErrors, 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 - }) - }() - } 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(), - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.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) - } -} - // MigrateResolve resolves a failed migration func (a *App) MigrateResolve() { // Get migrations panel @@ -871,114 +696,6 @@ func (a *App) executeResolve(migrationName string, action string) { } } -// Studio toggles Prisma Studio -func (a *App) Studio() { - outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext) - if !ok { - return - } - - // Check if Studio is already running - if a.studioRunning { - // 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, - err.Error(), - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - return - } - a.studioCmd = nil - } - a.studioRunning = false - outputPanel.LogAction(a.Tr.LogActionStudioStopped, a.Tr.LogMsgStudioHasStopped) - - // Clear subtitle - outputPanel.SetSubtitle("") - - // Update UI - a.g.Update(func(g *gocui.Gui) error { - // Trigger redraw of status bar - return nil - }) - - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioStopped, - a.Tr.ModalMsgStudioStopped, - ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow}) - a.OpenModal(modal) - return - } - - // Start Studio - // Try to start command - if another command is running, block - if !a.tryStartCommand("Start Studio") { - a.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, - err.Error(), - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - return - } - - // Log action start - outputPanel.LogAction(a.Tr.LogActionStudio, a.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, - err.Error(), - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - return - } - - // 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 - a.studioRunning = true - a.studioCmd = studioCmd // Save Command object - - outputPanel.LogAction(a.Tr.LogActionStudioStarted, a.Tr.LogMsgStudioListeningAt) - outputPanel.SetSubtitle(a.Tr.LogMsgStudioListeningAt) - - // Show info modal - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioStarted, - a.Tr.ModalMsgStudioRunningAt, - a.Tr.ModalMsgPressStopStudio, - ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) - a.OpenModal(modal) - return nil - }) - }() -} - // DeleteMigration deletes a pending migration func (a *App) DeleteMigration() { // Get migrations panel @@ -1062,78 +779,3 @@ func (a *App) executeDeleteMigration(path, name string) { ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) a.OpenModal(modal) } - -// CopyMigrationInfo copies migration info to clipboard -func (a *App) CopyMigrationInfo() { - // Get migrations panel - migrationsPanel, ok := a.panels[ViewMigrations].(*context.MigrationsContext) - if !ok { - return - } - - // Get selected migration - selected := migrationsPanel.GetSelectedMigration() - if selected == nil { - return - } - - items := []ListModalItem{ - { - Label: a.Tr.ListItemCopyName, - Description: selected.Name, - OnSelect: func() error { - a.CloseModal() - a.copyTextToClipboard(selected.Name, a.Tr.CopyLabelMigrationName) - return nil - }, - }, - { - Label: a.Tr.ListItemCopyPath, - Description: selected.Path, - OnSelect: func() error { - a.CloseModal() - a.copyTextToClipboard(selected.Path, a.Tr.CopyLabelMigrationPath) - return nil - }, - }, - } - - // If it has a checksum, allow copying it - if selected.Checksum != "" { - items = append(items, ListModalItem{ - Label: a.Tr.ListItemCopyChecksum, - Description: selected.Checksum, - OnSelect: func() error { - a.CloseModal() - a.copyTextToClipboard(selected.Checksum, a.Tr.CopyLabelChecksum) - return nil - }, - }) - } - - modal := NewListModal(a.g, a.Tr, a.Tr.ModalTitleCopyToClipboard, items, - func() { - a.CloseModal() - }, - ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}) - - a.OpenModal(modal) -} - -func (a *App) copyTextToClipboard(text, label string) { - if err := CopyToClipboard(text); err != nil { - modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleClipboardError, - a.Tr.ModalMsgFailedCopyClipboard, - err.Error(), - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.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), - ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) - a.OpenModal(modal) -} diff --git a/pkg/app/studio_controller.go b/pkg/app/studio_controller.go new file mode 100644 index 0000000..ef38828 --- /dev/null +++ b/pkg/app/studio_controller.go @@ -0,0 +1,118 @@ +package app + +import ( + "os" + "time" + + "github.com/dokadev/lazyprisma/pkg/commands" + "github.com/dokadev/lazyprisma/pkg/gui/context" + "github.com/jesseduffield/gocui" +) + +// Studio toggles Prisma Studio +func (a *App) Studio() { + outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext) + if !ok { + return + } + + // Check if Studio is already running + if a.studioRunning { + // 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, + err.Error(), + ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) + a.OpenModal(modal) + return + } + a.studioCmd = nil + } + a.studioRunning = false + outputPanel.LogAction(a.Tr.LogActionStudioStopped, a.Tr.LogMsgStudioHasStopped) + + // Clear subtitle + outputPanel.SetSubtitle("") + + // Update UI + a.g.Update(func(g *gocui.Gui) error { + // Trigger redraw of status bar + return nil + }) + + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioStopped, + a.Tr.ModalMsgStudioStopped, + ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow}) + a.OpenModal(modal) + return + } + + // Start Studio + // Try to start command - if another command is running, block + if !a.tryStartCommand("Start Studio") { + a.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, + err.Error(), + ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) + a.OpenModal(modal) + return + } + + // Log action start + outputPanel.LogAction(a.Tr.LogActionStudio, a.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, + err.Error(), + ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) + a.OpenModal(modal) + return + } + + // 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 + a.studioRunning = true + a.studioCmd = studioCmd // Save Command object + + outputPanel.LogAction(a.Tr.LogActionStudioStarted, a.Tr.LogMsgStudioListeningAt) + outputPanel.SetSubtitle(a.Tr.LogMsgStudioListeningAt) + + // Show info modal + modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioStarted, + a.Tr.ModalMsgStudioRunningAt, + a.Tr.ModalMsgPressStopStudio, + ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) + a.OpenModal(modal) + return nil + }) + }() +} From 11f476cf6fb5a3853d1749e02242558d6da8a7c2 Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 14:57:00 +0900 Subject: [PATCH 06/26] correct format argument in generate failure log path --- pkg/app/{global_controller.go => clipboard_controller.go} | 0 pkg/app/generate_controller.go | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename pkg/app/{global_controller.go => clipboard_controller.go} (100%) diff --git a/pkg/app/global_controller.go b/pkg/app/clipboard_controller.go similarity index 100% rename from pkg/app/global_controller.go rename to pkg/app/clipboard_controller.go diff --git a/pkg/app/generate_controller.go b/pkg/app/generate_controller.go index d6cb1ac..b1ef93a 100644 --- a/pkg/app/generate_controller.go +++ b/pkg/app/generate_controller.go @@ -102,7 +102,7 @@ func (a *App) Generate() { a.OpenModal(modal) } else { // Schema is valid but generate failed for other reasons - out.LogAction(a.Tr.LogActionGenerateFailed, fmt.Sprintf(a.Tr.LogMsgFoundSchemaErrors, exitCode)) + 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, From 23769478caff3b1ca9ec2c8f77e6ad5c9dd2d2ab Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 15:05:30 +0900 Subject: [PATCH 07/26] extract BaseModal to consolidate duplicated modal logic --- pkg/app/base_modal.go | 185 +++++++++++++++++++++++++++++++++++++++ pkg/app/confirm_modal.go | 121 ++++--------------------- pkg/app/input_modal.go | 66 +++----------- pkg/app/list_modal.go | 126 +++++--------------------- pkg/app/message_modal.go | 120 +++---------------------- 5 files changed, 251 insertions(+), 367 deletions(-) create mode 100644 pkg/app/base_modal.go diff --git a/pkg/app/base_modal.go b/pkg/app/base_modal.go new file mode 100644 index 0000000..66eb8da --- /dev/null +++ b/pkg/app/base_modal.go @@ -0,0 +1,185 @@ +package app + +import ( + "strings" + + "github.com/dokadev/lazyprisma/pkg/i18n" + "github.com/jesseduffield/gocui" +) + +// ModalStyle holds styling options for modals +// (renamed from MessageModalStyle for shared use across all modal types) +type ModalStyle = MessageModalStyle + +// BaseModal provides common infrastructure shared by all modal types +type BaseModal struct { + id string + g *gocui.Gui + tr *i18n.TranslationSet + style MessageModalStyle +} + +// NewBaseModal creates a new BaseModal with default style +func NewBaseModal(id string, g *gocui.Gui, tr *i18n.TranslationSet) *BaseModal { + return &BaseModal{ + id: id, + g: g, + tr: tr, + style: MessageModalStyle{}, + } +} + +// SetStyle sets the modal style (used by embedding structs' WithStyle methods) +func (b *BaseModal) SetStyle(style MessageModalStyle) { + b.style = style +} + +// Style returns the current modal style +func (b *BaseModal) Style() MessageModalStyle { + return b.style +} + +// ID returns the modal's view ID +func (b *BaseModal) ID() string { + return b.id +} + +// CalculateDimensions computes centered coordinates for a modal view. +// widthRatio is the fraction of screen width (e.g., 4.0/7.0). +// heightContent is the number of content lines (borders added by caller). +// minWidth is the minimum width for the modal. +func (b *BaseModal) CalculateDimensions(widthRatio float64, minWidth int) (width int) { + screenWidth, _ := b.g.Size() + + width = int(widthRatio * float64(screenWidth)) + if width < minWidth { + if screenWidth-2 < minWidth { + width = screenWidth - 2 + } else { + width = minWidth + } + } + + return width +} + +// CenterBox returns centered screen coordinates for a box of the given width and height. +func (b *BaseModal) CenterBox(width, height int) (x0, y0, x1, y1 int) { + screenWidth, screenHeight := b.g.Size() + + // Clamp height to screen + maxHeight := screenHeight - 4 + if height > maxHeight { + height = maxHeight + } + + x0 = (screenWidth - width) / 2 + y0 = (screenHeight - height) / 2 + x1 = x0 + width + y1 = y0 + height + return +} + +// SetupView creates (or retrieves) a gocui view and applies common frame settings. +// It handles the "unknown view" error from SetView, applies frame runes, title, footer, +// and style colours. Returns the view and whether it was newly created. +func (b *BaseModal) SetupView(name string, x0, y0, x1, y1 int, zIndex byte, title, footer string) (*gocui.View, bool, error) { + v, err := b.g.SetView(name, x0, y0, x1, y1, zIndex) + isNew := err != nil + if err != nil && err.Error() != "unknown view" { + return nil, false, err + } + + v.Frame = true + v.FrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} + v.Title = title + v.Footer = footer + + // Apply frame color (border) if set + if b.style.BorderColor != ColorDefault { + v.FrameColor = gocui.Attribute(ColorToGocuiAttr(b.style.BorderColor)) + } + + // Apply title color if set + if b.style.TitleColor != ColorDefault { + v.TitleColor = gocui.Attribute(ColorToGocuiAttr(b.style.TitleColor)) + } + + return v, isNew, nil +} + +// OnClose deletes the modal's primary view +func (b *BaseModal) OnClose() { + b.g.DeleteView(b.id) +} + +// ColorToGocuiAttr converts a Color to a gocui color attribute value. +// Exported so it can be used by any code that needs this conversion. +func ColorToGocuiAttr(c Color) int { + switch c { + case ColorBlack: + return int(gocui.ColorBlack) + case ColorRed: + return int(gocui.ColorRed) + case ColorGreen: + return int(gocui.ColorGreen) + case ColorYellow: + return int(gocui.ColorYellow) + case ColorBlue: + return int(gocui.ColorBlue) + case ColorMagenta: + return int(gocui.ColorMagenta) + case ColorCyan: + return int(gocui.ColorCyan) + case ColorWhite: + return int(gocui.ColorWhite) + default: + return int(gocui.ColorDefault) + } +} + +// WrapText wraps text to fit within the specified width. +// Each resulting line is prefixed with the given padding string. +// Handles multiple paragraphs separated by newlines. +func WrapText(text string, maxWidth int, padding string) []string { + if maxWidth <= 0 { + return []string{padding + text} + } + + var lines []string + paragraphs := strings.Split(text, "\n") + + for _, para := range paragraphs { + if len(para) == 0 { + lines = append(lines, "") + continue + } + + if len(para) <= maxWidth { + lines = append(lines, padding+para) + } else { + // Word wrapping + words := strings.Fields(para) + currentLine := padding + + for _, word := range words { + if len(currentLine)+len(word)+1 <= maxWidth+len(padding) { + if currentLine == padding { + currentLine += word + } else { + currentLine += " " + word + } + } else { + lines = append(lines, currentLine) + currentLine = padding + word + } + } + + if currentLine != padding { + lines = append(lines, currentLine) + } + } + } + + return lines +} diff --git a/pkg/app/confirm_modal.go b/pkg/app/confirm_modal.go index 799d0bf..27e30de 100644 --- a/pkg/app/confirm_modal.go +++ b/pkg/app/confirm_modal.go @@ -2,7 +2,6 @@ package app import ( "fmt" - "strings" "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/jesseduffield/gocui" @@ -11,99 +10,54 @@ import ( // ConfirmModal displays a confirmation dialog with Yes/No options type ConfirmModal struct { - g *gocui.Gui - tr *i18n.TranslationSet + *BaseModal title string message string onYes func() onNo func() width int height int - style MessageModalStyle } // NewConfirmModal creates a new confirmation modal func NewConfirmModal(g *gocui.Gui, tr *i18n.TranslationSet, title string, message string, onYes func(), onNo func()) *ConfirmModal { return &ConfirmModal{ - g: g, - tr: tr, - title: title, - message: message, - onYes: onYes, - onNo: onNo, - style: MessageModalStyle{}, // Default style + BaseModal: NewBaseModal("confirm_modal", g, tr), + title: title, + message: message, + onYes: onYes, + onNo: onNo, } } // WithStyle sets the modal style func (m *ConfirmModal) WithStyle(style MessageModalStyle) *ConfirmModal { - m.style = style + m.SetStyle(style) return m } -// ID returns the modal's view ID -func (m *ConfirmModal) ID() string { - return "confirm_modal" -} - // Draw renders the modal func (m *ConfirmModal) Draw(dim boxlayout.Dimensions) error { - // Get screen size - screenWidth, screenHeight := m.g.Size() - - // Calculate width (4/7 of screen, min 80) - m.width = 4 * screenWidth / 7 - minWidth := 80 - if m.width < minWidth { - if screenWidth-2 < minWidth { - m.width = screenWidth - 2 - } else { - m.width = minWidth - } - } + // Calculate width + m.width = m.CalculateDimensions(4.0/7.0, 80) // Parse message into lines availableWidth := m.width - 4 - lines := m.wrapText(m.message, availableWidth) + lines := WrapText(m.message, availableWidth, " ") // Calculate height based on content - contentHeight := len(lines) - m.height = contentHeight + 2 // +2 for borders - - // Don't exceed screen height - maxHeight := screenHeight - 4 - if m.height > maxHeight { - m.height = maxHeight - } + m.height = len(lines) + 2 // +2 for borders // Center the modal - x0 := (screenWidth - m.width) / 2 - y0 := (screenHeight - m.height) / 2 - x1 := x0 + m.width - y1 := y0 + m.height + x0, y0, x1, y1 := m.CenterBox(m.width, m.height) // Create modal view - v, err := m.g.SetView(m.ID(), x0, y0, x1, y1, 0) - if err != nil && err.Error() != "unknown view" { + v, _, err := m.SetupView(m.ID(), x0, y0, x1, y1, 0, " "+m.title+" ", m.tr.ModalFooterConfirmYesNo) + if err != nil { return err } v.Clear() - v.Frame = true - v.FrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} - v.Title = " " + m.title + " " - v.Footer = m.tr.ModalFooterConfirmYesNo - - // Apply frame color (border) if set - if m.style.BorderColor != ColorDefault { - v.FrameColor = gocui.Attribute(colorToAnsiCode(m.style.BorderColor)) - } - - // Apply title color if set - if m.style.TitleColor != ColorDefault { - v.TitleColor = gocui.Attribute(colorToAnsiCode(m.style.TitleColor)) - } - v.Wrap = false // Render content @@ -114,50 +68,6 @@ func (m *ConfirmModal) Draw(dim boxlayout.Dimensions) error { return nil } -// wrapText wraps text to fit within the specified width -func (m *ConfirmModal) wrapText(text string, width int) []string { - if width <= 0 { - return []string{text} - } - - var lines []string - - if len(text) == 0 { - lines = append(lines, "") - return lines - } - - // Word wrap - if len(text) <= width { - lines = append(lines, " "+text) - } else { - // Simple word wrapping - words := strings.Fields(text) - currentLine := " " - - for _, word := range words { - if len(currentLine)+len(word)+1 <= width+2 { // +2 for initial " " - if currentLine == " " { - currentLine += word - } else { - currentLine += " " + word - } - } else { - // Current line is full, start new line - lines = append(lines, currentLine) - currentLine = " " + word - } - } - - // Add remaining line - if currentLine != " " { - lines = append(lines, currentLine) - } - } - - return lines -} - // HandleKey handles keyboard input func (m *ConfirmModal) HandleKey(key any, mod gocui.Modifier) error { switch key { @@ -180,6 +90,5 @@ func (m *ConfirmModal) HandleKey(key any, mod gocui.Modifier) error { // OnClose is called when the modal is closed func (m *ConfirmModal) OnClose() { - // Delete the modal view - m.g.DeleteView(m.ID()) + m.BaseModal.OnClose() } diff --git a/pkg/app/input_modal.go b/pkg/app/input_modal.go index a6a08da..4a6622a 100644 --- a/pkg/app/input_modal.go +++ b/pkg/app/input_modal.go @@ -10,14 +10,12 @@ import ( // InputModal displays an input field for user text entry type InputModal struct { - g *gocui.Gui - tr *i18n.TranslationSet + *BaseModal title string // Used as placeholder subtitle string // Optional subtitle footer string // Key bindings description width int height int - style MessageModalStyle onSubmit func(string) onCancel func() required bool @@ -27,19 +25,17 @@ type InputModal struct { // NewInputModal creates a new input modal func NewInputModal(g *gocui.Gui, tr *i18n.TranslationSet, title string, onSubmit func(string), onCancel func()) *InputModal { return &InputModal{ - g: g, - tr: tr, - title: title, - footer: tr.ModalFooterInputSubmitCancel, - style: MessageModalStyle{}, // Default style - onSubmit: onSubmit, - onCancel: onCancel, + BaseModal: NewBaseModal("input_modal", g, tr), + title: title, + footer: tr.ModalFooterInputSubmitCancel, + onSubmit: onSubmit, + onCancel: onCancel, } } // WithStyle sets the modal style func (m *InputModal) WithStyle(style MessageModalStyle) *InputModal { - m.style = style + m.SetStyle(style) return m } @@ -61,67 +57,33 @@ func (m *InputModal) OnValidationFail(callback func(string)) *InputModal { return m } -// ID returns the modal's view ID -func (m *InputModal) ID() string { - return "input_modal" -} - // Draw renders the input modal func (m *InputModal) Draw(dim boxlayout.Dimensions) error { - // Get screen size - screenWidth, screenHeight := m.g.Size() - - // Calculate width (4/7 of screen, min 80) - m.width = 4 * screenWidth / 7 - minWidth := 80 - if m.width < minWidth { - if screenWidth-2 < minWidth { - m.width = screenWidth - 2 - } else { - m.width = minWidth - } - } + // Calculate width + m.width = m.CalculateDimensions(4.0/7.0, 80) // Height for input modal: minimal single line m.height = 2 // Center the modal - x0 := (screenWidth - m.width) / 2 - y0 := (screenHeight - m.height) / 2 - x1 := x0 + m.width - y1 := y0 + m.height + x0, y0, x1, y1 := m.CenterBox(m.width, m.height) // Create input view - v, err := m.g.SetView(m.ID(), x0, y0, x1, y1, 0) - isNewView := err != nil - if err != nil && err.Error() != "unknown view" { + v, isNew, err := m.SetupView(m.ID(), x0, y0, x1, y1, 0, " "+m.title+" ", m.footer) + if err != nil { return err } // Only clear on first creation (TextArea manages content) - if isNewView { + if isNew { v.Clear() // Initial render to make footer visible v.RenderTextArea() } - v.Frame = true - v.FrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} - v.Title = " " + m.title + " " if m.subtitle != "" { v.Subtitle = " " + m.subtitle + " " } - v.Footer = m.footer - - // Apply frame color (border) if set - if m.style.BorderColor != ColorDefault { - v.FrameColor = gocui.Attribute(colorToAnsiCode(m.style.BorderColor)) - } - - // Apply title color if set - if m.style.TitleColor != ColorDefault { - v.TitleColor = gocui.Attribute(colorToAnsiCode(m.style.TitleColor)) - } // Input field settings (CRITICAL - DO NOT CHANGE) v.Editable = true @@ -188,5 +150,5 @@ func (m *InputModal) OnClose() { // Disable cursor at Gui level m.g.Cursor = false // Delete the input modal view - m.g.DeleteView(m.ID()) + m.BaseModal.OnClose() } diff --git a/pkg/app/list_modal.go b/pkg/app/list_modal.go index 1bfa962..cc7f457 100644 --- a/pkg/app/list_modal.go +++ b/pkg/app/list_modal.go @@ -2,7 +2,6 @@ package app import ( "fmt" - "strings" "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/jesseduffield/gocui" @@ -11,49 +10,40 @@ import ( // ListModalItem represents a selectable item in the list modal type ListModalItem struct { - Label string // Display text in the list - Description string // Description shown in the bottom view + Label string // Display text in the list + Description string // Description shown in the bottom view OnSelect func() error // Callback when item is selected with Enter } // ListModal displays a list of items with descriptions type ListModal struct { - g *gocui.Gui - tr *i18n.TranslationSet - title string - items []ListModalItem - selectedIdx int - originY int // Scroll position for list view - width int - height int - style MessageModalStyle - onCancel func() + *BaseModal + title string + items []ListModalItem + selectedIdx int + originY int // Scroll position for list view + width int + height int + onCancel func() } // NewListModal creates a new list modal func NewListModal(g *gocui.Gui, tr *i18n.TranslationSet, title string, items []ListModalItem, onCancel func()) *ListModal { return &ListModal{ - g: g, - tr: tr, + BaseModal: NewBaseModal("list_modal", g, tr), title: title, items: items, selectedIdx: 0, - style: MessageModalStyle{}, // Default style onCancel: onCancel, } } // WithStyle sets the modal style func (m *ListModal) WithStyle(style MessageModalStyle) *ListModal { - m.style = style + m.SetStyle(style) return m } -// ID returns the modal's view ID -func (m *ListModal) ID() string { - return "list_modal" -} - // listViewID returns the list view ID func (m *ListModal) listViewID() string { return "list_modal_list" @@ -66,26 +56,15 @@ func (m *ListModal) descViewID() string { // Draw renders the list modal with two views (list on top, description on bottom) func (m *ListModal) Draw(dim boxlayout.Dimensions) error { - // Get screen size - screenWidth, screenHeight := m.g.Size() - // Calculate width (5/7 of screen, min 80) - m.width = 5 * screenWidth / 7 - minWidth := 80 - if m.width < minWidth { - if screenWidth-2 < minWidth { - m.width = screenWidth - 2 - } else { - m.width = minWidth - } - } + m.width = m.CalculateDimensions(5.0/7.0, 80) // Calculate description height dynamically based on selected item's description availableWidth := m.width - 4 // Minus frame and padding var descContentLines int if m.selectedIdx >= 0 && m.selectedIdx < len(m.items) { desc := m.items[m.selectedIdx].Description - wrappedLines := m.wrapText(desc, availableWidth) + wrappedLines := WrapText(desc, availableWidth, "") descContentLines = len(wrappedLines) } @@ -98,6 +77,8 @@ func (m *ListModal) Draw(dim boxlayout.Dimensions) error { m.height = listHeight + descHeight + 1 // +1 for gap // Don't exceed screen height + screenWidth, screenHeight := m.g.Size() + _ = screenWidth maxHeight := screenHeight - 4 if m.height > maxHeight { m.height = maxHeight @@ -111,8 +92,7 @@ func (m *ListModal) Draw(dim boxlayout.Dimensions) error { } // Center the modal - x0 := (screenWidth - m.width) / 2 - y0 := (screenHeight - m.height) / 2 + x0, y0, _, _ := m.CenterBox(m.width, m.height) // Draw list view (top) listX0 := x0 @@ -139,27 +119,12 @@ func (m *ListModal) Draw(dim boxlayout.Dimensions) error { // drawListView renders the list view (top) func (m *ListModal) drawListView(x0, y0, x1, y1 int) error { - v, err := m.g.SetView(m.listViewID(), x0, y0, x1, y1, 0) - if err != nil && err.Error() != "unknown view" { + v, _, err := m.SetupView(m.listViewID(), x0, y0, x1, y1, 0, " "+m.title+" ", "") + if err != nil { return err } v.Clear() - v.Frame = true - v.FrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} - v.Title = " " + m.title + " " - v.Footer = "" - - // Apply frame color (border) if set - if m.style.BorderColor != ColorDefault { - v.FrameColor = gocui.Attribute(colorToAnsiCode(m.style.BorderColor)) - } - - // Apply title color if set - if m.style.TitleColor != ColorDefault { - v.TitleColor = gocui.Attribute(colorToAnsiCode(m.style.TitleColor)) - } - v.Wrap = false // Enable highlight for selection (like MigrationsPanel) @@ -183,6 +148,7 @@ func (m *ListModal) drawListView(x0, y0, x1, y1 int) error { // drawDescView renders the description view (bottom) func (m *ListModal) drawDescView(x0, y0, x1, y1 int) error { + style := m.Style() v, err := m.g.SetView(m.descViewID(), x0, y0, x1, y1, 0) if err != nil && err.Error() != "unknown view" { return err @@ -195,8 +161,8 @@ func (m *ListModal) drawDescView(x0, y0, x1, y1 int) error { v.Footer = m.tr.ModalFooterListNavigate // Apply frame color (border) if set - if m.style.BorderColor != ColorDefault { - v.FrameColor = gocui.Attribute(colorToAnsiCode(m.style.BorderColor)) + if style.BorderColor != ColorDefault { + v.FrameColor = gocui.Attribute(ColorToGocuiAttr(style.BorderColor)) } v.Wrap = true @@ -207,7 +173,7 @@ func (m *ListModal) drawDescView(x0, y0, x1, y1 int) error { // Word wrap description availableWidth := (x1 - x0) - 4 // Minus frame and padding - wrappedLines := m.wrapText(desc, availableWidth) + wrappedLines := WrapText(desc, availableWidth, "") for _, line := range wrappedLines { fmt.Fprintln(v, " "+line) @@ -217,52 +183,6 @@ func (m *ListModal) drawDescView(x0, y0, x1, y1 int) error { return nil } -// wrapText wraps text to fit within the specified width -func (m *ListModal) wrapText(text string, width int) []string { - if width <= 0 { - return []string{text} - } - - var lines []string - paragraphs := strings.Split(text, "\n") - - for _, para := range paragraphs { - if len(para) == 0 { - lines = append(lines, "") - continue - } - - if len(para) <= width { - lines = append(lines, para) - } else { - // Simple word wrapping - words := strings.Fields(para) - currentLine := "" - - for _, word := range words { - if len(currentLine)+len(word)+1 <= width { - if currentLine == "" { - currentLine = word - } else { - currentLine += " " + word - } - } else { - // Current line is full, start new line - lines = append(lines, currentLine) - currentLine = word - } - } - - // Add remaining line - if currentLine != "" { - lines = append(lines, currentLine) - } - } - } - - return lines -} - // HandleKey handles keyboard input func (m *ListModal) HandleKey(key any, mod gocui.Modifier) error { switch key { diff --git a/pkg/app/message_modal.go b/pkg/app/message_modal.go index 9dc9bfc..23b683a 100644 --- a/pkg/app/message_modal.go +++ b/pkg/app/message_modal.go @@ -2,7 +2,6 @@ package app import ( "fmt" - "strings" "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/jesseduffield/gocui" @@ -17,97 +16,50 @@ type MessageModalStyle struct { // MessageModal displays a message with title and content type MessageModal struct { - g *gocui.Gui - tr *i18n.TranslationSet + *BaseModal title string contentLines []string // Original content lines lines []string // Wrapped content lines width int height int - style MessageModalStyle } // NewMessageModal creates a new message modal func NewMessageModal(g *gocui.Gui, tr *i18n.TranslationSet, title string, lines ...string) *MessageModal { return &MessageModal{ - g: g, - tr: tr, + BaseModal: NewBaseModal("modal", g, tr), title: title, contentLines: lines, - style: MessageModalStyle{}, // Default style } } // WithStyle sets the modal style func (m *MessageModal) WithStyle(style MessageModalStyle) *MessageModal { - m.style = style + m.SetStyle(style) return m } -// ID returns the modal's view ID -func (m *MessageModal) ID() string { - return "modal" -} - // Draw renders the modal func (m *MessageModal) Draw(dim boxlayout.Dimensions) error { - // Get screen size - screenWidth, screenHeight := m.g.Size() - - // Calculate width (4/7 of screen, min 80) - m.width = 4 * screenWidth / 7 - minWidth := 80 - if m.width < minWidth { - if screenWidth-2 < minWidth { - m.width = screenWidth - 2 - } else { - m.width = minWidth - } - } + // Calculate width + m.width = m.CalculateDimensions(4.0/7.0, 80) // Parse content into lines and calculate required height m.parseContent() // Calculate height based on content - // Content + 2 (top and bottom borders with title/footer) - contentHeight := len(m.lines) - // m.height = contentHeight + 2 - m.height = contentHeight + 1 - - // Don't exceed screen height - maxHeight := screenHeight - 4 - if m.height > maxHeight { - m.height = maxHeight - } + m.height = len(m.lines) + 1 // Center the modal - x0 := (screenWidth - m.width) / 2 - y0 := (screenHeight - m.height) / 2 - x1 := x0 + m.width - y1 := y0 + m.height + x0, y0, x1, y1 := m.CenterBox(m.width, m.height) // Create modal view - v, err := m.g.SetView(m.ID(), x0, y0, x1, y1, 0) - if err != nil && err.Error() != "unknown view" { + v, _, err := m.SetupView(m.ID(), x0, y0, x1, y1, 0, " "+m.title+" ", m.tr.ModalFooterMessageClose) + if err != nil { return err } v.Clear() - v.Frame = true - v.FrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} - v.Title = " " + m.title + " " - v.Footer = m.tr.ModalFooterMessageClose - - // Apply frame color (border) if set - if m.style.BorderColor != ColorDefault { - v.FrameColor = gocui.Attribute(colorToAnsiCode(m.style.BorderColor)) - } - - // Apply title color if set - if m.style.TitleColor != ColorDefault { - v.TitleColor = gocui.Attribute(colorToAnsiCode(m.style.TitleColor)) - } - v.Wrap = false // Render content @@ -131,33 +83,8 @@ func (m *MessageModal) parseContent() { continue } - // Word wrap long lines - if len(line) <= availableWidth { - m.lines = append(m.lines, " "+line) - } else { - // Simple word wrapping - words := strings.Fields(line) - currentLine := " " - - for _, word := range words { - if len(currentLine)+len(word)+1 <= availableWidth+2 { // +2 for initial " " - if currentLine == " " { - currentLine += word - } else { - currentLine += " " + word - } - } else { - // Current line is full, start new line - m.lines = append(m.lines, currentLine) - currentLine = " " + word - } - } - - // Add remaining line - if currentLine != " " { - m.lines = append(m.lines, currentLine) - } - } + wrapped := WrapText(line, availableWidth, " ") + m.lines = append(m.lines, wrapped...) } } @@ -174,30 +101,11 @@ func (m *MessageModal) HandleKey(key any, mod gocui.Modifier) error { // OnClose is called when the modal is closed func (m *MessageModal) OnClose() { - // Delete the modal view - m.g.DeleteView(m.ID()) + m.BaseModal.OnClose() } // colorToAnsiCode converts Color to gocui color attribute +// Deprecated: use ColorToGocuiAttr instead. Kept for backward compatibility. func colorToAnsiCode(c Color) int { - switch c { - case ColorBlack: - return int(gocui.ColorBlack) - case ColorRed: - return int(gocui.ColorRed) - case ColorGreen: - return int(gocui.ColorGreen) - case ColorYellow: - return int(gocui.ColorYellow) - case ColorBlue: - return int(gocui.ColorBlue) - case ColorMagenta: - return int(gocui.ColorMagenta) - case ColorCyan: - return int(gocui.ColorCyan) - case ColorWhite: - return int(gocui.ColorWhite) - default: - return int(gocui.ColorDefault) - } + return ColorToGocuiAttr(c) } From 9b9b777d078ba8b00a997c92c087cce83febf6e9 Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 15:09:35 +0900 Subject: [PATCH 08/26] remove dead code and complete BaseModal adoption nin list_modal --- pkg/app/base_modal.go | 4 ---- pkg/app/list_modal.go | 15 ++------------- pkg/app/message_modal.go | 5 ----- 3 files changed, 2 insertions(+), 22 deletions(-) diff --git a/pkg/app/base_modal.go b/pkg/app/base_modal.go index 66eb8da..4c537a8 100644 --- a/pkg/app/base_modal.go +++ b/pkg/app/base_modal.go @@ -7,10 +7,6 @@ import ( "github.com/jesseduffield/gocui" ) -// ModalStyle holds styling options for modals -// (renamed from MessageModalStyle for shared use across all modal types) -type ModalStyle = MessageModalStyle - // BaseModal provides common infrastructure shared by all modal types type BaseModal struct { id string diff --git a/pkg/app/list_modal.go b/pkg/app/list_modal.go index cc7f457..d408eb4 100644 --- a/pkg/app/list_modal.go +++ b/pkg/app/list_modal.go @@ -148,23 +148,12 @@ func (m *ListModal) drawListView(x0, y0, x1, y1 int) error { // drawDescView renders the description view (bottom) func (m *ListModal) drawDescView(x0, y0, x1, y1 int) error { - style := m.Style() - v, err := m.g.SetView(m.descViewID(), x0, y0, x1, y1, 0) - if err != nil && err.Error() != "unknown view" { + v, _, err := m.SetupView(m.descViewID(), x0, y0, x1, y1, 0, "", m.tr.ModalFooterListNavigate) + if err != nil { return err } v.Clear() - v.Frame = true - v.FrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} - v.Title = "" - v.Footer = m.tr.ModalFooterListNavigate - - // Apply frame color (border) if set - if style.BorderColor != ColorDefault { - v.FrameColor = gocui.Attribute(ColorToGocuiAttr(style.BorderColor)) - } - v.Wrap = true // Render description for selected item diff --git a/pkg/app/message_modal.go b/pkg/app/message_modal.go index 23b683a..bced1ee 100644 --- a/pkg/app/message_modal.go +++ b/pkg/app/message_modal.go @@ -104,8 +104,3 @@ func (m *MessageModal) OnClose() { m.BaseModal.OnClose() } -// colorToAnsiCode converts Color to gocui color attribute -// Deprecated: use ColorToGocuiAttr instead. Kept for backward compatibility. -func colorToAnsiCode(c Color) int { - return ColorToGocuiAttr(c) -} From 0a4b2d40631355d0c963024bb84be34df7b0f71d Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 15:17:15 +0900 Subject: [PATCH 09/26] extract ANSI colour helpers into pkg/gui/style --- pkg/gui/context/details_context.go | 145 ++++++++------------------ pkg/gui/context/migrations_context.go | 38 +------ pkg/gui/context/output_context.go | 36 +------ pkg/gui/context/statusbar_context.go | 38 +------ pkg/gui/context/workspace_context.go | 77 +++----------- pkg/gui/style/style.go | 108 +++++++++++++++++++ 6 files changed, 178 insertions(+), 264 deletions(-) create mode 100644 pkg/gui/style/style.go diff --git a/pkg/gui/context/details_context.go b/pkg/gui/context/details_context.go index 37b376b..689bbcf 100644 --- a/pkg/gui/context/details_context.go +++ b/pkg/gui/context/details_context.go @@ -10,6 +10,7 @@ import ( "github.com/alecthomas/chroma/v2/formatters" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" + "github.com/dokadev/lazyprisma/pkg/gui/style" "github.com/dokadev/lazyprisma/pkg/gui/types" "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/dokadev/lazyprisma/pkg/prisma" @@ -17,70 +18,6 @@ import ( "github.com/jesseduffield/lazycore/pkg/boxlayout" ) -// ============================================================================ -// Self-contained ANSI styling helpers (avoid importing pkg/app for colours) -// ============================================================================ - -func detailsRed(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[31m%s\x1b[0m", text) -} - -func detailsGreen(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[32m%s\x1b[0m", text) -} - -func detailsYellow(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[33m%s\x1b[0m", text) -} - -func detailsMagenta(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[35m%s\x1b[0m", text) -} - -func detailsCyan(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[36m%s\x1b[0m", text) -} - -func detailsOrange(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[38;5;208m%s\x1b[0m", text) -} - -// detailsStylize applies combined ANSI styling (fg colour + bold). -func detailsStylize(text string, fgCode string, bold bool) string { - if text == "" { - return text - } - codes := make([]string, 0, 2) - if fgCode != "" { - codes = append(codes, fgCode) - } - if bold { - codes = append(codes, "1") - } - if len(codes) == 0 { - return text - } - return fmt.Sprintf("\x1b[%sm%s\x1b[0m", strings.Join(codes, ";"), text) -} - // Frame and title styling constants (matching app.panel.go values) var ( detailsDefaultFrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} @@ -304,18 +241,18 @@ func (d *DetailsContext) buildMigrationDetailContent(migration *prisma.Migration // buildFailedMigrationContent builds content for failed/in-transaction migrations. func (d *DetailsContext) buildFailedMigrationContent(migration *prisma.Migration) string { timestamp, name := detailsParseMigrationName(migration.Name) - header := fmt.Sprintf(d.tr.DetailsNameLabel, detailsCyan(name)) + header := fmt.Sprintf(d.tr.DetailsNameLabel, style.Cyan(name)) header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) if migration.Path != "" { header += fmt.Sprintf(d.tr.DetailsPathLabel, detailsGetRelativePath(migration.Path)) } - header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n", detailsCyan(d.tr.MigrationStatusInTransaction)) + header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n", style.Cyan(d.tr.MigrationStatusInTransaction)) // Show down migration availability if migration.HasDownSQL { - header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", detailsGreen(d.tr.DetailsDownMigrationAvailable)) + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", style.Green(d.tr.DetailsDownMigrationAvailable)) } else { - header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", detailsRed(d.tr.DetailsDownMigrationNotAvailable)) + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", style.Red(d.tr.DetailsDownMigrationNotAvailable)) } // Show started_at if available @@ -323,13 +260,13 @@ func (d *DetailsContext) buildFailedMigrationContent(migration *prisma.Migration header += fmt.Sprintf(d.tr.DetailsStartedAtLabel+"%s\n", migration.StartedAt.Format("2006-01-02 15:04:05")) } - header += "\n" + detailsYellow(d.tr.DetailsInTransactionWarning) - header += "\n" + detailsYellow(d.tr.DetailsNoAdditionalMigrationsWarning) + header += "\n" + style.Yellow(d.tr.DetailsInTransactionWarning) + header += "\n" + style.Yellow(d.tr.DetailsNoAdditionalMigrationsWarning) header += "\n\n" + d.tr.DetailsResolveManuallyInstruction // Show logs if available if migration.Logs != nil && *migration.Logs != "" { - header += "\n" + d.tr.DetailsErrorLogsLabel + "\n" + detailsRed(*migration.Logs) + header += "\n" + d.tr.DetailsErrorLogsLabel + "\n" + style.Red(*migration.Logs) } // Read and show migration.sql content (if Path is available - not DB-Only) @@ -346,7 +283,7 @@ func (d *DetailsContext) buildFailedMigrationContent(migration *prisma.Migration downContent, err := os.ReadFile(downSQLPath) if err == nil { highlightedDownSQL := detailsHighlightSQL(string(downContent)) - result += "\n\n" + detailsYellow(d.tr.DetailsDownMigrationSQLLabel) + "\n\n" + highlightedDownSQL + result += "\n\n" + style.Yellow(d.tr.DetailsDownMigrationSQLLabel) + "\n\n" + highlightedDownSQL } } return result @@ -359,9 +296,9 @@ func (d *DetailsContext) buildFailedMigrationContent(migration *prisma.Migration // buildDBOnlyContent builds content for DB-only migrations. func (d *DetailsContext) buildDBOnlyContent(migration *prisma.Migration) string { timestamp, name := detailsParseMigrationName(migration.Name) - header := fmt.Sprintf(d.tr.DetailsNameLabel, detailsYellow(name)) + header := fmt.Sprintf(d.tr.DetailsNameLabel, style.Yellow(name)) header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) - header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n\n", detailsRed(d.tr.MigrationStatusDBOnly)) + header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n\n", style.Red(d.tr.MigrationStatusDBOnly)) header += d.tr.DetailsDBOnlyDescription return header } @@ -369,32 +306,32 @@ func (d *DetailsContext) buildDBOnlyContent(migration *prisma.Migration) string // buildChecksumMismatchContent builds content for checksum mismatch migrations. func (d *DetailsContext) buildChecksumMismatchContent(migration *prisma.Migration) string { timestamp, name := detailsParseMigrationName(migration.Name) - header := fmt.Sprintf(d.tr.DetailsNameLabel, detailsOrange(name)) + header := fmt.Sprintf(d.tr.DetailsNameLabel, style.Orange(name)) header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) if migration.Path != "" { header += fmt.Sprintf(d.tr.DetailsPathLabel, detailsGetRelativePath(migration.Path)) } // Show Applied status with Checksum Mismatch warning - statusLine := fmt.Sprintf(d.tr.DetailsStatusLabel+"%s", detailsGreen(d.tr.MigrationStatusApplied)) + statusLine := fmt.Sprintf(d.tr.DetailsStatusLabel+"%s", style.Green(d.tr.MigrationStatusApplied)) if migration.AppliedAt != nil { statusLine += fmt.Sprintf(" (%s)", fmt.Sprintf(d.tr.DetailsAppliedAtLabel, migration.AppliedAt.Format("2006-01-02 15:04:05"))) } - statusLine += fmt.Sprintf(" - %s\n", detailsOrange(d.tr.MigrationStatusChecksumMismatch)) + statusLine += fmt.Sprintf(" - %s\n", style.Orange(d.tr.MigrationStatusChecksumMismatch)) header += statusLine // Show down migration availability if migration.HasDownSQL { - header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", detailsGreen(d.tr.DetailsDownMigrationAvailable)) + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", style.Green(d.tr.DetailsDownMigrationAvailable)) } else { - header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", detailsRed(d.tr.DetailsDownMigrationNotAvailable)) + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", style.Red(d.tr.DetailsDownMigrationNotAvailable)) } header += "\n" + d.tr.DetailsChecksumModifiedDescription header += d.tr.DetailsChecksumIssuesWarning // Show checksum values (in orange for emphasis) - header += fmt.Sprintf(d.tr.DetailsLocalChecksumLabel+"%s\n", detailsOrange(migration.Checksum)) - header += fmt.Sprintf(d.tr.DetailsHistoryChecksumLabel+"%s\n", detailsOrange(migration.DBChecksum)) + header += fmt.Sprintf(d.tr.DetailsLocalChecksumLabel+"%s\n", style.Orange(migration.Checksum)) + header += fmt.Sprintf(d.tr.DetailsHistoryChecksumLabel+"%s\n", style.Orange(migration.DBChecksum)) // Read and show migration.sql content sqlPath := filepath.Join(migration.Path, "migration.sql") @@ -409,7 +346,7 @@ func (d *DetailsContext) buildChecksumMismatchContent(migration *prisma.Migratio downContent, err := os.ReadFile(downSQLPath) if err == nil { highlightedDownSQL := detailsHighlightSQL(string(downContent)) - result += "\n\n" + detailsYellow(d.tr.DetailsDownMigrationSQLLabel) + "\n\n" + highlightedDownSQL + result += "\n\n" + style.Yellow(d.tr.DetailsDownMigrationSQLLabel) + "\n\n" + highlightedDownSQL } } return result @@ -421,18 +358,18 @@ func (d *DetailsContext) buildChecksumMismatchContent(migration *prisma.Migratio // buildEmptyMigrationContent builds content for empty migrations. func (d *DetailsContext) buildEmptyMigrationContent(migration *prisma.Migration) string { timestamp, name := detailsParseMigrationName(migration.Name) - header := fmt.Sprintf(d.tr.DetailsNameLabel, detailsMagenta(name)) + header := fmt.Sprintf(d.tr.DetailsNameLabel, style.Magenta(name)) header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) if migration.Path != "" { header += fmt.Sprintf(d.tr.DetailsPathLabel, detailsGetRelativePath(migration.Path)) } - header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n", detailsRed(d.tr.MigrationStatusEmptyMigration)) + header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n", style.Red(d.tr.MigrationStatusEmptyMigration)) // Show down migration availability (even for empty migrations) if migration.HasDownSQL { - header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", detailsGreen(d.tr.DetailsDownMigrationAvailable)) + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", style.Green(d.tr.DetailsDownMigrationAvailable)) } else { - header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", detailsRed(d.tr.DetailsDownMigrationNotAvailable)) + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", style.Red(d.tr.DetailsDownMigrationNotAvailable)) } header += "\n" + d.tr.DetailsEmptyMigrationDescription @@ -456,28 +393,28 @@ func (d *DetailsContext) buildNormalMigrationContent(migration *prisma.Migration timestamp, name := detailsParseMigrationName(migration.Name) var header string if migration.AppliedAt != nil { - header = fmt.Sprintf(d.tr.DetailsNameLabel, detailsGreen(name)) + header = fmt.Sprintf(d.tr.DetailsNameLabel, style.Green(name)) header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) if migration.Path != "" { header += fmt.Sprintf(d.tr.DetailsPathLabel, detailsGetRelativePath(migration.Path)) } header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s (%s)\n", - detailsGreen(d.tr.MigrationStatusApplied), + style.Green(d.tr.MigrationStatusApplied), fmt.Sprintf(d.tr.DetailsAppliedAtLabel, migration.AppliedAt.Format("2006-01-02 15:04:05"))) } else { - header = fmt.Sprintf(d.tr.DetailsNameLabel, detailsYellow(name)) + header = fmt.Sprintf(d.tr.DetailsNameLabel, style.Yellow(name)) header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) if migration.Path != "" { header += fmt.Sprintf(d.tr.DetailsPathLabel, detailsGetRelativePath(migration.Path)) } - header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n", detailsYellow(d.tr.MigrationStatusPending)) + header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n", style.Yellow(d.tr.MigrationStatusPending)) } // Show down migration availability if migration.HasDownSQL { - header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", detailsGreen(d.tr.DetailsDownMigrationAvailable)) + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", style.Green(d.tr.DetailsDownMigrationAvailable)) } else { - header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", detailsRed(d.tr.DetailsDownMigrationNotAvailable)) + header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", style.Red(d.tr.DetailsDownMigrationNotAvailable)) } // Apply syntax highlighting to SQL content @@ -491,7 +428,7 @@ func (d *DetailsContext) buildNormalMigrationContent(migration *prisma.Migration downContent, err := os.ReadFile(downSQLPath) if err == nil { highlightedDownSQL := detailsHighlightSQL(string(downContent)) - result += "\n\n" + detailsYellow(d.tr.DetailsDownMigrationSQLLabel) + "\n\n" + highlightedDownSQL + result += "\n\n" + style.Yellow(d.tr.DetailsDownMigrationSQLLabel) + "\n\n" + highlightedDownSQL } } @@ -574,7 +511,7 @@ func (d *DetailsContext) buildActionNeededContent() string { var content strings.Builder // Header - content.WriteString(fmt.Sprintf("%s (%d%s", detailsYellow(d.tr.ActionNeededHeader), totalCount, d.tr.ActionNeededIssueSingular)) + content.WriteString(fmt.Sprintf("%s (%d%s", style.Yellow(d.tr.ActionNeededHeader), totalCount, d.tr.ActionNeededIssueSingular)) if totalCount > 1 { content.WriteString(d.tr.ActionNeededIssuePlural) } @@ -583,7 +520,7 @@ func (d *DetailsContext) buildActionNeededContent() string { // Empty Migrations Section if emptyCount > 0 { content.WriteString(strings.Repeat("━", 40) + "\n") - content.WriteString(fmt.Sprintf("%s (%d)\n", detailsRed(d.tr.ActionNeededEmptyMigrationsHeader), emptyCount)) + content.WriteString(fmt.Sprintf("%s (%d)\n", style.Red(d.tr.ActionNeededEmptyMigrationsHeader), emptyCount)) content.WriteString(strings.Repeat("━", 40) + "\n\n") content.WriteString(d.tr.ActionNeededEmptyDescription) @@ -591,7 +528,7 @@ func (d *DetailsContext) buildActionNeededContent() string { content.WriteString(d.tr.ActionNeededAffectedLabel) for _, mig := range emptyMigrations { _, name := detailsParseMigrationName(mig.Name) - content.WriteString(fmt.Sprintf(" • %s\n", detailsRed(name))) + content.WriteString(fmt.Sprintf(" • %s\n", style.Red(name))) } content.WriteString("\n" + d.tr.ActionNeededRecommendedLabel) @@ -603,18 +540,18 @@ func (d *DetailsContext) buildActionNeededContent() string { // Checksum Mismatch Section if mismatchCount > 0 { content.WriteString(strings.Repeat("━", 40) + "\n") - content.WriteString(fmt.Sprintf("%s (%d)\n", detailsOrange(d.tr.ActionNeededChecksumMismatchHeader), mismatchCount)) + content.WriteString(fmt.Sprintf("%s (%d)\n", style.Orange(d.tr.ActionNeededChecksumMismatchHeader), mismatchCount)) content.WriteString(strings.Repeat("━", 40) + "\n\n") content.WriteString(d.tr.ActionNeededChecksumModifiedDescription) - content.WriteString(detailsYellow(d.tr.ActionNeededWarningPrefix)) + content.WriteString(style.Yellow(d.tr.ActionNeededWarningPrefix)) content.WriteString(d.tr.ActionNeededEditingInconsistenciesWarning) content.WriteString(d.tr.ActionNeededAffectedLabel) for _, mig := range mismatchMigrations { _, name := detailsParseMigrationName(mig.Name) - content.WriteString(fmt.Sprintf(" • %s\n", detailsOrange(name))) + content.WriteString(fmt.Sprintf(" • %s\n", style.Orange(name))) } content.WriteString("\n" + d.tr.ActionNeededRecommendedLabel) @@ -626,7 +563,7 @@ func (d *DetailsContext) buildActionNeededContent() string { // Schema Validation Section if validationErrorCount > 0 { content.WriteString(strings.Repeat("━", 40) + "\n") - content.WriteString(fmt.Sprintf("%s (%d)\n", detailsRed(d.tr.ActionNeededSchemaValidationErrorsHeader), validationErrorCount)) + content.WriteString(fmt.Sprintf("%s (%d)\n", style.Red(d.tr.ActionNeededSchemaValidationErrorsHeader), validationErrorCount)) content.WriteString(strings.Repeat("━", 40) + "\n\n") content.WriteString(d.tr.ActionNeededSchemaValidationFailedDesc) @@ -634,15 +571,15 @@ func (d *DetailsContext) buildActionNeededContent() string { // Show full validation output (contains detailed error info) if d.validationResult.Output != "" { - content.WriteString(detailsStylize(d.tr.ActionNeededValidationOutputLabel, "33", true) + "\n") + content.WriteString(style.Stylize(d.tr.ActionNeededValidationOutputLabel, "33", true) + "\n") // Display the full output with proper formatting (preserve all line breaks) outputLines := strings.Split(d.validationResult.Output, "\n") for _, line := range outputLines { // Highlight error lines if strings.Contains(line, "Error:") || strings.Contains(line, "error:") { - content.WriteString(detailsRed(line) + "\n") + content.WriteString(style.Red(line) + "\n") } else if strings.Contains(line, "-->") { - content.WriteString(detailsYellow(line) + "\n") + content.WriteString(style.Yellow(line) + "\n") } else { // Preserve empty lines and all other content as-is content.WriteString(line + "\n") @@ -651,7 +588,7 @@ func (d *DetailsContext) buildActionNeededContent() string { content.WriteString("\n") } - content.WriteString(detailsStylize(d.tr.ActionNeededRecommendedActionsLabel, "33", true) + "\n") + content.WriteString(style.Stylize(d.tr.ActionNeededRecommendedActionsLabel, "33", true) + "\n") content.WriteString(d.tr.ActionNeededFixSchemaErrors) content.WriteString(d.tr.ActionNeededCheckLineNumbers) content.WriteString(d.tr.ActionNeededReferPrismaDocumentation) diff --git a/pkg/gui/context/migrations_context.go b/pkg/gui/context/migrations_context.go index 476ee54..4665ed2 100644 --- a/pkg/gui/context/migrations_context.go +++ b/pkg/gui/context/migrations_context.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/dokadev/lazyprisma/pkg/database" + "github.com/dokadev/lazyprisma/pkg/gui/style" "github.com/dokadev/lazyprisma/pkg/gui/types" "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/dokadev/lazyprisma/pkg/prisma" @@ -13,35 +14,6 @@ import ( "github.com/jesseduffield/lazycore/pkg/boxlayout" ) -// Self-contained ANSI colour helpers (avoid importing pkg/app) -func migRed(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[31m%s\x1b[0m", text) -} - -func migYellow(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[33m%s\x1b[0m", text) -} - -func migCyan(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[36m%s\x1b[0m", text) -} - -func migOrange(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[38;5;208m%s\x1b[0m", text) -} - // Frame and title styling constants (matching app.panel.go values) var ( migDefaultFrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} @@ -689,13 +661,13 @@ func (m *MigrationsContext) loadItemsForCurrentTab() { // Colour priority: Failed > Checksum Mismatch > Empty > Pending > Normal if mig.IsFailed { - m.items[i] = indexPrefix + migCyan(displayName) + m.items[i] = indexPrefix + style.Cyan(displayName) } else if mig.ChecksumMismatch { - m.items[i] = indexPrefix + migOrange(displayName) + m.items[i] = indexPrefix + style.Orange(displayName) } else if mig.IsEmpty { - m.items[i] = indexPrefix + migRed(displayName) + m.items[i] = indexPrefix + style.Red(displayName) } else if m.dbConnected && mig.AppliedAt == nil { - m.items[i] = indexPrefix + migYellow(displayName) + m.items[i] = indexPrefix + style.Yellow(displayName) } else { m.items[i] = indexPrefix + displayName } diff --git a/pkg/gui/context/output_context.go b/pkg/gui/context/output_context.go index 887c41a..559539d 100644 --- a/pkg/gui/context/output_context.go +++ b/pkg/gui/context/output_context.go @@ -4,41 +4,13 @@ import ( "fmt" "time" + "github.com/dokadev/lazyprisma/pkg/gui/style" "github.com/dokadev/lazyprisma/pkg/gui/types" "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazycore/pkg/boxlayout" ) -// ANSI styling helpers (self-contained to avoid circular import with app) -func gray(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[38;5;240m%s\x1b[0m", text) -} - -func red(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[31m%s\x1b[0m", text) -} - -func cyanBold(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[36;1m%s\x1b[0m", text) -} - -func redBold(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[31;1m%s\x1b[0m", text) -} - // Frame and title styling constants (matching app.panel.go values) var ( outputDefaultFrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} @@ -181,7 +153,7 @@ func (o *OutputContext) LogAction(action string, details ...string) { o.content += "\n" } - header := fmt.Sprintf("%s %s", gray(timestamp), cyanBold(action)) + header := fmt.Sprintf("%s %s", style.Gray(timestamp), style.CyanBold(action)) o.content += header + "\n" for _, detail := range details { @@ -199,11 +171,11 @@ func (o *OutputContext) LogActionRed(action string, details ...string) { o.content += "\n" } - header := fmt.Sprintf("%s %s", gray(timestamp), redBold(action)) + header := fmt.Sprintf("%s %s", style.Gray(timestamp), style.RedBold(action)) o.content += header + "\n" for _, detail := range details { - o.content += " " + red(detail) + "\n" + o.content += " " + style.Red(detail) + "\n" } o.autoScrollToBottom = true diff --git a/pkg/gui/context/statusbar_context.go b/pkg/gui/context/statusbar_context.go index 1f9628d..636f9e8 100644 --- a/pkg/gui/context/statusbar_context.go +++ b/pkg/gui/context/statusbar_context.go @@ -3,41 +3,13 @@ package context import ( "fmt" + "github.com/dokadev/lazyprisma/pkg/gui/style" "github.com/dokadev/lazyprisma/pkg/gui/types" "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazycore/pkg/boxlayout" ) -// ANSI styling helpers for status bar (self-contained to avoid circular import with app) -func statusCyan(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[36m%s\x1b[0m", text) -} - -func statusGray(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[38;5;240m%s\x1b[0m", text) -} - -func statusGreen(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[32m%s\x1b[0m", text) -} - -func statusBlue(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[34m%s\x1b[0m", text) -} - // StatusBarState provides callbacks for accessing App state without direct dependency. type StatusBarState struct { IsCommandRunning func() bool @@ -129,7 +101,7 @@ func (s *StatusBarContext) Draw(dim boxlayout.Dimensions) error { // Get running task name taskName := s.state.GetCommandName() - leftContent = fmt.Sprintf(" %s %s ", statusCyan(spinner), statusGray(taskName)) + leftContent = fmt.Sprintf(" %s %s ", style.Cyan(spinner), style.Gray(taskName)) visibleLen += 1 + 1 + 1 + len(taskName) + 1 // " " + spinner + " " + taskName + " " } else { leftContent = " " // Single space when not running @@ -139,7 +111,7 @@ func (s *StatusBarContext) Draw(dim boxlayout.Dimensions) error { // Show Studio status if running if s.state.IsStudioRunning() { studioMsg := s.tr.StatusStudioOn - leftContent += fmt.Sprintf("%s ", statusGreen(studioMsg)) + leftContent += fmt.Sprintf("%s ", style.Green(studioMsg)) visibleLen += len(studioMsg) + 1 } @@ -147,7 +119,7 @@ func (s *StatusBarContext) Draw(dim boxlayout.Dimensions) error { // Returns styled string and its visible length appendKey := func(key, desc string) { // Style: [key]desc - styled := fmt.Sprintf("[%s]%s", statusCyan(key), statusGray(desc)) + styled := fmt.Sprintf("[%s]%s", style.Cyan(key), style.Gray(desc)) // Visible: [key]desc vLen := 1 + len(key) + 1 + len(desc) @@ -164,7 +136,7 @@ func (s *StatusBarContext) Draw(dim boxlayout.Dimensions) error { appendKey("c", s.tr.KeyHintCopy) // Right content (Metadata) - styledRight := fmt.Sprintf("%s %s", statusBlue(s.config.Developer), statusGray(s.config.Version)) + styledRight := fmt.Sprintf("%s %s", style.Blue(s.config.Developer), style.Gray(s.config.Version)) rightLen := len(s.config.Developer) + 1 + len(s.config.Version) // Calculate padding diff --git a/pkg/gui/context/workspace_context.go b/pkg/gui/context/workspace_context.go index d88e7e8..4ad9d6e 100644 --- a/pkg/gui/context/workspace_context.go +++ b/pkg/gui/context/workspace_context.go @@ -8,6 +8,7 @@ import ( "github.com/dokadev/lazyprisma/pkg/database" "github.com/dokadev/lazyprisma/pkg/git" + "github.com/dokadev/lazyprisma/pkg/gui/style" "github.com/dokadev/lazyprisma/pkg/gui/types" "github.com/dokadev/lazyprisma/pkg/i18n" "github.com/dokadev/lazyprisma/pkg/node" @@ -16,54 +17,6 @@ import ( "github.com/jesseduffield/lazycore/pkg/boxlayout" ) -// ANSI styling helpers (self-contained to avoid circular import with app) -func stylize(text string, fg string, bold bool) string { - if text == "" { - return text - } - codes := "" - if fg != "" { - codes = fg - } - if bold { - if codes != "" { - codes += ";1" - } else { - codes = "1" - } - } - if codes == "" { - return text - } - return fmt.Sprintf("\x1b[%sm%s\x1b[0m", codes, text) -} - -func yellowBold(text string) string { - return stylize(text, "33", true) -} - -func greenBold(text string) string { - return stylize(text, "32", true) -} - -func wsRed(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[31m%s\x1b[0m", text) -} - -func wsRedBold(text string) string { - return stylize(text, "31", true) -} - -func orange(text string) string { - if text == "" { - return text - } - return fmt.Sprintf("\x1b[38;5;208m%s\x1b[0m", text) -} - // Frame and title styling constants (matching app.panel.go values) var ( wsDefaultFrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} @@ -155,11 +108,11 @@ func (w *WorkspaceContext) Draw(dim boxlayout.Dimensions) error { var lines []string // Node and Prisma version on one line - nodeVersionStyled := yellowBold(w.nodeVersion) - prismaVersionStyled := yellowBold(w.prismaVersion) + nodeVersionStyled := style.YellowBold(w.nodeVersion) + prismaVersionStyled := style.YellowBold(w.prismaVersion) versionLine := fmt.Sprintf(w.tr.WorkspaceVersionLine, nodeVersionStyled, prismaVersionStyled) if w.prismaGlobal { - versionLine += " " + orange(w.tr.WorkspacePrismaGlobalIndicator) + versionLine += " " + style.Orange(w.tr.WorkspacePrismaGlobalIndicator) } lines = append(lines, versionLine) @@ -169,12 +122,12 @@ func (w *WorkspaceContext) Draw(dim boxlayout.Dimensions) error { // Git line with optional schema modified indicator gitLine := fmt.Sprintf(w.tr.WorkspaceGitLine, w.gitRepoName) if w.schemaModified { - gitLine += " " + orange(w.tr.WorkspaceSchemaModifiedIndicator) + gitLine += " " + style.Orange(w.tr.WorkspaceSchemaModifiedIndicator) } lines = append(lines, gitLine) // Branch on separate line - branchStyled := yellowBold(w.gitBranch) + branchStyled := style.YellowBold(w.gitBranch) lines = append(lines, fmt.Sprintf(w.tr.WorkspaceBranchFormat, branchStyled)) } else { lines = append(lines, w.tr.WorkspaceNotGitRepository) @@ -376,23 +329,23 @@ func (w *WorkspaceContext) buildDatabaseLines() []string { // Display provider with status on the same line providerName := database.GetProviderDisplayName(w.dbProvider) - providerName = yellowBold(providerName) + providerName = style.YellowBold(providerName) // Build provider line with status var providerLine string if w.dbConnected { - statusStyled := greenBold(w.tr.WorkspaceConnected) + statusStyled := style.GreenBold(w.tr.WorkspaceConnected) providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled) } else if w.dbError != "" { if w.isConfigurationError() { - statusStyled := wsRedBold(w.tr.WorkspaceNotConfigured) + statusStyled := style.RedBold(w.tr.WorkspaceNotConfigured) providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled) } else { - statusStyled := wsRedBold(w.tr.WorkspaceDisconnected) + statusStyled := style.RedBold(w.tr.WorkspaceDisconnected) providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled) } } else { - statusStyled := wsRedBold(w.tr.WorkspaceDisconnected) + statusStyled := style.RedBold(w.tr.WorkspaceDisconnected) providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled) } lines = append(lines, providerLine) @@ -406,7 +359,7 @@ func (w *WorkspaceContext) buildDatabaseLines() []string { // Add hardcoded warning if applicable if w.isHardcoded { - lines = append(lines, fmt.Sprintf("%s %s", displayURL, wsRed(w.tr.WorkspaceHardcodedIndicator))) + lines = append(lines, fmt.Sprintf("%s %s", displayURL, style.Red(w.tr.WorkspaceHardcodedIndicator))) } else { lines = append(lines, displayURL) } @@ -414,10 +367,10 @@ func (w *WorkspaceContext) buildDatabaseLines() []string { // Only show error in URL field if it's a configuration issue // Apply styling: bold+red env var name, red "not configured" if w.envVarName != "" && strings.Contains(w.dbError, w.tr.WorkspaceNotConfiguredSuffix) { - styledError := wsRedBold(w.envVarName) + wsRed(w.tr.WorkspaceNotConfiguredSuffix) + styledError := style.RedBold(w.envVarName) + style.Red(w.tr.WorkspaceNotConfiguredSuffix) lines = append(lines, styledError) } else { - lines = append(lines, wsRed(w.dbError)) + lines = append(lines, style.Red(w.dbError)) } } else { lines = append(lines, w.tr.WorkspaceNotSet) @@ -425,7 +378,7 @@ func (w *WorkspaceContext) buildDatabaseLines() []string { // Show detailed error message if disconnected (not configuration error) if !w.dbConnected && w.dbError != "" && !w.isConfigurationError() { - lines = append(lines, wsRed(fmt.Sprintf(w.tr.WorkspaceErrorFormat, w.dbError))) + lines = append(lines, style.Red(fmt.Sprintf(w.tr.WorkspaceErrorFormat, w.dbError))) } return lines diff --git a/pkg/gui/style/style.go b/pkg/gui/style/style.go new file mode 100644 index 0000000..85f0999 --- /dev/null +++ b/pkg/gui/style/style.go @@ -0,0 +1,108 @@ +package style + +import ( + "fmt" + "strings" +) + +// Stylize applies combined ANSI styling (foreground colour code + bold flag). +// fgCode is a raw ANSI colour code such as "31" (red) or "38;5;208" (orange). +// If both fgCode and bold are empty/false the original text is returned unchanged. +func Stylize(text string, fgCode string, bold bool) string { + if text == "" { + return text + } + codes := make([]string, 0, 2) + if fgCode != "" { + codes = append(codes, fgCode) + } + if bold { + codes = append(codes, "1") + } + if len(codes) == 0 { + return text + } + return fmt.Sprintf("\x1b[%sm%s\x1b[0m", strings.Join(codes, ";"), text) +} + +// --------------------------------------------------------------------------- +// Single-colour helpers +// --------------------------------------------------------------------------- + +// Red colours text red (ANSI 31). +func Red(text string) string { + return Stylize(text, "31", false) +} + +// Green colours text green (ANSI 32). +func Green(text string) string { + return Stylize(text, "32", false) +} + +// Yellow colours text yellow (ANSI 33). +func Yellow(text string) string { + return Stylize(text, "33", false) +} + +// Blue colours text blue (ANSI 34). +func Blue(text string) string { + return Stylize(text, "34", false) +} + +// Magenta colours text magenta (ANSI 35). +func Magenta(text string) string { + return Stylize(text, "35", false) +} + +// Cyan colours text cyan (ANSI 36). +func Cyan(text string) string { + return Stylize(text, "36", false) +} + +// Orange colours text orange (256-colour ANSI 208). +func Orange(text string) string { + return Stylize(text, "38;5;208", false) +} + +// Gray colours text gray (256-colour ANSI 240). +func Gray(text string) string { + return Stylize(text, "38;5;240", false) +} + +// --------------------------------------------------------------------------- +// Compound helpers (colour + bold) +// --------------------------------------------------------------------------- + +// RedBold colours text red and makes it bold. +func RedBold(text string) string { + return Stylize(text, "31", true) +} + +// GreenBold colours text green and makes it bold. +func GreenBold(text string) string { + return Stylize(text, "32", true) +} + +// YellowBold colours text yellow and makes it bold. +func YellowBold(text string) string { + return Stylize(text, "33", true) +} + +// CyanBold colours text cyan and makes it bold. +func CyanBold(text string) string { + return Stylize(text, "36", true) +} + +// OrangeBold colours text orange and makes it bold. +func OrangeBold(text string) string { + return Stylize(text, "38;5;208", true) +} + +// --------------------------------------------------------------------------- +// Attribute-only helpers +// --------------------------------------------------------------------------- + +// Bold makes text bold (ANSI 1). +func Bold(text string) string { + return Stylize(text, "", true) +} From 1f8bf91b22873a7e98d12cd7614285166cd64b0e Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 15:22:11 +0900 Subject: [PATCH 10/26] eliminate style variable shadowing and remaining inline ANSI codes --- pkg/gui/context/details_context.go | 14 +++++++------- pkg/gui/context/migrations_context.go | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/gui/context/details_context.go b/pkg/gui/context/details_context.go index 689bbcf..232d26f 100644 --- a/pkg/gui/context/details_context.go +++ b/pkg/gui/context/details_context.go @@ -571,7 +571,7 @@ func (d *DetailsContext) buildActionNeededContent() string { // Show full validation output (contains detailed error info) if d.validationResult.Output != "" { - content.WriteString(style.Stylize(d.tr.ActionNeededValidationOutputLabel, "33", true) + "\n") + content.WriteString(style.YellowBold(d.tr.ActionNeededValidationOutputLabel) + "\n") // Display the full output with proper formatting (preserve all line breaks) outputLines := strings.Split(d.validationResult.Output, "\n") for _, line := range outputLines { @@ -588,7 +588,7 @@ func (d *DetailsContext) buildActionNeededContent() string { content.WriteString("\n") } - content.WriteString(style.Stylize(d.tr.ActionNeededRecommendedActionsLabel, "33", true) + "\n") + content.WriteString(style.YellowBold(d.tr.ActionNeededRecommendedActionsLabel) + "\n") content.WriteString(d.tr.ActionNeededFixSchemaErrors) content.WriteString(d.tr.ActionNeededCheckLineNumbers) content.WriteString(d.tr.ActionNeededReferPrismaDocumentation) @@ -689,9 +689,9 @@ func detailsHighlightSQL(code string) string { } // Get style (dracula is a popular dark theme) - style := styles.Get("dracula") - if style == nil { - style = styles.Fallback + chromaStyle := styles.Get("dracula") + if chromaStyle == nil { + chromaStyle = styles.Fallback } // Get terminal formatter with 256 colors @@ -707,7 +707,7 @@ func detailsHighlightSQL(code string) string { return code // Return original if highlighting fails } - err = formatter.Format(&buf, style, iterator) + err = formatter.Format(&buf, chromaStyle, iterator) if err != nil { return code // Return original if highlighting fails } @@ -722,7 +722,7 @@ func detailsHighlightSQL(code string) string { result.WriteString("\n") } // Line number in gray color, right-aligned to 4 digits - result.WriteString(fmt.Sprintf("\033[90m%4d │\033[0m %s", i+1, line)) + result.WriteString(style.Stylize(fmt.Sprintf("%4d │", i+1), "90", false) + " " + line) } return result.String() diff --git a/pkg/gui/context/migrations_context.go b/pkg/gui/context/migrations_context.go index 4665ed2..c04ce70 100644 --- a/pkg/gui/context/migrations_context.go +++ b/pkg/gui/context/migrations_context.go @@ -652,11 +652,11 @@ func (m *MigrationsContext) loadItemsForCurrentTab() { // Add index number with colour based on migration status var indexPrefix string if mig.IsEmpty { - indexPrefix = fmt.Sprintf("\033[31m%4d │\033[0m ", i+1) // Red for empty + indexPrefix = style.Red(fmt.Sprintf("%4d │", i+1)) + " " // Red for empty } else if mig.HasDownSQL { - indexPrefix = fmt.Sprintf("\033[32m%4d │\033[0m ", i+1) // Green for down.sql + indexPrefix = style.Green(fmt.Sprintf("%4d │", i+1)) + " " // Green for down.sql } else { - indexPrefix = fmt.Sprintf("\033[90m%4d │\033[0m ", i+1) // Gray for normal + indexPrefix = style.Stylize(fmt.Sprintf("%4d │", i+1), "90", false) + " " // Gray for normal } // Colour priority: Failed > Checksum Mismatch > Empty > Pending > Normal From c84dc9173ca48f61003eed6fc501866496b79506 Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 15:27:08 +0900 Subject: [PATCH 11/26] remove test utilities and unused keybindings --- pkg/app/keybinding.go | 56 ------ pkg/app/test.go | 257 -------------------------- pkg/gui/context/details_context.go | 2 +- pkg/gui/context/migrations_context.go | 2 +- 4 files changed, 2 insertions(+), 315 deletions(-) delete mode 100644 pkg/app/test.go diff --git a/pkg/app/keybinding.go b/pkg/app/keybinding.go index 499ff43..2ad1307 100644 --- a/pkg/app/keybinding.go +++ b/pkg/app/keybinding.go @@ -227,39 +227,6 @@ func (a *App) RegisterKeybindings() error { return err } - // 'i' key - test ping to google.com - if err := a.g.SetKeybinding("", 'i', gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { - if a.HasActiveModal() { - return nil - } - a.TestPing() - return nil - }); err != nil { - return err - } - - // // 't' key - test modal (temporary) - // if err := a.g.SetKeybinding("", 't', gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { - // if a.HasActiveModal() { - // return nil - // } - // a.TestModal() - // return nil - // }); err != nil { - // return err - // } - - // // 'm' key - test input modal (temporary) - // if err := a.g.SetKeybinding("", 'm', gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { - // if a.HasActiveModal() { - // return nil - // } - // a.TestInputModal() - // return nil - // }); err != nil { - // return err - // } - // 'd' key - migrate dev if err := a.g.SetKeybinding("", 'd', gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { if a.HasActiveModal() { @@ -367,28 +334,5 @@ func (a *App) RegisterKeybindings() error { return err } - // // 'l' key - test list modal (temporary) - // if err := a.g.SetKeybinding("", 'l', gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { - // if a.HasActiveModal() { - // return nil - // } - // a.TestListModal() - // return nil - // }); err != nil { - // return err - // } - - // // 'y' key - test confirm modal (temporary) - // if err := a.g.SetKeybinding("", 'y', gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { - // if a.HasActiveModal() { - // // Pass 'y' to modal (for ConfirmModal) - // return a.activeModal.HandleKey('y', gocui.ModNone) - // } - // a.TestConfirmModal() - // return nil - // }); err != nil { - // return err - // } - return nil } diff --git a/pkg/app/test.go b/pkg/app/test.go deleted file mode 100644 index dc22291..0000000 --- a/pkg/app/test.go +++ /dev/null @@ -1,257 +0,0 @@ -package app - -import ( - "fmt" - "os" - - "github.com/dokadev/lazyprisma/pkg/commands" - "github.com/dokadev/lazyprisma/pkg/gui/context" - "github.com/dokadev/lazyprisma/pkg/prisma" - "github.com/jesseduffield/gocui" -) - -// TestModal opens a test modal (temporary for testing) -func (a *App) TestModal() { - // Get current working directory - cwd, err := os.Getwd() - if err != nil { - modal := NewMessageModal(a.g, a.Tr,"Error", - "Failed to get working directory:", - err.Error(), - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - return - } - - // Run validation - result, err := prisma.Validate(cwd) - if err != nil { - modal := NewMessageModal(a.g, a.Tr,"Validation Error", - "Failed to run validation:", - err.Error(), - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - return - } - - // Show result - if result.Valid { - // Validation passed - modal := NewMessageModal(a.g, a.Tr,"Schema Validation Passed", - "Your Prisma schema is valid!", - ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}) - a.OpenModal(modal) - } else { - // Validation failed - show errors - lines := []string{"Schema validation failed with the following errors:"} - if len(result.Errors) > 0 { - for _, err := range result.Errors { - styledErr := Stylize(err, Style{FgColor: ColorRed, Bold: true}) - lines = append(lines, styledErr) - } - } else { - styledOutput := Stylize(result.Output, Style{FgColor: ColorRed, Bold: true}) - lines = append(lines, styledOutput) - } - - modal := NewMessageModal(a.g, a.Tr,"Schema Validation Failed", lines...). - WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(modal) - } -} - -// TestInputModal opens a test input modal (temporary for testing) -func (a *App) TestInputModal() { - modal := NewInputModal(a.g, a.Tr,"Enter migration name", - func(input string) { - // Close input modal - a.CloseModal() - - // Show result in message modal - resultModal := NewMessageModal(a.g, a.Tr,"Input Received", - "You entered:", - input, - ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) - a.OpenModal(resultModal) - }, - func() { - // Cancel - just close modal - a.CloseModal() - }, - ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}). - WithRequired(true). - OnValidationFail(func(reason string) { - // Close input modal and show error modal - a.CloseModal() - - errorModal := NewMessageModal(a.g, a.Tr,"Validation Failed", - reason, - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(errorModal) - }) - - a.OpenModal(modal) -} - -// TestListModal opens a test list modal (temporary for testing) -func (a *App) TestListModal() { - items := []ListModalItem{ - { - Label: "Create Migration", - Description: "Create a new migration file.\n\nThis will:\n• Generate a new migration file in prisma/migrations\n• Include timestamp in the filename\n• Prompt for migration name", - OnSelect: func() error { - a.CloseModal() - resultModal := NewMessageModal(a.g, a.Tr,"Action Selected", - "You selected: Create Migration", - ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) - a.OpenModal(resultModal) - return nil - }, - }, - { - Label: "Run Migrations", - Description: "Apply pending migrations to the database.\n\nThis will:\n• Execute all pending migrations in order\n• Update _prisma_migrations table\n• May modify database schema", - OnSelect: func() error { - a.CloseModal() - resultModal := NewMessageModal(a.g, a.Tr,"Action Selected", - "You selected: Run Migrations", - ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) - a.OpenModal(resultModal) - return nil - }, - }, - { - Label: "Reset Database", - Description: "Reset the database to a clean state.\n\nWARNING: This will:\n• Drop all tables and data\n• Re-run all migrations from scratch\n• Cannot be undone", - OnSelect: func() error { - a.CloseModal() - resultModal := NewMessageModal(a.g, a.Tr,"Action Selected", - "You selected: Reset Database", - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(resultModal) - return nil - }, - }, - { - Label: "Validate Schema", - Description: "Validate the Prisma schema file.\n\nThis will:\n• Check for syntax errors\n• Verify model relationships\n• Validate field types\n• Report any issues", - OnSelect: func() error { - a.CloseModal() - resultModal := NewMessageModal(a.g, a.Tr,"Action Selected", - "You selected: Validate Schema", - ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) - a.OpenModal(resultModal) - return nil - }, - }, - } - - modal := NewListModal(a.g, a.Tr,"Select Action", items, - func() { - // Cancel - just close modal - a.CloseModal() - }, - ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}) - - a.OpenModal(modal) -} - -// TestConfirmModal opens a test confirm modal (temporary for testing) -func (a *App) TestConfirmModal() { - modal := NewConfirmModal(a.g, a.Tr,"Confirm Action", - "Are you sure you want to proceed with this action? This cannot be undone.", - func() { - // Yes callback - close confirm modal and show result - a.CloseModal() - resultModal := NewMessageModal(a.g, a.Tr,"Confirmed", - "You clicked Yes!", - ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen}) - a.OpenModal(resultModal) - }, - func() { - // No callback - close confirm modal and show result - a.CloseModal() - resultModal := NewMessageModal(a.g, a.Tr,"Cancelled", - "You clicked No!", - ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) - a.OpenModal(resultModal) - }, - ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow}) - - a.OpenModal(modal) -} - -// TestPing tests network connectivity by pinging google.com -func (a *App) TestPing() { - // Try to start command - if another command is running, block - if !a.tryStartCommand("Network Test") { - a.logCommandBlocked("Network Test") - return - } - - outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext) - if !ok { - a.finishCommand() // Clean up if panel not found - return - } - - // Log action start - outputPanel.LogAction("Network Test", "Pinging google.com...") - - // Create command builder - builder := commands.NewCommandBuilder(commands.NewPlatform()) - - // Build ping command (4 pings) - pingCmd := builder.New("ping", "-c", "4", "google.com"). - 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(" [ERROR] " + line) - } - return nil - }) - }). - OnComplete(func(exitCode int) { - defer a.finishCommand() // Mark command as complete - - // 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 { - out.LogAction("Network Test Complete", "Ping successful") - } else { - out.LogAction("Network Test Failed", fmt.Sprintf("Ping failed with exit code: %d", exitCode)) - } - } - return nil - }) - }). - OnError(func(err error) { - defer a.finishCommand() // Mark command as complete even on error - - // Update UI on main thread - a.g.Update(func(g *gocui.Gui) error { - if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok { - out.LogAction("Network Test Error", err.Error()) - } - return nil - }) - }) - - // Run async to avoid blocking UI - if err := pingCmd.RunAsync(); err != nil { - a.finishCommand() // Clean up if command fails to start - outputPanel.LogAction("Network Test Error", "Failed to start ping: "+err.Error()) - } -} diff --git a/pkg/gui/context/details_context.go b/pkg/gui/context/details_context.go index 232d26f..bf1841c 100644 --- a/pkg/gui/context/details_context.go +++ b/pkg/gui/context/details_context.go @@ -722,7 +722,7 @@ func detailsHighlightSQL(code string) string { result.WriteString("\n") } // Line number in gray color, right-aligned to 4 digits - result.WriteString(style.Stylize(fmt.Sprintf("%4d │", i+1), "90", false) + " " + line) + result.WriteString(style.Gray(fmt.Sprintf("%4d │", i+1)) + " " + line) } return result.String() diff --git a/pkg/gui/context/migrations_context.go b/pkg/gui/context/migrations_context.go index c04ce70..aa6f102 100644 --- a/pkg/gui/context/migrations_context.go +++ b/pkg/gui/context/migrations_context.go @@ -656,7 +656,7 @@ func (m *MigrationsContext) loadItemsForCurrentTab() { } else if mig.HasDownSQL { indexPrefix = style.Green(fmt.Sprintf("%4d │", i+1)) + " " // Green for down.sql } else { - indexPrefix = style.Stylize(fmt.Sprintf("%4d │", i+1), "90", false) + " " // Gray for normal + indexPrefix = style.Gray(fmt.Sprintf("%4d │", i+1)) + " " // Gray for normal } // Colour priority: Failed > Checksum Mismatch > Empty > Pending > Normal From 5bbe65579cd9ed754ea56c4baaa114b2b9c3dc9a Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 15:36:44 +0900 Subject: [PATCH 12/26] remove remaining test utilities and commented-out keybindings --- pkg/app/keybinding.go | 11 -- pkg/commands/test.go | 135 ----------------------- pkg/database/test.go | 246 ------------------------------------------ 3 files changed, 392 deletions(-) delete mode 100644 pkg/commands/test.go delete mode 100644 pkg/database/test.go diff --git a/pkg/app/keybinding.go b/pkg/app/keybinding.go index 2ad1307..c561f8d 100644 --- a/pkg/app/keybinding.go +++ b/pkg/app/keybinding.go @@ -22,17 +22,6 @@ func (a *App) RegisterKeybindings() error { return err } - // Quit or close modal (uppercase Q) - // if err := a.g.SetKeybinding("", 'Q', gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { - // if a.HasActiveModal() { - // a.CloseModal() - // return nil - // } - // return gocui.ErrQuit - // }); err != nil { - // return err - // } - // Ctrl+C to quit if err := a.g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { return gocui.ErrQuit diff --git a/pkg/commands/test.go b/pkg/commands/test.go deleted file mode 100644 index d9c5934..0000000 --- a/pkg/commands/test.go +++ /dev/null @@ -1,135 +0,0 @@ -package commands - -import ( - "context" - "fmt" - "time" -) - -// RunTestSuite executes all command system tests -func RunTestSuite() { - fmt.Println("=== LazyPrisma Command Executor Test ===\n") - - // Create a context with cancellation support - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Create command builder - builder := NewCommandBuilder(NewPlatform()) - - TestGitStatus(ctx, builder) - TestEchoCommand(builder) - TestGitLogWithStreamHandler(builder) - TestPingStreaming(ctx, builder) - - fmt.Println("=== All Tests Completed ===") -} - -// TestGitStatus tests git status with streaming output -func TestGitStatus(ctx context.Context, builder *CommandBuilder) { - fmt.Println("Test 1: Git Status (Streaming)") - fmt.Println("--------------------------------") - - cmd := builder.NewWithContext(ctx, "git", "status", "--short"). - StreamOutput(). - OnStdout(func(line string) { - fmt.Printf("[STDOUT] %s\n", line) - }). - OnStderr(func(line string) { - fmt.Printf("[STDERR] %s\n", line) - }). - OnComplete(func(exitCode int) { - fmt.Printf("\n✓ Command completed with exit code: %d\n\n", exitCode) - }). - OnError(func(err error) { - fmt.Printf("\n✗ Command error: %v\n\n", err) - }) - - if err := cmd.RunAsync(); err != nil { - fmt.Printf("Failed to start command: %v\n", err) - return - } - - time.Sleep(2 * time.Second) -} - -// TestEchoCommand tests echo with captured output -func TestEchoCommand(builder *CommandBuilder) { - fmt.Println("Test 2: Echo Command (Captured Output)") - fmt.Println("---------------------------------------") - - echoCmd := builder.New("echo", "Hello from LazyPrisma!") - result, err := echoCmd.RunWithOutput() - if err != nil { - fmt.Printf("Error: %v\n", err) - } else { - fmt.Printf("Output: %s", result.Stdout) - fmt.Printf("Exit Code: %d\n", result.ExitCode) - fmt.Printf("Duration: %v\n\n", result.Duration) - } -} - -// TestGitLogWithStreamHandler tests git log with StreamHandler -func TestGitLogWithStreamHandler(builder *CommandBuilder) { - fmt.Println("Test 3: Git Log with StreamHandler") - fmt.Println("-----------------------------------") - - logHandler := NewStreamHandler(func(stdout, stderr []string) { - fmt.Printf("[Handler Update] %d stdout lines, %d stderr lines\n", - len(stdout), len(stderr)) - }) - - gitLogCmd := builder.New("git", "log", "--oneline", "-n", "5"). - StreamOutput(). - OnStdout(logHandler.HandleStdout). - OnStderr(logHandler.HandleStderr). - OnComplete(func(exitCode int) { - stdout, stderr := logHandler.GetOutput() - fmt.Printf("\nFinal buffered output:\n") - for _, line := range stdout { - fmt.Printf(" %s\n", line) - } - if len(stderr) > 0 { - fmt.Printf("\nErrors:\n") - for _, line := range stderr { - fmt.Printf(" %s\n", line) - } - } - fmt.Printf("\n✓ Git log completed (exit code: %d)\n\n", exitCode) - }) - - if err := gitLogCmd.RunAsync(); err != nil { - fmt.Printf("Failed to start git log: %v\n", err) - return - } - - time.Sleep(2 * time.Second) -} - -// TestPingStreaming tests ping with real-time streaming -func TestPingStreaming(ctx context.Context, builder *CommandBuilder) { - fmt.Println("Test 4: Ping google.com (Streaming)") - fmt.Println("------------------------------------") - - pingCmd := builder.NewWithContext(ctx, "ping", "-c", "4", "google.com"). - StreamOutput(). - OnStdout(func(line string) { - fmt.Printf("[PING] %s\n", line) - }). - OnStderr(func(line string) { - fmt.Printf("[PING ERR] %s\n", line) - }). - OnComplete(func(exitCode int) { - fmt.Printf("\n✓ Ping completed with exit code: %d\n\n", exitCode) - }). - OnError(func(err error) { - fmt.Printf("\n✗ Ping error: %v\n\n", err) - }) - - if err := pingCmd.RunAsync(); err != nil { - fmt.Printf("Failed to start ping: %v\n", err) - return - } - - time.Sleep(5 * time.Second) -} diff --git a/pkg/database/test.go b/pkg/database/test.go deleted file mode 100644 index 774e45d..0000000 --- a/pkg/database/test.go +++ /dev/null @@ -1,246 +0,0 @@ -package database - -import ( - "fmt" - "time" -) - -// RunTestSuite runs database module tests -func RunTestSuite() { - fmt.Println("=== LazyPrisma Database Module Test ===\n") - - TestRegistryList() - TestConfigBuilders() - TestClientCreation() - - // Real DB connection tests - TestMySQLPrismaMigrations() - TestPostgresPrismaMigrations() - - fmt.Println("=== Database Tests Completed ===") -} - -// TestMySQLPrismaMigrations connects to MySQL and fetches Prisma migrations -func TestMySQLPrismaMigrations() { - fmt.Println("Test 4: MySQL Prisma Migrations") - fmt.Println("--------------------------------") - - client, err := NewClient("mysql") - if err != nil { - fmt.Printf("✗ Failed to create client: %v\n", err) - return - } - - cfg := NewConfig(). - WithHost("localhost"). - WithPort(3308). - WithUser("root"). - WithPassword("1234"). - WithDatabase("linkareer_local_dev_db") - - fmt.Printf("Connecting to: mysql://%s:****@%s:%d/%s\n", cfg.User, cfg.Host, cfg.Port, cfg.Database) - - if err := client.Connect(cfg); err != nil { - fmt.Printf("✗ Failed to connect: %v\n", err) - return - } - defer client.Close() - - fmt.Println("✓ Connected to MySQL\n") - - // Query Prisma migrations table - rows, err := client.Query(` - SELECT - id, - migration_name, - started_at, - finished_at, - applied_steps_count - FROM _prisma_migrations - ORDER BY started_at DESC - LIMIT 10 - `) - if err != nil { - fmt.Printf("✗ Failed to query migrations: %v\n", err) - return - } - defer rows.Close() - - fmt.Println("\nPrisma Migrations (latest 10):") - - count := 0 - for rows.Next() { - var id, name string - var startedAt time.Time - var finishedAt *time.Time - var steps int - - if err := rows.Scan(&id, &name, &startedAt, &finishedAt, &steps); err != nil { - fmt.Printf("✗ Scan error: %v\n", err) - continue - } - - fmt.Printf("[%d] %s\n", count+1, name) - fmt.Printf(" ID: %s\n", id[:8]) - fmt.Printf(" Started: %s\n", startedAt.Format("2006-01-02 15:04:05")) - if finishedAt != nil { - fmt.Printf(" Finished: %s\n", finishedAt.Format("2006-01-02 15:04:05")) - } else { - fmt.Printf(" Finished: (failed)\n") - } - fmt.Printf(" Steps: %d\n\n", steps) - count++ - } - - if count == 0 { - fmt.Println("No migrations found.") - } else { - fmt.Printf("✓ Total: %d migration(s)\n", count) - } - fmt.Println() -} - -// TestPostgresPrismaMigrations connects to PostgreSQL and fetches Prisma migrations -func TestPostgresPrismaMigrations() { - fmt.Println("Test 5: PostgreSQL Prisma Migrations") - fmt.Println("-------------------------------------") - - client, err := NewClient("postgres") - if err != nil { - fmt.Printf("✗ Failed to create client: %v\n", err) - return - } - - cfg := NewConfig(). - WithHost("localhost"). - WithPort(6432). - WithUser("linkareer"). - WithPassword("1234"). - WithDatabase("linkareer_chat_local_db") - - fmt.Printf("Connecting to: postgresql://%s:****@%s:%d/%s\n", cfg.User, cfg.Host, cfg.Port, cfg.Database) - - if err := client.Connect(cfg); err != nil { - fmt.Printf("✗ Failed to connect: %v\n\n", err) - return - } - defer client.Close() - - fmt.Println("✓ Connected to PostgreSQL") - - // Query Prisma migrations table - rows, err := client.Query(` - SELECT - id, - migration_name, - started_at, - finished_at, - applied_steps_count - FROM _prisma_migrations - ORDER BY started_at DESC - LIMIT 10 - `) - if err != nil { - fmt.Printf("✗ Failed to query migrations: %v\n\n", err) - return - } - defer rows.Close() - - fmt.Println("\nPrisma Migrations (latest 10):") - - count := 0 - for rows.Next() { - var id, name string - var startedAt time.Time - var finishedAt *time.Time - var steps int - - if err := rows.Scan(&id, &name, &startedAt, &finishedAt, &steps); err != nil { - fmt.Printf("✗ Scan error: %v\n", err) - continue - } - - fmt.Printf("[%d] %s\n", count+1, name) - fmt.Printf(" ID: %s\n", id[:8]) - fmt.Printf(" Started: %s\n", startedAt.Format("2006-01-02 15:04:05")) - if finishedAt != nil { - fmt.Printf(" Finished: %s\n", finishedAt.Format("2006-01-02 15:04:05")) - } else { - fmt.Printf(" Finished: (failed)\n") - } - fmt.Printf(" Steps: %d\n\n", steps) - count++ - } - - if count == 0 { - fmt.Println("No migrations found.") - } else { - fmt.Printf("✓ Total: %d migration(s)\n", count) - } - fmt.Println() -} - -// TestRegistryList tests driver registration -func TestRegistryList() { - fmt.Println("Test 1: Registry - List Registered Drivers") - fmt.Println("-------------------------------------------") - - drivers := List() - fmt.Printf("Registered drivers: %v\n", drivers) - - for _, name := range drivers { - fmt.Printf(" - %s: Has=%v\n", name, Has(name)) - } - fmt.Println() -} - -// TestConfigBuilders tests config DSN builders -func TestConfigBuilders() { - fmt.Println("Test 2: Config - DSN Builders") - fmt.Println("-----------------------------") - - cfg := NewConfig(). - WithHost("localhost"). - WithPort(5432). - WithUser("admin"). - WithPassword("secret"). - WithDatabase("testdb"). - WithSSLMode("disable") - - fmt.Printf("PostgreSQL DSN:\n %s\n\n", cfg.PostgresDSN()) - - cfg.WithPort(3306) - fmt.Printf("MySQL DSN:\n %s\n\n", cfg.MySQLDSN()) -} - -// TestClientCreation tests client creation without actual connection -func TestClientCreation() { - fmt.Println("Test 3: Client - Creation (no connection)") - fmt.Println("------------------------------------------") - - // Test postgres client - pgClient, err := NewClient("postgres") - if err != nil { - fmt.Printf("✗ Failed to create postgres client: %v\n", err) - } else { - fmt.Printf("✓ Created postgres client (driver: %s)\n", pgClient.DriverName()) - } - - // Test mysql client - mysqlClient, err := NewClient("mysql") - if err != nil { - fmt.Printf("✗ Failed to create mysql client: %v\n", err) - } else { - fmt.Printf("✓ Created mysql client (driver: %s)\n", mysqlClient.DriverName()) - } - - // Test unknown driver - _, err = NewClient("unknown") - if err != nil { - fmt.Printf("✓ Correctly rejected unknown driver: %v\n", err) - } else { - fmt.Printf("✗ Should have rejected unknown driver\n") - } - - fmt.Println() -} From 7f0b5a5dfac5c03ee871bb4a813bf75b168065cc Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 16:04:49 +0900 Subject: [PATCH 13/26] add Json-based i18n with Germany translation and english fallback --- go.mod | 1 + go.sum | 2 + pkg/config/config.go | 2 +- pkg/i18n/i18n.go | 51 ++++++- pkg/i18n/translations/de.json | 265 ++++++++++++++++++++++++++++++++++ 5 files changed, 313 insertions(+), 8 deletions(-) create mode 100644 pkg/i18n/translations/de.json diff --git a/go.mod b/go.mod index 4715e72..cd98ca7 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/dokadev/lazyprisma go 1.25.5 require ( + dario.cat/mergo v1.0.2 github.com/alecthomas/chroma/v2 v2.21.1 github.com/go-sql-driver/mysql v1.9.3 github.com/jesseduffield/gocui v0.3.1-0.20260128194906-9d8c3cdfac18 diff --git a/go.sum b/go.sum index f2321cb..3af77b7 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= diff --git a/pkg/config/config.go b/pkg/config/config.go index a7a8022..7b82a50 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -130,7 +130,7 @@ scan: # - /full/path/to/exclude # - dirname-to-exclude -# Language setting ("auto" for system detection, "en" for English, "ko" for Korean) +# Language setting ("auto" for system detection, or a language code like "en", "de") language: auto ` return os.WriteFile(path, []byte(defaultConfig), 0644) diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go index 305c7bf..a6349f4 100644 --- a/pkg/i18n/i18n.go +++ b/pkg/i18n/i18n.go @@ -1,14 +1,19 @@ package i18n import ( + "embed" + "encoding/json" + "errors" + "fmt" + "io/fs" "os" "strings" + + "dario.cat/mergo" ) -// Supported language codes -var supportedLanguages = map[string]func() *TranslationSet{ - "en": EnglishTranslationSet, -} +//go:embed translations/*.json +var translationsFS embed.FS // NewTranslationSet returns a TranslationSet for the given language. // If language is "auto", it detects the system language. @@ -18,11 +23,43 @@ func NewTranslationSet(language string) *TranslationSet { language = detectSystemLanguage() } - if factory, ok := supportedLanguages[language]; ok { - return factory() + if language == "en" { + return EnglishTranslationSet() + } + + base := EnglishTranslationSet() + overlay, err := loadLanguageJSON(language) + if err != nil { + fmt.Printf("warning: failed to load translations for %q: %v\n", language, err) + return base + } + + if err := mergo.Merge(base, &overlay, mergo.WithOverride); err != nil { + fmt.Printf("warning: failed to merge translations for %q: %v\n", language, err) + return EnglishTranslationSet() + } + + return base +} + +// loadLanguageJSON reads a translation JSON file from the embedded filesystem. +// If the file does not exist, it returns an empty TranslationSet with nil error. +func loadLanguageJSON(language string) (TranslationSet, error) { + filename := fmt.Sprintf("translations/%s.json", language) + data, err := translationsFS.ReadFile(filename) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return TranslationSet{}, nil + } + return TranslationSet{}, fmt.Errorf("reading %s: %w", filename, err) + } + + var ts TranslationSet + if err := json.Unmarshal(data, &ts); err != nil { + return TranslationSet{}, fmt.Errorf("invalid JSON in %s: %w", filename, err) } - return EnglishTranslationSet() + return ts, nil } // detectSystemLanguage checks LANG, LC_ALL, LC_MESSAGES environment variables. diff --git a/pkg/i18n/translations/de.json b/pkg/i18n/translations/de.json new file mode 100644 index 0000000..05e33b5 --- /dev/null +++ b/pkg/i18n/translations/de.json @@ -0,0 +1,265 @@ +{ + "PanelTitleOutput": "Ausgabe", + "PanelTitleWorkspace": "Arbeitsbereich", + "PanelTitleDetails": "Details", + + "TabLocal": "Lokal", + "TabPending": "Ausstehend", + "TabDBOnly": "Nur-DB", + "TabDetails": "Details", + "TabActionNeeded": "Handlungsbedarf", + + "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:", + "ErrorCannotExecuteCommand": "'%s' kann nicht ausgeführt werden", + "ErrorCommandCurrentlyRunning": "' wird derzeit ausgeführt'", + "ErrorOperationBlocked": "Vorgang blockiert", + + "ModalTitleError": "Fehler", + "ModalTitleDBConnectionRequired": "Datenbankverbindung erforderlich", + "ModalTitleMigrationError": "Migrationsfehler", + "ModalTitleMigrationCreated": "Migration erstellt", + "ModalTitleMigrationFailed": "Migration fehlgeschlagen", + "ModalTitleMigrateDeploySuccess": "Migrate Deploy erfolgreich", + "ModalTitleMigrateDeployFailed": "Migrate Deploy fehlgeschlagen", + "ModalTitleMigrateDeployError": "Migrate Deploy Fehler", + "ModalTitleGenerateSuccess": "Generierung erfolgreich", + "ModalTitleGenerateFailed": "Generierung fehlgeschlagen", + "ModalTitleGenerateError": "Generierungsfehler", + "ModalTitleSchemaValidationFailed": "Schema-Validierung fehlgeschlagen", + "ModalTitleNoMigrationSelected": "Keine Migration ausgewählt", + "ModalTitleCannotResolveMigration": "Migration kann nicht aufgelöst werden", + "ModalTitleMigrateResolveSuccess": "Migrate Resolve erfolgreich", + "ModalTitleMigrateResolveFailed": "Migrate Resolve fehlgeschlagen", + "ModalTitleMigrateResolveError": "Migrate Resolve Fehler", + "ModalTitleStudioError": "Studio-Fehler", + "ModalTitleStudioStopped": "Studio gestoppt", + "ModalTitleStudioStarted": "Prisma Studio gestartet", + "ModalTitleNoSelection": "Keine Auswahl", + "ModalTitleCannotDelete": "Löschen nicht möglich", + "ModalTitleDeleteError": "Löschfehler", + "ModalTitleDeleted": "Gelöscht", + "ModalTitleClipboardError": "Zwischenablagefehler", + "ModalTitleCopied": "Kopiert", + "ModalTitlePendingMigrationsDetected": "Ausstehende Migrationen erkannt", + "ModalTitleDBOnlyMigrationsDetected": "Nur-DB-Migrationen erkannt", + "ModalTitleChecksumMismatchDetected": "Prüfsummen-Abweichung erkannt", + "ModalTitleEmptyPendingDetected": "Leere ausstehende Migration erkannt", + "ModalTitleOperationBlocked": "Vorgang blockiert", + "ModalTitleDeleteMigration": "Migration löschen", + "ModalTitleValidationFailed": "Validierung fehlgeschlagen", + "ModalTitleMigrateDev": "Migrate Dev", + "ModalTitleResolveMigration": "Migration auflösen: %s", + "ModalTitleCopyToClipboard": "In Zwischenablage kopieren", + "ModalTitleEnterMigrationName": "Migrationsname eingeben", + + "ModalMsgMigrationCreatedSuccess": "Migration '%s' erfolgreich erstellt!", + "ModalMsgMigrationCreatedDetail": "Sie finden sie im Verzeichnis prisma/migrations.", + "ModalMsgMigrationFailedWithCode": "prisma migrate dev mit Exit-Code fehlgeschlagen: %d", + "ModalMsgCheckOutputPanel": "Überprüfen Sie das Ausgabe-Panel für Details.", + "ModalMsgMigrationsAppliedSuccess": "Migrationen erfolgreich angewendet!", + "ModalMsgMigrateDeployFailedWithCode": "prisma migrate deploy mit Exit-Code fehlgeschlagen: %d", + "ModalMsgFailedRunMigrateDeploy": "prisma migrate deploy konnte nicht ausgeführt werden:", + "ModalMsgFailedStartMigrateDeploy": "Migrate Deploy konnte nicht gestartet werden:", + "ModalMsgPrismaClientGenerated": "Prisma Client erfolgreich generiert!", + "ModalMsgGenerateFailedSchemaErrors": "Generierung aufgrund von Schema-Fehlern fehlgeschlagen.", + "ModalMsgGenerateFailedWithCode": "prisma generate mit Exit-Code fehlgeschlagen: %d", + "ModalMsgSchemaValidCheckOutput": "Schema ist gültig. Überprüfen Sie das Ausgabe-Panel für Details.", + "ModalMsgFailedRunGenerate": "prisma generate konnte nicht ausgeführt werden:", + "ModalMsgFailedStartGenerate": "Generierung konnte nicht gestartet werden:", + "ModalMsgSelectMigrationResolve": "Bitte wählen Sie eine Migration zum Auflösen aus.", + "ModalMsgOnlyInTransactionResolve": "Nur Migrationen im Status 'In-Transaction' können aufgelöst werden.", + "ModalMsgMigrationNotFailed": "Migration '%s' befindet sich nicht in einem fehlgeschlagenen Zustand.", + "ModalMsgMigrationMarkedSuccess": "Migration erfolgreich als %s markiert!", + "ModalMsgMigrateResolveFailedWithCode": "prisma migrate resolve mit Exit-Code fehlgeschlagen: %d", + "ModalMsgFailedRunMigrateResolve": "prisma migrate resolve konnte nicht ausgeführt werden:", + "ModalMsgFailedStartMigrateResolve": "Migrate Resolve konnte nicht gestartet werden:", + "ModalMsgFailedStopStudio": "Prisma Studio konnte nicht gestoppt werden:", + "ModalMsgStudioStopped": "Prisma Studio wurde gestoppt.", + "ModalMsgFailedStartStudio": "Prisma Studio konnte nicht gestartet werden:", + "ModalMsgStudioRunningAt": "Prisma Studio läuft unter http://localhost:5555", + "ModalMsgPressStopStudio": "Drücken Sie erneut 'S', um es zu stoppen.", + "ModalMsgSelectMigrationDelete": "Bitte wählen Sie eine Migration zum Löschen aus.", + "ModalMsgMigrationDBOnly": "Diese Migration existiert nur in der Datenbank (Nur-DB).", + "ModalMsgCannotDeleteNoLocalFile": "Eine Migration ohne lokale Datei kann nicht gelöscht werden.", + "ModalMsgMigrationAlreadyApplied": "Diese Migration wurde bereits auf die Datenbank angewendet.", + "ModalMsgDeleteLocalInconsistency": "Lokales Löschen würde Inkonsistenzen verursachen.", + "ModalMsgFailedDeleteFolder": "Migrationsordner konnte nicht gelöscht werden:", + "ModalMsgMigrationDeletedSuccess": "Migration erfolgreich gelöscht.", + "ModalMsgFailedCopyClipboard": "Kopieren in die Zwischenablage fehlgeschlagen:", + "ModalMsgCopiedToClipboard": "%s in die Zwischenablage kopiert!", + "ModalMsgPendingMigrationsWarning": "Prisma wendet ausstehende Migrationen automatisch an, bevor neue erstellt werden. Dies kann in Zukunft zu unerwartetem Verhalten führen. Möchten Sie fortfahren?", + "ModalMsgCannotCreateWithDBOnly": "Neue Migration kann nicht erstellt werden, solange Nur-DB-Migrationen vorhanden sind.", + "ModalMsgResolveDBOnlyFirst": "Bitte lösen Sie zuerst die Nur-DB-Migrationen auf.", + "ModalMsgCannotCreateWithMismatch": "Neue Migration kann nicht erstellt werden, solange Prüfsummen-Abweichungen bestehen.", + "ModalMsgMigrationModifiedLocally": "Migration '%s' wurde lokal geändert.", + "ModalMsgCannotCreateWithEmpty": "Neue Migration kann nicht erstellt werden, solange leere ausstehende Migrationen vorhanden sind.", + "ModalMsgMigrationPendingEmpty": "Migration '%s' ist ausstehend und leer.", + "ModalMsgDeleteOrAddContent": "Bitte löschen Sie sie oder fügen Sie SQL-Inhalt hinzu.", + "ModalMsgAnotherOperationRunning": "Ein anderer Vorgang wird derzeit ausgeführt.", + "ModalMsgWaitComplete": "Bitte warten Sie, bis er abgeschlossen ist.", + "ModalMsgConfirmDeleteMigration": "Sind Sie sicher, dass Sie diese Migration löschen möchten?\n\n%s\n\nDiese Aktion kann nicht rückgängig gemacht werden.", + "ModalMsgSpacesReplaced": "Leerzeichen werden durch Unterstriche ersetzt", + "ModalMsgInputRequired": "Eingabe erforderlich", + "ModalMsgManualMigrationCreated": "Erstellt: %s", + "ModalMsgManualMigrationLocation": "Speicherort: %s", + "CopyLabelMigrationName": "Migrationsname", + "CopyLabelMigrationPath": "Migrationspfad", + "CopyLabelChecksum": "Prüfsumme", + + "ModalFooterInputSubmitCancel": "[Enter] Absenden [ESC] Abbrechen", + "ModalFooterListNavigate": "[↑/↓] Navigieren [Enter] Auswählen [ESC] Abbrechen", + "ModalFooterMessageClose": " [Enter/q/ESC] Schließen ", + "ModalFooterConfirmYesNo": " [Y] Ja [N] Nein [ESC] Abbrechen ", + + "StatusStudioOn": "[Studio: AN]", + + "LogActionMigrateDeploy": "Migrate Deploy", + "LogMsgRunningMigrateDeploy": "prisma migrate deploy wird ausgeführt...", + "LogActionMigrateDeployComplete": "Migrate Deploy abgeschlossen", + "LogMsgMigrationsAppliedSuccess": "Migrationen erfolgreich angewendet", + "LogActionMigrateDeployFailed": "Migrate Deploy fehlgeschlagen", + "LogMsgMigrateDeployFailedCode": "Migrate Deploy mit Exit-Code fehlgeschlagen: %d", + "LogActionMigrateResolve": "Migrate Resolve", + "LogMsgMarkingMigration": "Migration wird als %s markiert: %s", + "LogActionMigrateResolveComplete": "Migrate Resolve abgeschlossen", + "LogMsgMigrationMarked": "Migration erfolgreich als %s markiert", + "LogActionMigrateResolveFailed": "Migrate Resolve fehlgeschlagen", + "LogMsgMigrateResolveFailedCode": "Migrate Resolve mit Exit-Code fehlgeschlagen: %d", + "LogActionMigrateResolveError": "Migrate Resolve Fehler", + "LogActionGenerate": "Generierung", + "LogMsgRunningGenerate": "prisma generate wird ausgeführt...", + "LogActionGenerateComplete": "Generierung abgeschlossen", + "LogMsgPrismaClientGeneratedSuccess": "Prisma Client erfolgreich generiert", + "LogActionGenerateFailed": "Generierung fehlgeschlagen", + "LogMsgCheckingSchemaErrors": "Schema wird auf Fehler überprüft...", + "LogActionSchemaValidationFailed": "Schema-Validierung fehlgeschlagen", + "LogMsgFoundSchemaErrors": "%d Schema-Fehler gefunden", + "LogActionGenerateError": "Generierungsfehler", + "LogActionStudio": "Studio", + "LogMsgStartingStudio": "Prisma Studio wird gestartet...", + "LogActionStudioStarted": "Studio gestartet", + "LogMsgStudioListeningAt": "Prisma Studio läuft unter http://localhost:5555", + "LogActionStudioStopped": "Studio gestoppt", + "LogMsgStudioHasStopped": "Prisma Studio wurde gestoppt", + "LogActionMigrateDev": "Migrate Dev", + "LogMsgCreatingMigration": "Migration wird erstellt: %s", + "LogActionMigrateComplete": "Migration abgeschlossen", + "LogMsgMigrationCreatedSuccess": "Migration erfolgreich erstellt", + "LogActionMigrateFailed": "Migration fehlgeschlagen", + "LogMsgMigrationCreationFailedCode": "Migrationserstellung mit Exit-Code fehlgeschlagen: %d", + "LogActionMigrationError": "Migrationsfehler", + "LogMsgFailedDeleteMigration": "Migration konnte nicht gelöscht werden: %s", + "LogActionDeleted": "Gelöscht", + "LogMsgMigrationDeleted": "Migration '%s' gelöscht", + "SuccessAllPanelsRefreshed": "Alle Panels wurden aktualisiert", + "ActionRefresh": "Aktualisieren", + + "ListItemSchemaDiffMigration": "Schema-Diff-basierte Migration", + "ListItemDescSchemaDiffMigration": "Erstellt eine Migration basierend auf Änderungen im Prisma-Schema, wendet sie auf die Datenbank an und löst Generatoren aus (z.B. Prisma Client)", + "ListItemManualMigration": "Manuelle Migration", + "ListItemDescManualMigration": "Dieses Tool erstellt manuelle Migrationen für Datenbankänderungen, die nicht über Prisma-Schema-Diff ausgedrückt werden können. Es wird verwendet, um datenbankspezifische Logik wie Trigger, Funktionen und DML-Operationen explizit zu erfassen und zu versionieren, die auf Prisma-Schema-Ebene nicht verwaltet werden können.", + "ListItemMarkApplied": "Als angewendet markieren", + "ListItemDescMarkApplied": "Markiert diese Migration als erfolgreich auf die Datenbank angewendet. Verwenden Sie dies, wenn Sie das Problem manuell behoben haben und die Migrationsänderungen nun in der Datenbank vorhanden sind.", + "ListItemMarkRolledBack": "Als zurückgesetzt markieren", + "ListItemDescMarkRolledBack": "Markiert diese Migration als zurückgesetzt (aus der Datenbank entfernt). Verwenden Sie dies, wenn Sie die Änderungen manuell rückgängig gemacht haben und die Migration nicht mehr auf die Datenbank angewendet ist.", + "ListItemCopyName": "Name kopieren", + "ListItemCopyPath": "Pfad kopieren", + "ListItemCopyChecksum": "Prüfsumme kopieren", + + "MigrationStatusInTransaction": "⚠ In-Transaction", + "MigrationStatusDBOnly": "✗ Nur DB", + "MigrationStatusChecksumMismatch": "⚠ Prüfsummen-Abweichung", + "MigrationStatusApplied": "✓ Angewendet", + "MigrationStatusEmptyMigration": "⚠ Leere Migration", + "MigrationStatusPending": "⚠ Ausstehend", + + "DetailsPanelInitialPlaceholder": "Details\n\nWählen Sie eine Migration aus, um Details anzuzeigen...", + "DetailsNameLabel": "Name: %s\n", + "DetailsTimestampLabel": "Zeitstempel: %s\n", + "DetailsPathLabel": "Pfad: %s\n", + "DetailsStatusLabel": "Status: ", + "DetailsAppliedAtLabel": "Angewendet am: %s", + "DetailsDownMigrationLabel": "Down-Migration: ", + "DetailsDownMigrationAvailable": "✓ Verfügbar", + "DetailsDownMigrationNotAvailable": "✗ Nicht verfügbar", + "DetailsStartedAtLabel": "Gestartet am: ", + "DetailsInTransactionWarning": "⚠ WARNUNG: Diese Migration befindet sich in einem unvollständigen Zustand.", + "DetailsNoAdditionalMigrationsWarning": "Keine weiteren Migrationen können angewendet werden, bis dies gelöst ist.", + "DetailsResolveManuallyInstruction": "Bitte lösen Sie diese Migration manuell auf, bevor Sie fortfahren.\n", + "DetailsErrorLogsLabel": "Fehlerprotokolle:", + "DetailsDBOnlyDescription": "Diese Migration existiert in der Datenbank, aber nicht in lokalen Dateien.", + "DetailsChecksumModifiedDescription": "Die lokale Migrationsdatei wurde nach der Anwendung auf die Datenbank geändert.\n", + "DetailsChecksumIssuesWarning": "Dies kann beim Deployment zu Problemen führen.\n\n", + "DetailsLocalChecksumLabel": "Lokale Prüfsumme: ", + "DetailsHistoryChecksumLabel": "Verlauf-Prüfsumme: ", + "DetailsEmptyMigrationDescription": "Dieser Migrationsordner ist leer oder migration.sql fehlt.\n", + "DetailsEmptyMigrationWarning": "Dies kann beim Deployment zu Problemen führen.", + "DetailsDownMigrationSQLLabel": "Down-Migration SQL:", + "DetailsTimestampNA": "N/V", + "ErrorReadingMigrationSQL": "Fehler beim Lesen von migration.sql:\n%v", + + "ActionNeededNoIssuesMessage": "Kein Handlungsbedarf\n\nAlle Migrationen sind in gutem Zustand und das Schema ist gültig.", + "ActionNeededHeader": "⚠ Handlungsbedarf", + "ActionNeededIssueSingular": " Problem", + "ActionNeededIssuePlural": "e", + "ActionNeededEmptyMigrationsHeader": "Leere Migrationen", + "ActionNeededEmptyDescription": "Diese Migrationen haben keinen SQL-Inhalt.\n\n", + "ActionNeededAffectedLabel": "Betroffen:\n", + "ActionNeededRecommendedLabel": "Empfohlene Maßnahmen:\n", + "ActionNeededAddMigrationSQL": " → migration.sql manuell hinzufügen\n", + "ActionNeededDeleteEmptyFolders": " → Leere Migrationsordner löschen\n", + "ActionNeededMarkAsBaseline": " → Als Baseline-Migration markieren\n\n", + "ActionNeededChecksumMismatchHeader": "Prüfsummen-Abweichung", + "ActionNeededChecksumModifiedDescription": "Migrationsinhalt wurde nach der Anwendung\nauf die Datenbank geändert.\n\n", + "ActionNeededWarningPrefix": "⚠ WARNUNG: ", + "ActionNeededEditingInconsistenciesWarning": "Bearbeitung angewendeter Migrationen\nkann Inkonsistenzen verursachen.\n\n", + "ActionNeededRevertLocalChanges": " → Lokale Änderungen rückgängig machen\n", + "ActionNeededCreateNewInstead": " → Stattdessen neue Migration erstellen\n", + "ActionNeededContactTeamIfNeeded": " → Bei Bedarf das Team kontaktieren\n\n", + "ActionNeededSchemaValidationErrorsHeader": "Schema-Validierungsfehler", + "ActionNeededSchemaValidationFailedDesc": "Schema-Validierung fehlgeschlagen.\n", + "ActionNeededFixBeforeMigration": "Beheben Sie diese Probleme, bevor Sie Migrationen ausführen.\n\n", + "ActionNeededValidationOutputLabel": "Validierungsausgabe:", + "ActionNeededRecommendedActionsLabel": "Empfohlene Maßnahmen:", + "ActionNeededFixSchemaErrors": " → schema.prisma-Fehler beheben\n", + "ActionNeededCheckLineNumbers": " → Zeilennummern in der obigen Ausgabe überprüfen\n", + "ActionNeededReferPrismaDocumentation": " → Prisma-Dokumentation konsultieren\n", + + "WorkspaceVersionLine": "Node: %s | Prisma: %s", + "WorkspacePrismaGlobalIndicator": " (Global)", + "WorkspaceGitLine": "Git: %s", + "WorkspaceSchemaModifiedIndicator": " (Schema geändert)", + "WorkspaceBranchFormat": "(%s)", + "WorkspaceNotGitRepository": "Git: Kein Git-Repository", + "WorkspaceConnected": "✓ Verbunden", + "WorkspaceNotConfigured": "✗ Nicht konfiguriert", + "WorkspaceDisconnected": "✗ Nicht verbunden", + "WorkspaceProviderLine": "Anbieter: %s %s", + "WorkspaceHardcodedIndicator": " (Fest codiert)", + "WorkspaceNotSet": "Nicht gesetzt", + "WorkspaceErrorFormat": "Fehler: %s", + "WorkspaceErrorGetWorkingDirectory": "Fehler beim Ermitteln des Arbeitsverzeichnisses", + "WorkspaceErrorSchemaNotFound": "Schema-Datei nicht gefunden", + "WorkspaceNotConfiguredSuffix": " nicht konfiguriert", + "WorkspaceDatabaseURLNotConfigured": "DATABASE_URL nicht konfiguriert", + "WorkspaceNoDatabaseURL": "Keine DATABASE_URL", + "WorkspaceVersionNotFound": "Nicht gefunden", + + "MigrationsFooterFormat": "%d von %d", + + "VersionOutput": "LazyPrisma %s (%s)\n", + "ErrorFailedGetCurrentDir": "Fehler: Aktuelles Verzeichnis konnte nicht ermittelt werden: %v\n", + "ErrorNotPrismaWorkspace": "Fehler: Das aktuelle Verzeichnis ist kein Prisma-Arbeitsbereich.\n", + "ErrorExpectedOneOf": "\nErwartet wird eines der folgenden:\n", + "ErrorExpectedConfigV7Plus": " - prisma.config.ts (Prisma v7.0+)\n", + "ErrorExpectedSchemaV7Minus": " - prisma/schema.prisma (Prisma < v7.0)\n", + "ErrorFailedCreateApp": "App konnte nicht erstellt werden: %v\n", + "ErrorFailedRegisterKeybindings": "Tastenbelegungen konnten nicht registriert werden: %v\n", + "ErrorAppRuntime": "App-Fehler: %v\n" +} From 8411420f5b251c28d54cce3040e7d879e97a4a75 Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 16:10:59 +0900 Subject: [PATCH 14/26] bump version to v0.3.0 --- Makefile | 2 +- main.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index c3e66ea..8f4068c 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Binary name BINARY_NAME=lazyprisma -VERSION ?= 0.2.2 +VERSION ?= 0.3.0 # Directories BUILD_DIR=build diff --git a/main.go b/main.go index cc40df9..d2784db 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,7 @@ import ( ) const ( - Version = "v0.2.2" + Version = "v0.3.0" Developer = "DokaLab" ) From 33d22459931ebc1ddae42e508999637e60142b04 Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 16:46:11 +0900 Subject: [PATCH 15/26] add missing %s format verb in ErrorCommandCurrentlyRunning --- pkg/i18n/english.go | 2 +- pkg/i18n/translations/de.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 3c4bf37..fee2a82 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -311,7 +311,7 @@ func EnglishTranslationSet() *TranslationSet { ErrorEnsureDBAccessible: "Please ensure your database is running and accessible.", ErrorFailedGetWorkingDir: "Failed to get working directory:", ErrorCannotExecuteCommand: "Cannot execute '%s'", - ErrorCommandCurrentlyRunning: "' is currently running'", + ErrorCommandCurrentlyRunning: " — '%s' is currently running", ErrorOperationBlocked: "Operation Blocked", // Modal Titles diff --git a/pkg/i18n/translations/de.json b/pkg/i18n/translations/de.json index 05e33b5..f6f1b42 100644 --- a/pkg/i18n/translations/de.json +++ b/pkg/i18n/translations/de.json @@ -17,7 +17,7 @@ "ErrorEnsureDBAccessible": "Bitte stellen Sie sicher, dass Ihre Datenbank läuft und erreichbar ist.", "ErrorFailedGetWorkingDir": "Arbeitsverzeichnis konnte nicht ermittelt werden:", "ErrorCannotExecuteCommand": "'%s' kann nicht ausgeführt werden", - "ErrorCommandCurrentlyRunning": "' wird derzeit ausgeführt'", + "ErrorCommandCurrentlyRunning": " — '%s' wird derzeit ausgeführt", "ErrorOperationBlocked": "Vorgang blockiert", "ModalTitleError": "Fehler", From ceb57ffa8a4c21bbf3ef59d422ba4b289517ad76 Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 16:48:05 +0900 Subject: [PATCH 16/26] use i18n key instead of hardcoded 'db-only' tab name comparison --- pkg/gui/context/details_context.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/gui/context/details_context.go b/pkg/gui/context/details_context.go index bf1841c..51b39d5 100644 --- a/pkg/gui/context/details_context.go +++ b/pkg/gui/context/details_context.go @@ -221,7 +221,7 @@ func (d *DetailsContext) buildMigrationDetailContent(migration *prisma.Migration return d.buildFailedMigrationContent(migration) } - if tabName == "DB-Only" { + if tabName == d.tr.TabDBOnly { return d.buildDBOnlyContent(migration) } From a32fe39e7d3b83c5c45defb7c212901ba90b40eb Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 16:51:36 +0900 Subject: [PATCH 17/26] close previous dbClient before reassigning to prevent connection leak --- pkg/gui/context/migrations_context.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/gui/context/migrations_context.go b/pkg/gui/context/migrations_context.go index aa6f102..e385f2c 100644 --- a/pkg/gui/context/migrations_context.go +++ b/pkg/gui/context/migrations_context.go @@ -581,6 +581,9 @@ func (m *MigrationsContext) loadMigrations() { if err == nil && ds.URL != "" { client, err := database.NewClientFromDSN(ds.Provider, ds.URL) if err == nil { + if m.dbClient != nil { + m.dbClient.Close() + } m.dbClient = client dbMigrations, err = prisma.GetDBMigrations(client.DB()) if err == nil { From 5d342035081b42e4617612929c5098644daabb8b Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 16:53:52 +0900 Subject: [PATCH 18/26] set studioRunning flag immediately after RunAsync to prevent double-start --- pkg/app/studio_controller.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/app/studio_controller.go b/pkg/app/studio_controller.go index ef38828..b93942a 100644 --- a/pkg/app/studio_controller.go +++ b/pkg/app/studio_controller.go @@ -94,14 +94,16 @@ func (a *App) Studio() { return } + // Mark studio as running immediately to prevent double-start + a.studioRunning = true + a.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 - a.studioRunning = true - a.studioCmd = studioCmd // Save Command object outputPanel.LogAction(a.Tr.LogActionStudioStarted, a.Tr.LogMsgStudioListeningAt) outputPanel.SetSubtitle(a.Tr.LogMsgStudioListeningAt) From 4ddd192c2ed46cc565819525ffb23ea1cf560dff Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 16:55:28 +0900 Subject: [PATCH 19/26] use fmt.Fprint instead of fmt.Fprintf for non-format strings --- main.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index d2784db..80c02d8 100644 --- a/main.go +++ b/main.go @@ -39,10 +39,10 @@ func main() { } if !prisma.IsWorkspace(cwd) { - fmt.Fprintf(os.Stderr, tr.ErrorNotPrismaWorkspace) - fmt.Fprintf(os.Stderr, tr.ErrorExpectedOneOf) - fmt.Fprintf(os.Stderr, tr.ErrorExpectedConfigV7Plus) - fmt.Fprintf(os.Stderr, tr.ErrorExpectedSchemaV7Minus) + fmt.Fprint(os.Stderr, tr.ErrorNotPrismaWorkspace) + fmt.Fprint(os.Stderr, tr.ErrorExpectedOneOf) + fmt.Fprint(os.Stderr, tr.ErrorExpectedConfigV7Plus) + fmt.Fprint(os.Stderr, tr.ErrorExpectedSchemaV7Minus) os.Exit(1) } From fea524d1a27c18f536dae47b9839bb75bbe75a3c Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 17:13:28 +0900 Subject: [PATCH 20/26] correct ITabbedContext.GetCurrentTab return type from in to string --- main.go | 10 +++++----- pkg/app/app.go | 2 +- pkg/app/layout.go | 6 +++--- pkg/app/panel.go | 2 +- pkg/gui/types/context.go | 2 +- pkg/prisma/workspace.go | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/main.go b/main.go index 80c02d8..bc28b21 100644 --- a/main.go +++ b/main.go @@ -46,7 +46,7 @@ func main() { os.Exit(1) } - // App 생성 + // Create app tuiApp, err := app.NewApp(app.AppConfig{ DebugMode: false, AppName: "LazyPrisma", @@ -59,7 +59,7 @@ func main() { os.Exit(1) } - // 패널 생성 및 등록 + // Create and register panels workspace := context.NewWorkspaceContext(context.WorkspaceContextOpts{ Gui: tuiApp.GetGui(), Tr: tr, @@ -112,16 +112,16 @@ func main() { tuiApp.RegisterPanel(output) tuiApp.RegisterPanel(statusbar) - // 키바인딩 등록 + // Register keybindings if err := tuiApp.RegisterKeybindings(); err != nil { fmt.Fprintf(os.Stderr, tr.ErrorFailedRegisterKeybindings, err) os.Exit(1) } - // 마우스 바인딩 등록 + // Register mouse bindings tuiApp.RegisterMouseBindings() - // 실행 + // Run if err := tuiApp.Run(); err != nil { fmt.Fprintf(os.Stderr, tr.ErrorAppRuntime, err) os.Exit(1) diff --git a/pkg/app/app.go b/pkg/app/app.go index 2be210b..c52808c 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -88,7 +88,7 @@ func (a *App) Run() error { } }() - // 초기 포커스 + // Initial focus if len(a.focusOrder) > 0 { if panel, ok := a.panels[a.focusOrder[0]]; ok { panel.OnFocus() diff --git a/pkg/app/layout.go b/pkg/app/layout.go index 3808d4e..f200dd0 100644 --- a/pkg/app/layout.go +++ b/pkg/app/layout.go @@ -21,7 +21,7 @@ func (a *App) layoutManager(g *gocui.Gui) error { Children: []*boxlayout.Box{ { Window: ViewWorkspace, - Size: 10, // 실제 컨텐츠 길이 확인 후 재조정 필요 + Size: 10, // May need readjusting based on actual content length }, { Window: ViewMigrations, @@ -52,10 +52,10 @@ func (a *App) layoutManager(g *gocui.Gui) error { }, } - // boxlayout으로 차원 계산 + // Calculate dimensions via boxlayout dimensionMap := boxlayout.ArrangeWindows(root, 0, 0, width, height) - // 각 패널 렌더링 + // Render each panel for id, dim := range dimensionMap { if panel, ok := a.panels[id]; ok { if err := panel.Draw(dim); err != nil { diff --git a/pkg/app/panel.go b/pkg/app/panel.go index 58a77aa..99b1f67 100644 --- a/pkg/app/panel.go +++ b/pkg/app/panel.go @@ -66,7 +66,7 @@ func (bp *BasePanel) OnBlur() { } } -// SetupView는 공통 뷰 설정을 처리합니다 +// SetupView handles common view setup func (bp *BasePanel) SetupView(v *gocui.View, title string) { bp.v = v v.Clear() diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index ead497e..e698690 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -64,7 +64,7 @@ type ITabbedContext interface { NextTab() PrevTab() - GetCurrentTab() int + GetCurrentTab() string } // IScrollableContext is a context that supports vertical scrolling. diff --git a/pkg/prisma/workspace.go b/pkg/prisma/workspace.go index 5266cc0..473f474 100644 --- a/pkg/prisma/workspace.go +++ b/pkg/prisma/workspace.go @@ -9,7 +9,7 @@ const ( // v7.0+ config file ConfigFileName = "prisma.config.ts" - // v7.0 이전 schema file + // Schema file prior to v7.0 SchemaFileName = "schema.prisma" SchemaDirName = "prisma" ) From 832f3fc24dc1f23b135b67341e72c885991dd1f9 Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 17:21:21 +0900 Subject: [PATCH 21/26] replace hardcoded english string matching in isConfigurationError with bool flag --- pkg/gui/context/workspace_context.go | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/pkg/gui/context/workspace_context.go b/pkg/gui/context/workspace_context.go index 4ad9d6e..5a31f16 100644 --- a/pkg/gui/context/workspace_context.go +++ b/pkg/gui/context/workspace_context.go @@ -48,6 +48,7 @@ type WorkspaceContext struct { dbProvider string dbConnected bool dbError string + dbConfigError bool envVarName string // Environment variable name (e.g., "DATABASE_URL") isHardcoded bool // True if URL is hardcoded in schema/config } @@ -250,6 +251,7 @@ func (w *WorkspaceContext) loadDatabaseInfo() { w.maskedURL = "" w.dbConnected = false w.dbError = "" + w.dbConfigError = false w.envVarName = "" w.isHardcoded = false @@ -276,6 +278,7 @@ func (w *WorkspaceContext) loadDatabaseInfo() { errMsg := err.Error() if strings.Contains(errMsg, "not found") { w.dbError = w.tr.WorkspaceErrorSchemaNotFound + w.dbConfigError = true } else if strings.Contains(errMsg, "incomplete") { // Store plain text, styling will be applied in buildDatabaseLines() if w.envVarName != "" { @@ -283,6 +286,7 @@ func (w *WorkspaceContext) loadDatabaseInfo() { } else { w.dbError = w.tr.WorkspaceDatabaseURLNotConfigured } + w.dbConfigError = true } else { w.dbError = errMsg } @@ -303,6 +307,7 @@ func (w *WorkspaceContext) loadDatabaseInfo() { } else { w.dbError = w.tr.WorkspaceNoDatabaseURL } + w.dbConfigError = true return } @@ -386,23 +391,5 @@ func (w *WorkspaceContext) buildDatabaseLines() []string { // isConfigurationError checks if the error is a configuration issue func (w *WorkspaceContext) isConfigurationError() bool { - if w.dbError == "" { - return false - } - - configErrors := []string{ - "not found", - "not configured", - "not set", - "incomplete", - "no database_url", - } - - errLower := strings.ToLower(w.dbError) - for _, substr := range configErrors { - if strings.Contains(errLower, substr) { - return true - } - } - return false + return w.dbConfigError } From 1d7630105fbb23a2b47e668c8b638621905a4d7b Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 17:25:51 +0900 Subject: [PATCH 22/26] clamp ScrollDown to max origin to prevent out of bounds originY --- pkg/gui/context/scrollable_trait.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pkg/gui/context/scrollable_trait.go b/pkg/gui/context/scrollable_trait.go index 1d84fc9..173219d 100644 --- a/pkg/gui/context/scrollable_trait.go +++ b/pkg/gui/context/scrollable_trait.go @@ -36,10 +36,16 @@ func (self *ScrollableTrait) ScrollUp() { } } -// ScrollDown scrolls the view down by 1 line. -// AdjustScroll should be called during render to clamp within bounds. +// ScrollDown scrolls the view down by 1 line, clamping to the maximum scrollable position. func (self *ScrollableTrait) ScrollDown() { - self.originY++ + if self.view == nil { + return + } + + maxOrigin := self.maxOrigin() + if self.originY < maxOrigin { + self.originY++ + } } // ScrollUpByWheel scrolls the view up by the wheel increment. From 8f116bd1f508846aae526c858eb10337107a6dc0 Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 17:27:40 +0900 Subject: [PATCH 23/26] remove unused DetailsTimestampNA translation key --- pkg/i18n/english.go | 2 -- pkg/i18n/translations/de.json | 1 - 2 files changed, 3 deletions(-) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index fee2a82..a688b29 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -221,7 +221,6 @@ type TranslationSet struct { DetailsEmptyMigrationDescription string DetailsEmptyMigrationWarning string DetailsDownMigrationSQLLabel string - DetailsTimestampNA string ErrorReadingMigrationSQL string // Details Panel - Action Needed @@ -510,7 +509,6 @@ func EnglishTranslationSet() *TranslationSet { DetailsEmptyMigrationDescription: "This migration folder is empty or missing migration.sql.\n", DetailsEmptyMigrationWarning: "This may cause issues during deployment.", DetailsDownMigrationSQLLabel: "Down Migration SQL:", - DetailsTimestampNA: "N/A", ErrorReadingMigrationSQL: "Error reading migration.sql:\n%v", // Details Panel - Action Needed diff --git a/pkg/i18n/translations/de.json b/pkg/i18n/translations/de.json index f6f1b42..1b54ef9 100644 --- a/pkg/i18n/translations/de.json +++ b/pkg/i18n/translations/de.json @@ -201,7 +201,6 @@ "DetailsEmptyMigrationDescription": "Dieser Migrationsordner ist leer oder migration.sql fehlt.\n", "DetailsEmptyMigrationWarning": "Dies kann beim Deployment zu Problemen führen.", "DetailsDownMigrationSQLLabel": "Down-Migration SQL:", - "DetailsTimestampNA": "N/V", "ErrorReadingMigrationSQL": "Fehler beim Lesen von migration.sql:\n%v", "ActionNeededNoIssuesMessage": "Kein Handlungsbedarf\n\nAlle Migrationen sind in gutem Zustand und das Schema ist gültig.", From 073ed0803e1d821f3651de01d2a60707d6f5e6f3 Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 17:35:01 +0900 Subject: [PATCH 24/26] add 'made with prisma' badge to readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ad8c048..17c0501 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ lazyprisma_ico_scl [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/DokaDev/lazyprisma) +[![Made with Prisma](https://made-with.prisma.io/dark.svg)](https://prisma.io) A Terminal UI tool for managing Prisma migrations and the database, designed for developers who prefer the command line. From 6e37c07c8f9152e9b4f36c287c326df9b4a488d8 Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 17:41:17 +0900 Subject: [PATCH 25/26] use correct i18n keys for folder creation and file write errors --- pkg/app/migrations_controller.go | 4 ++-- pkg/i18n/english.go | 4 ++++ pkg/i18n/translations/de.json | 2 ++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/app/migrations_controller.go b/pkg/app/migrations_controller.go index 0d496c3..c56ae13 100644 --- a/pkg/app/migrations_controller.go +++ b/pkg/app/migrations_controller.go @@ -426,7 +426,7 @@ 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.ModalMsgFailedDeleteFolder, + a.Tr.ModalMsgFailedCreateFolder, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) @@ -439,7 +439,7 @@ func (a *App) createManualMigration(migrationName string) { if err := os.WriteFile(migrationFile, []byte(initialContent), 0644); err != nil { modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError, - a.Tr.ErrorFailedGetWorkingDir, + a.Tr.ModalMsgFailedWriteMigrationFile, err.Error(), ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed}) a.OpenModal(modal) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index a688b29..a7ec08d 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -96,7 +96,9 @@ type TranslationSet struct { ModalMsgCannotDeleteNoLocalFile string ModalMsgMigrationAlreadyApplied string ModalMsgDeleteLocalInconsistency string + ModalMsgFailedCreateFolder string ModalMsgFailedDeleteFolder string + ModalMsgFailedWriteMigrationFile string ModalMsgMigrationDeletedSuccess string ModalMsgFailedCopyClipboard string ModalMsgCopiedToClipboard string @@ -384,7 +386,9 @@ func EnglishTranslationSet() *TranslationSet { ModalMsgCannotDeleteNoLocalFile: "Cannot delete a migration that has no local file.", ModalMsgMigrationAlreadyApplied: "This migration has already been applied to the database.", ModalMsgDeleteLocalInconsistency: "Deleting it locally will cause inconsistency.", + ModalMsgFailedCreateFolder: "Failed to create migration folder:", ModalMsgFailedDeleteFolder: "Failed to delete migration folder:", + ModalMsgFailedWriteMigrationFile: "Failed to write migration file:", ModalMsgMigrationDeletedSuccess: "Migration deleted successfully.", ModalMsgFailedCopyClipboard: "Failed to copy to clipboard:", ModalMsgCopiedToClipboard: "%s copied to clipboard!", diff --git a/pkg/i18n/translations/de.json b/pkg/i18n/translations/de.json index 1b54ef9..f45b847 100644 --- a/pkg/i18n/translations/de.json +++ b/pkg/i18n/translations/de.json @@ -89,7 +89,9 @@ "ModalMsgCannotDeleteNoLocalFile": "Eine Migration ohne lokale Datei kann nicht gelöscht werden.", "ModalMsgMigrationAlreadyApplied": "Diese Migration wurde bereits auf die Datenbank angewendet.", "ModalMsgDeleteLocalInconsistency": "Lokales Löschen würde Inkonsistenzen verursachen.", + "ModalMsgFailedCreateFolder": "Migrationsordner konnte nicht erstellt werden:", "ModalMsgFailedDeleteFolder": "Migrationsordner konnte nicht gelöscht werden:", + "ModalMsgFailedWriteMigrationFile": "Migrationsdatei konnte nicht geschrieben werden:", "ModalMsgMigrationDeletedSuccess": "Migration erfolgreich gelöscht.", "ModalMsgFailedCopyClipboard": "Kopieren in die Zwischenablage fehlgeschlagen:", "ModalMsgCopiedToClipboard": "%s in die Zwischenablage kopiert!", From 28bd1ede5b0503d8c79fdb806e94b4147b9e9919 Mon Sep 17 00:00:00 2001 From: Awesome Date: Sat, 7 Mar 2026 17:43:33 +0900 Subject: [PATCH 26/26] translate hardcoded action labels for migrate resolve --- pkg/app/migrations_controller.go | 4 ++-- pkg/i18n/english.go | 8 ++++++++ pkg/i18n/translations/de.json | 3 +++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/pkg/app/migrations_controller.go b/pkg/app/migrations_controller.go index c56ae13..9d04a37 100644 --- a/pkg/app/migrations_controller.go +++ b/pkg/app/migrations_controller.go @@ -611,9 +611,9 @@ func (a *App) executeResolve(migrationName string, action string) { } // Log action start - actionLabel := "applied" + actionLabel := a.Tr.ActionLabelApplied if action == "rolled-back" { - actionLabel = "rolled back" + actionLabel = a.Tr.ActionLabelRolledBack } outputPanel.LogAction(a.Tr.LogActionMigrateResolve, fmt.Sprintf(a.Tr.LogMsgMarkingMigration, actionLabel, migrationName)) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index a7ec08d..fe51d65 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -137,6 +137,10 @@ type TranslationSet struct { KeyHintStudio string KeyHintCopy string + // Action Labels + ActionLabelApplied string + ActionLabelRolledBack string + // Log Actions LogActionMigrateDeploy string LogMsgRunningMigrateDeploy string @@ -427,6 +431,10 @@ func EnglishTranslationSet() *TranslationSet { KeyHintStudio: "tudio", KeyHintCopy: "opy", + // Action Labels + ActionLabelApplied: "applied", + ActionLabelRolledBack: "rolled back", + // Log Actions LogActionMigrateDeploy: "Migrate Deploy", LogMsgRunningMigrateDeploy: "Running prisma migrate deploy...", diff --git a/pkg/i18n/translations/de.json b/pkg/i18n/translations/de.json index f45b847..710b923 100644 --- a/pkg/i18n/translations/de.json +++ b/pkg/i18n/translations/de.json @@ -121,6 +121,9 @@ "StatusStudioOn": "[Studio: AN]", + "ActionLabelApplied": "angewendet", + "ActionLabelRolledBack": "zurückgesetzt", + "LogActionMigrateDeploy": "Migrate Deploy", "LogMsgRunningMigrateDeploy": "prisma migrate deploy wird ausgeführt...", "LogActionMigrateDeployComplete": "Migrate Deploy abgeschlossen",