Repository: gokcehan/lf (go)

Issue:
Make :delete async
So today I wanted to delete a very large directory. It surprised me that Lf was frozen during the deletion. Is `:delete` async? I assume not, in comparison to `paste`. Anyway, I think it should be.

Pull request description:
Asynchronous delete
Fixes #215 

Tried to implement it as similar to moving as I could.

 I did test it by closing the lf instance while it was deleting large files and it worked fine. Therefore, I am still confused and looking for feedback why just an asynchronous `os.RemoveAll` wasn't enough.  (I can only guess os specific?)  Additionally, since it worked in my tests, I have not implemented the feature that async copy has - that lf can't be closed while it's deleting. If that's necessary, of course I can add it. 

Patch:
diff --git a/app.go b/app.go
index 2eb095a..35e718e 100644
--- a/app.go
+++ b/app.go
@@ -179,6 +179,11 @@ func (app *app) loop() {
 				continue
 			}
 
+			if app.nav.deleting {
+				app.ui.echoerr("quit: delete operation in progress")
+				continue
+			}
+
 			log.Print("bye!")
 
 			if err := app.writeHistory(); err != nil {
@@ -323,6 +328,7 @@ func (app *app) runShell(s string, args []string, prefix string) {
 	cmd := shellCommand(s, args)
 
 	var out io.Reader
+	var err error
 	switch prefix {
 	case "$", "!":
 		cmd.Stdin = os.Stdin
@@ -332,6 +338,8 @@ func (app *app) runShell(s string, args []string, prefix string) {
 		app.ui.pause()
 		defer app.ui.resume()
 		defer app.nav.renew()
+
+		err = cmd.Run()
 	case "%":
 		stdin, err := cmd.StdinPipe()
 		if err != nil {
@@ -344,13 +352,8 @@ func (app *app) runShell(s string, args []string, prefix string) {
 		}
 		out = stdout
 		cmd.Stderr = cmd.Stdout
-	}
-
-	var err error
-	switch prefix {
-	case "$", "!":
-		err = cmd.Run()
-	case "%", "&":
+		fallthrough
+	case "&":
 		err = cmd.Start()
 	}
 
diff --git a/complete.go b/complete.go
index 3f742e8..745f439 100644
--- a/complete.go
+++ b/complete.go
@@ -256,13 +256,13 @@ func matchFile(s string) (matches []string, longest string) {
 
 		name := f.Name()
 		if isRoot(s) || filepath.Base(s) != s {
-			name = filepath.Join(filepath.Dir(s), f.Name())
+			name = filepath.Join(filepath.Dir(unescape(s)), f.Name())
 		}
 		name = escape(name)
 
 		item := f.Name()
 		if f.Mode().IsDir() {
-			item += string(filepath.Separator)
+			item += escape(string(filepath.Separator))
 		}
 		matches = append(matches, item)
 
@@ -272,7 +272,7 @@ func matchFile(s string) (matches []string, longest string) {
 			if f.Mode().IsRegular() {
 				longest = name + " "
 			} else {
-				longest = name + string(filepath.Separator)
+				longest = name + escape(string(filepath.Separator))
 			}
 		}
 	}
diff --git a/doc.go b/doc.go
index d681707..350f4dd 100644
--- a/doc.go
+++ b/doc.go
@@ -174,6 +174,9 @@ The following additional keybindings are provided by default:
     map sn :set sortby natural; set info
     map ss :set sortby size; set info size
     map st :set sortby time; set info time
+    map sa :set sortby atime; set info atime
+    map sc :set sortby ctime; set info ctime
+    map se :set sortby ext; set info
     map gh cd ~
 
 The following keybindings to applications are provided by default:
@@ -635,6 +638,9 @@ You may want to use either file extensions or mime types from 'file' command:
         esac
     }}
 
+You may want to use 'setsid' before your opener command to have persistent
+processes that continue to run after lf quits.
+
 Following command is provided by default:
 
     cmd open &$OPENER $f
diff --git a/docstring.go b/docstring.go
index 2132b35..f9d5111 100644
--- a/docstring.go
+++ b/docstring.go
@@ -128,15 +128,15 @@ The following options can be used to customize the behavior of lf:
     tabstop         integer  (default 8)
     errorfmt        string   (default "\033[7;31;47m%s\033[0m")
     filesep         string   (default "\n")
-    ifs             string   (default ”) (not exported if empty)
-    previewer       string   (default ”) (not filtered if empty)
+    ifs             string   (default '') (not exported if empty)
+    previewer       string   (default '') (not filtered if empty)
     promptfmt       string   (default "\033[32;1m%u@%h\033[0m:\033[34;1m%w/\033[0m\033[1m%f\033[0m")
     shell           string   (default 'sh')
     sortby          string   (default 'natural')
     timefmt         string   (default 'Mon Jan _2 15:04:05 2006')
     ratios          string   (default '1:2:3')
-    info            string   (default ”)
-    shellopts       string   (default ”)
+    info            string   (default '')
+    shellopts       string   (default '')
 
 The following variables are exported for shell commands:
 
@@ -179,6 +179,7 @@ The following additional keybindings are provided by default:
     map st :set sortby time; set info time
     map sa :set sortby atime; set info atime
     map sc :set sortby ctime; set info ctime
+    map se :set sortby ext; set info
     map gh cd ~
 
 The following keybindings to applications are provided by default:
@@ -665,6 +666,9 @@ command:
         esac
     }}
 
+You may want to use 'setsid' before your opener command to have persistent
+processes that continue to run after lf quits.
+
 Following command is provided by default:
 
     cmd open &$OPENER $f
diff --git a/etc/lf.zsh b/etc/lf.zsh
new file mode 100644
index 0000000..937c034
--- /dev/null
+++ b/etc/lf.zsh
@@ -0,0 +1,29 @@
+#compdef lf
+
+# For using these completions you must: 
+# - rename this file to _lf
+# - add the containing folder to $fpath in .zshrc, like this:
+# '''
+# fpath=(/path/to/folder/containing_lf $fpath)
+# autoload -U compinit
+# compinit
+# '''
+#
+# zsh completions for 'lf'
+# automatically generated with http://github.com/RobSis/zsh-completion-generator
+local arguments
+
+arguments=(
+  '-command[command to execute on client initialization]'
+  '-cpuprofile[path to the file to write the CPU profile]'
+  '-doc[show documentation]'
+  '-last-dir-path[path to the file to write the last dir on exit (to use for cd)]'
+  '-memprofile[path to the file to write the memory profile]'
+  '-remote[send remote command to server]'
+  '-selection-path[path to the file to write selected files on open (to use as open file dialog)]'
+  '-server[start server (automatic)]'
+  '-version[show version]'
+  '*:filename:_files'
+)
+
+_arguments -s $arguments
diff --git a/etc/lfrc.example b/etc/lfrc.example
index 41a957b..3904ad8 100644
--- a/etc/lfrc.example
+++ b/etc/lfrc.example
@@ -38,7 +38,7 @@ map O $mimeopen --ask $f
 cmd open ${{
     case $(file --mime-type $f -b) in
         text/*) $EDITOR $fx;;
-        *) for f in $fx; do $OPENER $f > /dev/null 2> /dev/null & done;;
+        *) for f in $fx; do setsid $OPENER $f > /dev/null 2> /dev/null & done;;
     esac
 }}
 
diff --git a/eval.go b/eval.go
index 9f4a5eb..814a00e 100644
--- a/eval.go
+++ b/eval.go
@@ -251,8 +251,10 @@ func (e *setExpr) eval(app *app, args []string) {
 			gOpts.sortType.method = ctimeSort
 		case "atime":
 			gOpts.sortType.method = atimeSort
+		case "ext":
+			gOpts.sortType.method = extSort
 		default:
-			app.ui.echoerr("sortby: value should either be 'natural', 'name', 'size', 'time', 'atime' or 'ctime'")
+			app.ui.echoerr("sortby: value should either be 'natural', 'name', 'size', 'time', 'atime', 'ctime' or 'ext'")
 			return
 		}
 		app.nav.sort()
@@ -468,7 +470,7 @@ func insert(app *app, arg string) {
 		normal(app)
 
 		if arg == "y" {
-			if err := app.nav.del(); err != nil {
+			if err := app.nav.del(app.ui); err != nil {
 				app.ui.echoerrf("delete: %s", err)
 				return
 			}
diff --git a/lf.1 b/lf.1
index 62ddfcb..1f733aa 100644
--- a/lf.1
+++ b/lf.1
@@ -140,15 +140,15 @@ The following options can be used to customize the behavior of lf:
     tabstop         integer  (default 8)
     errorfmt        string   (default "\e033[7;31;47m%s\e033[0m")
     filesep         string   (default "\en")
-    ifs             string   (default ”) (not exported if empty)
-    previewer       string   (default ”) (not filtered if empty)
+    ifs             string   (default '') (not exported if empty)
+    previewer       string   (default '') (not filtered if empty)
     promptfmt       string   (default "\e033[32;1m%u@%h\e033[0m:\e033[34;1m%w/\e033[0m\e033[1m%f\e033[0m")
     shell           string   (default 'sh')
     sortby          string   (default 'natural')
     timefmt         string   (default 'Mon Jan _2 15:04:05 2006')
     ratios          string   (default '1:2:3')
-    info            string   (default ”)
-    shellopts       string   (default ”)
+    info            string   (default '')
+    shellopts       string   (default '')
 .EE
 .PP
 The following variables are exported for shell commands:
@@ -197,6 +197,9 @@ The following additional keybindings are provided by default:
     map sn :set sortby natural; set info
     map ss :set sortby size; set info size
     map st :set sortby time; set info time
+    map sa :set sortby atime; set info atime
+    map sc :set sortby ctime; set info ctime
+    map se :set sortby ext; set info
     map gh cd ~
 .EE
 .PP
@@ -598,6 +601,8 @@ You may want to use either file extensions or mime types from 'file' command:
     }}
 .EE
 .PP
+You may want to use 'setsid' before your opener command to have persistent processes that continue to run after lf quits.
+.PP
 Following command is provided by default:
 .PP
 .EX
diff --git a/misc.go b/misc.go
index f9c2dc2..14e2316 100644
--- a/misc.go
+++ b/misc.go
@@ -41,22 +41,12 @@ func runeSliceWidthRange(rs []rune, beg, end int) []rune {
 	return nil
 }
 
-// This function is used to escape whitespaces with backlashes in a given
-// string. If a whitespace is already escaped then it is not escaped again.
+// This function is used to escape whitespaces and special characters with
+// backlashes in a given string.
 func escape(s string) string {
-	esc := false
 	buf := make([]rune, 0, len(s))
 	for _, r := range s {
-		if r == '\\' {
-			esc = true
-			continue
-		}
-		if esc {
-			esc = false
-			buf = append(buf, '\\', r)
-			continue
-		}
-		if unicode.IsSpace(r) {
+		if unicode.IsSpace(r) || r == '\\' || r == ';' || r == '#' {
 			buf = append(buf, '\\')
 		}
 		buf = append(buf, r)
@@ -65,26 +55,29 @@ func escape(s string) string {
 }
 
 // This function is used to remove backlashes that are used to escape
-// whitespaces in a given string.
+// whitespaces and special characters in a given string.
 func unescape(s string) string {
 	esc := false
 	buf := make([]rune, 0, len(s))
 	for _, r := range s {
-		if r == '\\' {
-			esc = true
-			continue
-		}
 		if esc {
-			esc = false
-			if unicode.IsSpace(r) {
-				buf = append(buf, r)
-			} else {
-				buf = append(buf, '\\', r)
+			if !unicode.IsSpace(r) && r != '\\' && r != ';' && r != '#' {
+				buf = append(buf, '\\')
 			}
+			buf = append(buf, r)
+			esc = false
 			continue
 		}
+		if r == '\\' {
+			esc = true
+			continue
+		}
+		esc = false
 		buf = append(buf, r)
 	}
+	if esc {
+		buf = append(buf, '\\')
+	}
 	return string(buf)
 }
 
diff --git a/nav.go b/nav.go
index 7b369c5..e55c6c6 100644
--- a/nav.go
+++ b/nav.go
@@ -32,6 +32,7 @@ type file struct {
 	dirCount   int
 	accessTime time.Time
 	changeTime time.Time
+	ext        string
 }
 
 func readdir(path string) ([]*file, error) {
@@ -77,6 +78,10 @@ func readdir(path string) ([]*file, error) {
 			ct = lstat.ModTime()
 		}
 
+		// returns an empty string if extension could not be determined
+		// i.e. directories, filenames without extensions
+		ext := filepath.Ext(fpath)
+
 		files = append(files, &file{
 			FileInfo:   lstat,
 			linkState:  linkState,
@@ -84,6 +89,7 @@ func readdir(path string) ([]*file, error) {
 			dirCount:   -1,
 			accessTime: at,
 			changeTime: ct,
+			ext:        ext,
 		})
 	}
 
@@ -149,6 +155,26 @@ func (dir *dir) sort() {
 		sort.SliceStable(dir.files, func(i, j int) bool {
 			return dir.files[i].changeTime.Before(dir.files[j].changeTime)
 		})
+	case extSort:
+		sort.SliceStable(dir.files, func(i, j int) bool {
+			leftExt := strings.ToLower(dir.files[i].ext)
+			rightExt := strings.ToLower(dir.files[j].ext)
+
+			// if the extension could not be determined (directories, files without)
+			// use a zero byte so that these files can be ranked higher
+			if leftExt == "" {
+				leftExt = "\x00"
+			}
+			if rightExt == "" {
+				rightExt = "\x00"
+			}
+
+			// in order to also have natural sorting with the filenames
+			// combine the name with the ext but have the ext at the front
+			left := leftExt + strings.ToLower(dir.files[i].Name())
+			right := rightExt + strings.ToLower(dir.files[j].Name())
+			return left < right
+		})
 	}
 
 	if gOpts.sortType.option&reverseSort != 0 {
@@ -222,6 +248,7 @@ type nav struct {
 	moveCount     int
 	moveTotal     int
 	moveUpdate    int
+	deleting      bool
 	copyBytesChan chan int64
 	copyTotalChan chan int64
 	moveCountChan chan int
@@ -749,16 +776,29 @@ func (nav *nav) paste(ui *ui) error {
 	return nil
 }
 
-func (nav *nav) del() error {
+func (nav *nav) del(ui *ui) error {
 	list, err := nav.currFileOrSelections()
 	if err != nil {
 		return err
 	}
 
-	for _, path := range list {
-		if err := os.RemoveAll(path); err != nil {
-			return err
+	go func() {
+		echo := &callExpr{"echoerr", []string{""}, 1}
+		errCount := 0
+		nav.deleting = true
+
+		for _, path := range list {
+			if err := os.RemoveAll(path); err != nil {
+				errCount++
+				echo.args[0] = fmt.Sprintf("[%d] %s", errCount, err)
+				ui.exprChan <- echo
+			}
 		}
+		nav.deleting = false
+	}()
+
+	if err := remote("send sync"); err != nil {
+		return fmt.Errorf("delete: %s", err)
 	}
 
 	return nil
diff --git a/opts.go b/opts.go
index aef8c06..25cdc1c 100644
--- a/opts.go
+++ b/opts.go
@@ -11,6 +11,7 @@ const (
 	timeSort
 	atimeSort
 	ctimeSort
+	extSort
 )
 
 type sortOption byte
@@ -157,6 +158,7 @@ func init() {
 	gOpts.keys["st"] = &listExpr{[]expr{&setExpr{"sortby", "time"}, &setExpr{"info", "time"}}}
 	gOpts.keys["sa"] = &listExpr{[]expr{&setExpr{"sortby", "atime"}, &setExpr{"info", "atime"}}}
 	gOpts.keys["sc"] = &listExpr{[]expr{&setExpr{"sortby", "ctime"}, &setExpr{"info", "ctime"}}}
+	gOpts.keys["se"] = &listExpr{[]expr{&setExpr{"sortby", "ext"}, &setExpr{"info", ""}}}
 	gOpts.keys["gh"] = &callExpr{"cd", []string{"~"}, 1}
 
 	gOpts.cmdkeys = make(map[string]expr)
diff --git a/scan.go b/scan.go
index 6159876..35743f2 100644
--- a/scan.go
+++ b/scan.go
@@ -216,25 +216,24 @@ scan:
 		for !s.eof && s.chr != '"' {
 			if s.chr == '\\' {
 				s.next()
-				switch s.chr {
-				case '"', '\\':
+				switch {
+				case s.chr == '"' || s.chr == '\\':
 					buf = append(buf, s.chr)
-				case 'a':
+				case s.chr == 'a':
 					buf = append(buf, '\a')
-				case 'b':
+				case s.chr == 'b':
 					buf = append(buf, '\b')
-				case 'f':
+				case s.chr == 'f':
 					buf = append(buf, '\f')
-				case 'n':
+				case s.chr == 'n':
 					buf = append(buf, '\n')
-				case 'r':
+				case s.chr == 'r':
 					buf = append(buf, '\r')
-				case 't':
+				case s.chr == 't':
 					buf = append(buf, '\t')
-				case 'v':
+				case s.chr == 'v':
 					buf = append(buf, '\v')
-				}
-				if isDigit(s.chr) {
+				case isDigit(s.chr):
 					var oct []byte
 					for isDigit(s.chr) {
 						oct = append(oct, s.chr)
@@ -245,13 +244,15 @@ scan:
 						log.Printf("scanning: %s", err)
 					}
 					buf = append(buf, byte(n))
-				} else {
-					s.next()
+					continue
+				default:
+					buf = append(buf, '\\', s.chr)
 				}
-			} else {
-				buf = append(buf, s.chr)
 				s.next()
+				continue
 			}
+			buf = append(buf, s.chr)
+			s.next()
 		}
 		s.typ = tokenIdent
 		s.tok = string(buf)
@@ -293,12 +294,9 @@ scan:
 		for !s.eof && !isSpace(s.chr) && s.chr != ';' && s.chr != '#' {
 			if s.chr == '\\' {
 				s.next()
-				if isSpace(s.chr) || s.chr == '\\' {
-					buf = append(buf, s.chr)
-					s.next()
-				} else {
-					s.next()
-				}
+				buf = append(buf, s.chr)
+				s.next()
+				continue
 			}
 			buf = append(buf, s.chr)
 			s.next()
diff --git a/ui.go b/ui.go
index cf9ab41..87cabcc 100644
--- a/ui.go
+++ b/ui.go
@@ -557,9 +557,11 @@ func (ui *ui) loadFileInfo(nav *nav) {
 func (ui *ui) drawPromptLine(nav *nav) {
 	fg, bg := termbox.ColorDefault, termbox.ColorDefault
 
-	dir := nav.currDir()
-
-	pwd := strings.Replace(dir.path, gUser.HomeDir, "~", -1)
+	pwd := nav.currDir().path
+	nohome := strings.TrimPrefix(pwd, gUser.HomeDir)
+	if len(nohome) < len(pwd) {
+		pwd = "~/" + nohome
+	}
 	pwd = filepath.Clean(pwd)
 
 	var fname string


Test patch:
diff --git a/eval_test.go b/eval_test.go
index 24947c6..788b4df 100644
--- a/eval_test.go
+++ b/eval_test.go
@@ -67,8 +67,8 @@ var gEvalTests = []struct {
 
 	{
 		`echo "hello\zworld"`,
-		[]string{"echo", "helloworld", "\n"},
-		[]expr{&callExpr{"echo", []string{"helloworld"}, 1}},
+		[]string{"echo", `hello\zworld`, "\n"},
+		[]expr{&callExpr{"echo", []string{`hello\zworld`}, 1}},
 	},
 
 	{
@@ -90,7 +90,7 @@ var gEvalTests = []struct {
 	},
 
 	{
-		`echo hello\	world`,
+		"echo hello\\\tworld",
 		[]string{"echo", "hello\tworld", "\n"},
 		[]expr{&callExpr{"echo", []string{"hello\tworld"}, 1}},
 	},
@@ -109,8 +109,8 @@ var gEvalTests = []struct {
 
 	{
 		`echo hello\zworld`,
-		[]string{"echo", "helloworld", "\n"},
-		[]expr{&callExpr{"echo", []string{"helloworld"}, 1}},
+		[]string{"echo", "hellozworld", "\n"},
+		[]expr{&callExpr{"echo", []string{"hellozworld"}, 1}},
 	},
 
 	{
@@ -131,6 +131,18 @@ var gEvalTests = []struct {
 		[]expr{&setExpr{"hidden", ""}, &setExpr{"preview", ""}},
 	},
 
+	{
+		`set ifs ""`,
+		[]string{"set", "ifs", "", "\n"},
+		[]expr{&setExpr{"ifs", ""}},
+	},
+
+	{
+		`set ifs "\n"`,
+		[]string{"set", "ifs", "\n", "\n"},
+		[]expr{&setExpr{"ifs", "\n"}},
+	},
+
 	{
 		"set ratios 1:2:3",
 		[]string{"set", "ratios", "1:2:3", "\n"},
diff --git a/misc_test.go b/misc_test.go
index 72881b8..920f99a 100644
--- a/misc_test.go
+++ b/misc_test.go
@@ -80,8 +80,13 @@ func TestEscape(t *testing.T) {
 		{"foo", "foo"},
 		{"foo bar", `foo\ bar`},
 		{"foo  bar", `foo\ \ bar`},
-		{`foo\ bar`, `foo\ bar`},
-		{`foo\bar`, `foo\bar`},
+		{`foo\bar`, `foo\\bar`},
+		{`foo\ bar`, `foo\\\ bar`},
+		{`foo;bar`, `foo\;bar`},
+		{`foo#bar`, `foo\#bar`},
+		{`foo\tbar`, `foo\\tbar`},
+		{"foo\tbar", "foo\\\tbar"},
+		{`foo\`, `foo\\`},
 	}
 
 	for _, test := range tests {
@@ -98,9 +103,15 @@ func TestUnescape(t *testing.T) {
 	}{
 		{"", ""},
 		{"foo", "foo"},
-		{`foo\bar`, `foo\bar`},
 		{`foo\ bar`, "foo bar"},
 		{`foo\ \ bar`, "foo  bar"},
+		{`foo\\bar`, `foo\bar`},
+		{`foo\\\ bar`, `foo\ bar`},
+		{`foo\;bar`, `foo;bar`},
+		{`foo\#bar`, `foo#bar`},
+		{`foo\\tbar`, `foo\tbar`},
+		{"foo\\\tbar", "foo\tbar"},
+		{`foo\`, `foo\`},
 	}
 
 	for _, test := range tests {