From 3d193da0d2a185fafecc932c27d1331b60ef75c6 Mon Sep 17 00:00:00 2001 From: cod3ddy Date: Mon, 6 Jul 2026 00:33:36 +0200 Subject: [PATCH 1/4] feat: implement a tui using bubbletea and lipgloss and chumbucket... --- internal/prompter/model.go | 184 ++++++++++++++++++++++++++++++++ internal/prompter/model_test.go | 135 +++++++++++++++++++++++ internal/prompter/prompt.go | 12 ++- internal/prompter/tui.go | 27 +++++ internal/prompter/tui_test.go | 47 ++++++++ 5 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 internal/prompter/model.go create mode 100644 internal/prompter/model_test.go create mode 100644 internal/prompter/tui.go create mode 100644 internal/prompter/tui_test.go diff --git a/internal/prompter/model.go b/internal/prompter/model.go new file mode 100644 index 0000000..17e2eaa --- /dev/null +++ b/internal/prompter/model.go @@ -0,0 +1,184 @@ +package prompter + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + confirmViewportHeight = 15 // default visible diff lines before scrolling + confirmMinViewportHeight = 3 + confirmMaxBoxWidth = 96 + confirmMinBoxWidth = 40 + boxChromeWidth = 6 + confirmChromeLines = 10 +) + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) + warningStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")) + addStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + removeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + hunkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("38")) + fileStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("250")) + proceedStyle = lipgloss.NewStyle().Bold(true) + hintStyle = lipgloss.NewStyle().Faint(true) + boxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")).Padding(1, 2) +) + +type confirmModel struct { + header string + body string + footer string + viewport viewport.Model + termWidth int + termHeight int + confirmed bool + quitting bool +} + +func newConfirmModel(message string) confirmModel { + header, body, footer := splitPrompt(message) + + vp := viewport.New(confirmMaxBoxWidth-boxChromeWidth, confirmViewportHeight) + vp.SetContent(colorizeBody(body)) + + return confirmModel{header: header, body: body, footer: footer, viewport: vp} +} + +func (m confirmModel) Init() tea.Cmd { return nil } + +func (m confirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.termWidth, m.termHeight = msg.Width, msg.Height + + boxWidth := confirmMaxBoxWidth + if avail := msg.Width - 4; avail < boxWidth { + boxWidth = avail + } + if boxWidth < confirmMinBoxWidth { + boxWidth = confirmMinBoxWidth + } + m.viewport.Width = boxWidth - boxChromeWidth + + switch h := msg.Height - confirmChromeLines; { + case h < confirmMinViewportHeight: + m.viewport.Height = confirmMinViewportHeight + case h < confirmViewportHeight: + m.viewport.Height = h + } + return m, nil + case tea.KeyMsg: + switch msg.String() { + case "y", "Y", "enter": + m.confirmed, m.quitting = true, true + return m, tea.Quit + case "n", "N", "esc", "ctrl+c": + m.confirmed, m.quitting = false, true + return m, tea.Quit + default: + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + } + return m, nil +} + +func (m confirmModel) View() string { + boxWidth := m.viewport.Width + boxChromeWidth + centered := lipgloss.NewStyle().Width(boxWidth).Align(lipgloss.Center) + + parts := []string{centered.Render(colorizeHeader(m.header))} + + if strings.TrimSpace(m.body) != "" { + parts = append(parts, boxStyle.Width(m.viewport.Width).Render(m.viewport.View())) + } + + parts = append(parts, centered.Render(proceedStyle.Render(m.footer))) + + hint := "y/enter: proceed n/esc/ctrl+c: abort ↑/↓: scroll" + if m.viewport.TotalLineCount() > m.viewport.Height { + hint = fmt.Sprintf("%s (%.0f%%)", hint, m.viewport.ScrollPercent()*100) + } + parts = append(parts, centered.Render(hintStyle.Render(hint))) + + content := strings.Join(parts, "\n\n") + + if m.termWidth > 0 && m.termHeight > 0 { + return lipgloss.Place(m.termWidth, m.termHeight, lipgloss.Center, lipgloss.Center, content) + } + return content +} + +func splitPrompt(message string) (header, body, footer string) { + lines := strings.Split(message, "\n") + + end := len(lines) + for end > 0 && strings.TrimSpace(lines[end-1]) == "" { + end-- + } + + if end > 0 { + footer = lines[end-1] + lines = lines[:end-1] + } + + i := 0 + var headerLines []string + for i < len(lines) && (strings.HasPrefix(lines[i], "gynx:") || strings.HasPrefix(lines[i], "warning:")) { + headerLines = append(headerLines, lines[i]) + i++ + } + header = strings.Join(headerLines, "\n") + + rest := lines[i:] + start, restEnd := 0, len(rest) + for start < restEnd && strings.TrimSpace(rest[start]) == "" { + start++ + } + for restEnd > start && strings.TrimSpace(rest[restEnd-1]) == "" { + restEnd-- + } + body = strings.Join(rest[start:restEnd], "\n") + + return header, body, footer +} + +func colorizeHeader(header string) string { + lines := strings.Split(header, "\n") + for i, line := range lines { + switch { + case strings.HasPrefix(line, "gynx:"): + lines[i] = titleStyle.Render(line) + case strings.HasPrefix(line, "warning:"): + lines[i] = warningStyle.Render(line) + } + } + return strings.Join(lines, "\n") +} + +func colorizeBody(body string) string { + if body == "" { + return "" + } + lines := strings.Split(body, "\n") + for i, line := range lines { + switch { + case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"): + lines[i] = fileStyle.Render(line) + case strings.HasPrefix(line, "@@"): + lines[i] = hunkStyle.Render(line) + case strings.HasPrefix(line, "+"): + lines[i] = addStyle.Render(line) + case strings.HasPrefix(line, "-"): + lines[i] = removeStyle.Render(line) + } + } + return strings.Join(lines, "\n") +} diff --git a/internal/prompter/model_test.go b/internal/prompter/model_test.go new file mode 100644 index 0000000..9a1d5c2 --- /dev/null +++ b/internal/prompter/model_test.go @@ -0,0 +1,135 @@ +package prompter + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func isQuitCmd(cmd tea.Cmd) bool { + if cmd == nil { + return false + } + _, ok := cmd().(tea.QuitMsg) + return ok +} + +func TestConfirmModel_YKeyConfirmsAndQuits(t *testing.T) { + m := newConfirmModel("hello") + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) + cm := updated.(confirmModel) + + assert.True(t, cm.confirmed) + require.True(t, isQuitCmd(cmd)) +} + +func TestConfirmModel_EnterKeyConfirmsAndQuits(t *testing.T) { + m := newConfirmModel("hello") + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + cm := updated.(confirmModel) + + assert.True(t, cm.confirmed) + require.True(t, isQuitCmd(cmd)) +} + +func TestConfirmModel_NKeyAbortsAndQuits(t *testing.T) { + m := newConfirmModel("hello") + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) + cm := updated.(confirmModel) + + assert.False(t, cm.confirmed) + require.True(t, isQuitCmd(cmd)) +} + +func TestConfirmModel_EscKeyAbortsAndQuits(t *testing.T) { + m := newConfirmModel("hello") + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + cm := updated.(confirmModel) + + assert.False(t, cm.confirmed) + require.True(t, isQuitCmd(cmd)) +} + +func TestConfirmModel_CtrlCAbortsAndQuits(t *testing.T) { + m := newConfirmModel("hello") + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + cm := updated.(confirmModel) + + assert.False(t, cm.confirmed) + require.True(t, isQuitCmd(cmd)) +} + +func TestConfirmModel_UnrecognizedKeyDoesNotQuit(t *testing.T) { + m := newConfirmModel("hello") + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + cm := updated.(confirmModel) + + assert.False(t, cm.confirmed) + assert.False(t, isQuitCmd(cmd)) +} + +func TestConfirmModel_WindowSizeMsgCapsBoxWidthAndCentersOnLargeTerminal(t *testing.T) { + m := newConfirmModel("hello") + + updated, _ := m.Update(tea.WindowSizeMsg{Width: 200, Height: 50}) + cm := updated.(confirmModel) + + assert.Equal(t, confirmMaxBoxWidth-boxChromeWidth, cm.viewport.Width) + assert.Equal(t, confirmViewportHeight, cm.viewport.Height) +} + +func TestConfirmModel_SmallWindowShrinksBoxAndClampsMinimums(t *testing.T) { + m := newConfirmModel("hello") + + updated, _ := m.Update(tea.WindowSizeMsg{Width: 40, Height: 10}) + cm := updated.(confirmModel) + + // avail (36) is below confirmMinBoxWidth, so it clamps up to the minimum..... + assert.Equal(t, confirmMinBoxWidth-boxChromeWidth, cm.viewport.Width) + + assert.Equal(t, confirmMinViewportHeight, cm.viewport.Height) +} + +func TestSplitPrompt_SeparatesHeaderBodyAndFooter(t *testing.T) { + message := "gynx: cp src dest\nwarning: Copy can overwrite existing files\n\n--- dest\n+++ src\n@@ -1 +1 @@\n-old\n+new\n\nProceed?" + + header, body, footer := splitPrompt(message) + + assert.Equal(t, "gynx: cp src dest\nwarning: Copy can overwrite existing files", header) + assert.Equal(t, "--- dest\n+++ src\n@@ -1 +1 @@\n-old\n+new", body) + assert.Equal(t, "Proceed?", footer) +} + +func TestSplitPrompt_NoBodyWhenNotApplicable(t *testing.T) { + message := "gynx: danger\nwarning: be careful\nProceed?" + + header, body, footer := splitPrompt(message) + + assert.Equal(t, "gynx: danger\nwarning: be careful", header) + assert.Equal(t, "", body) + assert.Equal(t, "Proceed?", footer) +} + +func TestConfirmModel_View_OmitsBoxWhenBodyEmpty(t *testing.T) { + m := newConfirmModel("gynx: danger\nwarning: be careful\nProceed?") + + view := m.View() + + assert.NotContains(t, view, "╭") +} + +func TestConfirmModel_View_RendersBoxWhenBodyPresent(t *testing.T) { + m := newConfirmModel("gynx: cp a b\nwarning: overwrite\n\n--- b\n+++ a\n@@ -1 +1 @@\n-old\n+new\n\nProceed?") + + view := m.View() + + assert.Contains(t, view, "╭") +} diff --git a/internal/prompter/prompt.go b/internal/prompter/prompt.go index 3f07bd3..1074126 100644 --- a/internal/prompter/prompt.go +++ b/internal/prompter/prompt.go @@ -7,8 +7,18 @@ import ( "strings" ) -// Confirm asks for interactive consent and defaults to "no". +// Confirm asks for interactive consent via a Bubble Tea prompt, falling back +// to a plain-text y/N prompt if the TUI fails to initialize. func Confirm(message string) bool { + if confirmed, err := runTUIConfirmFn(message); err == nil { + return confirmed + } + return legacyConfirm(message) +} + +// legacyConfirm is the original bufio-based y/N prompt +// for non-tty edge cases that slip past isInteractiveFn or an unexpected bubbletea error. +func legacyConfirm(message string) bool { fmt.Printf("%s [y/N]: ", message) input, err := bufio.NewReader(os.Stdin).ReadString('\n') diff --git a/internal/prompter/tui.go b/internal/prompter/tui.go new file mode 100644 index 0000000..5e8b618 --- /dev/null +++ b/internal/prompter/tui.go @@ -0,0 +1,27 @@ +package prompter + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +// runTUIConfirmFn launches the Bubble Tea confirmation program and returns +// the user's choice. +var runTUIConfirmFn = func(message string) (confirmed bool, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("tui confirm panic: %v", r) + } + }() + + final, runErr := tea.NewProgram(newConfirmModel(message)).Run() + if runErr != nil { + return false, runErr + } + cm, ok := final.(confirmModel) + if !ok { + return false, fmt.Errorf("unexpected model type %T from tea.Program", final) + } + return cm.confirmed, nil +} diff --git a/internal/prompter/tui_test.go b/internal/prompter/tui_test.go new file mode 100644 index 0000000..f0e84fb --- /dev/null +++ b/internal/prompter/tui_test.go @@ -0,0 +1,47 @@ +package prompter + +import ( + "errors" + "io" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func resetTUIHook(t *testing.T) { + orig := runTUIConfirmFn + t.Cleanup(func() { runTUIConfirmFn = orig }) +} + +func TestConfirm_TUISucceeds_ReturnsTUIResultWithoutTouchingStdin(t *testing.T) { + resetTUIHook(t) + runTUIConfirmFn = func(message string) (bool, error) { + return true, nil + } + + assert.True(t, Confirm("does this matter")) +} + +func TestConfirm_TUIFails_FallsBackToLegacyPrompt(t *testing.T) { + resetTUIHook(t) + runTUIConfirmFn = func(message string) (bool, error) { + return false, errors.New("tui unavailable") + } + + origStdin := os.Stdin + t.Cleanup(func() { os.Stdin = origStdin }) + + inFile, err := os.CreateTemp("", "gynx-confirm-stdin-*") + require.NoError(t, err) + t.Cleanup(func() { _ = os.Remove(inFile.Name()) }) + + _, err = inFile.WriteString("y\n") + require.NoError(t, err) + _, err = inFile.Seek(0, io.SeekStart) + require.NoError(t, err) + os.Stdin = inFile + + assert.True(t, Confirm("legacy prompt fallback")) +} From 57d007b7ae2be03254eb4b395f0a4587a9afe413 Mon Sep 17 00:00:00 2001 From: cod3ddy Date: Mon, 6 Jul 2026 00:35:52 +0200 Subject: [PATCH 2/4] chore(doc): update readme include the now new tui support --- README.md | 17 +++++++++++------ go.mod | 23 ++++++++++++++++++++++- go.sum | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2743b56..6d0a32c 100644 --- a/README.md +++ b/README.md @@ -32,14 +32,20 @@ touch /tmp/demo-file go run . chmod 777 /tmp/demo-file ``` -You'll get: +You'll get an interactive confirmation prompt: + ``` -gynx: chmod 777 /tmp/demo-file -warning: Setting world-writable permissions -Proceed? [y/N]: y +╭──────────────────────────────────────────╮ +│ gynx: chmod 777 /tmp/demo-file │ +│ warning: Setting world-writable permissions │ +│ Proceed? │ +╰──────────────────────────────────────────╯ +y/enter: proceed n/esc/ctrl+c: abort ↑/↓: scroll ``` -the output for `y` ofcourse will just be whatever cmd result that binary that went through gives you, but for the `N` option, you'll be presented this: +For `cp`/`mv` commands that would overwrite an existing file, gynx shows a unified diff of what's about to change right inside that same prompt before you decide. + +Pressing `y`/enter runs the real command and streams its normal output; pressing `n`/esc/ctrl+c aborts: ``` aborted: command not executed @@ -83,7 +89,6 @@ gynx uninstall # remove all aliases - Community watchlist presets (Docker, Kubernetes, database tooling) - Dry-run mode — show what would be intercepted without prompting - Audit log — keep a record of intercepted commands -- If it's cp or overwriting a file, show them diffs? or ask them if they meant to rewrite file `x` to `y` Please be reminded, this is still in development, you might sometimes not get the desired outcome. diff --git a/go.mod b/go.mod index d26a321..aa546f0 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,10 @@ module github.com/cod3ddy/gynx go 1.25.1 require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/pmezard/go-difflib v1.0.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 @@ -11,18 +15,35 @@ require ( ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.28.0 // indirect diff --git a/go.sum b/go.sum index 5c0f214..edbf2d3 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,30 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -15,10 +39,26 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -41,8 +81,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= From 1f5b6334c140aeff54bb8aca0847ceea4cabca6a Mon Sep 17 00:00:00 2001 From: cod3ddy Date: Mon, 6 Jul 2026 00:47:28 +0200 Subject: [PATCH 3/4] chore: added diff preview for mv and/or cp commands --- internal/diffpreview/diffpreview.go | 135 +++++++++++++++++++ internal/diffpreview/diffpreview_test.go | 161 +++++++++++++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 internal/diffpreview/diffpreview.go create mode 100644 internal/diffpreview/diffpreview_test.go diff --git a/internal/diffpreview/diffpreview.go b/internal/diffpreview/diffpreview.go new file mode 100644 index 0000000..df76813 --- /dev/null +++ b/internal/diffpreview/diffpreview.go @@ -0,0 +1,135 @@ +package diffpreview + +import ( + "bytes" + "fmt" + "os" + + "github.com/pmezard/go-difflib/difflib" +) + +const ( + maxDiffBytes = 512 * 1024 // per file read cap + binarySniffBytes = 8000 // bytes scanned for a NUL byte, mirrors git's binary deetection heuristic +) + +type Result struct { + Applicable bool // true if there's something worth showing before the confirm prompt + Text string +} + +// Build inspects a cp/mv invocation's arguments and, if the destination +// already exists as a regular file, returns a preview of what will happen to +// it. It never errors: any failure to stat/read a file degrades to a +// non-applicable Result, since this is advisory only and must never block or +// crash command execution. +func Build(command string, args []string) Result { + if command != "cp" && command != "mv" { + return Result{} + } + + src, dest, ok := extractSrcDest(args) + if !ok { + return Result{} + } + + destInfo, err := os.Stat(dest) + if err != nil || destInfo.IsDir() { + return Result{} + } + + srcInfo, err := os.Stat(src) + if err != nil || srcInfo.IsDir() { + return Result{} + } + + if destInfo.Size() > maxDiffBytes || srcInfo.Size() > maxDiffBytes { + return Result{ + Applicable: true, + Text: fmt.Sprintf("%s will overwrite %s (file too large to preview)", src, dest), + } + } + + destBytes, err := os.ReadFile(dest) + if err != nil { + return Result{} + } + srcBytes, err := os.ReadFile(src) + if err != nil { + return Result{} + } + + if bytes.Equal(destBytes, srcBytes) { + return Result{ + Applicable: true, + Text: fmt.Sprintf("no differences: %s already matches %s", dest, src), + } + } + + if isBinary(destBytes) || isBinary(srcBytes) { + return Result{ + Applicable: true, + Text: fmt.Sprintf("this will overwrite %s with %s (binary file, no diff available)", dest, src), + } + } + + diffText, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: difflib.SplitLines(string(destBytes)), + B: difflib.SplitLines(string(srcBytes)), + FromFile: dest, + ToFile: src, + Context: 3, + }) + if err != nil || diffText == "" { + return Result{ + Applicable: true, + Text: fmt.Sprintf("no differences: %s already matches %s", dest, src), + } + } + + return Result{Applicable: true, Text: diffText} +} + +// extractSrcDest pulls the final two positional (non-flag) arguments out of +// a cp/mv invocation. +// +// BUT: for now this this is not a real CLI argument parser. It +// assumes GNU-style boolean flags with no attached value (-r, -f, -v, -rf, +// --recursive, ...) and treats every other token as positional, honoring +// "--" as an explicit end-of-flags marker. It does NOT understand flags that +// consume the next token as a value (-t DIR, --target-directory DIR), or +// multiple sources into a directory (cp a b c dir/). Any of those shapes +// simply fail to produce exactly 2 positional args and Build returns a +// non-applicable Result — cp/mv still executes normally, it just doesn't get +// a preview. Upgrade path if this ever matters: a real flag table per +// command. +func extractSrcDest(args []string) (src, dest string, ok bool) { + var positional []string + flagsDone := false + + for _, arg := range args { + if !flagsDone && arg == "--" { + flagsDone = true + continue + } + if !flagsDone && len(arg) > 1 && arg[0] == '-' { + continue + } + positional = append(positional, arg) + } + + if len(positional) != 2 { + return "", "", false + } + + return positional[0], positional[1], true +} + +// isBinary reports whether data looks like binary content: a NUL byte +// anywhere in the first binarySniffBytes bytes, the same heuristic git uses apparently... +func isBinary(data []byte) bool { + if len(data) > binarySniffBytes { + data = data[:binarySniffBytes] + } + return bytes.IndexByte(data, 0) != -1 +} diff --git a/internal/diffpreview/diffpreview_test.go b/internal/diffpreview/diffpreview_test.go new file mode 100644 index 0000000..9938097 --- /dev/null +++ b/internal/diffpreview/diffpreview_test.go @@ -0,0 +1,161 @@ +package diffpreview + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeFile(t *testing.T, dir, name string, content []byte) string { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, content, 0o644)) + return path +} + +func TestBuild_NonCpMvCommand_NotApplicable(t *testing.T) { + dir := t.TempDir() + dest := writeFile(t, dir, "dest.txt", []byte("hello")) + src := writeFile(t, dir, "src.txt", []byte("world")) + + result := Build("rm", []string{src, dest}) + assert.False(t, result.Applicable) +} + +func TestBuild_DestMissing_NotApplicable(t *testing.T) { + dir := t.TempDir() + src := writeFile(t, dir, "src.txt", []byte("hello")) + + result := Build("cp", []string{src, filepath.Join(dir, "missing.txt")}) + assert.False(t, result.Applicable) +} + +func TestBuild_DestIsDirectory_NotApplicable(t *testing.T) { + dir := t.TempDir() + src := writeFile(t, dir, "src.txt", []byte("hello")) + destDir := filepath.Join(dir, "destdir") + require.NoError(t, os.Mkdir(destDir, 0o755)) + + result := Build("cp", []string{src, destDir}) + assert.False(t, result.Applicable) +} + +func TestBuild_SrcMissing_NotApplicable(t *testing.T) { + dir := t.TempDir() + dest := writeFile(t, dir, "dest.txt", []byte("hello")) + + result := Build("cp", []string{filepath.Join(dir, "missing.txt"), dest}) + assert.False(t, result.Applicable) +} + +func TestBuild_SrcIsDirectory_NotApplicable(t *testing.T) { + dir := t.TempDir() + dest := writeFile(t, dir, "dest.txt", []byte("hello")) + srcDir := filepath.Join(dir, "srcdir") + require.NoError(t, os.Mkdir(srcDir, 0o755)) + + result := Build("cp", []string{srcDir, dest}) + assert.False(t, result.Applicable) +} + +func TestBuild_FlagsBeforePositionals_Parses(t *testing.T) { + dir := t.TempDir() + src := writeFile(t, dir, "src.txt", []byte("new content\n")) + dest := writeFile(t, dir, "dest.txt", []byte("old content\n")) + + result := Build("cp", []string{"-r", "-v", src, dest}) + assert.True(t, result.Applicable) + assert.Contains(t, result.Text, "---") + assert.Contains(t, result.Text, "+++") +} + +func TestBuild_CombinedFlag_Parses(t *testing.T) { + dir := t.TempDir() + src := writeFile(t, dir, "src.txt", []byte("new\n")) + dest := writeFile(t, dir, "dest.txt", []byte("old\n")) + + result := Build("cp", []string{"-rf", src, dest}) + assert.True(t, result.Applicable) +} + +func TestBuild_DoubleDashSeparator_Parses(t *testing.T) { + dir := t.TempDir() + src := writeFile(t, dir, "src.txt", []byte("new\n")) + dest := writeFile(t, dir, "dest.txt", []byte("old\n")) + + result := Build("cp", []string{"--", src, dest}) + assert.True(t, result.Applicable) +} + +func TestBuild_FlagWithSeparateValue_NotApplicable(t *testing.T) { + dir := t.TempDir() + src := writeFile(t, dir, "src.txt", []byte("hello")) + destDir := filepath.Join(dir, "destdir") + require.NoError(t, os.Mkdir(destDir, 0o755)) + + // extractSrcDest doesn't know -t takes a value, so it treats DIR as the + // "source" -- which then fails the IsDir() source check in Build. This + // documents the scope boundary: -t/--target-directory isn't understood + // and yields no preview (for the "wrong" reason, but the right outcome). + result := Build("cp", []string{"-t", destDir, src}) + assert.False(t, result.Applicable) +} + +func TestBuild_MultipleSources_NotApplicable(t *testing.T) { + dir := t.TempDir() + a := writeFile(t, dir, "a.txt", []byte("a")) + b := writeFile(t, dir, "b.txt", []byte("b")) + destDir := filepath.Join(dir, "destdir") + require.NoError(t, os.Mkdir(destDir, 0o755)) + + result := Build("cp", []string{a, b, destDir}) + assert.False(t, result.Applicable) +} + +func TestBuild_IdenticalContent_NoDifferences(t *testing.T) { + dir := t.TempDir() + content := []byte("same bytes\n") + src := writeFile(t, dir, "src.txt", content) + dest := writeFile(t, dir, "dest.txt", content) + + result := Build("cp", []string{src, dest}) + require.True(t, result.Applicable) + assert.Contains(t, result.Text, "no differences") +} + +func TestBuild_DifferingTextContent_ProducesUnifiedDiff(t *testing.T) { + dir := t.TempDir() + src := writeFile(t, dir, "src.txt", []byte("line one\nline two\n")) + dest := writeFile(t, dir, "dest.txt", []byte("line one\nold line two\n")) + + result := Build("mv", []string{src, dest}) + require.True(t, result.Applicable) + assert.Contains(t, result.Text, "---") + assert.Contains(t, result.Text, "+++") + assert.Contains(t, result.Text, "@@") +} + +func TestBuild_BinaryContent_NoDiffAvailable(t *testing.T) { + dir := t.TempDir() + src := writeFile(t, dir, "src.bin", []byte{0x00, 0x01, 0x02}) + dest := writeFile(t, dir, "dest.bin", []byte("text content")) + + result := Build("cp", []string{src, dest}) + require.True(t, result.Applicable) + assert.Contains(t, result.Text, "binary") +} + +func TestBuild_OversizedFile_TooLargeNotice(t *testing.T) { + dir := t.TempDir() + big := strings.Repeat("x", maxDiffBytes+1) + src := writeFile(t, dir, "src.txt", []byte(big)) + dest := writeFile(t, dir, "dest.txt", []byte("small")) + + result := Build("cp", []string{src, dest}) + require.True(t, result.Applicable) + assert.Contains(t, result.Text, "too large") +} From 0eccb1895ac462ffae458ec4af2ae0e175899915 Mon Sep 17 00:00:00 2001 From: cod3ddy Date: Mon, 6 Jul 2026 00:52:21 +0200 Subject: [PATCH 4/4] chore: check for diff preview, prompt confirmation, and other args when processing and computing the user's cmd --- cmd/proxy_execution_test.go | 115 ++++++++++++++++++++++++++++++++++++ cmd/root.go | 13 +++- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/cmd/proxy_execution_test.go b/cmd/proxy_execution_test.go index ea36bf7..707fcd1 100644 --- a/cmd/proxy_execution_test.go +++ b/cmd/proxy_execution_test.go @@ -6,9 +6,11 @@ import ( "io" "os" "os/exec" + "strings" "testing" "github.com/cod3ddy/gynx/internal/config" + "github.com/cod3ddy/gynx/internal/diffpreview" "github.com/cod3ddy/gynx/internal/watchlist" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -18,6 +20,7 @@ func resetProxyHooks(t *testing.T) { origLoadConfig := loadConfigFn origLoadWatchlist := loadWatchlistFn origMatch := matchRuleFn + origDiffPreview := diffPreviewFn origConfirm := confirmPromptFn origCommandFactory := commandFactoryFn origInteractive := isInteractiveFn @@ -26,6 +29,7 @@ func resetProxyHooks(t *testing.T) { loadConfigFn = origLoadConfig loadWatchlistFn = origLoadWatchlist matchRuleFn = origMatch + diffPreviewFn = origDiffPreview confirmPromptFn = origConfirm commandFactoryFn = origCommandFactory isInteractiveFn = origInteractive @@ -284,3 +288,114 @@ func TestExecuteProxy_ForwardsStdIOToCommand(t *testing.T) { assert.Equal(t, "proxy-stdio\n", string(outBytes)) } + +func TestExecuteProxy_PreviewApplicableIncludedInPrompt(t *testing.T) { + resetProxyHooks(t) + + loadConfigFn = func(path string) (config.Config, error) { + return config.Default(), nil + } + + loadWatchlistFn = func(path string) ([]watchlist.Rule, error) { + return []watchlist.Rule{{Command: "cp", Warning: "Copy can overwrite existing files"}}, nil + } + + matchRuleFn = func(command string, args []string, rules []watchlist.Rule) (watchlist.Rule, bool) { + return watchlist.Rule{Command: "cp", Warning: "Copy can overwrite existing files"}, true + } + + isInteractiveFn = func() bool { return true } + diffPreviewFn = func(command string, args []string) diffpreview.Result { + return diffpreview.Result{Applicable: true, Text: "--- dest\n+++ src\n@@ -1 +1 @@\n-old\n+new"} + } + + var gotPrompt string + confirmPromptFn = func(message string) bool { + gotPrompt = message + return true + } + + commandFactoryFn = func(ctx context.Context, name string, args ...string) *exec.Cmd { + return exec.Command("bash", "-lc", "true") + } + + err := executeProxy("ignored", "ignored", []string{"cp", "src", "dest"}) + require.NoError(t, err) + + warnIdx := strings.Index(gotPrompt, "warning:") + diffIdx := strings.Index(gotPrompt, "--- dest") + proceedIdx := strings.Index(gotPrompt, "Proceed?") + require.True(t, warnIdx >= 0 && diffIdx >= 0 && proceedIdx >= 0) + assert.True(t, warnIdx < diffIdx && diffIdx < proceedIdx) +} + +func TestExecuteProxy_PreviewNotApplicable_MessageUnchanged(t *testing.T) { + resetProxyHooks(t) + + loadConfigFn = func(path string) (config.Config, error) { + return config.Default(), nil + } + loadWatchlistFn = func(path string) ([]watchlist.Rule, error) { + return []watchlist.Rule{{Command: "cp", Warning: "Copy can overwrite existing files"}}, nil + } + matchRuleFn = func(command string, args []string, rules []watchlist.Rule) (watchlist.Rule, bool) { + return watchlist.Rule{Command: "cp", Warning: "Copy can overwrite existing files"}, true + } + isInteractiveFn = func() bool { return true } + diffPreviewFn = func(command string, args []string) diffpreview.Result { + return diffpreview.Result{} + } + + var gotPrompt string + confirmPromptFn = func(message string) bool { + gotPrompt = message + return true + } + + commandFactoryFn = func(ctx context.Context, name string, args ...string) *exec.Cmd { + return exec.Command("bash", "-lc", "true") + } + + err := executeProxy("ignored", "ignored", []string{"cp", "src", "dest"}) + require.NoError(t, err) + assert.Equal(t, "gynx: cp src dest\nwarning: Copy can overwrite existing files\nProceed?", gotPrompt) +} + +func TestExecuteProxy_RealDiffPreviewWiring_CpOverwritesExistingFile(t *testing.T) { + resetProxyHooks(t) + + dir := t.TempDir() + dest := dir + "/dest.txt" + src := dir + "/src.txt" + require.NoError(t, os.WriteFile(dest, []byte("old content\n"), 0o644)) + require.NoError(t, os.WriteFile(src, []byte("new content\n"), 0o644)) + + loadConfigFn = func(path string) (config.Config, error) { + return config.Default(), nil + } + loadWatchlistFn = func(path string) ([]watchlist.Rule, error) { + return []watchlist.Rule{{Command: "cp", Warning: "Copy can overwrite existing files"}}, nil + } + matchRuleFn = func(command string, args []string, rules []watchlist.Rule) (watchlist.Rule, bool) { + return watchlist.Rule{Command: "cp", Warning: "Copy can overwrite existing files"}, true + } + isInteractiveFn = func() bool { return true } + + var gotPrompt string + confirmPromptFn = func(message string) bool { + gotPrompt = message + return true + } + commandFactoryFn = func(ctx context.Context, name string, args ...string) *exec.Cmd { + return exec.Command(name, args...) + } + + err := executeProxy("ignored", "ignored", []string{"cp", src, dest}) + require.NoError(t, err) + assert.Contains(t, gotPrompt, "-old content") + assert.Contains(t, gotPrompt, "+new content") + + gotContent, err := os.ReadFile(dest) + require.NoError(t, err) + assert.Equal(t, "new content\n", string(gotContent)) +} diff --git a/cmd/root.go b/cmd/root.go index 245a635..e274226 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,6 +8,7 @@ import ( "time" "github.com/cod3ddy/gynx/internal/config" + "github.com/cod3ddy/gynx/internal/diffpreview" "github.com/cod3ddy/gynx/internal/executor" "github.com/cod3ddy/gynx/internal/matcher" "github.com/cod3ddy/gynx/internal/prompter" @@ -30,6 +31,7 @@ var ( loadConfigFn = config.Load loadWatchlistFn = watchlist.Load matchRuleFn = matcher.MatchRule + diffPreviewFn = diffpreview.Build confirmPromptFn = prompter.Confirm commandFactoryFn = executor.Command isInteractiveFn = isInteractiveSession @@ -83,13 +85,22 @@ func executeProxy(configPath, watchlistPath string, args []string) error { if isInteractiveFn() { fullCommand := command + if len(commandArgs) > 0 { fullCommand = fmt.Sprintf("%s %s", command, strings.Join(commandArgs, " ")) } - message := fmt.Sprintf("gynx: %s\nwarning: %s\nProceed?", fullCommand, warning) + + message := fmt.Sprintf("gynx: %s\nwarning: %s", fullCommand, warning) + if preview := diffPreviewFn(command, commandArgs); preview.Applicable { + message = fmt.Sprintf("%s\n\n%s\n\nProceed?", message, preview.Text) + } else { + message = fmt.Sprintf("%s\nProceed?", message) + } + if !confirmPromptFn(strings.TrimSpace(message)) { return fmt.Errorf("aborted: command not executed") } + } else if !cfg.NonInteractive.Passthrough { return fmt.Errorf("blocked in non-interactive mode: %s", command) }