Merge branch 'integrate-lazygit' into integrate-ghdash

Kujtim Hoxha created

Change summary

internal/tui/components/dialogs/commands/commands.go     |  5 
internal/tui/components/dialogs/lazygit/lazygit.go       | 62 +++++++--
internal/tui/components/dialogs/termdialog/termdialog.go | 30 ++++
internal/tui/tui.go                                      |  7 
4 files changed, 76 insertions(+), 28 deletions(-)

Detailed changes

internal/tui/components/dialogs/commands/commands.go 🔗

@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	"os"
+	"os/exec"
 	"slices"
 	"strings"
 
@@ -18,7 +19,6 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/pubsub"
-	"github.com/charmbracelet/crush/internal/shell"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
@@ -441,8 +441,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 	}
 
 	// Add lazygit command if lazygit is installed.
-	sh := shell.NewShell(nil)
-	if _, _, err := sh.Exec(c.ctx, "which lazygit"); err == nil {
+	if _, err := exec.LookPath("lazygit"); err == nil {
 		commands = append(commands, Command{
 			ID:          "lazygit",
 			Title:       "Open Lazygit",

internal/tui/components/dialogs/lazygit/lazygit.go 🔗

@@ -5,7 +5,9 @@ import (
 	"context"
 	"fmt"
 	"image/color"
+	"log/slog"
 	"os"
+	"path/filepath"
 
 	"github.com/charmbracelet/crush/internal/terminal"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
@@ -13,35 +15,62 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/styles"
 )
 
-// DialogID is the unique identifier for the lazygit dialog.
-const DialogID dialogs.DialogID = "lazygit"
+// LazygitDialogID is the unique identifier for the lazygit dialog.
+const LazygitDialogID dialogs.DialogID = "lazygit"
 
 // NewDialog creates a new lazygit dialog. The context controls the lifetime
 // of the lazygit process - when cancelled, the process will be killed.
 func NewDialog(ctx context.Context, workingDir string) *termdialog.Dialog {
-	configFile := createThemedConfig()
+	themeConfig := createThemedConfig()
+	configEnv := buildConfigEnv(themeConfig)
 
 	cmd := terminal.PrepareCmd(
 		ctx,
 		"lazygit",
 		nil,
 		workingDir,
-		[]string{"LG_CONFIG_FILE=" + configFile},
+		[]string{configEnv},
 	)
 
 	return termdialog.New(termdialog.Config{
-		ID:         DialogID,
+		ID:         LazygitDialogID,
 		Title:      "Lazygit",
 		LoadingMsg: "Starting lazygit...",
 		Term:       terminal.New(terminal.Config{Context: ctx, Cmd: cmd}),
+		QuitHint:   "q to close",
 		OnClose: func() {
-			if configFile != "" {
-				_ = os.Remove(configFile)
+			if themeConfig != "" {
+				if err := os.Remove(themeConfig); err != nil {
+					slog.Debug("failed to remove lazygit theme config", "error", err, "path", themeConfig)
+				}
 			}
 		},
 	})
 }
 
+// buildConfigEnv builds the LG_CONFIG_FILE env var, merging user's default
+// config (if it exists) with our theme override. User config comes first so
+// our theme settings take precedence.
+func buildConfigEnv(themeConfig string) string {
+	userConfig := defaultConfigPath()
+	if userConfig != "" {
+		if _, err := os.Stat(userConfig); err == nil {
+			return "LG_CONFIG_FILE=" + userConfig + "," + themeConfig
+		}
+	}
+	return "LG_CONFIG_FILE=" + themeConfig
+}
+
+// defaultConfigPath returns the default lazygit config path for the current OS.
+func defaultConfigPath() string {
+	configDir, err := os.UserConfigDir()
+	if err != nil {
+		slog.Debug("failed to get user config directory", "error", err)
+		return ""
+	}
+	return filepath.Join(configDir, "lazygit", "config.yml")
+}
+
 // colorToHex converts a color.Color to a hex string.
 func colorToHex(c color.Color) string {
 	r, g, b, _ := c.RGBA()
@@ -56,15 +85,9 @@ func colorToHex(c color.Color) string {
 func createThemedConfig() string {
 	t := styles.CurrentTheme()
 
-	config := fmt.Sprintf(`gui:
-  border: rounded
-  showFileTree: true
-  showRandomTip: false
-  showCommandLog: false
-  showBottomLine: true
-  showPanelJumps: false
-  nerdFontsVersion: ""
-  showFileIcons: false
+	config := fmt.Sprintf(`git:
+  autoFetch: true
+gui:
   theme:
     activeBorderColor:
       - "%s"
@@ -108,10 +131,15 @@ func createThemedConfig() string {
 
 	f, err := os.CreateTemp("", "crush-lazygit-*.yml")
 	if err != nil {
+		slog.Error("failed to create temporary lazygit config", "error", err)
 		return ""
 	}
 	defer f.Close()
 
-	_, _ = f.WriteString(config)
+	if _, err := f.WriteString(config); err != nil {
+		slog.Error("failed to write lazygit theme config", "error", err)
+		_ = os.Remove(f.Name()) // remove the empty file
+		return ""
+	}
 	return f.Name()
 }

internal/tui/components/dialogs/termdialog/termdialog.go 🔗

@@ -17,8 +17,11 @@ const (
 	// headerHeight is the height of the dialog header (title + padding).
 	headerHeight = 2
 	// fullscreenWidthBreakpoint is the width below which the dialog goes
-	// fullscreen. Matches CompactModeWidthBreakpoint in chat.go.
-	fullscreenWidthBreakpoint = 120
+	// fullscreen.
+	fullscreenWidthBreakpoint = 125
+	// fullscreenHeightBreakpoint is the height below which the dialog goes
+	// fullscreen. Lazygit degrades significantly below 40 rows.
+	fullscreenHeightBreakpoint = 40
 )
 
 // Config holds configuration for a terminal dialog.
@@ -33,6 +36,8 @@ type Config struct {
 	Term *terminal.Terminal
 	// OnClose is called when the dialog is closed (optional).
 	OnClose func()
+	// QuitHint is shown in the header (e.g., "q to close"). If empty, no hint is shown.
+	QuitHint string
 }
 
 // Dialog is a dialog that embeds a terminal application.
@@ -42,6 +47,7 @@ type Dialog struct {
 	loadingMsg string
 	term       *terminal.Terminal
 	onClose    func()
+	quitHint   string
 
 	wWidth     int
 	wHeight    int
@@ -63,6 +69,7 @@ func New(cfg Config) *Dialog {
 		loadingMsg: loadingMsg,
 		term:       cfg.Term,
 		onClose:    cfg.OnClose,
+		quitHint:   cfg.QuitHint,
 	}
 }
 
@@ -102,8 +109,8 @@ func (d *Dialog) handleResize(msg tea.WindowSizeMsg) (util.Model, tea.Cmd) {
 	d.wWidth = msg.Width
 	d.wHeight = msg.Height
 
-	// Go fullscreen when window is below compact mode breakpoint.
-	d.fullscreen = msg.Width < fullscreenWidthBreakpoint
+	// Go fullscreen when window is below size breakpoints.
+	d.fullscreen = msg.Width < fullscreenWidthBreakpoint || msg.Height < fullscreenHeightBreakpoint
 
 	var outerWidth, outerHeight int
 	if d.fullscreen {
@@ -192,7 +199,20 @@ func (d *Dialog) View() string {
 		termContent = d.loadingMsg
 	}
 
-	header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title(d.title, d.width-2))
+	// Build header with title and optional quit hint on the right.
+	var header string
+	if d.quitHint != "" {
+		hintStyle := t.S().Base.Foreground(t.Secondary)
+		hint := hintStyle.Render(d.quitHint)
+		hintWidth := lipgloss.Width(hint)
+		titleWidth := d.width - 2 - hintWidth - 1 // -1 for space between title and hint
+		title := core.Title(d.title, titleWidth)
+		headerContent := title + " " + hint
+		header = t.S().Base.Padding(0, 1, 1, 1).Render(headerContent)
+	} else {
+		header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title(d.title, d.width-2))
+	}
+
 	content := lipgloss.JoinVertical(lipgloss.Left, header, termContent)
 
 	dialogStyle := t.S().Base.

internal/tui/tui.go 🔗

@@ -307,7 +307,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		})
 	// Lazygit
 	case commands.OpenLazygitMsg:
-		if a.dialog.ActiveDialogID() == lazygit.DialogID {
+		if a.dialog.ActiveDialogID() == lazygit.LazygitDialogID {
 			return a, util.CmdHandler(dialogs.CloseDialogMsg{})
 		}
 		return a, util.CmdHandler(dialogs.OpenDialogMsg{
@@ -467,8 +467,9 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
 		cmds = append(cmds, pageCmd)
 	}
 
-	// Update the dialogs
-	dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
+	// Update the dialogs with full window dimensions so they can overlay
+	// everything including the status bar.
+	dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: a.wWidth, Height: a.wHeight})
 	if model, ok := dialog.(dialogs.DialogCmp); ok {
 		a.dialog = model
 	}