1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
|
// Package cli — shell loop and command dispatcher.
//
// This file implements the interactive readline shell loop (shellLoop) and the
// two-tier command dispatcher (dispatch → dispatchSimple | dispatchSearch).
// It intentionally contains no flag parsing or backend construction; those
// responsibilities live in cli_flags.go and cli_backend.go respectively.
package cli
import (
"context"
"fmt"
"io"
"os"
"strings"
"codeberg.org/snonux/foostore/internal/store"
"codeberg.org/snonux/foostore/internal/version"
)
// run dispatches a single command (when argv is non-empty and no shell flag is
// set) or enters the interactive shell loop. Returns an exit code.
func (c *CLI) run(ctx context.Context, argv []string) int {
// Enter shell mode when: no arguments, $FOOSTORE_SHELL is set, or the first
// argument is "shell". Mirrors the Ruby shell_loop entry conditions.
enterShell := len(argv) == 0 ||
os.Getenv("FOOSTORE_SHELL") != "" ||
(len(argv) > 0 && argv[0] == "shell")
if enterShell {
return c.shellLoop(ctx)
}
return c.dispatch(ctx, argv)
}
// shellLoop runs the interactive readline loop, reading commands until the
// user presses Ctrl+D (EOF) or types "exit". Mirrors Ruby#shell_loop.
// c.lastResult is updated by dispatch and accessible between iterations.
func (c *CLI) shellLoop(ctx context.Context) int {
ec := 0
logMsg("Interactive mode (vi keys): Ctrl-] for normal mode, i for insert | Enter fuzzy picker | ctrl-t/y/o/e (or alt-t/y/o/e) in picker")
for {
line, err := c.sh.ReadLine(ctx)
if err == io.EOF {
break // Ctrl+D — clean exit.
}
if err != nil {
warn(fmt.Sprintf("readline error: %v", err))
continue
}
argv := strings.Fields(line)
if len(argv) == 0 {
ec = c.handleEmptyInput(ctx)
continue
}
// "last" and "exit" are intercepted before dispatch.
if argv[0] == "last" {
fmt.Println(c.lastResult)
continue
}
if argv[0] == "exit" {
logMsg("Good bye")
break
}
ec = c.dispatch(ctx, argv)
}
return ec
}
// handleEmptyInput handles an empty input line in the shell loop by running
// the fzf picker. Updates c.lastResult when a selection is made and
// dispatches the chosen picker action. Returns an exit code.
func (c *CLI) handleEmptyInput(ctx context.Context) int {
result, fzfErr := c.st.FzfInteractive(ctx)
if fzfErr != nil {
warn(fzfErr.Error())
return 1
}
if result.Description != "" {
c.lastResult = result.Description
logMsg(fmt.Sprintf("Picked: %s", result.Description))
return c.dispatchPickerAction(ctx, result)
}
return 0
}
// pickerActionArgv maps a picker action to the argv slice for dispatch.
// Returns nil for actions that do not map to a CLI command (e.g. PickerSelect).
func pickerActionArgv(action store.PickerAction, description string) []string {
switch action {
case store.PickerCat:
return []string{"cat", description}
case store.PickerPaste:
return []string{"paste", description}
case store.PickerOpen:
return []string{"open", description}
case store.PickerEdit:
return []string{"edit", description}
default:
return nil
}
}
// dispatchPickerAction converts a PickerResult into an argv and dispatches it.
// Returns 0 for picker actions that have no CLI command equivalent.
func (c *CLI) dispatchPickerAction(ctx context.Context, result store.PickerResult) int {
argv := pickerActionArgv(result.Action, result.Description)
if len(argv) == 0 {
return 0
}
return c.dispatch(ctx, argv)
}
// dispatch routes a parsed argv slice to the appropriate handler.
// It returns an exit code and updates c.lastResult when a non-empty result
// is produced. The function delegates to dispatchSimple and dispatchSearch.
func (c *CLI) dispatch(ctx context.Context, argv []string) int {
if len(argv) == 0 {
result, err := c.st.Fzf(ctx)
if err != nil {
warn(err.Error())
return 1
}
if result != "" {
c.lastResult = result
}
return 0
}
cmd := argv[0]
// Commands handled by dispatchSimple (no search term needed).
if ec, result, handled := c.dispatchSimple(ctx, argv, cmd); handled {
if result != "" {
c.lastResult = result
}
return ec
}
// Commands that require a search term (argv[1] or fallback to c.lastResult).
ec, result := c.dispatchSearch(ctx, argv, cmd)
if result != "" {
c.lastResult = result
}
return ec
}
// dispatchSimple handles commands that don't require a search term.
// Returns (exitCode, lastResult, handled). handled=false means the command
// is not in this set and should fall through to dispatchSearch.
func (c *CLI) dispatchSimple(ctx context.Context, argv []string, cmd string) (int, string, bool) {
switch cmd {
case "ls":
indexes, err := c.st.Search(ctx, ".", store.ActionNone, nil, printIndex)
if err != nil {
warn(err.Error())
return 1, "", true
}
logMsg(fmt.Sprintf("%d entries", len(indexes)))
return 0, "", true
case "add":
return c.cmdAdd(ctx, argv), "", true
case "import":
return c.cmdImport(ctx, argv), "", true
case "import_r":
return c.cmdImportR(ctx, argv), "", true
case "sync":
return c.dispatchGitOp(func() error { return c.g.Sync(ctx, c.cfg.SyncRepos) })
case "status":
return c.dispatchGitOp(func() error { return c.g.Status(ctx) })
case "commit":
return c.dispatchGitOp(func() error { return c.g.Commit(ctx) })
case "reset":
return c.dispatchGitOp(func() error { return c.g.Reset(ctx) })
case "fullcommit":
return c.cmdFullCommit(ctx), "", true
case "shred":
return c.dispatchGitOp(func() error { return c.st.ShredAllExported(ctx) })
case "migrate-kdbx":
return c.cmdMigrateKDBX(ctx, argv), "", true
case "version":
logMsg(fmt.Sprintf("foostore %s", version.Version))
return 0, "", true
case "commands":
for _, name := range CommandList {
fmt.Println(name)
}
return 0, "", true
case "help":
printHelp()
return 0, "", true
case "shell":
// When typed in the shell loop, "shell" is intercepted by run() before
// dispatch is called, so this branch only fires in one-shot mode where
// switching to interactive mode is not meaningful.
logMsg("Use foostore without arguments to enter interactive mode")
return 0, "", true
case "exit":
logMsg("Good bye")
return 0, "", true
case "last":
// In shell mode "last" is intercepted by shellLoop; in one-shot mode
// there is no persistent lastResult, so we print the zero value.
fmt.Println(c.lastResult)
return 0, "", true
}
// Not a simple command — let dispatchSearch handle it.
return 0, "", false
}
// dispatchGitOp is a small adapter that wraps a no-argument git (or store)
// operation into the (exitCode, lastResult, handled) triple expected by
// dispatchSimple. Prints a warning and returns exit code 1 on error.
// The context.Context parameter is omitted entirely: each op closure already
// captures ctx from its call site in dispatchSimple, so no forwarding is needed.
func (c *CLI) dispatchGitOp(op func() error) (int, string, bool) {
if err := op(); err != nil {
warn(err.Error())
return 1, "", true
}
return 0, "", true
}
// dispatchSearch handles commands that accept a search term: search, cat,
// paste, get, export, pathexport, open, edit, rm, and the catch-all.
// When no explicit term is supplied, c.lastResult is used as the fallback,
// mirroring Ruby's `search_term = argv.length < 2 ? last_result : argv[1]`.
func (c *CLI) dispatchSearch(ctx context.Context, argv []string, cmd string) (int, string) {
term := c.lastResult // fallback to last search result when no term given
if len(argv) > 1 {
term = argv[1]
}
switch cmd {
case "search":
return c.cmdSearchOnly(ctx, term)
case "get":
// "get" is an alias for "cat".
return c.cmdSearchAction(ctx, term, store.ActionCat, nil)
case "rm":
if err := c.st.Remove(ctx, term, os.Stdin); err != nil {
warn(err.Error())
return 1, ""
}
return 0, ""
case "cat", "paste", "export", "pathexport", "open", "edit":
action := SearchActions[cmd]
return c.cmdSearchAction(ctx, term, action, c.makeActionFn(ctx, action))
default:
// Unknown command: treat as a search term, mirroring Ruby's else branch.
// This allows bare search terms to be typed without prefixing "search".
return c.cmdSearchFallback(ctx, cmd)
}
}
// cmdSearchFallback treats an unrecognised command as a search term. This
// mirrors the Ruby else branch in dispatch and allows users to type bare
// search terms in the shell loop.
func (c *CLI) cmdSearchFallback(ctx context.Context, term string) (int, string) {
indexes, err := c.st.Search(ctx, term, store.ActionNone, nil, printIndex)
if err != nil {
warn(err.Error())
return 1, ""
}
if len(indexes) > 0 {
return 0, indexes[0].Description
}
return 0, ""
}
// cmdSearchOnly runs a search without any action and returns the result.
func (c *CLI) cmdSearchOnly(ctx context.Context, term string) (int, string) {
indexes, err := c.st.Search(ctx, term, store.ActionNone, nil, printIndex)
if err != nil {
warn(err.Error())
return 1, ""
}
if len(indexes) > 0 {
return 0, indexes[0].Description
}
return 0, ""
}
// cmdSearchAction runs a search with the given action and optional callback.
func (c *CLI) cmdSearchAction(ctx context.Context, term string, action store.Action, actionFn func(context.Context, *store.Index, *store.Data) error) (int, string) {
indexes, err := c.st.Search(ctx, term, action, actionFn, printIndex)
if err != nil {
warn(err.Error())
return 1, ""
}
if len(indexes) > 0 {
return 0, indexes[0].Description
}
return 0, ""
}
|