1package modes
2
3import (
4	"errors"
5	"fmt"
6	"math"
7	"path/filepath"
8	"regexp"
9	"strings"
10
11	"src.elv.sh/pkg/cli"
12	"src.elv.sh/pkg/cli/tk"
13	"src.elv.sh/pkg/fsutil"
14	"src.elv.sh/pkg/store/storedefs"
15	"src.elv.sh/pkg/ui"
16)
17
18// Location is a mode for viewing location history and changing to a selected
19// directory. It is based on the ComboBox widget.
20type Location interface {
21	tk.ComboBox
22}
23
24// LocationSpec is the configuration to start the location history feature.
25type LocationSpec struct {
26	// Key bindings.
27	Bindings tk.Bindings
28	// Store provides the directory history and the function to change directory.
29	Store LocationStore
30	// IteratePinned specifies pinned directories by calling the given function
31	// with all pinned directories.
32	IteratePinned func(func(string))
33	// IterateHidden specifies hidden directories by calling the given function
34	// with all hidden directories.
35	IterateHidden func(func(string))
36	// IterateWorksapce specifies workspace configuration.
37	IterateWorkspaces LocationWSIterator
38	// Configuration for the filter.
39	Filter FilterSpec
40}
41
42// LocationStore defines the interface for interacting with the directory history.
43type LocationStore interface {
44	Dirs(blacklist map[string]struct{}) ([]storedefs.Dir, error)
45	Chdir(dir string) error
46	Getwd() (string, error)
47}
48
49// A special score for pinned directories.
50var pinnedScore = math.Inf(1)
51
52var errNoDirectoryHistoryStore = errors.New("no directory history store")
53
54// NewLocation creates a new location mode.
55func NewLocation(app cli.App, cfg LocationSpec) (Location, error) {
56	if cfg.Store == nil {
57		return nil, errNoDirectoryHistoryStore
58	}
59
60	dirs := []storedefs.Dir{}
61	blacklist := map[string]struct{}{}
62	wsKind, wsRoot := "", ""
63
64	if cfg.IteratePinned != nil {
65		cfg.IteratePinned(func(s string) {
66			blacklist[s] = struct{}{}
67			dirs = append(dirs, storedefs.Dir{Score: pinnedScore, Path: s})
68		})
69	}
70	if cfg.IterateHidden != nil {
71		cfg.IterateHidden(func(s string) { blacklist[s] = struct{}{} })
72	}
73	wd, err := cfg.Store.Getwd()
74	if err == nil {
75		blacklist[wd] = struct{}{}
76		if cfg.IterateWorkspaces != nil {
77			wsKind, wsRoot = cfg.IterateWorkspaces.Parse(wd)
78		}
79	}
80	storedDirs, err := cfg.Store.Dirs(blacklist)
81	if err != nil {
82		return nil, fmt.Errorf("db error: %v", err)
83	}
84	for _, dir := range storedDirs {
85		if filepath.IsAbs(dir.Path) {
86			dirs = append(dirs, dir)
87		} else if wsKind != "" && hasPathPrefix(dir.Path, wsKind) {
88			dirs = append(dirs, dir)
89		}
90	}
91
92	l := locationList{dirs}
93
94	w := tk.NewComboBox(tk.ComboBoxSpec{
95		CodeArea: tk.CodeAreaSpec{
96			Prompt:      modePrompt(" LOCATION ", true),
97			Highlighter: cfg.Filter.Highlighter,
98		},
99		ListBox: tk.ListBoxSpec{
100			Bindings: cfg.Bindings,
101			OnAccept: func(it tk.Items, i int) {
102				path := it.(locationList).dirs[i].Path
103				if strings.HasPrefix(path, wsKind) {
104					path = wsRoot + path[len(wsKind):]
105				}
106				err := cfg.Store.Chdir(path)
107				if err != nil {
108					app.Notify(err.Error())
109				}
110				app.PopAddon()
111			},
112		},
113		OnFilter: func(w tk.ComboBox, p string) {
114			w.ListBox().Reset(l.filter(cfg.Filter.makePredicate(p)), 0)
115		},
116	})
117	return w, nil
118}
119
120func hasPathPrefix(path, prefix string) bool {
121	return path == prefix ||
122		strings.HasPrefix(path, prefix+string(filepath.Separator))
123}
124
125// LocationWSIterator is a function that iterates all workspaces by calling
126// the passed function with the name and pattern of each kind of workspace.
127// Iteration should stop when the called function returns false.
128type LocationWSIterator func(func(kind, pattern string) bool)
129
130// Parse returns whether the path matches any kind of workspace. If there is
131// a match, it returns the kind of the workspace and the root. It there is no
132// match, it returns "", "".
133func (ws LocationWSIterator) Parse(path string) (kind, root string) {
134	var foundKind, foundRoot string
135	ws(func(kind, pattern string) bool {
136		if !strings.HasPrefix(pattern, "^") {
137			pattern = "^" + pattern
138		}
139		re, err := regexp.Compile(pattern)
140		if err != nil {
141			// TODO(xiaq): Surface the error.
142			return true
143		}
144		if root := re.FindString(path); root != "" {
145			foundKind, foundRoot = kind, root
146			return false
147		}
148		return true
149	})
150	return foundKind, foundRoot
151}
152
153type locationList struct {
154	dirs []storedefs.Dir
155}
156
157func (l locationList) filter(p func(string) bool) locationList {
158	var filteredDirs []storedefs.Dir
159	for _, dir := range l.dirs {
160		if p(fsutil.TildeAbbr(dir.Path)) {
161			filteredDirs = append(filteredDirs, dir)
162		}
163	}
164	return locationList{filteredDirs}
165}
166
167func (l locationList) Show(i int) ui.Text {
168	return ui.T(fmt.Sprintf("%s %s",
169		showScore(l.dirs[i].Score), fsutil.TildeAbbr(l.dirs[i].Path)))
170}
171
172func (l locationList) Len() int { return len(l.dirs) }
173
174func showScore(f float64) string {
175	if f == pinnedScore {
176		return "  *"
177	}
178	return fmt.Sprintf("%3.0f", f)
179}
180