commit 4b266c97e94c4fa04b6be18bbace0ef7e941bf72 Author: Gokcehan Date: Sat Aug 13 15:49:04 2016 +0300 initial commit 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() +}