From 5291dae422bc05aabb2828de572c57d04bfb402e Mon Sep 17 00:00:00 2001 From: Qi Xiao Date: Mon, 22 Apr 2019 23:39:51 +0100 Subject: [PATCH] newedit: Implement histlist mode. This addresses #778. --- newedit/default_bindings.go | 3 +- newedit/editor.go | 23 +++++++-- newedit/histlist/histlist.go | 81 +++++++++++++++++++++++++++++++ newedit/histlist/histlist_test.go | 57 ++++++++++++++++++++++ newedit/histlist_api.go | 30 ++++++++++++ newedit/histlist_api_test.go | 44 +++++++++++++++++ newedit/lastcmd_api_test.go | 2 +- newedit/listing/listing.go | 4 +- newedit/listing/listing_test.go | 10 ++++ newedit/listing/state.go | 17 +++++-- 10 files changed, 258 insertions(+), 13 deletions(-) create mode 100644 newedit/histlist/histlist.go create mode 100644 newedit/histlist/histlist_test.go create mode 100644 newedit/histlist_api.go create mode 100644 newedit/histlist_api_test.go diff --git a/newedit/default_bindings.go b/newedit/default_bindings.go index 1d1d5b62..8a900df4 100644 --- a/newedit/default_bindings.go +++ b/newedit/default_bindings.go @@ -22,7 +22,8 @@ insert:binding = (binding-map [ &Ctrl-U= $kill-sol~ &Ctrl-K= $kill-eol~ - &Alt-,= $lastcmd:start~ + &Alt-,= $lastcmd:start~ + &Ctrl-R= $histlist:start~ &Ctrl-D= $commit-eof~ &Default= $insert:default-handler~ diff --git a/newedit/editor.go b/newedit/editor.go index 8d7c0bfe..c02ce29f 100644 --- a/newedit/editor.go +++ b/newedit/editor.go @@ -1,8 +1,10 @@ package newedit import ( + "fmt" "os" + "github.com/elves/elvish/edit/history/histutil" "github.com/elves/elvish/eval" "github.com/elves/elvish/eval/vars" "github.com/elves/elvish/newedit/core" @@ -41,11 +43,18 @@ func NewEditor(in, out *os.File, ev *eval.Evaler, st storedefs.Store) *Editor { }). AddGoFns("", bufferBuiltins(ed.State())) - // Add the builtin hook of appending history in after-readline. - ed.AddAfterReadline(func(code string) { - st.AddCmd(code) - // TODO: Log errors - }) + histFuser, err := histutil.NewFuser(st) + if err == nil { + // Add the builtin hook of appending history in after-readline. + ed.AddAfterReadline(func(code string) { + err := histFuser.AddCmd(code) + if err != nil { + fmt.Fprintln(out, "failed to add command to history") + } + }) + } else { + fmt.Fprintln(out, "failed to initialize history facilities") + } // Elvish hook APIs var beforeReadline func() @@ -67,9 +76,13 @@ func NewEditor(in, out *os.File, ev *eval.Evaler, st storedefs.Store) *Editor { // Listing modes. lsMode, lsBinding, lsNs := initListing(ed) ns.AddNs("listing", lsNs) + lastcmdNs := initLastcmd(ed, ev, st, lsMode, lsBinding) ns.AddNs("lastcmd", lastcmdNs) + histlistNs := initHistlist(ed, ev, histFuser.AllCmds, lsMode, lsBinding) + ns.AddNs("histlist", histlistNs) + // Evaluate default bindings. evalDefaultBinding(ev, ns) diff --git a/newedit/histlist/histlist.go b/newedit/histlist/histlist.go new file mode 100644 index 00000000..6ae6880c --- /dev/null +++ b/newedit/histlist/histlist.go @@ -0,0 +1,81 @@ +package histlist + +import ( + "fmt" + "strings" + + "github.com/elves/elvish/edit/ui" + "github.com/elves/elvish/newedit/listing" + "github.com/elves/elvish/newedit/types" + "github.com/elves/elvish/styled" +) + +// Mode represents the histlist mode. It implements the types.Mode interface by +// embedding a *listing.Mode. +type Mode struct { + *listing.Mode + KeyHandler func(ui.Key) types.HandlerAction +} + +// Start starts the histlist mode. +func (m *Mode) Start(cmds []string) { + m.Mode.Start(listing.StartConfig{ + Name: "HISTLIST", + KeyHandler: m.KeyHandler, + ItemsGetter: func(p string) listing.Items { + return getEntries(cmds, p) + }, + StartFilter: true, + }) +} + +// Given all commands, and a pattern, returning all matching entries. +func getEntries(cmds []string, p string) items { + // TODO: Show the real in-storage IDs of cmds, not their in-memory indicies. + var entries []entry + for i, line := range cmds { + if strings.Contains(line, p) { + entries = append(entries, entry{line, i}) + } + } + return entries +} + +// A slice of entries, implementing the listing.Items interface. +type items []entry + +// An entry to show, which is just a line plus its index. +type entry struct { + content string + index int +} + +func (it items) Len() int { + return len(it) +} + +func (it items) Show(i int) styled.Text { + // TODO: The alignment of the index works up to 10000 entries. + return styled.Unstyled(fmt.Sprintf("%4d %s", it[i].index+1, it[i].content)) +} + +func (it items) Accept(i int, st *types.State) { + st.Mutex.Lock() + defer st.Mutex.Unlock() + raw := &st.Raw + + if raw.Code == "" { + insertAtDot(raw, it[i].content) + } else { + // TODO: This works well when the cursor is at the end, but can be + // unexpected when the cursor is in the middle. + insertAtDot(raw, "\n"+it[i].content) + } +} + +func insertAtDot(raw *types.RawState, text string) { + // NOTE: This is an duplicate with (*types.State).InsertAtDot, without any + // locks because we accept RawState. + raw.Code = raw.Code[:raw.Dot] + text + raw.Code[raw.Dot:] + raw.Dot += len(text) +} diff --git a/newedit/histlist/histlist_test.go b/newedit/histlist/histlist_test.go new file mode 100644 index 00000000..2bce4659 --- /dev/null +++ b/newedit/histlist/histlist_test.go @@ -0,0 +1,57 @@ +package histlist + +import ( + "testing" + + "github.com/elves/elvish/newedit/listing" + "github.com/elves/elvish/newedit/types" + "github.com/elves/elvish/styled" + "github.com/elves/elvish/tt" +) + +var Args = tt.Args + +var testCmds = []string{} + +func TestGetEntries(t *testing.T) { + cmds := []string{ + "put 1", + "echo 2", + "print 3", + "repr 4", + } + + tt.Test(t, tt.Fn("getEntries", getEntries), tt.Table{ + // Show all commands. + Args(cmds, "").Rets(listing.MatchItems( + styled.Unstyled(" 1 put 1"), + styled.Unstyled(" 2 echo 2"), + styled.Unstyled(" 3 print 3"), + styled.Unstyled(" 4 repr 4"), + )), + // Filter. + Args(cmds, "pr").Rets(listing.MatchItems( + styled.Unstyled(" 3 print 3"), + styled.Unstyled(" 4 repr 4"), + )), + }) +} + +func TestAccept(t *testing.T) { + cmds := []string{ + "put 1", + "echo 2", + } + entries := getEntries(cmds, "") + st := types.State{} + + entries.Accept(0, &st) + if st.Code() != "put 1" { + t.Errorf("Accept doesn't insert command") + } + + entries.Accept(1, &st) + if st.Code() != "put 1\necho 2" { + t.Errorf("Accept doesn't insert command with newline") + } +} diff --git a/newedit/histlist_api.go b/newedit/histlist_api.go new file mode 100644 index 00000000..272ec646 --- /dev/null +++ b/newedit/histlist_api.go @@ -0,0 +1,30 @@ +package newedit + +import ( + "github.com/elves/elvish/eval" + "github.com/elves/elvish/newedit/histlist" + "github.com/elves/elvish/newedit/listing" +) + +// Initializes states for the histlist mode and its API. +func initHistlist(ed editor, ev *eval.Evaler, getCmds func() ([]string, error), lsMode *listing.Mode, lsBinding *bindingMap) eval.Ns { + binding := emptyBindingMap + mode := histlist.Mode{ + Mode: lsMode, + KeyHandler: keyHandlerFromBindings(ed, ev, &binding, lsBinding), + } + ns := eval.Ns{}. + AddGoFn("", "start", func() { + startHistlist(ed, getCmds, &mode) + }) + return ns +} + +func startHistlist(ed editor, getCmds func() ([]string, error), mode *histlist.Mode) { + cmds, err := getCmds() + if err != nil { + ed.Notify("db error: " + err.Error()) + } + mode.Start(cmds) + ed.State().SetMode(mode) +} diff --git a/newedit/histlist_api_test.go b/newedit/histlist_api_test.go new file mode 100644 index 00000000..9c1c69be --- /dev/null +++ b/newedit/histlist_api_test.go @@ -0,0 +1,44 @@ +package newedit + +import ( + "reflect" + "testing" + + "github.com/elves/elvish/edit/ui" + + "github.com/elves/elvish/edit/history/histutil" + "github.com/elves/elvish/eval" + "github.com/elves/elvish/newedit/listing" + "github.com/elves/elvish/newedit/types" +) + +func TestHistlist_Start(t *testing.T) { + ed := &fakeEditor{} + ev := eval.NewEvaler() + lsMode := listing.Mode{} + lsBinding := emptyBindingMap + // TODO: Move this into common setup. + histFuser, err := histutil.NewFuser(testStore) + if err != nil { + panic(err) + } + + ns := initHistlist(ed, ev, histFuser.AllCmds, &lsMode, &lsBinding) + + // Call :start. + fm := eval.NewTopFrame(ev, eval.NewInternalSource("[test]"), nil) + fm.Call(getFn(ns, "start"), eval.NoArgs, eval.NoOpts) + + // Verify that the current mode supports listing. + lister, ok := ed.state.Mode().(types.Lister) + if !ok { + t.Errorf("Mode is not Lister after :start") + } + // Verify the actual listing. + buf := ui.Render(lister.List(10), 30) + wantBuf := ui.NewBufferBuilder(30). + WriteString(" 1 echo hello world", "7").Buffer() + if !reflect.DeepEqual(buf, wantBuf) { + t.Errorf("Rendered listing is %v, want %v", buf, wantBuf) + } +} diff --git a/newedit/lastcmd_api_test.go b/newedit/lastcmd_api_test.go index 66baf298..4ac817e3 100644 --- a/newedit/lastcmd_api_test.go +++ b/newedit/lastcmd_api_test.go @@ -25,7 +25,7 @@ func TestInitLastCmd_Start(t *testing.T) { // Verify that the current mode supports listing. lister, ok := ed.state.Mode().(types.Lister) if !ok { - t.Errorf("Mode is not Lister after :start") + t.Errorf("Mode is not Lister after :start") } // Verify the listing. buf := ui.Render(lister.List(10), 20) diff --git a/newedit/listing/listing.go b/newedit/listing/listing.go index 307ea515..79aee9ec 100644 --- a/newedit/listing/listing.go +++ b/newedit/listing/listing.go @@ -38,6 +38,7 @@ type StartConfig struct { ItemsGetter func(filter string) Items StartFilter bool AutoAccept bool + SelectLast bool } // Items is an interface for accessing items to show in the listing mode. @@ -62,7 +63,8 @@ func (m *Mode) Start(cfg StartConfig) { *m = Mode{ StartConfig: cfg, state: State{ - filtering: cfg.StartFilter, itemsGetter: cfg.ItemsGetter}, + itemsGetter: cfg.ItemsGetter, selectLast: cfg.SelectLast, + filtering: cfg.StartFilter}, } m.state.refilter("") } diff --git a/newedit/listing/listing_test.go b/newedit/listing/listing_test.go index 361679ee..caa804d0 100644 --- a/newedit/listing/listing_test.go +++ b/newedit/listing/listing_test.go @@ -53,6 +53,16 @@ func TestModeRenderFlag(t *testing.T) { } } +func TestStart_SelectLast(t *testing.T) { + m := Mode{} + m.Start(StartConfig{ItemsGetter: func(string) Items { + return fakeItems{10} + }, SelectLast: true}) + if m.state.selected != 9 { + t.Errorf("SelectLast did not cause the last item to be selected") + } +} + func TestHandleEvent_CallsKeyHandler(t *testing.T) { m := Mode{} key := ui.K('a') diff --git a/newedit/listing/state.go b/newedit/listing/state.go index 6d4b3d04..61bf4a86 100644 --- a/newedit/listing/state.go +++ b/newedit/listing/state.go @@ -3,11 +3,13 @@ package listing // State keeps the state of the listing mode. type State struct { itemsGetter func(string) Items - filtering bool - filter string - items Items - first int - selected int + selectLast bool + + filtering bool + filter string + items Items + first int + selected int } func (st *State) refilter(f string) { @@ -17,6 +19,11 @@ func (st *State) refilter(f string) { } else { st.items = st.itemsGetter(f) } + if st.selectLast { + st.selected = st.items.Len() - 1 + } else { + st.selected = 0 + } } // Up moves the selection up.