initial commit

This commit is contained in:
Gokcehan 2016-08-13 15:49:04 +03:00
commit 4b266c97e9
21 changed files with 2797 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
lf
tags

21
LICENSE Normal file
View File

@ -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.

12
Makefile Normal file
View File

@ -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

69
README.md Normal file
View File

@ -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)

123
app.go Normal file
View File

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

112
client.go Normal file
View File

@ -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
}

39
doc/reference.md Normal file
View File

@ -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 "<space>")
yank (default "y")
delete (default "d")
paste (default "p")
redraw (default "<c-l>")
## 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

90
doc/tutorial.md Normal file
View File

@ -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.

21
etc/lf.sh Normal file
View File

@ -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"
}

31
etc/lf.vim Normal file
View File

@ -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 <leader>l :<c-u>StartLF<cr>
" 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()

88
etc/lfrc.example Normal file
View File

@ -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 <cr> 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"
}}

284
eval.go Normal file
View File

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

99
main.go Normal file
View File

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

111
misc.go Normal file
View File

@ -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 --
//

450
nav.go Normal file
View File

@ -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
}

52
opts.go Normal file
View File

@ -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["<space>"] = &CallExpr{"toggle", nil}
gOpts.keys["y"] = &CallExpr{"yank", nil}
gOpts.keys["d"] = &CallExpr{"delete", nil}
gOpts.keys["p"] = &CallExpr{"paste", nil}
gOpts.keys["<c-l>"] = &CallExpr{"redraw", nil}
gOpts.cmds = make(map[string]Expr)
}

243
parse.go Normal file
View File

@ -0,0 +1,243 @@
package main
// Grammar of the language used in the evaluator
//
// Expr = SetExpr
// | MapExpr
// | CmdExpr
// | CallExpr
// | ExecExpr
// | ListExpr
//
// SetExpr = 'set' <opt> <val> ';'
//
// MapExpr = 'map' <keys> Expr ';'
//
// CmdExpr = 'cmd' <name> Expr ';'
//
// CallExpr = <name> <args> ';'
//
// ExecExpr = Prefix <expr> '\n'
// | Prefix '{{' <expr> '}}' ';'
//
// 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
}

244
scan.go Normal file
View File

@ -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
}

125
scan_test.go Normal file
View File

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

94
server.go Normal file
View File

@ -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()
}

487
ui.go Normal file
View File

@ -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()
}