From c8a4c8d39229c845fc92f3fb3a55db42f6d74dc1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 25 Sep 2026 15:03:39 +0200 Subject: [PATCH] Let the command of a diff renderer be a template The command of a diff renderer can refer to values like the width it renders at, as {{width}}. They are filled in by plain replacement, so a command can't choose between options depending on them. The next commit adds a value that needs this: whether the terminal is dark or light. delta takes --dark or --light, but for other renderers the choice has to be spelled out differently, for example as the name of a syntax theme. Resolve the command as a Go template instead. The values become its variables, so that {{if gt .width 160}} --side-by-side{{end}} works too. To keep the existing commands working, a variable can still be written without the leading dot. A mistake in a template, such as a misspelled variable, now makes resolving the command fail, instead of leaving the placeholder in it. Check the commands when the config is loaded, by resolving each of them with made-up values, so that the mistake shows up as an invalid config. This also rejects a variable that the kind of renderer doesn't have, such as {{columnWidth}} in the command of an external diff; until now, it reached the renderer as it was. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs-master/Custom_DiffRenderers.md | 10 ++++- pkg/config/diff_renderer_config_manager.go | 36 ++++++++++------ .../diff_renderer_config_manager_test.go | 41 ++++++++++++++++++- pkg/config/user_config_validation.go | 14 +++++++ pkg/config/user_config_validation_test.go | 5 +++ pkg/gui/main_view_render.go | 11 ++++- 6 files changed, 99 insertions(+), 18 deletions(-) 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, " ")