diff --git a/docs-master/Custom_DiffRenderers.md b/docs-master/Custom_DiffRenderers.md index 509f42ebf..bba3fecf0 100644 --- a/docs-master/Custom_DiffRenderers.md +++ b/docs-master/Custom_DiffRenderers.md @@ -23,12 +23,18 @@ Fields only for `extDiff`: - **command** The command line to use for the `diff.external` git config. If left empty, it uses the global value of git's `diff.external` config; this can be useful if you also want to use it for diffs on the command line, and it also has the advantage that you can configure it per file type in `.gitattributes`; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver. - You can include the `{{diffContext}}` template variable to pass lazygit's current diff context size (the value controlled by the `{`/`}` keybindings) to the diff tool. - Fields only for `rawGit`: - **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`), as an array of strings. +The `command` of a `stdinFilter` or `extDiff` renderer is a [Go template](https://pkg.go.dev/text/template) with these variables: + +- `{{width}}`: the width of the view that the diff is rendered into. +- `{{columnWidth}}` (only for `stdinFilter`): the width of one side of a side-by-side rendering, e.g. for `ydiff -p cat -s -w {{columnWidth}}`. +- `{{diffContext}}` (only for `extDiff`): lazygit's current diff context size, the value controlled by the `{`/`}` keybindings. + +A variable can also be written with a leading dot, as in `{{.width}}`. The command can use template expressions too; for example, `delta --paging=never {{if gt .width 160}}--side-by-side{{end}}` shows the diff side by side only when there is room for it. + Here's an example for a multi-renderer setup: ```yaml diff --git a/pkg/config/diff_renderer_config_manager.go b/pkg/config/diff_renderer_config_manager.go index be8fef166..b3af0397f 100644 --- a/pkg/config/diff_renderer_config_manager.go +++ b/pkg/config/diff_renderer_config_manager.go @@ -1,8 +1,9 @@ package config import ( - "strconv" + "fmt" "strings" + "text/template" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/utils" @@ -68,10 +69,10 @@ type DiffRendererValues struct { DiffContext uint64 } -func (self *DiffRendererConfigManager) GetStdinFilterCommand(values DiffRendererValues) string { +func (self *DiffRendererConfigManager) GetStdinFilterCommand(values DiffRendererValues) (string, error) { currentDiffRendererConfig := self.currentDiffRendererConfig() if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_StdinFilter { - return "" + return "", nil } return currentDiffRendererConfig.resolveCommand(values) @@ -90,30 +91,41 @@ func (self *DiffRendererConfigManager) GetColorArg() string { return colorArg } -func (self *DiffRendererConfigManager) GetExternalDiffCommand(values DiffRendererValues) string { +func (self *DiffRendererConfigManager) GetExternalDiffCommand(values DiffRendererValues) (string, error) { currentDiffRendererConfig := self.currentDiffRendererConfig() if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_ExtDiff { - return "" + return "", nil } return currentDiffRendererConfig.resolveCommand(values) } -// resolveCommand fills in the values that the renderer's command refers to. -func (self *DiffRendererConfig) resolveCommand(values DiffRendererValues) string { - placeholders := map[string]string{ - "width": strconv.Itoa(values.Width), +// resolveCommand resolves the renderer's command, which is a Go template with +// the values it can refer to as its variables. A variable can be written with +// or without the leading dot, as in {{.width}} or {{width}}. +func (self *DiffRendererConfig) resolveCommand(values DiffRendererValues) (string, error) { + variables := map[string]any{ + "width": values.Width, } switch self.getType() { case DiffRendererType_StdinFilter: - placeholders["columnWidth"] = strconv.Itoa(values.Width/2 - 6) + variables["columnWidth"] = values.Width/2 - 6 case DiffRendererType_ExtDiff: - placeholders["diffContext"] = strconv.Itoa(int(values.DiffContext)) + variables["diffContext"] = values.DiffContext case DiffRendererType_RawGit: // has no command } - return utils.ResolvePlaceholderString(string(self.Command), placeholders) + funcs := template.FuncMap{} + for name, value := range variables { + funcs[name] = func() any { return value } + } + + command, err := utils.ResolveTemplate(string(self.Command), variables, funcs) + if err != nil { + return "", fmt.Errorf("git.diffRenderers: can't use the command '%s': %w", self.Command, err) + } + return command, nil } func (self *DiffRendererConfigManager) GetRawGitArgs() []string { diff --git a/pkg/config/diff_renderer_config_manager_test.go b/pkg/config/diff_renderer_config_manager_test.go index bcced5e5c..cc23b3261 100644 --- a/pkg/config/diff_renderer_config_manager_test.go +++ b/pkg/config/diff_renderer_config_manager_test.go @@ -69,6 +69,7 @@ func TestGetStdinFilterCommand(t *testing.T) { diffRendererConfig DiffRendererConfig width int expected string + expectedError string }{ { name: "a command without template variables is passed through", @@ -94,6 +95,24 @@ func TestGetStdinFilterCommand(t *testing.T) { width: 120, expected: "delta --width=120", }, + { + name: "the command can use template expressions", + diffRendererConfig: DiffRendererConfig{Command: "delta{{if gt .width 100}} --side-by-side{{end}}"}, + width: 120, + expected: "delta --side-by-side", + }, + { + name: "an unknown template variable is an error", + diffRendererConfig: DiffRendererConfig{Command: "delta --width={{.widht}}"}, + width: 120, + expectedError: "can't use the command 'delta --width={{.widht}}'", + }, + { + name: "an unknown template variable without a leading dot is an error too", + diffRendererConfig: DiffRendererConfig{Command: "delta --width={{widht}}"}, + width: 120, + expectedError: "can't use the command 'delta --width={{widht}}'", + }, { name: "nothing is returned for a renderer of another type", diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{width}}"}, @@ -108,7 +127,13 @@ func TestGetStdinFilterCommand(t *testing.T) { userConfig.Git.DiffRenderers = []DiffRendererConfig{s.diffRendererConfig} config := NewDiffRendererConfigManager(func() *UserConfig { return userConfig }) - assert.Equal(t, s.expected, config.GetStdinFilterCommand(DiffRendererValues{Width: s.width})) + command, err := config.GetStdinFilterCommand(DiffRendererValues{Width: s.width}) + if s.expectedError != "" { + assert.ErrorContains(t, err, s.expectedError) + } else { + assert.NoError(t, err) + assert.Equal(t, s.expected, command) + } }) } } @@ -118,6 +143,7 @@ func TestGetExternalDiffCommand(t *testing.T) { name string diffRendererConfig DiffRendererConfig expected string + expectedError string }{ { name: "a command without template variables is passed through", @@ -134,6 +160,11 @@ func TestGetExternalDiffCommand(t *testing.T) { diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{width}} --context={{diffContext}}"}, expected: "difft --width=120 --context=3", }, + { + name: "a variable of stdin filters is an error", + diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{columnWidth}}"}, + expectedError: "can't use the command 'difft --width={{columnWidth}}'", + }, { name: "nothing is returned for a renderer of another type", diffRendererConfig: DiffRendererConfig{Command: "delta --width={{width}}"}, @@ -147,7 +178,13 @@ func TestGetExternalDiffCommand(t *testing.T) { userConfig.Git.DiffRenderers = []DiffRendererConfig{s.diffRendererConfig} config := NewDiffRendererConfigManager(func() *UserConfig { return userConfig }) - assert.Equal(t, s.expected, config.GetExternalDiffCommand(DiffRendererValues{Width: 120, DiffContext: 3})) + command, err := config.GetExternalDiffCommand(DiffRendererValues{Width: 120, DiffContext: 3}) + if s.expectedError != "" { + assert.ErrorContains(t, err, s.expectedError) + } else { + assert.NoError(t, err) + assert.Equal(t, s.expected, command) + } }) } } diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 2e7ab4cf8..6d993bd87 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -124,10 +124,16 @@ func validateDiffRenderers(diffRenderers []DiffRendererConfig) error { if len(diffRenderer.Args) > 0 { return errors.New("git.diffRenderers: 'args' cannot be used with diff renderer type 'stdinFilter'.") } + if err := validateDiffRendererCommand(diffRenderer); err != nil { + return err + } case "extDiff": if len(diffRenderer.Args) > 0 { return errors.New("git.diffRenderers: 'args' cannot be used with diff renderer type 'extDiff'.") } + if err := validateDiffRendererCommand(diffRenderer); err != nil { + return err + } case "rawGit": if diffRenderer.Command != "" { return errors.New("git.diffRenderers: 'command' cannot be used with diff renderer type 'rawGit'.") @@ -139,6 +145,14 @@ func validateDiffRenderers(diffRenderers []DiffRendererConfig) error { return nil } +// validateDiffRendererCommand resolves the command with made-up values, so that +// a mistake in it shows up when the config is loaded rather than when a diff +// is rendered. +func validateDiffRendererCommand(diffRenderer DiffRendererConfig) error { + _, err := diffRenderer.resolveCommand(DiffRendererValues{Width: 80, DiffContext: 3}) + return err +} + func validateEnum(name string, value string, allowedValues []string) error { if slices.Contains(allowedValues, value) { return nil diff --git a/pkg/config/user_config_validation_test.go b/pkg/config/user_config_validation_test.go index 7977e3e4c..c113c472c 100644 --- a/pkg/config/user_config_validation_test.go +++ b/pkg/config/user_config_validation_test.go @@ -409,9 +409,14 @@ func TestUserConfigValidate_diffRenderers(t *testing.T) { {name: "stdinFilter with explicit type", diffRenderer: DiffRendererConfig{Type: "stdinFilter"}, valid: false}, {name: "stdinFilter with type default without command", diffRenderer: DiffRendererConfig{}, valid: false}, {name: "stdinFilter with args", diffRenderer: DiffRendererConfig{Type: "stdinFilter", Command: "delta", Args: []string{"-x"}}, valid: false}, + {name: "stdinFilter with a template", diffRenderer: DiffRendererConfig{Command: "delta --width={{width}}{{if gt .width 100}} --side-by-side{{end}}"}, valid: true}, + {name: "stdinFilter with an unknown template variable", diffRenderer: DiffRendererConfig{Command: "delta --width={{.widht}}"}, valid: false}, + {name: "stdinFilter with a broken template", diffRenderer: DiffRendererConfig{Command: "delta {{if .width}}"}, valid: false}, {name: "external diff", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft"}, valid: true}, {name: "external diff without command", diffRenderer: DiffRendererConfig{Type: "extDiff"}, valid: true}, {name: "external diff with args", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft", Args: []string{"-x"}}, valid: false}, + {name: "external diff with a template", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft --context={{diffContext}}"}, valid: true}, + {name: "external diff with a variable of stdin filters", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{columnWidth}}"}, valid: false}, {name: "raw git", diffRenderer: DiffRendererConfig{Type: "rawGit"}, valid: true}, {name: "raw git with args", diffRenderer: DiffRendererConfig{Type: "rawGit", Args: []string{"-x"}}, valid: true}, {name: "raw git with command", diffRenderer: DiffRendererConfig{Type: "rawGit", Command: "delta"}, valid: false}, diff --git a/pkg/gui/main_view_render.go b/pkg/gui/main_view_render.go index 234f327f2..dcb0216e6 100644 --- a/pkg/gui/main_view_render.go +++ b/pkg/gui/main_view_render.go @@ -1,6 +1,7 @@ package gui import ( + "errors" "fmt" "io" "os" @@ -57,8 +58,14 @@ func (gui *Gui) newRenderTask(view *gocui.View, cmd *exec.Cmd, prefix string) er width := view.InnerWidth() diffRendererConfigManager := gui.stateAccessor.GetDiffRendererConfigManager() values := config.DiffRendererValues{Width: width, DiffContext: gui.c.UserConfig().Git.DiffContextSize} - stdinFilter := diffRendererConfigManager.GetStdinFilterCommand(values) - externalDiff := diffRendererConfigManager.GetExternalDiffCommand(values) + stdinFilter, stdinFilterErr := diffRendererConfigManager.GetStdinFilterCommand(values) + externalDiff, externalDiffErr := diffRendererConfigManager.GetExternalDiffCommand(values) + if err := errors.Join(stdinFilterErr, externalDiffErr); err != nil { + // The commands are checked with made-up values when the config is + // loaded, so only one that fails for some values and not for others + // gets here. Git's own diff is shown instead. + gui.c.ErrorToast(err.Error()) + } cmdStr := strings.Join(cmd.Args, " ")