diff --git a/docs/Config.md b/docs/Config.md index b2711b603..d428603bc 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -14,16 +14,44 @@ - white optionsTextColor: - blue - git: - # stuff relating to git - os: - # stuff relating to the OS update: method: prompt # can be: prompt | background | never days: 14 # how often an update is checked for reporting: 'undetermined' # one of: 'on' | 'off' | 'undetermined' ``` +## Platform Defaults: + +### Windows: + +``` + os: + openCommand: 'cmd /c "start "" {{filename}}"' +``` + +### Linux: + +``` + os: + openCommand: 'bash -c \"xdg-open {{filename}} &>/dev/null &\"' +``` + +### OSX: + +``` + os: + openCommand: 'open {{filename}}' +``` + +### Recommended Config Values: + +for users of VSCode + +``` + os: + openCommand: 'code -r {{filename}}' +``` + ## Color Attributes: For color attributes you can choose an array of attributes (with max one color attribute) diff --git a/docs/Keybindings.md b/docs/Keybindings.md index 9160b9c2b..2f10a297c 100644 --- a/docs/Keybindings.md +++ b/docs/Keybindings.md @@ -54,6 +54,7 @@
s: squash down (only available for topmost commit) r: rename commit + shift+R: rename commit using git editor g: reset to this commitdiff --git a/pkg/app/app.go b/pkg/app/app.go index ffd8807f0..fa2415fc3 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -73,7 +73,7 @@ func NewApp(config config.AppConfigurer) (*App, error) { } var err error app.Log = newLogger(config) - app.OSCommand = commands.NewOSCommand(app.Log) + app.OSCommand = commands.NewOSCommand(app.Log, config) app.Tr = i18n.NewLocalizer(app.Log) diff --git a/pkg/commands/git.go b/pkg/commands/git.go index 619db05e7..704a45c73 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -422,6 +422,11 @@ func (c *GitCommand) PrepareCommitSubProcess() *exec.Cmd { return c.OSCommand.PrepareSubProcess("git", "commit") } +// PrepareCommitAmendSubProcess prepares a subprocess for `git commit --amend --allow-empty` +func (c *GitCommand) PrepareCommitAmendSubProcess() *exec.Cmd { + return c.OSCommand.PrepareSubProcess("git", "commit", "--amend", "--allow-empty") +} + // GetBranchGraph gets the color-formatted graph of the log for the given branch // Currently it limits the result to 100 commits, but when we get async stuff // working we can do lazy loading diff --git a/pkg/commands/git_test.go b/pkg/commands/git_test.go index 8117ed1e5..fb3bafe6c 100644 --- a/pkg/commands/git_test.go +++ b/pkg/commands/git_test.go @@ -189,6 +189,19 @@ func TestGitCommandStashSave(t *testing.T) { assert.NoError(t, gitCmd.StashSave("A stash message")) } +func TestGitCommandCommitAmend(t *testing.T) { + gitCmd := newDummyGitCommand() + gitCmd.OSCommand.command = func(cmd string, args ...string) *exec.Cmd { + assert.EqualValues(t, "git", cmd) + assert.EqualValues(t, []string{"commit", "--amend", "--allow-empty"}, args) + + return exec.Command("echo") + } + + _, err := gitCmd.PrepareCommitAmendSubProcess().CombinedOutput() + assert.NoError(t, err) +} + func TestGitCommandMergeStatusFiles(t *testing.T) { type scenario struct { oldFiles []File diff --git a/pkg/commands/os.go b/pkg/commands/os.go index 0fa92f724..834c45376 100644 --- a/pkg/commands/os.go +++ b/pkg/commands/os.go @@ -6,7 +6,8 @@ import ( "os/exec" "strings" - "github.com/davecgh/go-spew/spew" + "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/mgutz/str" @@ -20,22 +21,25 @@ type Platform struct { shell string shellArg string escapedQuote string + openCommand string } // OSCommand holds all the os commands type OSCommand struct { Log *logrus.Entry Platform *Platform + Config config.AppConfigurer command func(string, ...string) *exec.Cmd getGlobalGitConfig func(string) (string, error) getenv func(string) string } // NewOSCommand os command runner -func NewOSCommand(log *logrus.Entry) *OSCommand { +func NewOSCommand(log *logrus.Entry, config config.AppConfigurer) *OSCommand { return &OSCommand{ Log: log, Platform: getPlatform(), + Config: config, command: exec.Command, getGlobalGitConfig: gitconfig.Global, getenv: os.Getenv, @@ -47,7 +51,6 @@ func (c *OSCommand) RunCommandWithOutput(command string) (string, error) { c.Log.WithField("command", command).Info("RunCommand") splitCmd := str.ToArgv(command) c.Log.Info(splitCmd) - return sanitisedCommandOutput( c.command(splitCmd[0], splitCmd[1:]...).CombinedOutput(), ) @@ -74,12 +77,9 @@ func (c *OSCommand) FileType(path string) string { // RunDirectCommand wrapper around direct commands func (c *OSCommand) RunDirectCommand(command string) (string, error) { c.Log.WithField("command", command).Info("RunDirectCommand") - args := str.ToArgv(c.Platform.shellArg + " " + command) - c.Log.Info(spew.Sdump(args)) return sanitisedCommandOutput( - exec. - Command(c.Platform.shell, args...). + c.command(c.Platform.shell, c.Platform.shellArg, command). CombinedOutput(), ) } @@ -89,51 +89,24 @@ func sanitisedCommandOutput(output []byte, err error) (string, error) { if err != nil { // errors like 'exit status 1' are not very useful so we'll create an error // from the combined output + if outputString == "" { + return "", err + } return outputString, errors.New(outputString) } return outputString, nil } -// getOpenCommand get open command -func (c *OSCommand) getOpenCommand() (string, string, error) { - //NextStep open equivalents: xdg-open (linux), cygstart (cygwin), open (OSX) - trailMap := map[string]string{ - "xdg-open": " &>/dev/null &", - "cygstart": "", - "open": "", - } - - for name, trail := range trailMap { - if err := c.RunCommand("which " + name); err == nil { - return name, trail, nil - } - } - return "", "", errors.New("Unsure what command to use to open this file") -} - -// VsCodeOpenFile opens the file in code, with the -r flag to open in the -// current window -// each of these open files needs to have the same function signature because -// they're being passed as arguments into another function, -// but only editFile actually returns a *exec.Cmd -func (c *OSCommand) VsCodeOpenFile(filename string) (*exec.Cmd, error) { - return nil, c.RunCommand("code -r " + filename) -} - -// SublimeOpenFile opens the filein sublime -// may be deprecated in the future -func (c *OSCommand) SublimeOpenFile(filename string) (*exec.Cmd, error) { - return nil, c.RunCommand("subl " + filename) -} - // OpenFile opens a file with the given func (c *OSCommand) OpenFile(filename string) error { - cmdName, cmdTrail, err := c.getOpenCommand() - if err != nil { - return err + commandTemplate := c.Config.GetUserConfig().GetString("os.openCommand") + templateValues := map[string]string{ + "filename": c.Quote(filename), } - return c.RunCommand(cmdName + " " + c.Quote(filename) + cmdTrail) // TODO: test on linux + command := utils.ResolvePlaceholderString(commandTemplate, templateValues) + err := c.RunCommand(command) + return err } // EditFile opens a file in a subprocess using whatever editor is available, diff --git a/pkg/commands/os_default_platform.go b/pkg/commands/os_default_platform.go index f6ac1b515..f106bbd62 100644 --- a/pkg/commands/os_default_platform.go +++ b/pkg/commands/os_default_platform.go @@ -12,5 +12,6 @@ func getPlatform() *Platform { shell: "bash", shellArg: "-c", escapedQuote: "\"", + openCommand: "open {{filename}}", } } diff --git a/pkg/commands/os_test.go b/pkg/commands/os_test.go index f73b78d47..5d1644a38 100644 --- a/pkg/commands/os_test.go +++ b/pkg/commands/os_test.go @@ -5,11 +5,28 @@ import ( "os/exec" "testing" + "github.com/jesseduffield/lazygit/pkg/config" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" + yaml "gopkg.in/yaml.v2" ) func newDummyOSCommand() *OSCommand { - return NewOSCommand(newDummyLog()) + return NewOSCommand(newDummyLog(), newDummyAppConfig()) +} + +func newDummyAppConfig() *config.AppConfig { + appConfig := &config.AppConfig{ + Name: "lazygit", + Version: "unversioned", + Commit: "", + BuildDate: "", + Debug: false, + BuildSource: "", + UserConfig: viper.New(), + } + _ = yaml.Unmarshal([]byte{}, appConfig.AppState) + return appConfig } func TestOSCommandRunCommandWithOutput(t *testing.T) { @@ -59,44 +76,6 @@ func TestOSCommandRunCommand(t *testing.T) { } } -func TestOSCommandGetOpenCommand(t *testing.T) { - type scenario struct { - command func(string, ...string) *exec.Cmd - test func(string, string, error) - } - - scenarios := []scenario{ - { - func(name string, arg ...string) *exec.Cmd { - return exec.Command("exit", "1") - }, - func(name string, trail string, err error) { - assert.EqualError(t, err, "Unsure what command to use to open this file") - }, - }, - { - func(name string, arg ...string) *exec.Cmd { - assert.Equal(t, "which", name) - assert.Len(t, arg, 1) - assert.Regexp(t, "xdg-open|cygstart|open", arg[0]) - return exec.Command("echo") - }, - func(name string, trail string, err error) { - assert.NoError(t, err) - assert.Regexp(t, "xdg-open|cygstart|open", name) - assert.Regexp(t, " \\&\\>/dev/null \\&|", trail) - }, - }, - } - - for _, s := range scenarios { - OSCmd := newDummyOSCommand() - OSCmd.command = s.command - - s.test(OSCmd.getOpenCommand()) - } -} - func TestOSCommandOpenFile(t *testing.T) { type scenario struct { filename string @@ -111,29 +90,25 @@ func TestOSCommandOpenFile(t *testing.T) { return exec.Command("exit", "1") }, func(err error) { - assert.EqualError(t, err, "Unsure what command to use to open this file") + assert.Error(t, err) }, }, { "test", func(name string, arg ...string) *exec.Cmd { - if name == "which" { - return exec.Command("echo") - } - - switch len(arg) { - case 1: - assert.Regexp(t, "open|cygstart", name) - assert.EqualValues(t, "test", arg[0]) - case 3: - assert.Equal(t, "xdg-open", name) - assert.EqualValues(t, "test", arg[0]) - assert.Regexp(t, " \\&\\>/dev/null \\&|", arg[1]) - assert.EqualValues(t, "&", arg[2]) - default: - assert.Fail(t, "Unexisting command given") - } - + assert.Equal(t, "open", name) + assert.Equal(t, []string{"test"}, arg) + return exec.Command("echo") + }, + func(err error) { + assert.NoError(t, err) + }, + }, + { + "filename with spaces", + func(name string, arg ...string) *exec.Cmd { + assert.Equal(t, "open", name) + assert.Equal(t, []string{"filename with spaces"}, arg) return exec.Command("echo") }, func(err error) { @@ -145,6 +120,7 @@ func TestOSCommandOpenFile(t *testing.T) { for _, s := range scenarios { OSCmd := newDummyOSCommand() OSCmd.command = s.command + OSCmd.Config.GetUserConfig().Set("os.openCommand", "open {{filename}}") s.test(OSCmd.OpenFile(s.filename)) } diff --git a/pkg/commands/os_windows.go b/pkg/commands/os_windows.go index 28dd7a982..1658e5f36 100644 --- a/pkg/commands/os_windows.go +++ b/pkg/commands/os_windows.go @@ -5,6 +5,6 @@ func getPlatform() *Platform { os: "windows", shell: "cmd", shellArg: "/c", - escapedQuote: "\\\"", + escapedQuote: `\"`, } } diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index 899d27fbc..3c272e520 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -40,8 +40,7 @@ type AppConfigurer interface { // NewAppConfig makes a new app config func NewAppConfig(name, version, commit, date string, buildSource string, debuggingFlag *bool) (*AppConfig, error) { - defaultConfig := GetDefaultConfig() - userConfig, err := LoadConfig("config", defaultConfig) + userConfig, err := LoadConfig("config", true) if err != nil { return nil, err } @@ -113,13 +112,16 @@ func newViper(filename string) (*viper.Viper, error) { } // LoadConfig gets the user's config -func LoadConfig(filename string, defaults []byte) (*viper.Viper, error) { +func LoadConfig(filename string, withDefaults bool) (*viper.Viper, error) { v, err := newViper(filename) if err != nil { return nil, err } - if defaults != nil { - if err = LoadDefaults(v, defaults); err != nil { + if withDefaults { + if err = LoadDefaults(v, GetDefaultConfig()); err != nil { + return nil, err + } + if err = LoadDefaults(v, GetPlatformDefaultConfig()); err != nil { return nil, err } } @@ -131,7 +133,7 @@ func LoadConfig(filename string, defaults []byte) (*viper.Viper, error) { // LoadDefaults loads in the defaults defined in this file func LoadDefaults(v *viper.Viper, defaults []byte) error { - return v.ReadConfig(bytes.NewBuffer(defaults)) + return v.MergeConfig(bytes.NewBuffer(defaults)) } func prepareConfigFile(filename string) (string, error) { @@ -166,7 +168,7 @@ func LoadAndMergeFile(v *viper.Viper, filename string) error { func (c *AppConfig) WriteToUserConfig(key, value string) error { // reloading the user config directly (without defaults) so that we're not // writing any defaults back to the user's config - v, err := LoadConfig("config", nil) + v, err := LoadConfig("config", false) if err != nil { return err } @@ -220,10 +222,6 @@ func GetDefaultConfig() []byte { - white optionsTextColor: - blue -git: - # stuff relating to git -os: - # stuff relating to the OS update: method: prompt # can be: prompt | background | never days: 14 # how often a update is checked for diff --git a/pkg/config/config_default_platform.go b/pkg/config/config_default_platform.go new file mode 100644 index 000000000..f3c1a36e5 --- /dev/null +++ b/pkg/config/config_default_platform.go @@ -0,0 +1,10 @@ +// +build !windows,!linux + +package config + +// GetPlatformDefaultConfig gets the defaults for the platform +func GetPlatformDefaultConfig() []byte { + return []byte( + `os: + openCommand: 'open {{filename}}'`) +} diff --git a/pkg/config/config_linux.go b/pkg/config/config_linux.go new file mode 100644 index 000000000..ef30ac7d2 --- /dev/null +++ b/pkg/config/config_linux.go @@ -0,0 +1,8 @@ +package config + +// GetPlatformDefaultConfig gets the defaults for the platform +func GetPlatformDefaultConfig() []byte { + return []byte( + `os: + openCommand: 'bash -c \"xdg-open {{filename}} &>/dev/null &\"'`) +} diff --git a/pkg/config/config_windows.go b/pkg/config/config_windows.go new file mode 100644 index 000000000..b81a5fdb5 --- /dev/null +++ b/pkg/config/config_windows.go @@ -0,0 +1,8 @@ +package config + +// GetPlatformDefaultConfig gets the defaults for the platform +func GetPlatformDefaultConfig() []byte { + return []byte( + `os: + openCommand: 'cmd /c "start "" {{filename}}"'`) +} diff --git a/pkg/gui/commits_panel.go b/pkg/gui/commits_panel.go index c428e3b99..0969692b6 100644 --- a/pkg/gui/commits_panel.go +++ b/pkg/gui/commits_panel.go @@ -155,6 +155,19 @@ func (gui *Gui) handleRenameCommit(g *gocui.Gui, v *gocui.View) error { return nil } +func (gui *Gui) handleRenameCommitEditor(g *gocui.Gui, v *gocui.View) error { + if gui.getItemPosition(v) != 0 { + return gui.createErrorPanel(g, gui.Tr.SLocalize("OnlyRenameTopCommit")) + } + + gui.SubProcess = gui.GitCommand.PrepareCommitAmendSubProcess() + g.Update(func(g *gocui.Gui) error { + return gui.Errors.ErrSubProcess + }) + + return nil +} + func (gui *Gui) getSelectedCommit(g *gocui.Gui) (commands.Commit, error) { v, err := g.View("commits") if err != nil { diff --git a/pkg/gui/files_panel.go b/pkg/gui/files_panel.go index e7fbc5baf..a4c187c9c 100644 --- a/pkg/gui/files_panel.go +++ b/pkg/gui/files_panel.go @@ -7,7 +7,6 @@ import ( // "strings" - "os/exec" "strings" "github.com/fatih/color" @@ -250,11 +249,10 @@ func (gui *Gui) PrepareSubProcess(g *gocui.Gui, commands ...string) { }) } -func (gui *Gui) genericFileOpen(g *gocui.Gui, v *gocui.View, filename string, open func(string) (*exec.Cmd, error)) error { - - sub, err := open(filename) +func (gui *Gui) editFile(filename string) error { + sub, err := gui.OSCommand.EditFile(filename) if err != nil { - return gui.createErrorPanel(g, err.Error()) + return gui.createErrorPanel(gui.g, err.Error()) } if sub != nil { gui.SubProcess = sub @@ -268,7 +266,8 @@ func (gui *Gui) handleFileEdit(g *gocui.Gui, v *gocui.View) error { if err != nil { return err } - return gui.genericFileOpen(g, v, file.Name, gui.OSCommand.EditFile) + + return gui.editFile(file.Name) } func (gui *Gui) handleFileOpen(g *gocui.Gui, v *gocui.View) error { @@ -279,22 +278,6 @@ func (gui *Gui) handleFileOpen(g *gocui.Gui, v *gocui.View) error { return gui.openFile(file.Name) } -func (gui *Gui) handleSublimeFileOpen(g *gocui.Gui, v *gocui.View) error { - file, err := gui.getSelectedFile(g) - if err != nil { - return err - } - return gui.genericFileOpen(g, v, file.Name, gui.OSCommand.SublimeOpenFile) -} - -func (gui *Gui) handleVsCodeFileOpen(g *gocui.Gui, v *gocui.View) error { - file, err := gui.getSelectedFile(g) - if err != nil { - return err - } - return gui.genericFileOpen(g, v, file.Name, gui.OSCommand.VsCodeOpenFile) -} - func (gui *Gui) handleRefreshFiles(g *gocui.Gui, v *gocui.View) error { return gui.refreshFiles(g) } diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index 52496b918..c6decc24f 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -34,8 +34,6 @@ func (gui *Gui) keybindings(g *gocui.Gui) error { {ViewName: "files", Key: 'm', Modifier: gocui.ModNone, Handler: gui.handleSwitchToMerge}, {ViewName: "files", Key: 'e', Modifier: gocui.ModNone, Handler: gui.handleFileEdit}, {ViewName: "files", Key: 'o', Modifier: gocui.ModNone, Handler: gui.handleFileOpen}, - {ViewName: "files", Key: 's', Modifier: gocui.ModNone, Handler: gui.handleSublimeFileOpen}, - {ViewName: "files", Key: 'v', Modifier: gocui.ModNone, Handler: gui.handleVsCodeFileOpen}, {ViewName: "files", Key: 'i', Modifier: gocui.ModNone, Handler: gui.handleIgnoreFile}, {ViewName: "files", Key: 'r', Modifier: gocui.ModNone, Handler: gui.handleRefreshFiles}, {ViewName: "files", Key: 'S', Modifier: gocui.ModNone, Handler: gui.handleStashSave}, @@ -64,6 +62,7 @@ func (gui *Gui) keybindings(g *gocui.Gui) error { {ViewName: "branches", Key: 'm', Modifier: gocui.ModNone, Handler: gui.handleMerge}, {ViewName: "commits", Key: 's', Modifier: gocui.ModNone, Handler: gui.handleCommitSquashDown}, {ViewName: "commits", Key: 'r', Modifier: gocui.ModNone, Handler: gui.handleRenameCommit}, + {ViewName: "commits", Key: 'R', Modifier: gocui.ModNone, Handler: gui.handleRenameCommitEditor}, {ViewName: "commits", Key: 'g', Modifier: gocui.ModNone, Handler: gui.handleResetToCommit}, {ViewName: "commits", Key: 'f', Modifier: gocui.ModNone, Handler: gui.handleCommitFixup}, {ViewName: "stash", Key: gocui.KeySpace, Modifier: gocui.ModNone, Handler: gui.handleStashApply}, diff --git a/pkg/gui/status_panel.go b/pkg/gui/status_panel.go index a9dee6a7f..583d7805a 100644 --- a/pkg/gui/status_panel.go +++ b/pkg/gui/status_panel.go @@ -76,7 +76,7 @@ func (gui *Gui) handleOpenConfig(g *gocui.Gui, v *gocui.View) error { func (gui *Gui) handleEditConfig(g *gocui.Gui, v *gocui.View) error { filename := gui.Config.GetUserConfig().ConfigFileUsed() - return gui.genericFileOpen(g, v, filename, gui.OSCommand.EditFile) + return gui.editFile(filename) } func lazygitTitle() string { diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 0b6ffeddd..e28ab1824 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -91,3 +91,11 @@ func Loader() string { index := nanos / 50000000 % int64(len(characters)) return characters[index : index+1] } + +// ResolvePlaceholderString populates a template with values +func ResolvePlaceholderString(str string, arguments map[string]string) string { + for key, value := range arguments { + str = strings.Replace(str, "{{"+key+"}}", value, -1) + } + return str +} diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index 46b264945..0b2d35959 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -114,3 +114,56 @@ func TestNormalizeLinefeeds(t *testing.T) { assert.EqualValues(t, string(s.expected), NormalizeLinefeeds(string(s.byteArray))) } } + +func TestResolvePlaceholderString(t *testing.T) { + type scenario struct { + templateString string + arguments map[string]string + expected string + } + + scenarios := []scenario{ + { + "", + map[string]string{}, + "", + }, + { + "hello", + map[string]string{}, + "hello", + }, + { + "hello {{arg}}", + map[string]string{}, + "hello {{arg}}", + }, + { + "hello {{arg}}", + map[string]string{"arg": "there"}, + "hello there", + }, + { + "hello", + map[string]string{"arg": "there"}, + "hello", + }, + { + "{{nothing}}", + map[string]string{"nothing": ""}, + "", + }, + { + "{{}} {{ this }} { should not throw}} an {{{{}}}} error", + map[string]string{ + "blah": "blah", + "this": "won't match", + }, + "{{}} {{ this }} { should not throw}} an {{{{}}}} error", + }, + } + + for _, s := range scenarios { + assert.EqualValues(t, string(s.expected), ResolvePlaceholderString(s.templateString, s.arguments)) + } +}