1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-04-27 12:32:37 +02:00

Merge pull request from jesseduffield/hotfix/238-xdg-open

238: opening with xdg-open
This commit is contained in:
Jesse Duffield 2018-09-03 19:48:42 +10:00 committed by GitHub
commit 3c1935fee4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 186 additions and 142 deletions

@ -14,16 +14,44 @@
- white - white
optionsTextColor: optionsTextColor:
- blue - blue
git:
# stuff relating to git
os:
# stuff relating to the OS
update: update:
method: prompt # can be: prompt | background | never method: prompt # can be: prompt | background | never
days: 14 # how often an update is checked for days: 14 # how often an update is checked for
reporting: 'undetermined' # one of: 'on' | 'off' | 'undetermined' 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: ## Color Attributes:
For color attributes you can choose an array of attributes (with max one color attribute) For color attributes you can choose an array of attributes (with max one color attribute)

@ -73,7 +73,7 @@ func NewApp(config config.AppConfigurer) (*App, error) {
} }
var err error var err error
app.Log = newLogger(config) app.Log = newLogger(config)
app.OSCommand = commands.NewOSCommand(app.Log) app.OSCommand = commands.NewOSCommand(app.Log, config)
app.Tr = i18n.NewLocalizer(app.Log) app.Tr = i18n.NewLocalizer(app.Log)

@ -6,7 +6,8 @@ import (
"os/exec" "os/exec"
"strings" "strings"
"github.com/davecgh/go-spew/spew" "github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/mgutz/str" "github.com/mgutz/str"
@ -20,22 +21,25 @@ type Platform struct {
shell string shell string
shellArg string shellArg string
escapedQuote string escapedQuote string
openCommand string
} }
// OSCommand holds all the os commands // OSCommand holds all the os commands
type OSCommand struct { type OSCommand struct {
Log *logrus.Entry Log *logrus.Entry
Platform *Platform Platform *Platform
Config config.AppConfigurer
command func(string, ...string) *exec.Cmd command func(string, ...string) *exec.Cmd
getGlobalGitConfig func(string) (string, error) getGlobalGitConfig func(string) (string, error)
getenv func(string) string getenv func(string) string
} }
// NewOSCommand os command runner // NewOSCommand os command runner
func NewOSCommand(log *logrus.Entry) *OSCommand { func NewOSCommand(log *logrus.Entry, config config.AppConfigurer) *OSCommand {
return &OSCommand{ return &OSCommand{
Log: log, Log: log,
Platform: getPlatform(), Platform: getPlatform(),
Config: config,
command: exec.Command, command: exec.Command,
getGlobalGitConfig: gitconfig.Global, getGlobalGitConfig: gitconfig.Global,
getenv: os.Getenv, getenv: os.Getenv,
@ -47,7 +51,6 @@ func (c *OSCommand) RunCommandWithOutput(command string) (string, error) {
c.Log.WithField("command", command).Info("RunCommand") c.Log.WithField("command", command).Info("RunCommand")
splitCmd := str.ToArgv(command) splitCmd := str.ToArgv(command)
c.Log.Info(splitCmd) c.Log.Info(splitCmd)
return sanitisedCommandOutput( return sanitisedCommandOutput(
c.command(splitCmd[0], splitCmd[1:]...).CombinedOutput(), c.command(splitCmd[0], splitCmd[1:]...).CombinedOutput(),
) )
@ -74,12 +77,9 @@ func (c *OSCommand) FileType(path string) string {
// RunDirectCommand wrapper around direct commands // RunDirectCommand wrapper around direct commands
func (c *OSCommand) RunDirectCommand(command string) (string, error) { func (c *OSCommand) RunDirectCommand(command string) (string, error) {
c.Log.WithField("command", command).Info("RunDirectCommand") c.Log.WithField("command", command).Info("RunDirectCommand")
args := str.ToArgv(c.Platform.shellArg + " " + command)
c.Log.Info(spew.Sdump(args))
return sanitisedCommandOutput( return sanitisedCommandOutput(
exec. c.command(c.Platform.shell, c.Platform.shellArg, command).
Command(c.Platform.shell, args...).
CombinedOutput(), CombinedOutput(),
) )
} }
@ -89,51 +89,24 @@ func sanitisedCommandOutput(output []byte, err error) (string, error) {
if err != nil { if err != nil {
// errors like 'exit status 1' are not very useful so we'll create an error // errors like 'exit status 1' are not very useful so we'll create an error
// from the combined output // from the combined output
if outputString == "" {
return "", err
}
return outputString, errors.New(outputString) return outputString, errors.New(outputString)
} }
return outputString, nil 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 // OpenFile opens a file with the given
func (c *OSCommand) OpenFile(filename string) error { func (c *OSCommand) OpenFile(filename string) error {
cmdName, cmdTrail, err := c.getOpenCommand() commandTemplate := c.Config.GetUserConfig().GetString("os.openCommand")
if err != nil { templateValues := map[string]string{
return err "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, // EditFile opens a file in a subprocess using whatever editor is available,

@ -12,5 +12,6 @@ func getPlatform() *Platform {
shell: "bash", shell: "bash",
shellArg: "-c", shellArg: "-c",
escapedQuote: "\"", escapedQuote: "\"",
openCommand: "open {{filename}}",
} }
} }

@ -5,11 +5,28 @@ import (
"os/exec" "os/exec"
"testing" "testing"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
yaml "gopkg.in/yaml.v2"
) )
func newDummyOSCommand() *OSCommand { 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) { 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) { func TestOSCommandOpenFile(t *testing.T) {
type scenario struct { type scenario struct {
filename string filename string
@ -111,29 +90,25 @@ func TestOSCommandOpenFile(t *testing.T) {
return exec.Command("exit", "1") return exec.Command("exit", "1")
}, },
func(err error) { func(err error) {
assert.EqualError(t, err, "Unsure what command to use to open this file") assert.Error(t, err)
}, },
}, },
{ {
"test", "test",
func(name string, arg ...string) *exec.Cmd { func(name string, arg ...string) *exec.Cmd {
if name == "which" { assert.Equal(t, "open", name)
return exec.Command("echo") assert.Equal(t, []string{"test"}, arg)
} return exec.Command("echo")
},
switch len(arg) { func(err error) {
case 1: assert.NoError(t, err)
assert.Regexp(t, "open|cygstart", name) },
assert.EqualValues(t, "test", arg[0]) },
case 3: {
assert.Equal(t, "xdg-open", name) "filename with spaces",
assert.EqualValues(t, "test", arg[0]) func(name string, arg ...string) *exec.Cmd {
assert.Regexp(t, " \\&\\>/dev/null \\&|", arg[1]) assert.Equal(t, "open", name)
assert.EqualValues(t, "&", arg[2]) assert.Equal(t, []string{"filename with spaces"}, arg)
default:
assert.Fail(t, "Unexisting command given")
}
return exec.Command("echo") return exec.Command("echo")
}, },
func(err error) { func(err error) {
@ -145,6 +120,7 @@ func TestOSCommandOpenFile(t *testing.T) {
for _, s := range scenarios { for _, s := range scenarios {
OSCmd := newDummyOSCommand() OSCmd := newDummyOSCommand()
OSCmd.command = s.command OSCmd.command = s.command
OSCmd.Config.GetUserConfig().Set("os.openCommand", "open {{filename}}")
s.test(OSCmd.OpenFile(s.filename)) s.test(OSCmd.OpenFile(s.filename))
} }

@ -5,6 +5,6 @@ func getPlatform() *Platform {
os: "windows", os: "windows",
shell: "cmd", shell: "cmd",
shellArg: "/c", shellArg: "/c",
escapedQuote: "\\\"", escapedQuote: `\"`,
} }
} }

@ -40,8 +40,7 @@ type AppConfigurer interface {
// NewAppConfig makes a new app config // NewAppConfig makes a new app config
func NewAppConfig(name, version, commit, date string, buildSource string, debuggingFlag *bool) (*AppConfig, error) { func NewAppConfig(name, version, commit, date string, buildSource string, debuggingFlag *bool) (*AppConfig, error) {
defaultConfig := GetDefaultConfig() userConfig, err := LoadConfig("config", true)
userConfig, err := LoadConfig("config", defaultConfig)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -113,13 +112,16 @@ func newViper(filename string) (*viper.Viper, error) {
} }
// LoadConfig gets the user's config // 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) v, err := newViper(filename)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if defaults != nil { if withDefaults {
if err = LoadDefaults(v, defaults); err != nil { if err = LoadDefaults(v, GetDefaultConfig()); err != nil {
return nil, err
}
if err = LoadDefaults(v, GetPlatformDefaultConfig()); err != nil {
return nil, err 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 // LoadDefaults loads in the defaults defined in this file
func LoadDefaults(v *viper.Viper, defaults []byte) error { 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) { 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 { func (c *AppConfig) WriteToUserConfig(key, value string) error {
// reloading the user config directly (without defaults) so that we're not // reloading the user config directly (without defaults) so that we're not
// writing any defaults back to the user's config // writing any defaults back to the user's config
v, err := LoadConfig("config", nil) v, err := LoadConfig("config", false)
if err != nil { if err != nil {
return err return err
} }
@ -220,10 +222,6 @@ func GetDefaultConfig() []byte {
- white - white
optionsTextColor: optionsTextColor:
- blue - blue
git:
# stuff relating to git
os:
# stuff relating to the OS
update: update:
method: prompt # can be: prompt | background | never method: prompt # can be: prompt | background | never
days: 14 # how often a update is checked for days: 14 # how often a update is checked for

@ -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}}'`)
}

@ -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 &\"'`)
}

@ -0,0 +1,8 @@
package config
// GetPlatformDefaultConfig gets the defaults for the platform
func GetPlatformDefaultConfig() []byte {
return []byte(
`os:
openCommand: 'cmd /c "start "" {{filename}}"'`)
}

@ -7,7 +7,6 @@ import (
// "strings" // "strings"
"os/exec"
"strings" "strings"
"github.com/fatih/color" "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 { func (gui *Gui) editFile(filename string) error {
sub, err := gui.OSCommand.EditFile(filename)
sub, err := open(filename)
if err != nil { if err != nil {
return gui.createErrorPanel(g, err.Error()) return gui.createErrorPanel(gui.g, err.Error())
} }
if sub != nil { if sub != nil {
gui.SubProcess = sub gui.SubProcess = sub
@ -268,7 +266,8 @@ func (gui *Gui) handleFileEdit(g *gocui.Gui, v *gocui.View) error {
if err != nil { if err != nil {
return err 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 { 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) 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 { func (gui *Gui) handleRefreshFiles(g *gocui.Gui, v *gocui.View) error {
return gui.refreshFiles(g) return gui.refreshFiles(g)
} }

@ -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: 'm', Modifier: gocui.ModNone, Handler: gui.handleSwitchToMerge},
{ViewName: "files", Key: 'e', Modifier: gocui.ModNone, Handler: gui.handleFileEdit}, {ViewName: "files", Key: 'e', Modifier: gocui.ModNone, Handler: gui.handleFileEdit},
{ViewName: "files", Key: 'o', Modifier: gocui.ModNone, Handler: gui.handleFileOpen}, {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: 'i', Modifier: gocui.ModNone, Handler: gui.handleIgnoreFile},
{ViewName: "files", Key: 'r', Modifier: gocui.ModNone, Handler: gui.handleRefreshFiles}, {ViewName: "files", Key: 'r', Modifier: gocui.ModNone, Handler: gui.handleRefreshFiles},
{ViewName: "files", Key: 'S', Modifier: gocui.ModNone, Handler: gui.handleStashSave}, {ViewName: "files", Key: 'S', Modifier: gocui.ModNone, Handler: gui.handleStashSave},

@ -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 { func (gui *Gui) handleEditConfig(g *gocui.Gui, v *gocui.View) error {
filename := gui.Config.GetUserConfig().ConfigFileUsed() filename := gui.Config.GetUserConfig().ConfigFileUsed()
return gui.genericFileOpen(g, v, filename, gui.OSCommand.EditFile) return gui.editFile(filename)
} }
func lazygitTitle() string { func lazygitTitle() string {

@ -91,3 +91,11 @@ func Loader() string {
index := nanos / 50000000 % int64(len(characters)) index := nanos / 50000000 % int64(len(characters))
return characters[index : index+1] 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
}

@ -114,3 +114,56 @@ func TestNormalizeLinefeeds(t *testing.T) {
assert.EqualValues(t, string(s.expected), NormalizeLinefeeds(string(s.byteArray))) 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))
}
}