From 4b266c97e94c4fa04b6be18bbace0ef7e941bf72 Mon Sep 17 00:00:00 2001 From: Gokcehan Date: Sat, 13 Aug 2016 15:49:04 +0300 Subject: [PATCH] initial commit --- .gitignore | 2 + LICENSE | 21 ++ Makefile | 12 ++ README.md | 69 +++++++ app.go | 123 ++++++++++++ client.go | 112 +++++++++++ doc/reference.md | 39 ++++ doc/tutorial.md | 90 +++++++++ etc/lf.sh | 21 ++ etc/lf.vim | 31 +++ etc/lfrc.example | 88 +++++++++ eval.go | 284 +++++++++++++++++++++++++++ main.go | 99 ++++++++++ misc.go | 111 +++++++++++ nav.go | 450 +++++++++++++++++++++++++++++++++++++++++++ opts.go | 52 +++++ parse.go | 243 +++++++++++++++++++++++ scan.go | 244 ++++++++++++++++++++++++ scan_test.go | 125 ++++++++++++ server.go | 94 +++++++++ ui.go | 487 +++++++++++++++++++++++++++++++++++++++++++++++ 21 files changed, 2797 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 app.go create mode 100644 client.go create mode 100644 doc/reference.md create mode 100644 doc/tutorial.md create mode 100644 etc/lf.sh create mode 100644 etc/lf.vim create mode 100644 etc/lfrc.example create mode 100644 eval.go create mode 100644 main.go create mode 100644 misc.go create mode 100644 nav.go create mode 100644 opts.go create mode 100644 parse.go create mode 100644 scan.go create mode 100644 scan_test.go create mode 100644 server.go create mode 100644 ui.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a12b2dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +lf +tags diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6be15b7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Gökçehan Kara + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f33aae4 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +all: build + +build: + CGO_ENABLED=0 go build -ldflags '-s' + +install: + mv lf $(GOPATH)/bin + +test: + go test + +.PHONY: all test diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d56703 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# LF + +> Please note that this is an experimental file manager. +> One of the most dangerous pieces of software you can play with. +> You may accidentally lose your files or worse so use at your own risk. + +> Likewise it is a work in progress. +> You will most likely come across some shameful bugs. +> Also some essentials may not have been implemented yet. + +`lf` (as in "list files") is a terminal file manager written in Go. +It is heavily inspired by ranger with some missing and extra features. +Some of the missing features are deliberately ommited +since it is better if they are handled by external tools. + +![multicol-screenshot](http://i.imgur.com/DaTUenu.png) +![singlecol-screenshot](http://i.imgur.com/p95xzUj.png) + +## Features + +- no external runtime dependencies (except for terminfo database) +- fast startup and low memory footprint (due to native code and static binaries) +- server/client architecture to share selection between multiple instances +- custom commands as shell scripts (hence any other language as well) +- sync (waiting and skipping) and async commands +- fully customizable keybindings + +## Non-Features + +- tabs or windows (handled by the window manager or the terminal multiplexer) +- built-in pager (handled by your pager of choice) + +## May-Futures + +- enchanced previews (image, pdf etc.) +- bookmarks +- colorschemes +- periodic refresh +- progress bar for file yank/delete paste + +## Installation + +You can build from the source using: + + go get -u github.com/gokcehan/lf + +Currently there are no prebuilt binaries provided. + +## Usage + +After the installation `lf` command should start the application in the current directory. + +See [tutorial](doc/tutorial.md) for an introduction to the configuration. + +See [reference](doc/reference.md) for the list of keys, options and variables with their default values. + +See [etc](etc) directory to integrate `lf` to your shell or editor. + +## File Opener + +lf does not come bundled with a file opener. +By default it tries to call `xdg-open` from `xdg-utils` package. +You can change the file opener using the opener option (e.g. `:set opener mimeopen`). +Below are a few alternatives you can use: + +- [libfile-mimeinfo-perl](https://metacpan.org/release/File-MimeInfo) (executable name is `mimeopen`) +- [rifle](http://ranger.nongnu.org/) (ranger's default file opener) +- [mimeo](http://xyne.archlinux.ca/projects/mimeo/) +- custom (using file extensions and/or mimetypes with `file` command) diff --git a/app.go b/app.go new file mode 100644 index 0000000..8ec92da --- /dev/null +++ b/app.go @@ -0,0 +1,123 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + "strings" +) + +type App struct { + ui *UI + nav *Nav +} + +func waitKey() error { + // TODO: this should be done with termbox somehow + + cmd := exec.Command(envShell, "-c", "echo; echo -n 'Press any key to continue'; stty -echo; read -n 1; stty echo; echo") + + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err := cmd.Run() + if err != nil { + return fmt.Errorf("waiting key: %s", err) + } + + return nil +} + +func (app *App) handleInp() { + for { + if gExitFlag { + log.Print("bye!") + return + } + e := app.ui.getExpr() + if e == nil { + continue + } + e.eval(app, nil) + app.ui.draw(app.nav) + } +} + +func (app *App) exportVars() { + dir := app.nav.currDir() + + var envFile string + if len(dir.fi) != 0 { + envFile = app.nav.currPath() + } + + marks := app.nav.currMarks() + + envFiles := strings.Join(marks, ":") + + os.Setenv("f", envFile) + os.Setenv("fs", envFiles) + + if len(marks) == 0 { + os.Setenv("fx", envFile) + } else { + os.Setenv("fx", envFiles) + } +} + +// This function is used to run a command in shell. Following modes are used: +// +// Prefix Wait Async Stdin/Stdout/Stderr UI action (before/after) +// $ No No Yes Do nothing and then sync +// ! Yes No Yes pause and then resume +// & No Yes No Do nothing +// +// Waiting async commands are not used for now. +func (app *App) runShell(s string, args []string, wait bool, async bool) { + app.exportVars() + + if len(gOpts.ifs) != 0 { + s = fmt.Sprintf("IFS='%s'; %s", gOpts.ifs, s) + } + + args = append([]string{"-c", s, "--"}, args...) + cmd := exec.Command(envShell, args...) + + if !async { + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } + + if wait { + app.ui.pause() + defer app.ui.resume() + } else { + defer app.ui.sync() + } + + defer app.nav.renew(app.ui.wins[0].h) + + var err error + if async { + err = cmd.Start() + } else { + err = cmd.Run() + } + + if err != nil { + msg := fmt.Sprintf("running shell: %s", err) + app.ui.message = msg + log.Print(msg) + } + + if wait { + if err := waitKey(); err != nil { + msg := fmt.Sprintf("waiting shell: %s", err) + app.ui.message = msg + log.Print(msg) + } + } +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..514695e --- /dev/null +++ b/client.go @@ -0,0 +1,112 @@ +package main + +import ( + "bufio" + "fmt" + "log" + "net" + "os" + + "github.com/nsf/termbox-go" +) + +func client() { + logFile, err := os.Create(gLogPath) + if err != nil { + panic(err) + } + defer logFile.Close() + log.SetOutput(logFile) + + log.Print("hi!") + + if err := termbox.Init(); err != nil { + log.Fatalf("initializing termbox: %s", err) + } + defer termbox.Close() + + ui := newUI() + nav := newNav(ui.wins[0].h) + app := &App{ui, nav} + + rcFile, err := os.Open(gConfigPath) + if err != nil { + msg := fmt.Sprintf("opening configuration file: %s", err) + app.ui.message = msg + log.Printf(msg) + } else { + app.ui.echoFileInfo(app.nav) + } + defer rcFile.Close() + + p := newParser(rcFile) + for p.parse() { + p.expr.eval(app, nil) + } + + // TODO: parser error check + + app.ui.draw(app.nav) + + app.handleInp() +} + +func saveFiles(list []string, keep bool) error { + c, err := net.Dial("unix", gSocketPath) + if err != nil { + return fmt.Errorf("dialing to save files: %s", err) + } + defer c.Close() + + log.Printf("saving files: %v", list) + + fmt.Fprintln(c, "save") + + if keep { + fmt.Fprintln(c, "keep") + } else { + fmt.Fprintln(c, "move") + } + + for _, f := range list { + fmt.Fprintln(c, f) + } + + return nil +} + +func loadFiles() (list []string, keep bool, err error) { + c, e := net.Dial("unix", gSocketPath) + if e != nil { + err = fmt.Errorf("dialing to load files: %s", e) + return + } + defer c.Close() + + fmt.Fprintln(c, "load") + + s := bufio.NewScanner(c) + + switch s.Scan(); s.Text() { + case "keep": + keep = true + case "move": + keep = false + default: + err = fmt.Errorf("unexpected option to keep file(s): %s", s.Text()) + return + } + + for s.Scan() { + list = append(list, s.Text()) + } + + if s.Err() != nil { + err = fmt.Errorf("scanning file list: %s", s.Err()) + return + } + + log.Printf("loading files: %v", list) + + return +} diff --git a/doc/reference.md b/doc/reference.md new file mode 100644 index 0000000..1f53dbb --- /dev/null +++ b/doc/reference.md @@ -0,0 +1,39 @@ +# Reference + +## Keys + + down (default "j") + up (default "k") + updir (default "h") + open (default "l") + quit (default "q") + bot (default "G") + top (default "gg") + read (default ":") + read-shell (default "$") + read-shell-wait (default "!") + read-shell-async (default "&") + search (default "/") + search-back (default "?") + toggle (default "") + yank (default "y") + delete (default "d") + paste (default "p") + redraw (default "") + +## Options + + preview bool (default on) + hidden bool (default off) + tabstop int (default 8) + scrolloff int (default 0) + sortby string (default name) + showinfo string (default none) + opener string (default xdg-open) + ratios string (default 1:2:3) + +## Variables + + $f current file + $fs marked file(s) (seperated with ':') + $fx current file or marked file(s) if any diff --git a/doc/tutorial.md b/doc/tutorial.md new file mode 100644 index 0000000..c6da8fd --- /dev/null +++ b/doc/tutorial.md @@ -0,0 +1,90 @@ +# Tutorial + +## Configuration + +The configuration file should be located in `~/.config/lf/lfrc`. +A sample configuration file can be found [here](etc/lfrc.example). + +## Prefixes + +The following command prefixes are used by `lf`: + + : read (default) + $ read-shell + ! read-shell-wait + & read-shell-async + / search + ? search-back + +The same evaluator is used for the command line and the configuration file. +The difference is that prefixes are not necessary in the command line. +Instead different modes are provided to read corresponding commands. +Note that by default these modes are mapped to the prefix keys above. + +## Syntax + +Characters from `#` to `\n` are comments and ignored. + +There are three special commands for configuration. + +`set` is used to set an option which could be: + +- bool (e.g. `set hidden`, `set nohidden`, `set hidden!`) +- int (e.g. `set scrolloff 10`) +- string (e.g. `set sortby time`) + +`map` is used to bind a key to a command which could be: + +- built-in command (e.g. `map gh cd ~`) +- custom command (e.g. `map dD trash`) +- shell command (e.g. `map i $less "$f"`, `map u !du -h . | less`) + +`cmd` is used to define a custom command. + +If there is no prefix then `:` is assumed. +An explicit `:` could be provided to group statements until a `\n` occurs. +This is especially useful for `map` and `cmd` commands. +If you need multiline you can wrap statements in `{{` and `}}` after the proper prefix. + +## Custom Commands + +To wrap up let us write a shell command to move selected file(s) to trash. + +A first attempt to write such a command may look like this: + + cmd trash ${{ + mkdir -p ~/.trash + if [ -z $fs ]; then + mv --backup=numbered "$f" $HOME/.trash + else + IFS=':'; mv --backup=numbered $fs $HOME/.trash + fi + }} + +We check `$fs` to see if there are any marked files. +Otherwise we just delete the current file. +Since this is such a common pattern, a separate `$fx` variable is provided. +We can use this variable to get rid of the conditional. + + cmd trash ${{ + mkdir -p ~/.trash + IFS=':'; mv --backup=numbered $fx $HOME/.trash + }} + +The trash directory is checked each time the command is executed. +We can move it outside of the command so it would only run once at startup. + + ${{ mkdir -p ~/.trash }} + + cmd trash ${{ IFS=':'; mv --backup=numbered $fx $HOME/.trash }} + +Since these are one liners, we can drop `{{` and `}}. + + $mkdir -p ~/.trash + + cmd trash $IFS=':'; mv --backup=numbered $fx $HOME/.trash + +Finally note that we set `IFS` variable accordingly in the command. +Instead we could use the `ifs` option to set it for all commands (e.g. `set ifs :`). +This could be especially useful for interactive use (e.g. `rm $fs` would simply work). +This option is not set by default as things may behave unexpectedly at other places. diff --git a/etc/lf.sh b/etc/lf.sh new file mode 100644 index 0000000..c23a1ca --- /dev/null +++ b/etc/lf.sh @@ -0,0 +1,21 @@ +# change the directory to last dir on exit +# adapted from the similar script for ranger +# +# you need to add something like the following to your shell rc file (e.g. ~/.bashrc): +# +# LFSH="$GOPATH/src/github.com/gokcehan/lf/etc/lf.sh" +# if [ -f "$LFSH" ]; then +# source "$LFSH" +# bind '"\C-o":"\C-ulf\C-m"' +# fi +# + +lf () { + tmp="$(mktemp)" + command lf -last-dir-path="$tmp" "$@" + if [ -f "$tmp" ]; then + dir="$(cat "$tmp")" + [ "$dir" != "$(pwd)" ] && cd "$dir" + fi + rm -f "$tmp" +} diff --git a/etc/lf.vim b/etc/lf.vim new file mode 100644 index 0000000..2bc7998 --- /dev/null +++ b/etc/lf.vim @@ -0,0 +1,31 @@ +" use lf to select and open a file in vim +" adapted from the similar script for ranger +" +" you need to add something like the following to your ~/.vimrc: +" +" let lfvim = $GOPATH . "/src/github.com/gokcehan/lf/etc/lf.vim" +" if filereadable(lfvim) +" exec "source " . lfvim +" nnoremap l :StartLF +" endif +" + +function! StartLF() + let temp = tempname() + exec 'silent !lf -selection-path=' . shellescape(temp) + if !filereadable(temp) + redraw! + return + endif + let names = readfile(temp) + if empty(names) + redraw! + return + endif + exec 'edit ' . fnameescape(names[0]) + for name in names[1:] + exec 'argadd ' . fnameescape(name) + endfor + redraw! +endfunction +command! -bar StartLF call StartLF() diff --git a/etc/lfrc.example b/etc/lfrc.example new file mode 100644 index 0000000..47eab7a --- /dev/null +++ b/etc/lfrc.example @@ -0,0 +1,88 @@ +# mc alike mode (single column) +# you may also consider setting an alias for automatically splitting windows in tmux +# (e.g. "alias mc='tmux split -h lf; lf'") +#set ratios 1 +#set nopreview +#set showinfo size + +# leave some space at the top and the bottom of the screen +set scrolloff 10 + +# mappings for pager and editor (change as you like) +map i $less "$f" +map e $vim "$f" + +# use enter for shell commands +map read-shell + +# execute current file (must be executable) +map x $"$f" +map X !"$f" + +# you can either set your opener to 'mimeopen' +#set opener mimeopen +# or set dedicated keys for specific actions +map o &mimeopen "$f" +map m !mimeopen --ask "$f" + +# rename current file without overwrite +cmd rename $[ -e "$1" ] || mv "$f" "$1" + +# show disk usage +cmd usage $du -h . | less + +# make sure trash folder exists +$mkdir -p $HOME/.trash + +# move current file or selected files to trash folder +# see 'man mv' or 'mv --help' for backup options +cmd trash $IFS=':'; mv --backup=numbered $fx $HOME/.trash + +# here be dragons +#map dD trash + +# common directories +map gh cd ~ +map gr cd / + +# easily toggle options +map zp set preview! +map zh set hidden! + +# easily select what information to show +map zn set showinfo none +map zs set showinfo size +map zt set showinfo time + +# sort files and show the corresponding info +map sn :set sortby name; set showinfo none; +map ss :set sortby size; set showinfo size; +map st :set sortby time; set showinfo time; + +# sets internal field seperator (IFS) to ":" +# useful for interactive use to automatically split file names in $fs and $fx +# things may behave unexpectedly so use with caution +#set ifs : + +# extract the current file with the right command +# xkcd link: https://xkcd.com/1168/ +cmd extract ${{ + case "$f" in + *.tar.bz|*.tar.bz2|*.tbz|*.tbz2) tar xjvf "$f";; + *.tar.gz|*.tgz) tar xzvf "$f";; + *.tar.xz|*.txz) tar xJvf "$f";; + *.zip) unzip "$f";; + *.rar) unrar x "$f";; + *.7z) 7z x "$f";; + esac +}} + +# compress selected files with tar and gunzip +# takes the name without '.tar.gz' suffix as an argument +# (e.g. ":compress foo" creates "foo.tar.gz") +cmd compress ${{ + mkdir "$1" + IFS=':'; cp $fs "$1" + tar czvf "$1.tar.gz" "$1" + rm -rf "$1" +}} diff --git a/eval.go b/eval.go new file mode 100644 index 0000000..d049522 --- /dev/null +++ b/eval.go @@ -0,0 +1,284 @@ +package main + +import ( + "fmt" + "log" + "strconv" + "strings" +) + +func (e *SetExpr) eval(app *App, args []string) { + switch e.opt { + case "hidden": + gOpts.hidden = true + app.nav.renew(app.nav.height) + case "nohidden": + gOpts.hidden = false + app.nav.renew(app.nav.height) + case "hidden!": + gOpts.hidden = !gOpts.hidden + app.nav.renew(app.nav.height) + case "preview": + gOpts.preview = true + case "nopreview": + gOpts.preview = false + case "preview!": + gOpts.preview = !gOpts.preview + case "scrolloff": + n, err := strconv.Atoi(e.val) + if err != nil { + app.ui.message = err.Error() + log.Print(err) + return + } + if n < 0 { + msg := "scrolloff should be a non-negative number" + app.ui.message = msg + log.Print(msg) + return + } + max := app.ui.wins[0].h/2 - 1 + if n > max { + // TODO: stay at the same row while up/down in the middle + n = max + } + gOpts.scrolloff = n + case "tabstop": + n, err := strconv.Atoi(e.val) + if err != nil { + app.ui.message = err.Error() + log.Print(err) + return + } + if n <= 0 { + msg := "tabstop should be a positive number" + app.ui.message = msg + log.Print(msg) + return + } + gOpts.tabstop = n + case "ifs": + gOpts.ifs = e.val + case "showinfo": + if e.val != "none" && e.val != "size" && e.val != "time" { + msg := "showinfo should either be 'none', 'size' or 'time'" + app.ui.message = msg + log.Print(msg) + return + } + gOpts.showinfo = e.val + case "sortby": + if e.val != "name" && e.val != "size" && e.val != "time" { + msg := "sortby should either be 'name', 'size' or 'time'" + app.ui.message = msg + log.Print(msg) + return + } + gOpts.sortby = e.val + app.nav.renew(app.nav.height) + case "opener": + gOpts.opener = e.val + case "ratios": + toks := strings.Split(e.val, ":") + var rats []int + for _, s := range toks { + i, err := strconv.Atoi(s) + if err != nil { + log.Print(err) + return + } + rats = append(rats, i) + } + gOpts.ratios = rats + app.ui = newUI() + default: + msg := fmt.Sprintf("unknown option: %s", e.opt) + app.ui.message = msg + log.Print(msg) + } +} + +func (e *MapExpr) eval(app *App, args []string) { + gOpts.keys[e.keys] = e.expr +} + +func (e *CmdExpr) eval(app *App, args []string) { + gOpts.cmds[e.name] = e.expr +} + +func (e *CallExpr) eval(app *App, args []string) { + // TODO: check for extra toks in each case + switch e.name { + case "quit": + gExitFlag = true + case "echo": + app.ui.message = strings.Join(e.args, " ") + case "down": + app.nav.down() + app.ui.echoFileInfo(app.nav) + case "up": + app.nav.up() + app.ui.echoFileInfo(app.nav) + case "updir": + err := app.nav.updir() + if err != nil { + app.ui.message = err.Error() + log.Print(err) + return + } + app.ui.echoFileInfo(app.nav) + case "open": + dir := app.nav.currDir() + + if len(dir.fi) == 0 { + return + } + + curr := app.nav.currFile() + path := app.nav.currPath() + + if !curr.IsDir() && gSelectionPath == "" { + if len(app.nav.marks) == 0 { + app.runShell(fmt.Sprintf("%s '%s'", gOpts.opener, path), nil, false, false) + } else { + s := gOpts.opener + for m := range app.nav.marks { + s += fmt.Sprintf(" '%s'", m) + } + app.runShell(s, nil, false, false) + } + } else { + err := app.nav.open() + if err != nil { + app.ui.message = err.Error() + log.Print(err) + return + } + app.ui.echoFileInfo(app.nav) + } + case "bot": + app.nav.bot() + app.ui.echoFileInfo(app.nav) + case "top": + app.nav.top() + app.ui.echoFileInfo(app.nav) + case "cd": + err := app.nav.cd(e.args[0]) + if err != nil { + app.ui.message = err.Error() + log.Print(err) + return + } + app.ui.echoFileInfo(app.nav) + case "read": + s := app.ui.prompt(":") + if len(s) == 0 { + app.ui.echoFileInfo(app.nav) + return + } + log.Printf("command: %s", s) + p := newParser(strings.NewReader(s)) + for p.parse() { + p.expr.eval(app, nil) + } + if p.err != nil { + app.ui.message = p.err.Error() + log.Print(p.err) + } + case "read-shell": + s := app.ui.prompt("$") + log.Printf("shell: %s", s) + app.runShell(s, nil, false, false) + case "read-shell-wait": + s := app.ui.prompt("!") + log.Printf("shell-wait: %s", s) + app.runShell(s, nil, true, false) + case "read-shell-async": + s := app.ui.prompt("&") + log.Printf("shell-async: %s", s) + app.runShell(s, nil, false, true) + case "search": + s := app.ui.prompt("/") + log.Printf("search: %s", s) + app.ui.message = "sorry, search is not implemented yet!" + // TODO: implement + case "search-back": + s := app.ui.prompt("?") + log.Printf("search-back: %s", s) + app.ui.message = "sorry, search-back is not implemented yet!" + // TODO: implement + case "toggle": + app.nav.toggle() + case "yank": + err := app.nav.save(true) + if err != nil { + msg := fmt.Sprintf("yank: %s", err) + app.ui.message = msg + log.Printf(msg) + return + } + app.nav.marks = make(map[string]bool) + case "delete": + err := app.nav.save(false) + if err != nil { + msg := fmt.Sprintf("delete: %s", err) + app.ui.message = msg + log.Printf(msg) + return + } + app.nav.marks = make(map[string]bool) + case "paste": + err := app.nav.paste() + if err != nil { + msg := fmt.Sprintf("paste: %s", err) + app.ui.message = msg + log.Printf(msg) + return + } + app.nav.renew(app.nav.height) + app.nav.save(false) + saveFiles(nil, false) + case "redraw": + app.ui.renew() + app.nav.renew(app.ui.wins[0].h) + default: + cmd, ok := gOpts.cmds[e.name] + if !ok { + msg := fmt.Sprintf("command not found: %s", e.name) + app.ui.message = msg + log.Print(msg) + return + } + cmd.eval(app, e.args) + } +} + +func (e *ExecExpr) eval(app *App, args []string) { + switch e.pref { + case "$": + log.Printf("shell: %s -- %s", e, args) + app.ui.clearMsg() + app.runShell(e.expr, args, false, false) + app.ui.echoFileInfo(app.nav) + case "!": + log.Printf("shell-wait: %s -- %s", e, args) + app.runShell(e.expr, args, true, false) + case "&": + log.Printf("shell-async: %s -- %s", e, args) + app.runShell(e.expr, args, false, true) + case "/": + log.Printf("search: %s -- %s", e, args) + // TODO: implement + case "?": + log.Printf("search-back: %s -- %s", e, args) + // TODO: implement + default: + log.Printf("unknown execution prefix: %q", e.pref) + } +} + +func (e *ListExpr) eval(app *App, args []string) { + for _, expr := range e.exprs { + expr.eval(app, nil) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..730538c --- /dev/null +++ b/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/exec" + "path" +) + +var ( + envUser = os.Getenv("USER") + envHome = os.Getenv("HOME") + envHost = os.Getenv("HOSTNAME") + envShell = os.Getenv("SHELL") +) + +var ( + gExitFlag bool + gSelectionPath string + gSocketPath string + gLogPath string + gServerLogPath string + gConfigPath string +) + +func init() { + if envUser == "" { + log.Fatal("$USER not set") + } + if envHome == "" { + envHome = "/home/" + envUser + } + if envHost == "" { + host, err := os.Hostname() + if err != nil { + log.Fatal("$HOSTNAME not set") + } + envHost = host + } + + tmp := os.TempDir() + + gSocketPath = path.Join(tmp, fmt.Sprintf("lf.%s.sock", envUser)) + + // TODO: unique log file for each client + gLogPath = path.Join(tmp, fmt.Sprintf("lf.%s.log", envUser)) + gServerLogPath = path.Join(tmp, fmt.Sprintf("lf.%s.server.log", envUser)) + + // TODO: xdg-config-home etc. + gConfigPath = path.Join(envHome, ".config", "lf", "lfrc") +} + +func startServer() { + cmd := exec.Command(os.Args[0], "-server") + err := cmd.Start() + if err != nil { + log.Print(err) + } +} + +func main() { + serverMode := flag.Bool("server", false, "start server (automatic)") + lastDirPath := flag.String("last-dir-path", "", "path to the file to write the last dir on exit (to use for cd)") + flag.StringVar(&gSelectionPath, "selection-path", "", "path to the file to write selected files on exit (to use as open file dialog)") + + flag.Parse() + + if *serverMode { + serve() + } else { + // TODO: check if the socket is working + _, err := os.Stat(gSocketPath) + if err != nil { + startServer() + } + + client() + } + + if *lastDirPath != "" { + f, err := os.Create(*lastDirPath) + if err != nil { + log.Print(err) + } + defer f.Close() + + wd, err := os.Getwd() + if err != nil { + log.Print(err) + } + + _, err = f.WriteString(wd) + if err != nil { + log.Print(err) + } + } +} diff --git a/misc.go b/misc.go new file mode 100644 index 0000000..85909df --- /dev/null +++ b/misc.go @@ -0,0 +1,111 @@ +package main + +import ( + "fmt" + "log" + "path" + "strconv" + "unicode" + "unicode/utf8" +) + +func isRoot(name string) bool { + return path.Dir(name) == name +} + +func humanize(size int64) string { + if size < 1000 { + return fmt.Sprintf("%d", size) + } + + suffix := []string{ + "K", // kilo + "M", // mega + "G", // giga + "T", // tera + "P", // peta + "E", // exa + "Z", // zeta + "Y", // yotta + } + + curr := float64(size) / 1000 + for _, s := range suffix { + if curr < 10 { + return fmt.Sprintf("%.1f%s", curr, s) + } else if curr < 1000 { + return fmt.Sprintf("%d%s", int(curr), s) + } + curr /= 1000 + } + + return "" +} + +// This function extracts numbers from a string and returns with the rest. +// It is used for numeric sorting of files when the file name consists of +// both digits and letters. +// +// For instance if your input is 'foo123bar456' you get a slice of number +// consisting of elements '123' and '456' and rest of the string as a slice +// consisting of elements 'foo' and 'bar'. The last return argument denotes +// whether or not the first partition is a number. +func extractNums(s string) (nums []int, rest []string, numFirst bool) { + var buf []rune + + r, _ := utf8.DecodeRuneInString(s) + digit := unicode.IsDigit(r) + numFirst = digit + + for i, c := range s { + if unicode.IsDigit(c) == digit { + buf = append(buf, c) + if i != len(s)-1 { + continue + } + } + + if digit { + i, err := strconv.Atoi(string(buf)) + if err != nil { + // TODO: handle error + log.Printf("extracting numbers: %s", err) + } + nums = append(nums, i) + } else { + rest = append(rest, string(buf)) + } + + buf = nil + buf = append(buf, c) + digit = !digit + } + + return +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +// +// We don't need no generic code +// We don't need no thought control +// No dark templates in compiler +// Haskell leave them kids alone +// Hey Bjarne leave them kids alone +// All in all it's just another brick in the code +// All in all you're just another brick in the code +// +// -- Pink Trolled -- +// diff --git a/nav.go b/nav.go new file mode 100644 index 0000000..d875063 --- /dev/null +++ b/nav.go @@ -0,0 +1,450 @@ +package main + +import ( + "errors" + "fmt" + "io/ioutil" + "log" + "os" + "os/exec" + "path" + "sort" + "strings" +) + +type Dir struct { + ind int // which entry is highlighted + pos int // which line in the ui highlighted entry is + path string + fi []os.FileInfo +} + +type ByName []os.FileInfo + +func (a ByName) Len() int { return len(a) } +func (a ByName) Swap(i, j int) { a[i], a[j] = a[j], a[i] } + +func (a ByName) Less(i, j int) bool { + return strings.ToLower(a[i].Name()) < strings.ToLower(a[j].Name()) +} + +type BySize []os.FileInfo + +func (a BySize) Len() int { return len(a) } +func (a BySize) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a BySize) Less(i, j int) bool { return a[i].Size() < a[j].Size() } + +type ByTime []os.FileInfo + +func (a ByTime) Len() int { return len(a) } +func (a ByTime) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a ByTime) Less(i, j int) bool { return a[i].ModTime().Before(a[j].ModTime()) } + +type ByDir []os.FileInfo + +func (a ByDir) Len() int { return len(a) } +func (a ByDir) Swap(i, j int) { a[i], a[j] = a[j], a[i] } + +func (a ByDir) Less(i, j int) bool { + if a[i].IsDir() == a[j].IsDir() { + return i < j + } + return a[i].IsDir() +} + +type ByNum []os.FileInfo + +func (a ByNum) Len() int { return len(a) } +func (a ByNum) Swap(i, j int) { a[i], a[j] = a[j], a[i] } + +func (a ByNum) Less(i, j int) bool { + nums1, rest1, numFirst1 := extractNums(a[i].Name()) + nums2, rest2, numFirst2 := extractNums(a[j].Name()) + + if numFirst1 != numFirst2 { + return i < j + } + + if numFirst1 { + if nums1[0] != nums2[0] { + return nums1[0] < nums2[0] + } + nums1 = nums1[1:] + nums2 = nums2[1:] + } + + for k := 0; k < len(nums1) && k < len(nums2); k++ { + if rest1[k] != rest2[k] { + return i < j + } + if nums1[k] != nums2[k] { + return nums1[k] < nums2[k] + } + } + + return i < j +} + +func organizeFiles(fi []os.FileInfo) []os.FileInfo { + if !gOpts.hidden { + var tmp []os.FileInfo + for _, f := range fi { + if f.Name()[0] != '.' { + tmp = append(tmp, f) + } + } + fi = tmp + } + + switch gOpts.sortby { + case "name": + sort.Sort(ByName(fi)) + case "size": + sort.Sort(BySize(fi)) + case "time": + sort.Sort(ByTime(fi)) + default: + log.Printf("unknown sorting type: %s", gOpts.sortby) + } + + // TODO: these should be optional + sort.Stable(ByNum(fi)) + sort.Stable(ByDir(fi)) + + return fi +} + +func newDir(path string) *Dir { + fi, err := ioutil.ReadDir(path) + if err != nil { + log.Print(err) + } + + fi = organizeFiles(fi) + + return &Dir{ + path: path, + fi: fi, + } +} + +func (dir *Dir) renew(height int) { + fi, err := ioutil.ReadDir(dir.path) + if err != nil { + log.Print(err) + } + + fi = organizeFiles(fi) + + var name string + if len(dir.fi) != 0 { + name = dir.fi[dir.ind].Name() + } + + dir.fi = fi + + dir.load(dir.ind, dir.pos, height, name) +} + +func (dir *Dir) load(ind, pos, height int, name string) { + if len(dir.fi) == 0 { + dir.ind, dir.pos = 0, 0 + return + } + + ind = max(0, min(ind, len(dir.fi)-1)) + + if dir.fi[ind].Name() != name { + for i, f := range dir.fi { + if f.Name() == name { + ind = i + break + } + } + + edge := min(gOpts.scrolloff, len(dir.fi)-ind-1) + pos = min(ind, height-edge-1) + } + + dir.ind = ind + dir.pos = pos +} + +type Nav struct { + dirs []*Dir + inds map[string]int + poss map[string]int + names map[string]string + marks map[string]bool + height int +} + +func getDirs(wd string, height int) []*Dir { + var dirs []*Dir + + for curr, base := wd, ""; !isRoot(base); curr, base = path.Dir(curr), path.Base(curr) { + dir := newDir(curr) + for i, f := range dir.fi { + if f.Name() == base { + dir.ind = i + edge := min(gOpts.scrolloff, len(dir.fi)-dir.ind-1) + dir.pos = min(i, height-edge-1) + break + } + } + dirs = append(dirs, dir) + } + + for i, j := 0, len(dirs)-1; i < j; i, j = i+1, j-1 { + dirs[i], dirs[j] = dirs[j], dirs[i] + } + + return dirs +} + +func newNav(height int) *Nav { + wd, err := os.Getwd() + if err != nil { + log.Print(err) + } + + dirs := getDirs(wd, height) + + return &Nav{ + dirs: dirs, + inds: make(map[string]int), + poss: make(map[string]int), + names: make(map[string]string), + marks: make(map[string]bool), + height: height, + } +} + +func (nav *Nav) renew(height int) { + nav.height = height + for _, d := range nav.dirs { + d.renew(nav.height) + } + + for m := range nav.marks { + if _, err := os.Stat(m); os.IsNotExist(err) { + delete(nav.marks, m) + } + } +} + +func (nav *Nav) down() { + dir := nav.currDir() + + maxind := len(dir.fi) - 1 + + if dir.ind >= maxind { + return + } + + dir.ind++ + + dir.pos++ + edge := min(gOpts.scrolloff, maxind-dir.ind) + dir.pos = min(dir.pos, nav.height-edge-1) + dir.pos = min(dir.pos, maxind) +} + +func (nav *Nav) up() { + dir := nav.currDir() + + if dir.ind == 0 { + return + } + + dir.ind-- + + dir.pos-- + edge := min(gOpts.scrolloff, dir.ind) + dir.pos = max(dir.pos, edge) +} + +func (nav *Nav) updir() error { + if len(nav.dirs) <= 1 { + return nil + } + + dir := nav.currDir() + + nav.inds[dir.path] = dir.ind + nav.poss[dir.path] = dir.pos + + if len(dir.fi) != 0 { + nav.names[dir.path] = dir.fi[dir.ind].Name() + } + + nav.dirs = nav.dirs[:len(nav.dirs)-1] + + err := os.Chdir(path.Dir(dir.path)) + if err != nil { + return fmt.Errorf("updir: %s", err) + } + + return nil +} + +func (nav *Nav) open() error { + curr := nav.currFile() + path := nav.currPath() + + if curr.IsDir() { + dir := newDir(path) + + dir.load(nav.inds[path], nav.poss[path], nav.height, nav.names[path]) + + nav.dirs = append(nav.dirs, dir) + + err := os.Chdir(path) + if err != nil { + return fmt.Errorf("open: %s", err) + } + } else { + f, err := os.Create(gSelectionPath) + if err != nil { + return fmt.Errorf("open: %s", err) + } + defer f.Close() + + if len(nav.marks) != 0 { + marks := nav.currMarks() + path = strings.Join(marks, "\n") + } + + _, err = f.WriteString(path) + if err != nil { + return fmt.Errorf("open: %s", err) + } + + gExitFlag = true + } + + return nil +} + +func (nav *Nav) bot() { + dir := nav.currDir() + + dir.ind = len(dir.fi) - 1 + dir.pos = min(dir.ind, nav.height-1) +} + +func (nav *Nav) top() { + dir := nav.currDir() + + dir.ind = 0 + dir.pos = 0 +} + +func (nav *Nav) cd(wd string) error { + if !path.IsAbs(wd) { + wd = path.Join(nav.currDir().path, wd) + } + + wd = strings.Replace(wd, "~", envHome, -1) + + err := os.Chdir(wd) + if err != nil { + return fmt.Errorf("cd: %s", err) + } + + nav.dirs = getDirs(wd, nav.height) + + // TODO: save/load ind and pos from the map + + return nil +} + +func (nav *Nav) toggle() { + path := nav.currPath() + + if nav.marks[path] { + delete(nav.marks, path) + } else { + nav.marks[path] = true + } + + nav.down() +} + +func (nav *Nav) save(keep bool) error { + if len(nav.marks) == 0 { + path := nav.currPath() + + err := saveFiles([]string{path}, keep) + if err != nil { + return err + } + } else { + var fs []string + for f := range nav.marks { + fs = append(fs, f) + } + + err := saveFiles(fs, keep) + if err != nil { + return err + } + } + + return nil +} + +func (nav *Nav) paste() error { + list, keep, err := loadFiles() + if err != nil { + return err + } + + if len(list) == 0 { + return errors.New("no file in yank/delete buffer") + } + + dir := nav.currDir() + + args := append(list, dir.path) + + var sh string + if keep { + sh = "cp" + } else { + sh = "mv" + } + + cmd := exec.Command(sh, args...) + + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s: %s", sh, err) + } + + // TODO: async? + + return nil +} + +func (nav *Nav) currDir() *Dir { + return nav.dirs[len(nav.dirs)-1] +} + +func (nav *Nav) currFile() os.FileInfo { + last := nav.dirs[len(nav.dirs)-1] + return last.fi[last.ind] +} + +func (nav *Nav) currPath() string { + last := nav.dirs[len(nav.dirs)-1] + curr := last.fi[last.ind] + return path.Join(last.path, curr.Name()) +} + +func (nav *Nav) currMarks() []string { + marks := make([]string, 0, len(nav.marks)) + for m := range nav.marks { + marks = append(marks, m) + } + return marks +} diff --git a/opts.go b/opts.go new file mode 100644 index 0000000..557022e --- /dev/null +++ b/opts.go @@ -0,0 +1,52 @@ +package main + +type Opts struct { + hidden bool + preview bool + scrolloff int + tabstop int + ifs string + showinfo string + sortby string + opener string + ratios []int + keys map[string]Expr + cmds map[string]Expr +} + +var gOpts Opts + +func init() { + gOpts.hidden = false + gOpts.preview = true + gOpts.scrolloff = 0 + gOpts.tabstop = 8 + gOpts.ifs = "" + gOpts.showinfo = "none" + gOpts.sortby = "name" + gOpts.opener = "xdg-open" + gOpts.ratios = []int{1, 2, 3} + + gOpts.keys = make(map[string]Expr) + + gOpts.keys["j"] = &CallExpr{"down", nil} + gOpts.keys["k"] = &CallExpr{"up", nil} + gOpts.keys["h"] = &CallExpr{"updir", nil} + gOpts.keys["l"] = &CallExpr{"open", nil} + gOpts.keys["q"] = &CallExpr{"quit", nil} + gOpts.keys["G"] = &CallExpr{"bot", nil} + gOpts.keys["gg"] = &CallExpr{"top", nil} + gOpts.keys[":"] = &CallExpr{"read", nil} + gOpts.keys["$"] = &CallExpr{"read-shell", nil} + gOpts.keys["!"] = &CallExpr{"read-shell-wait", nil} + gOpts.keys["&"] = &CallExpr{"read-shell-async", nil} + gOpts.keys["/"] = &CallExpr{"search", nil} + gOpts.keys["?"] = &CallExpr{"search-back", nil} + gOpts.keys[""] = &CallExpr{"toggle", nil} + gOpts.keys["y"] = &CallExpr{"yank", nil} + gOpts.keys["d"] = &CallExpr{"delete", nil} + gOpts.keys["p"] = &CallExpr{"paste", nil} + gOpts.keys[""] = &CallExpr{"redraw", nil} + + gOpts.cmds = make(map[string]Expr) +} diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..4b85e70 --- /dev/null +++ b/parse.go @@ -0,0 +1,243 @@ +package main + +// Grammar of the language used in the evaluator +// +// Expr = SetExpr +// | MapExpr +// | CmdExpr +// | CallExpr +// | ExecExpr +// | ListExpr +// +// SetExpr = 'set' ';' +// +// MapExpr = 'map' Expr ';' +// +// CmdExpr = 'cmd' Expr ';' +// +// CallExpr = ';' +// +// ExecExpr = Prefix '\n' +// | Prefix '{{' '}}' ';' +// +// Prefix = '$' | '!' | '&' | '/' | '?' +// +// ListExpr = ':' ListExpr '\n' +// | ':' '{{' ListRest '}}' ';' +// +// ListRest = Nil +// | Expr ListRest + +import ( + "fmt" + "io" + "log" +) + +type Expr interface { + String() string + + eval(app *App, args []string) + // TODO: add a bind method to avoid passing args in eval +} + +type SetExpr struct { + opt string + val string +} + +func (e *SetExpr) String() string { return fmt.Sprintf("set %s %s", e.opt, e.val) } + +type MapExpr struct { + keys string + expr Expr +} + +func (e *MapExpr) String() string { return fmt.Sprintf("map %s %s", e.keys, e.expr) } + +type CmdExpr struct { + name string + expr Expr +} + +func (e *CmdExpr) String() string { return fmt.Sprintf("cmd %s %s", e.name, e.expr) } + +type CallExpr struct { + name string + args []string +} + +func (e *CallExpr) String() string { return fmt.Sprintf("%s -- %s", e.name, e.args) } + +type ExecExpr struct { + pref string + expr string +} + +func (e *ExecExpr) String() string { return fmt.Sprintf("%s %s", e.pref, e.expr) } + +type ListExpr struct { + exprs []Expr +} + +func (e *ListExpr) String() string { + buf := []byte{':', '{', '{', ' '} + for _, expr := range e.exprs { + buf = append(buf, expr.String()...) + buf = append(buf, ';', ' ') + } + buf = append(buf, '}', '}') + return string(buf) +} + +type Parser struct { + scanner *Scanner + expr Expr + err error +} + +func newParser(r io.Reader) *Parser { + scanner := newScanner(r) + + scanner.scan() + + return &Parser{ + scanner: scanner, + } +} + +func (p *Parser) parseExpr() Expr { + s := p.scanner + + var result Expr + + // TODO: syntax error check + + switch s.typ { + case TokenEOF: + return nil + case TokenSemicolon: + s.scan() + case TokenIdent: + switch s.tok { + case "set": + s.scan() + opt := s.tok + + s.scan() + var val string + if s.typ != TokenSemicolon { + val = s.tok + s.scan() + } + + s.scan() + + result = &SetExpr{opt, val} + case "map": + s.scan() + keys := s.tok + + s.scan() + expr := p.parseExpr() + + result = &MapExpr{keys, expr} + case "cmd": + s.scan() + name := s.tok + + s.scan() + expr := p.parseExpr() + + result = &CmdExpr{name, expr} + default: + name := s.tok + + var args []string + for s.scan() && s.typ != TokenSemicolon { + args = append(args, s.tok) + } + + s.scan() + + if s.err != nil { + p.err = fmt.Errorf("parsing: %s", s.err) + return nil + } + + result = &CallExpr{name, args} + } + case TokenColon: + s.scan() + + var exprs []Expr + if s.typ == TokenLBraces { + s.scan() + for { + e := p.parseExpr() + if e == nil { + return nil + } + exprs = append(exprs, e) + if s.typ == TokenRBraces { + break + } + } + s.scan() + } else { + for { + e := p.parseExpr() + if e == nil { + return nil + } + exprs = append(exprs, e) + if s.tok == "\n" { + break + } + } + } + + s.scan() + + result = &ListExpr{exprs} + case TokenPrefix: + pref := s.tok + + s.scan() + + var expr string + if s.typ == TokenLBraces { + s.scan() + expr = s.tok + s.scan() + } else if s.typ == TokenCommand { + expr = s.tok + } else { + // TODO: handle error + } + + s.scan() + s.scan() + + if s.err != nil { + p.err = fmt.Errorf("parsing: %s", s.err) + return nil + } + + result = &ExecExpr{pref, expr} + default: + // TODO: handle error + } + + log.Println("parsed:", result) + + return result +} + +func (p *Parser) parse() bool { + if p.expr = p.parseExpr(); p.expr == nil { + return false + } + + return true +} diff --git a/scan.go b/scan.go new file mode 100644 index 0000000..9dce836 --- /dev/null +++ b/scan.go @@ -0,0 +1,244 @@ +package main + +import ( + "fmt" + "io" + "io/ioutil" + "log" + "unicode" +) + +type TokenType int + +const ( + TokenErr TokenType = iota + TokenEOF // end of file + // no explicit keyword type + TokenIdent // e.g. set, ratios, 1:2:3 + TokenColon // : + TokenPrefix // $, !, &, / or ? + TokenLBraces // {{ + TokenRBraces // }} + TokenCommand // in between a prefix to \n or between {{ and }} + TokenSemicolon // ; + // comments are stripped +) + +type Scanner struct { + buf []byte // input buffer + off int // current offset in buf + chr byte // current character in buf + sem bool // insert semicolon + nln bool // insert newline + eof bool // buffer ended + blk bool // scanning block + cmd bool // scanning command + typ TokenType // scanned token type + tok string // scanned token value + err error // error if any + // TODO: pos +} + +func newScanner(r io.Reader) *Scanner { + // TODO: read input beforehand + buf, err := ioutil.ReadAll(r) + if err != nil { + msg := fmt.Sprintf("scanning: %s", err) + // app.ui.message = msg + log.Print(msg) + } + + var eof bool + var chr byte + + if len(buf) == 0 { + eof = true + } else { + eof = false + chr = buf[0] + } + + return &Scanner{ + buf: buf, + eof: eof, + chr: chr, + } +} + +func (s *Scanner) next() { + if s.off+1 < len(s.buf) { + // TODO: unicode + s.off++ + s.chr = s.buf[s.off] + return + } + + s.off = len(s.buf) + s.chr = 0 + s.eof = true +} + +func (s *Scanner) peek() byte { + if s.off+1 < len(s.buf) { + // TODO: unicode + return s.buf[s.off+1] + } + + return 0 +} + +func isSpace(b byte) bool { + return unicode.IsSpace(rune(b)) +} + +func isPrefix(b byte) bool { + // TODO: how to differentiate slash in path vs search? + return b == '$' || b == '!' || b == '&' // || b == '/' || b == '?' +} + +func (s *Scanner) scan() bool { + // log.Println("scanning:", s.tok) +scan: + switch { + case s.eof: + s.next() + if s.sem { + s.typ = TokenSemicolon + s.tok = "\n" + s.sem = false + return true + } + if s.nln { + s.typ = TokenSemicolon + s.tok = "\n" + s.nln = false + return true + } + return false + case s.blk: + // return here by setting s.cmd to false + // after scanning the command in the loop below + if !s.cmd { + s.next() + s.next() + s.typ = TokenRBraces + s.tok = "}}" + s.blk = false + s.sem = true + return true + } + + beg := s.off + + for !s.eof { + s.next() + if s.chr == '}' { + if !s.eof && s.peek() == '}' { + s.typ = TokenCommand + s.tok = string(s.buf[beg:s.off]) + s.cmd = false + return true + } + } + } + // TODO: fill error + return false + case s.cmd: + for !s.eof && isSpace(s.chr) { + s.next() + } + + if !s.eof && s.chr == '{' { + if s.peek() == '{' { + s.next() + s.next() + s.typ = TokenLBraces + s.tok = "{{" + s.blk = true + return true + } + } + + beg := s.off + + for !s.eof && s.chr != '\n' { + s.next() + } + + s.typ = TokenCommand + s.tok = string(s.buf[beg:s.off]) + s.cmd = false + s.sem = true + case s.chr == '\n': + s.next() + if s.sem { + s.typ = TokenSemicolon + s.tok = "\n" + s.sem = false + return true + } + if s.nln { + s.typ = TokenSemicolon + s.tok = "\n" + s.nln = false + return true + } + goto scan + case isSpace(s.chr): + for !s.eof && isSpace(s.chr) { + s.next() + } + goto scan + case s.chr == ';': + s.typ = TokenSemicolon + s.tok = ";" + s.sem = false + s.next() + case s.chr == '#': + for !s.eof && s.chr != '\n' { + s.next() + } + goto scan + case s.chr == ':': + s.typ = TokenColon + s.tok = ":" + s.nln = true + s.next() + case s.chr == '{': + if s.peek() == '{' { + s.next() + s.next() + s.typ = TokenLBraces + s.tok = "{{" + s.sem = false + s.nln = false + return true + } + // TODO: handle error + case s.chr == '}': + if s.peek() == '}' { + s.next() + s.next() + s.typ = TokenRBraces + s.tok = "}}" + s.sem = true + return true + } + // TODO: handle error + case isPrefix(s.chr): + s.typ = TokenPrefix + s.tok = string(s.chr) + s.cmd = true + s.next() + default: + beg := s.off + for !s.eof && !isSpace(s.chr) && s.chr != ';' && s.chr != '#' { + s.next() + } + s.typ = TokenIdent + s.tok = string(s.buf[beg:s.off]) + s.sem = true + } + + return true +} diff --git a/scan_test.go b/scan_test.go new file mode 100644 index 0000000..859ecbc --- /dev/null +++ b/scan_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "strings" + "testing" +) + +// TODO: use a slice and merge with outputs + +var inp0 = "" +var inp1 = "# comments start with '#'" +var inp2 = "set hidden # trailing comments are allowed" +var inp3 = "set hidden; set preview" +var inp4 = "set ratios 1:2:3" +var inp5 = "set ratios 1:2:3;" +var inp6 = ":set ratios 1:2:3" +var inp7 = ":set ratios 1:2:3;" +var inp8 = "map gh cd ~" +var inp9 = "map gh cd ~;" +var inp10 = "map gh :cd ~" +var inp11 = "map gh :cd ~;" +var inp12 = "cmd usage $du -h . | less" +var inp13 = "map u usage" +var inp14 = "map u usage;" +var inp15 = "map u :usage" +var inp16 = "map u :usage;" +var inp17 = "map u $du -h . | less" +var inp18 = `cmd usage $du -h "$1" | less` +var inp19 = "map u usage /" + +var inp20 = `cmd gohome :{{ + cd ~ + set hidden +}}` + +var inp21 = `map gh :{{ + cd ~ + set hidden +}}` + +var inp22 = `map c ${{ + mkdir foo + IFS=':'; cp ${fs} foo + tar -czvf "foo.tar.gz" foo + rm -rf foo +}}` + +var inp23 = `cmd compress ${{ + mkdir "$1" + IFS=':'; cp ${fs} "$1" + tar -czvf "$1.tar.gz" "$1" + rm -rf "$1" +}}` + +// unfinished command +var inp24 = `cmd compress ${{ + mkdir "$1"` + +var out0 = []string{} +var out1 = []string{} +var out2 = []string{"set", "hidden", "\n"} +var out3 = []string{"set", "hidden", ";", "set", "preview", "\n"} +var out4 = []string{"set", "ratios", "1:2:3", "\n"} +var out5 = []string{"set", "ratios", "1:2:3", ";"} +var out6 = []string{":", "set", "ratios", "1:2:3", "\n", "\n"} +var out7 = []string{":", "set", "ratios", "1:2:3", ";", "\n"} +var out8 = []string{"map", "gh", "cd", "~", "\n"} +var out9 = []string{"map", "gh", "cd", "~", ";"} +var out10 = []string{"map", "gh", ":", "cd", "~", "\n", "\n"} +var out11 = []string{"map", "gh", ":", "cd", "~", ";", "\n"} +var out12 = []string{"cmd", "usage", "$", "du -h . | less", "\n"} +var out13 = []string{"map", "u", "usage", "\n"} +var out14 = []string{"map", "u", "usage", ";"} +var out15 = []string{"map", "u", ":", "usage", "\n", "\n"} +var out16 = []string{"map", "u", ":", "usage", ";", "\n"} +var out17 = []string{"map", "u", "$", "du -h . | less", "\n"} +var out18 = []string{"cmd", "usage", "$", `du -h "$1" | less`, "\n"} +var out19 = []string{"map", "u", "usage", "/", "\n"} +var out20 = []string{"cmd", "gohome", ":", "{{", "cd", "~", "\n", "set", "hidden", "\n", "}}", "\n"} +var out21 = []string{"map", "gh", ":", "{{", "cd", "~", "\n", "set", "hidden", "\n", "}}", "\n"} +var out22 = []string{"map", "c", "$", "{{", "\n\tmkdir foo\n\tIFS=':'; cp ${fs} foo\n\ttar -czvf \"foo.tar.gz\" foo\n\trm -rf foo\n", "}}", "\n"} +var out23 = []string{"cmd", "compress", "$", "{{", "\n\tmkdir \"$1\"\n\tIFS=':'; cp ${fs} \"$1\"\n\ttar -czvf \"$1.tar.gz\" \"$1\"\n\trm -rf \"$1\"\n", "}}", "\n"} +var out24 = []string{"cmd", "compress", "$", "{{"} + +func compare(t *testing.T, inp string, out []string) { + s := newScanner(strings.NewReader(inp)) + + for _, tok := range out { + if s.scan(); s.tok != tok { + t.Errorf("at input '%s' expected '%s' but scanned '%s'", inp, tok, s.tok) + } + } + + if s.scan() { + t.Errorf("at input '%s' unexpected '%s'", inp, s.tok) + } +} + +func TestScan(t *testing.T) { + compare(t, inp0, out0) + compare(t, inp1, out1) + compare(t, inp2, out2) + compare(t, inp3, out3) + compare(t, inp4, out4) + compare(t, inp5, out5) + compare(t, inp6, out6) + compare(t, inp7, out7) + compare(t, inp8, out8) + compare(t, inp9, out9) + compare(t, inp10, out10) + compare(t, inp11, out11) + compare(t, inp12, out12) + compare(t, inp13, out13) + compare(t, inp14, out14) + compare(t, inp15, out15) + compare(t, inp16, out16) + compare(t, inp17, out17) + compare(t, inp18, out18) + compare(t, inp19, out19) + compare(t, inp20, out20) + compare(t, inp21, out21) + compare(t, inp22, out22) + compare(t, inp23, out23) + compare(t, inp24, out24) +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..e5f9c6b --- /dev/null +++ b/server.go @@ -0,0 +1,94 @@ +package main + +import ( + "bufio" + "fmt" + "log" + "net" + "os" +) + +var ( + gKeepFile bool + gFileList []string +) + +func serve() { + logFile, err := os.Create(gServerLogPath) + if err != nil { + panic(err) + } + defer logFile.Close() + log.SetOutput(logFile) + + log.Print("hi!") + + l, err := net.Listen("unix", gSocketPath) + if err != nil { + log.Printf("listening socket: %s", err) + } + defer l.Close() + + listen(l) +} + +func listen(l net.Listener) { + for { + c, err := l.Accept() + if err != nil { + log.Printf("accepting connection: %s", err) + } + defer c.Close() + + s := bufio.NewScanner(c) + + for s.Scan() { + switch s.Text() { + case "save": + saveFilesServer(s) + log.Printf("listen: save, list: %v, keep: %t", gFileList, gKeepFile) + case "load": + loadFilesServer(c) + log.Printf("listen: load, keep: %t", gKeepFile) + default: + log.Print("listen: unexpected command") + } + } + } +} + +func saveFilesServer(s *bufio.Scanner) { + switch s.Scan(); s.Text() { + case "keep": + gKeepFile = true + case "move": + gKeepFile = false + default: + log.Printf("unexpected option to keep file(s): %s", s.Text()) + return + } + + gFileList = nil + for s.Scan() { + gFileList = append(gFileList, s.Text()) + } + + if s.Err() != nil { + log.Printf("scanning: %s", s.Err()) + return + } +} + +func loadFilesServer(c net.Conn) { + if gKeepFile { + fmt.Fprintln(c, "keep") + } else { + fmt.Fprintln(c, "move") + } + + for _, f := range gFileList { + fmt.Fprintln(c, f) + } + + c.Close() +} diff --git a/ui.go b/ui.go new file mode 100644 index 0000000..1f7202e --- /dev/null +++ b/ui.go @@ -0,0 +1,487 @@ +package main + +import ( + "bufio" + "bytes" + "fmt" + "log" + "os" + "path" + "strings" + "text/tabwriter" + "time" + "unicode" + + "github.com/nsf/termbox-go" +) + +type Win struct { + w int + h int + x int + y int +} + +func newWin(w, h, x, y int) *Win { + return &Win{w, h, x, y} +} + +func (win *Win) renew(w, h, x, y int) { + win.w = w + win.h = h + win.x = x + win.y = y +} + +func (win *Win) print(x, y int, fg, bg termbox.Attribute, s string) { + off := x + for _, c := range s { + if x >= win.w { + break + } + + termbox.SetCell(win.x+x, win.y+y, c, fg, bg) + + if c == '\t' { + x += gOpts.tabstop - (x-off)%gOpts.tabstop + } else { + x++ + } + } +} + +func (win *Win) printf(x, y int, fg, bg termbox.Attribute, format string, a ...interface{}) { + win.print(x, y, fg, bg, fmt.Sprintf(format, a...)) +} + +func (win *Win) printl(x, y int, fg, bg termbox.Attribute, s string) { + win.printf(x, y, fg, bg, "%s%*s", s, win.w-len(s), "") +} + +func (win *Win) printd(dir *Dir, marks map[string]bool) { + if win.w < 3 { + return + } + + fg, bg := termbox.ColorDefault, termbox.ColorDefault + + if len(dir.fi) == 0 { + fg = termbox.AttrBold + win.print(0, 0, fg, bg, "empty") + return + } + + maxind := len(dir.fi) - 1 + + beg := max(dir.ind-dir.pos, 0) + end := min(beg+win.h, maxind+1) + + for i, f := range dir.fi[beg:end] { + switch { + case f.Mode().IsRegular(): + if f.Mode()&0111 != 0 { + fg = termbox.AttrBold | termbox.ColorGreen + } else { + fg = termbox.ColorDefault + } + case f.Mode().IsDir(): + fg = termbox.AttrBold | termbox.ColorBlue + case f.Mode()&os.ModeSymlink != 0: + fg = termbox.ColorCyan + case f.Mode()&os.ModeNamedPipe != 0: + fg = termbox.ColorRed + case f.Mode()&os.ModeSocket != 0: + fg = termbox.ColorYellow + case f.Mode()&os.ModeDevice != 0: + fg = termbox.ColorWhite + } + + path := path.Join(dir.path, f.Name()) + + if marks[path] { + win.print(0, i, fg, termbox.ColorMagenta, " ") + } + + if i == dir.pos { + fg = fg | termbox.AttrReverse + } + + var s []byte + + s = append(s, ' ') + + s = append(s, f.Name()...) + + if len(s) > win.w-2 { + s = s[:win.w-2] + } else { + s = append(s, make([]byte, win.w-2-len(s))...) + } + + switch gOpts.showinfo { + case "none": + break + case "size": + if win.w > 8 { + h := humanize(f.Size()) + s = append(s[:win.w-3-len(h)]) + s = append(s, ' ') + s = append(s, h...) + } + case "time": + if win.w > 24 { + t := f.ModTime().Format("Jan _2 15:04") + s = append(s[:win.w-3-len(t)]) + s = append(s, ' ') + s = append(s, t...) + } + default: + log.Printf("unknown showinfo type: %s", gOpts.showinfo) + } + + // TODO: add a trailing '~' to the name if cut + + win.print(1, i, fg, bg, string(s)) + } +} + +func (win *Win) printr(reg *os.File) error { + fg, bg := termbox.ColorDefault, termbox.ColorDefault + + buf := bufio.NewScanner(reg) + + for i := 0; i < win.h && buf.Scan(); i++ { + for _, r := range buf.Text() { + if unicode.IsSpace(r) { + continue + } + if !unicode.IsPrint(r) { + fg = termbox.AttrBold + win.print(0, 0, fg, bg, "binary") + return nil + } + } + } + + if buf.Err() != nil { + return fmt.Errorf("printing regular file: %s", buf.Err()) + } + + reg.Seek(0, 0) + + buf = bufio.NewScanner(reg) + + for i := 0; i < win.h && buf.Scan(); i++ { + win.print(2, i, fg, bg, buf.Text()) + } + + if buf.Err() != nil { + return fmt.Errorf("printing regular file: %s", buf.Err()) + } + + return nil +} + +type UI struct { + wins []*Win + pwdwin *Win + msgwin *Win + menuwin *Win + message string +} + +func getWidths(wtot int) []int { + rsum := 0 + for _, rat := range gOpts.ratios { + rsum += rat + } + + wlen := len(gOpts.ratios) + widths := make([]int, wlen) + + wsum := 0 + for i := 0; i < wlen-1; i++ { + widths[i] = gOpts.ratios[i] * (wtot / rsum) + wsum += widths[i] + } + widths[wlen-1] = wtot - wsum + + return widths +} + +func newUI() *UI { + wtot, htot := termbox.Size() + + var wins []*Win + + widths := getWidths(wtot) + + wacc := 0 + wlen := len(widths) + for i := 0; i < wlen; i++ { + wins = append(wins, newWin(widths[i], htot-2, wacc, 1)) + wacc += widths[i] + } + + return &UI{ + wins: wins, + pwdwin: newWin(wtot, 1, 0, 0), + msgwin: newWin(wtot, 1, 0, htot-1), + menuwin: newWin(wtot, 1, 0, htot-2), + } +} + +func (ui *UI) renew() { + termbox.Flush() + + wtot, htot := termbox.Size() + + widths := getWidths(wtot) + + wacc := 0 + wlen := len(widths) + for i := 0; i < wlen; i++ { + ui.wins[i].renew(widths[i], htot-2, wacc, 1) + wacc += widths[i] + } + + ui.msgwin.renew(wtot, 1, 0, htot-1) +} + +func (ui *UI) echoFileInfo(nav *Nav) { + dir := nav.currDir() + + if len(dir.fi) == 0 { + return + } + + curr := nav.currFile() + + ui.message = fmt.Sprintf("%v %v %v", curr.Mode(), humanize(curr.Size()), curr.ModTime().Format(time.ANSIC)) +} + +func (ui *UI) clearMsg() { + fg, bg := termbox.ColorDefault, termbox.ColorDefault + win := ui.msgwin + win.printl(0, 0, fg, bg, "") + termbox.SetCursor(win.x, win.y) + termbox.Flush() +} + +func (ui *UI) draw(nav *Nav) { + fg, bg := termbox.ColorDefault, termbox.ColorDefault + + termbox.Clear(fg, bg) + defer termbox.Flush() + + dir := nav.currDir() + + path := strings.Replace(dir.path, envHome, "~", -1) + + ui.pwdwin.printf(0, 0, termbox.AttrBold|termbox.ColorGreen, bg, "%s@%s", envUser, envHost) + ui.pwdwin.printf(len(envUser)+len(envHost)+1, 0, fg, bg, ":") + ui.pwdwin.printf(len(envUser)+len(envHost)+2, 0, termbox.AttrBold|termbox.ColorBlue, bg, "%s", path) + + length := min(len(ui.wins), len(nav.dirs)) + woff := len(ui.wins) - length + + if gOpts.preview { + length = min(len(ui.wins)-1, len(nav.dirs)) + woff = len(ui.wins) - 1 - length + } + + doff := len(nav.dirs) - length + for i := 0; i < length; i++ { + ui.wins[woff+i].printd(nav.dirs[doff+i], nav.marks) + } + + if gOpts.preview { + if len(dir.fi) == 0 { + return + } + + preview := ui.wins[len(ui.wins)-1] + curr := nav.currFile() + path := nav.currPath() + if curr.IsDir() { + dir := newDir(path) + dir.load(nav.inds[path], nav.poss[path], nav.height, nav.names[path]) + preview.printd(dir, nav.marks) + } else { + file, err := os.Open(path) + if err != nil { + ui.message = err.Error() + log.Print(err) + } + + if err := preview.printr(file); err != nil { + ui.message = err.Error() + log.Print(err) + } + } + } + + ui.msgwin.print(0, 0, fg, bg, ui.message) +} + +func findBinds(keys map[string]Expr, prefix string) (binds map[string]Expr, ok bool) { + binds = make(map[string]Expr) + for key, expr := range keys { + if strings.HasPrefix(key, prefix) { + binds[key] = expr + if key == prefix { + ok = true + } + } + } + return +} + +func (ui *UI) getExpr() Expr { + r := &CallExpr{"redraw", nil} + + var acc []rune + + for { + switch ev := termbox.PollEvent(); ev.Type { + case termbox.EventKey: + if ev.Ch != 0 { + acc = append(acc, ev.Ch) + } else { + // TODO: rest of the keys + switch ev.Key { + case termbox.KeySpace: + acc = append(acc, '<', 's', 'p', 'a', 'c', 'e', '>') + case termbox.KeyEnter: + acc = append(acc, '<', 'c', 'r', '>') + case termbox.KeyBackspace: + acc = append(acc, '<', 'b', 's', '>') + case termbox.KeyBackspace2: + acc = append(acc, '<', 'b', 's', '2', '>') + case termbox.KeyTab: + acc = append(acc, '<', 't', 'a', 'b', '>') + case termbox.KeyCtrlL: + acc = append(acc, '<', 'c', '-', 'l', '>') + case termbox.KeyEsc: + acc = nil + return r + } + } + + binds, ok := findBinds(gOpts.keys, string(acc)) + + switch len(binds) { + case 0: + ui.message = fmt.Sprintf("unknown mapping: %s", string(acc)) + acc = nil + return r + case 1: + if ok { + return gOpts.keys[string(acc)] + } + ui.listBinds(binds) + default: + if ok { + // TODO: use a delay + return gOpts.keys[string(acc)] + } + ui.listBinds(binds) + } + case termbox.EventResize: + return r + default: + // TODO: handle other events + } + } +} + +func (ui *UI) prompt(pref string) string { + fg, bg := termbox.ColorDefault, termbox.ColorDefault + + win := ui.msgwin + + win.printl(0, 0, fg, bg, pref) + termbox.SetCursor(win.x+len(pref), win.y) + defer termbox.HideCursor() + termbox.Flush() + + var acc []rune + + for { + switch ev := termbox.PollEvent(); ev.Type { + case termbox.EventKey: + if ev.Ch != 0 { + acc = append(acc, ev.Ch) + } else { + // TODO: rest of the keys + switch ev.Key { + case termbox.KeySpace: + acc = append(acc, ' ') + case termbox.KeyBackspace2: + if len(acc) > 0 { + acc = acc[:len(acc)-1] + } + case termbox.KeyEnter: + win.printl(0, 0, fg, bg, "") + termbox.SetCursor(win.x, win.y) + termbox.Flush() + return string(acc) + case termbox.KeyEsc: + return "" + } + } + + win.printl(0, 0, fg, bg, pref) + win.print(len(pref), 0, fg, bg, string(acc)) + termbox.SetCursor(win.x+len(pref)+len(acc), win.y) + termbox.Flush() + default: + // TODO: handle other events + } + } +} + +func (ui *UI) pause() { + termbox.Close() +} + +func (ui *UI) resume() { + if err := termbox.Init(); err != nil { + log.Fatal(err) + } +} + +func (ui *UI) sync() { + err := termbox.Sync() + if err != nil { + log.Printf("syncing: %s", err) + } + termbox.SetCursor(0, 0) + termbox.HideCursor() +} + +func (ui *UI) listBinds(binds map[string]Expr) { + t := new(tabwriter.Writer) + b := new(bytes.Buffer) + + t.Init(b, 0, 8, 0, '\t', 0) + fmt.Fprintln(t, "keys\tcommand") + for key, expr := range binds { + fmt.Fprintf(t, "%s\t%v\n", key, expr) + } + t.Flush() + + lines := strings.Split(b.String(), "\n") + + lines = lines[:len(lines)-1] + + ui.menuwin.h = len(lines) - 1 + ui.menuwin.y = ui.wins[0].h - ui.menuwin.h + + ui.menuwin.printl(0, 0, termbox.AttrBold, termbox.AttrBold, lines[0]) + for i, line := range lines[1:] { + ui.menuwin.printl(0, i+1, termbox.ColorDefault, termbox.ColorDefault, line) + } + + termbox.Flush() +}