diff --git a/cmd/contr/e2e_chatpaths_test.go b/cmd/contr/e2e_chatpaths_test.go new file mode 100644 index 00000000..36c3f394 --- /dev/null +++ b/cmd/contr/e2e_chatpaths_test.go @@ -0,0 +1,43 @@ +//go:build e2e + +package main + +import ( + "testing" + "time" +) + +// TestChatPathPopup is #708 (D279): "@" in the composer lists the paths of lead's repo, Tab on a +// folder puts it in and keeps the popup open on its content, Tab on a file closes it. +func TestChatPathPopup(t *testing.T) { + h := newHarness(t) + seeChatPaths(t, h) + c := h.launch(t) + c.waitLabel(t, "lead, ") + c.press(t, "Chat") + c.text(t, "Look at @app") + c.waitLabel(t, "@internal/app/") + c.waitLabel(t, "@wrapper.go") + if c.hasPrefix(t, "@internal/hc/") || c.hasPrefix(t, "@HANDOFF.md") { + t.Fatal("the popup shows a path without app, or a deleted file") + } + c.key(t, "Tab") + gone(t, c, "@wrapper.go", "the popup does not show the folder's content after Tab on it") + c.waitLabel(t, "@internal/app/tabs.go") + c.text(t, "tab") + gone(t, c, "@internal/app/panel.go", "the popup does not filter in the folder") + c.key(t, "Tab") + gone(t, c, "suggestions", "the popup stays open after Tab on a file") +} + +// gone waits until no element has label, and fails with msg after 10 s. +func gone(t *testing.T, c *client, label, msg string) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for c.labels(t)[label] { + if time.Now().After(deadline) { + t.Fatal(msg) + } + time.Sleep(100 * time.Millisecond) + } +} diff --git a/cmd/contr/e2e_see_test.go b/cmd/contr/e2e_see_test.go index 380aba4a..f70a26cd 100644 --- a/cmd/contr/e2e_see_test.go +++ b/cmd/contr/e2e_see_test.go @@ -228,6 +228,12 @@ var seeFixtures = map[string]seeFixture{ ready: "lead, ", press: []string{"Chat", "wait:work group 10 tool calls", "work group 10 tool calls", "wait:tool call Grep", "tool call Bash", "wait:call detail Bash"}, }, + "chat-paths": { + about: "the chat fixture with \"Look at @app\" typed in the composer (D279): the popup above the card with the folder and files of the repo whose paths hold app, the paths with a part that starts with it first", + setup: seeChatPaths, + ready: "lead, ", + press: []string{"Chat", "type:Look at @app", "wait:@internal/app/"}, + }, "session": { about: "the orchestrator lead with the todos and artifacts of canvas board 11 (D197)", setup: seeSession, @@ -359,6 +365,18 @@ func seeChatAs(t *testing.T, h *harness, profile string) { } } +// seeChatPaths is seeChat with the files of seeChanges in lead's repo and an untracked one whose +// name holds "app" in the middle. +func seeChatPaths(t *testing.T, h *harness) { + t.Helper() + seeChat(t, h) + repo := filepath.Join(h.dir, "demo") + seeChanges(t, repo) + if err := os.WriteFile(filepath.Join(repo, "wrapper.go"), []byte("package demo\n"), 0o644); err != nil { + t.Fatal(err) + } +} + // seeChatLong is seeChat with two more turns, so the transcript is longer than the window. func seeChatLong(t *testing.T, h *harness) { t.Helper() diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a14052c6..5809388b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -253,5 +253,6 @@ replaces it. | D276 | 2026-11-11 | The composer gets layout B of board Y5 (#711, owner pick, tracker #634; docs/spec/ChatView.md, Composer). The card holds the field (14px on 22px lines) and one 30px round button, with `space-8` padding: Send (`ink-100` fill, arrow up) while the field has text, Stop (1px ring, 9px square) while the agent works and the field is empty, and no button when idle and empty (was Send dimmed, D271). The Working line inside the card goes away. Under the card, outside it, one footer line 18px high, 16px side inset, mono `meta`, `ink-48`: at the left the working glyph and "Working 7s · Bash · gh pr diff 633" while the agent works (what it does clips with `…`; the model text never clips), else "⏎ send · ⇧⏎ new line"; at the right model, effort and context. The team row moves out of the card: it sits directly above it, 16px inset, no fill or border, and has no live text, so working shows once. Replaces the top line and foot parts of D271. | | D277 | 2026-11-11 | Settings → Updates updates hc (#712). The hc row gets an `Update hc` button that runs `hc update --json` off the UI thread after a confirm (`Update hc?`, `hc downloads the newest version and restarts the core. Running terminals keep running.`, `Cancel` and `Update`; Cancel and Escape run nothing) (5 minute limit) and shows `updating hc…`, the new version, `hc is up to date` or hc's error. hc has no check or dry-run flag, so contr shows no "newer hc" before the update and uses no release URL of its own. `hc update` replaces the hc program and restarts the core by itself; the terminals keep running (tried on a scratch core). So the result line says the core restarted on the new version; contr has no restart button of its own and never runs the update without a click (docs/spec/SettingsUpdates.md). | Owner in #712. | | D278 | 2026-11-11 | Settings → Setup: a read-only setup checklist (#713, S1). `Setup` is the first Settings tab: `Setup`, `General`, `Agents`, `Updates` (changes the tab list of D186). Package `internal/setup` checks hc (`hc --version`), the hc core (the frame's live state), the contr plugin (`claude plugin list --json`), the Claude meter (data under 24 h old), gh (`gh auth status`), and the optional codex and `contr` command. Each command has a 5 s limit and runs off the UI thread when Settings opens, on Recheck, and on activation while the tab shows; nothing runs while Settings is closed. No check reads files in `~/.claude` or `~/.codex`. A step shows a line with a Copy button; contr runs no command here (S3 adds a confirmed plugin install, D65) and never edits Claude Code's settings (the meter stays copy only, D66). The Setup tab and the sidebar Settings button get the D146 dot while a required check is not ready. Spec: `docs/spec/SettingsSetup.md`. | Owner, issue #713: a new Mac shows what is missing in one place. | +| D279 | 2026-11-11 | The composer completes file paths with "@" (#708, tracker #634; docs/spec/ChatView.md, Path popup). "@" at the caret, at the start or after a space, opens the popup of D273 with the paths of the session's working folder: `git ls-files --cached --others --exclude-standard` (no ignored files, no deleted ones) with their folders, or a plain read of the typed folder outside git. Parts that start with the text first, then paths that hold it, at most 50. A folder goes in as "@dir/" and keeps the popup open. The popup logic takes a query at the caret (`QueryAt`) for both "/" and "@", so there is one popup with two sources. | | D280 | 2026-11-11 | The first launch opens Settings → Setup (#713, S2). The setup checks of D278 run once at start, off the UI thread, after the window shows, so the D146 dot shows without opening Settings; then only when Settings opens, on Recheck and on activation while the tab shows; no timer. When the start pass finds a required check not ready (the hc core not counted: it can still be connecting) and `setupSeen` in the settings file is false, Settings opens on the Setup tab once and `setupSeen` is stored. It never opens by itself again. Spec: `docs/spec/SettingsSetup.md`. | Owner, issue #713: a new Mac shows what is missing without a search. | | D282 | 2026-11-11 | A Claude worker gets the PATH of the process that spawns it (#728). `hc update` restarts the core under launchd, whose PATH is short, and each terminal inherits the PATH of the core, so `claude` was not found and the worker ended at once. Spawn now sets `CONTR_PATH` in the terminal: the PATH of the spawning process, then `~/.local/bin`, `/opt/homebrew/bin` and `/usr/local/bin`, without doubles. The clean wrapper (D216) adds these folders after the PATH of the core and removes the variable, so the PATH of the core still comes first. A program that does not start through the clean wrapper (not Claude) gets no change. | Seen on 2026-11-11 after the first `Update hc` (D277): two spawns ended with "claude: not found". | diff --git a/docs/spec/ChatView.md b/docs/spec/ChatView.md index eb3e0007..bae45e34 100644 --- a/docs/spec/ChatView.md +++ b/docs/spec/ChatView.md @@ -199,6 +199,24 @@ Chat text size (D272). With no match, one line "No commands match" (or same. Esc closes the popup until the text changes and does not stop the agent. With no match, Enter sends the text as typed. +### Path popup + +"@" at the caret, at the start of the field or after a space, opens the same +popup (D279) for the files and folders of the session's working folder; the +text from "@" to the caret, with no space in it, filters it. Rows show +"@path" with no description; a folder ends in "/". With no match, the line is +"No files match". + +- The list is the files that git knows there and the untracked files that + git does not ignore, with each folder that holds them; files deleted in the + work tree are left out. Outside git, it is a plain read of the typed folder. + The read runs off the UI thread, once per folder typed. +- Paths with a part that starts with the typed text come first, then paths + that hold it, without regard to case; at most 50 show. +- Enter or Tab replace "@text" with "@path " and put the caret after it. A + folder goes in as "@dir/" with no space, and the popup stays open on its + content. Esc closes it as for "/". + ## Live line The working status of the agent (D276): the left of the composer's footer diff --git a/internal/app/chat.go b/internal/app/chat.go index f27736cd..8a16ba91 100644 --- a/internal/app/chat.go +++ b/internal/app/chat.go @@ -4,9 +4,13 @@ package app // chatview. import ( + "context" + "errors" "log" "os" + "path/filepath" "strings" + "time" "github.com/egoist/mygo/ui" @@ -15,6 +19,7 @@ import ( "github.com/contursofran/contr/internal/app/chatview" "github.com/contursofran/contr/internal/app/questions" "github.com/contursofran/contr/internal/app/view" + "github.com/contursofran/contr/internal/review" "github.com/contursofran/contr/internal/slash" ) @@ -41,6 +46,69 @@ func (s slashSource) Suggestions(addr string) func() ([]chatview.Suggestion, err } } +// pathSource gives the "@" popup the files and folders of the working folder of the session at +// an address (D279): what git lists there, or a plain read of the typed folder outside git. +type pathSource struct { + worker func(addr string) (agents.Worker, bool) +} + +// Paths finds the folder on the UI thread; the read runs off it. +func (s pathSource) Paths(addr, dir string) func() ([]chatview.Suggestion, error) { + var root string + if w, ok := s.worker(addr); ok { + root = w.Worktree + } + return func() ([]chatview.Suggestion, error) { + if root == "" { + return nil, errors.New("no working folder") + } + ctx, cancel := context.WithTimeout(context.Background(), pathTimeout) + defer cancel() + return listPaths(ctx, root, dir) + } +} + +// pathTimeout bounds the read of the paths for "@". +const pathTimeout = 10 * time.Second + +// listPaths lists the files of root that git knows or does not ignore, each folder that holds +// them once, as "dir/", before its files. Outside git it reads the folder dir of root alone. +func listPaths(ctx context.Context, root, dir string) ([]chatview.Suggestion, error) { + repo := review.Repo{Dir: root} + files, ok, err := repo.KnownFiles(ctx) + if err != nil { + return nil, err + } + var out []chatview.Suggestion + if !ok { + es, err := repo.Entries(ctx, strings.TrimSuffix(dir, "/")) + for _, e := range es { + name := dir + e.Name + if e.Dir { + name += "/" + } + out = append(out, chatview.Suggestion{Name: name}) + } + return out, err + } + seen := map[string]bool{} + for _, f := range files { + if _, err := os.Lstat(filepath.Join(root, filepath.FromSlash(f))); err != nil { + continue // deleted in the work tree, not yet in git + } + for i := range len(f) { + if d := f[:i+1]; f[i] == '/' && !seen[d] { + seen[d] = true + out = append(out, chatview.Suggestion{Name: d}) + } + } + if !seen[f] { // git lists a nested repository as "dir/" + out = append(out, chatview.Suggestion{Name: f}) + } + } + return out, nil +} + // attachChat makes the chat view, which reads the session files under the state dir of the // session panel. Its composer sends and interrupts through the agent source, as the orchestrate // pill and the questions card do (D252); without a source it sends nothing. The stop ends its @@ -65,6 +133,7 @@ func (f *Frame) attachChat(env view.Env, src agentstate.AgentSource) (stop func( } else { log.Printf("chat: no home folder, no slash popup: %v", err) } + f.Chat.Path = pathSource{worker: f.agentAt} if f.Outline != nil { f.Outline.Chat = f.chatOutline } diff --git a/internal/app/chatpaths_test.go b/internal/app/chatpaths_test.go new file mode 100644 index 00000000..005dc261 --- /dev/null +++ b/internal/app/chatpaths_test.go @@ -0,0 +1,81 @@ +package app + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "reflect" + "testing" +) + +func names(t *testing.T, root, dir string) []string { + t.Helper() + list, err := listPaths(context.Background(), root, dir) + if err != nil { + t.Fatal(err) + } + var out []string + for _, it := range list { + out = append(out, it.Name) + } + return out +} + +func write(t *testing.T, root string, files ...string) { + t.Helper() + for _, f := range files { + p := filepath.Join(root, filepath.FromSlash(f)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func TestListPathsInGit(t *testing.T) { + root := t.TempDir() + write(t, root, ".gitignore", "a/b.go", "c.md", "out/x.log", "new/n.go") + if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("out/\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"init", "-q"}, {"add", ".gitignore", "a", "c.md"}} { + cmd := exec.Command("git", args...) + cmd.Dir = root + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v %s", args, err, out) + } + } + if err := os.Remove(filepath.Join(root, "c.md")); err != nil { + t.Fatal(err) + } + nested := filepath.Join(root, "nest") + write(t, nested, "m.go") + if out, err := exec.Command("git", "-C", nested, "init", "-q").CombinedOutput(); err != nil { + t.Fatalf("git init: %v %s", err, out) + } + got := names(t, root, "") + want := []string{".gitignore", "a/", "a/b.go", "nest/", "new/", "new/n.go"} + if !reflect.DeepEqual(got, want) { + t.Errorf("paths = %q, want %q (tracked and untracked; not ignored, not deleted; a nested repo once)", got, want) + } +} + +func TestListPathsOutsideGitReadsTheTypedFolder(t *testing.T) { + root := t.TempDir() + write(t, root, "a/b.go", "a/sub/c.go", "d.md") + if got, want := names(t, root, ""), []string{"a/", "d.md"}; !reflect.DeepEqual(got, want) { + t.Errorf("root = %q, want %q", got, want) + } + if got, want := names(t, root, "a/"), []string{"a/sub/", "a/b.go"}; !reflect.DeepEqual(got, want) { + t.Errorf("a/ = %q, want %q", got, want) + } +} + +func TestListPathsOfAnEmptyFolder(t *testing.T) { + if got := names(t, t.TempDir(), ""); len(got) != 0 { + t.Errorf("paths = %q, want none", got) + } +} diff --git a/internal/app/chatview/chat.go b/internal/app/chatview/chat.go index 4316c825..7a67c70d 100644 --- a/internal/app/chatview/chat.go +++ b/internal/app/chatview/chat.go @@ -54,6 +54,9 @@ type Chat struct { // Slash gives the slash commands and skills of the session at addr for the composer's popup // (D273). Nil: "/" opens no popup. Slash SuggestSource + // Path gives the files and folders of the session at addr for the "@" popup (D279); nil + // shows none. + Path PathSource // sg is the state of the composer's suggestion popup. sg suggest diff --git a/internal/app/chatview/composer.go b/internal/app/chatview/composer.go index de1a3edf..52361d5e 100644 --- a/internal/app/chatview/composer.go +++ b/internal/app/chatview/composer.go @@ -148,6 +148,7 @@ func (ch *Chat) composer(c *ui.Context, id string, st liveState) { in.SetTextSelection(n, n) ch.sg.caret = 0 } + _, ch.sg.at = in.TextSelection() if in.Changed() { delete(ch.errs, id) } diff --git a/internal/app/chatview/pathsuggest_test.go b/internal/app/chatview/pathsuggest_test.go new file mode 100644 index 00000000..3806c02c --- /dev/null +++ b/internal/app/chatview/pathsuggest_test.go @@ -0,0 +1,123 @@ +package chatview + +import ( + "reflect" + "strconv" + "testing" + + "github.com/egoist/mygo/ui" +) + +// fakePaths is a PathSource with a fixed list; dirs holds the folders read. +type fakePaths struct { + items []Suggestion + dirs []string +} + +func (f *fakePaths) Paths(addr, dir string) func() ([]Suggestion, error) { + return func() ([]Suggestion, error) { f.dirs = append(f.dirs, dir); return f.items, nil } +} + +func pathSession(t *testing.T) (*sess, *fakePaths) { + s := newSession(t, "idle") + f := &fakePaths{items: []Suggestion{{Name: "cmd/"}, {Name: "cmd/main.go"}, {Name: "docs/"}, + {Name: "docs/spec/"}, {Name: "docs/spec/ChatView.md"}, {Name: "README.md"}}} + s.ch.Path = f + return s, f +} + +func TestQueryAtCaret(t *testing.T) { + for _, c := range []struct { + text string + caret int + ok bool + q Query + }{ + {"@", 1, true, Query{Mark: '@', Text: "", Start: 0, Caret: 1}}, + {"see @do", 7, true, Query{Mark: '@', Text: "do", Start: 4, Caret: 7}}, + {"see @docs/sp and x", 12, true, Query{Mark: '@', Text: "docs/sp", Start: 4, Caret: 12}}, + {"see @do x", 9, false, Query{}}, + {"a@b", 3, false, Query{}}, + {"plain", 5, false, Query{}}, + {"/co", 3, true, Query{Mark: '/', Text: "co", Caret: 3}}, + {"/co", 1, false, Query{}}, + } { + q, ok := QueryAt(c.text, c.caret) + if ok != c.ok || q != c.q { + t.Errorf("QueryAt(%q, %d) = %+v, %v; want %+v, %v", c.text, c.caret, q, ok, c.q, c.ok) + } + } +} + +func TestFilterPathsOrderAndCap(t *testing.T) { + items := []Suggestion{{Name: "internal/review/tree.go"}, {Name: "docs/spec/ChatView.md"}, {Name: "spec.md"}, {Name: "x/inspect.go"}} + var names []string + for _, it := range FilterPaths(items, "spec") { + names = append(names, it.Name) + } + if want := []string{"docs/spec/ChatView.md", "spec.md", "x/inspect.go"}; !reflect.DeepEqual(names, want) { + t.Errorf("FilterPaths = %v, want %v", names, want) + } + var many []Suggestion + for i := range maxPaths + 10 { + many = append(many, Suggestion{Name: "f" + strconv.Itoa(i)}) + } + if n := len(FilterPaths(many, "")); n != maxPaths { + t.Errorf("FilterPaths kept %d, want %d", n, maxPaths) + } +} + +func TestAtInsertsAFile(t *testing.T) { + s, _ := pathSession(t) + s.typeIn("see @main") + if !s.has(LabelSuggest) || !s.has("@cmd/main.go") || s.has("@README.md") { + t.Fatal("the popup does not show the match of @main") + } + s.key(0, ui.KeyTab) + if s.draft() != "see @cmd/main.go " || s.has(LabelSuggest) { + t.Fatalf("after Tab: draft %q, popup %v", s.draft(), s.has(LabelSuggest)) + } +} + +func TestAtInsertsAFolderAndStaysOpen(t *testing.T) { + s, f := pathSession(t) + s.typeIn("see @sp") + s.key(0, ui.KeyEnter) + if s.draft() != "see @docs/spec/" || len(s.sent) != 0 { + t.Fatalf("after Enter: draft %q, sent %q", s.draft(), s.sent) + } + if !s.has(LabelSuggest) || !s.has("@docs/spec/ChatView.md") { + t.Fatal("the popup does not stay open for the folder") + } + if want := []string{"", "docs/spec/"}; !reflect.DeepEqual(f.dirs, want) { + t.Errorf("folders read = %q, want %q", f.dirs, want) + } + s.typeIn("Ch") + if s.draft() != "see @docs/spec/Ch" { + t.Fatalf("typed after the folder: draft %q, want the caret at its end", s.draft()) + } + s.key(0, ui.KeyEscape) + if s.has(LabelSuggest) { + t.Error("Esc does not close the popup") + } +} + +func TestInsertKeepsTheTextAfterTheCaret(t *testing.T) { + ch := &Chat{} + text := "look at @do then" + q, ok := QueryAt(text, 11) + if !ok { + t.Fatal("no query") + } + if got := ch.insert(text, q, Suggestion{Name: "docs/a.md"}); got != "look at @docs/a.md then" || ch.sg.caret != 19 { + t.Errorf("insert = %q, caret %d", got, ch.sg.caret) + } +} + +func TestAtWithNoMatchSaysSo(t *testing.T) { + s, _ := pathSession(t) + s.typeIn("@zzz") + if !s.has(LabelSuggest) || !s.has(TextNoPath) { + t.Error("the popup does not say that no file matches") + } +} diff --git a/internal/app/chatview/suggest.go b/internal/app/chatview/suggest.go index f747b3b7..f1ef8b5f 100644 --- a/internal/app/chatview/suggest.go +++ b/internal/app/chatview/suggest.go @@ -1,9 +1,10 @@ package chatview -// The suggestion popup of the composer (D273): "/" at the start of the field opens the session's -// slash commands and skills above the card, filtered as the owner types. Up and Down move, Enter -// or Tab puts "/name " in the field, Esc closes. The list comes from a SuggestSource, so the -// path popup of "@" can use the same popup with another source. +// The suggestion popup of the composer (D273, D279): "/" at the start of the field opens the +// session's slash commands and skills above the card, and "@" at the caret (at the start or after +// a space) opens the files and folders of its working folder, filtered as the owner types. Up and +// Down move, Enter or Tab puts "/name " or "@path " in the field, Esc closes. A folder goes in as +// "@dir/" and the popup stays open for its content. import ( "strings" @@ -21,6 +22,8 @@ const ( LabelSuggest = "suggestions" // TextNoMatch shows when no item matches what was typed. TextNoMatch = "No commands match" + // TextNoPath shows when no file or folder matches what was typed after "@". + TextNoPath = "No files match" // TextLoading shows while the list is read. TextLoading = "Loading…" ) @@ -31,6 +34,8 @@ const ( suggestRows = 8 // the list scrolls past this many rows suggestPadY = 12 // the room above and below the texts of a row suggestGap = theme.Space6 + // maxPaths is the most paths the popup shows for "@". + maxPaths = 50 ) // Suggestion is an item of the popup: the name it inserts and a one-line description. @@ -44,6 +49,64 @@ type SuggestSource interface { Suggestions(addr string) func() ([]Suggestion, error) } +// PathSource gives the files and folders of the working folder of the session at addr for "@". +// Names are slash-separated and relative to that folder; a folder ends in "/". dir is the folder +// typed so far ("" or ending in "/"), for a source that reads one folder at a time. Paths runs on +// the UI thread and returns the read, which runs off it. +type PathSource interface { + Paths(addr, dir string) func() ([]Suggestion, error) +} + +// Query is what is typed at the caret for the popup: Mark is '/' or '@', Text is what follows it +// up to the caret, and Start and Caret are rune offsets of the mark and the caret. +type Query struct { + Mark rune + Text string + Start, Caret int +} + +// QueryAt finds the query at caret, a rune offset in text: a "/" command (see SlashQuery) with the +// caret at its end, or an "@" at the start or after a space with no space between it and the +// caret. ok is false when no query is typed there. +func QueryAt(text string, caret int) (Query, bool) { + r := []rune(text) + caret = min(max(caret, 0), len(r)) + if q, ok := SlashQuery(text); ok && caret == len(r) { + return Query{Mark: '/', Text: q, Caret: caret}, true + } + i := caret + for i > 0 && !strings.ContainsRune(" \t\n", r[i-1]) { + i-- + } + if i == caret || r[i] != '@' { + return Query{}, false + } + return Query{Mark: '@', Text: string(r[i+1 : caret]), Start: i, Caret: caret}, true +} + +// FilterPaths gives at most maxPaths items whose names hold query, without regard to case: the +// paths with a part that starts with it first, each group in the order of items. +func FilterPaths(items []Suggestion, query string) []Suggestion { + q := strings.ToLower(query) + var starts, holds []Suggestion + for _, it := range items { + n := strings.ToLower(it.Name) + switch { + case strings.HasPrefix(n, q) || strings.Contains(n, "/"+q): + starts = append(starts, it) + case strings.Contains(n, q): + holds = append(holds, it) + } + } + out := append(starts, holds...) + return out[:min(len(out), maxPaths)] +} + +// pathDir is the folder part of a path query: "" or the text up to its last "/". +func pathDir(q string) string { + return q[:strings.LastIndex(q, "/")+1] +} + // suggest is the popup's state for the composer. type suggest struct { // items and errs hold the read list and its failure for each terminal; loading is set while a @@ -56,8 +119,9 @@ type suggest struct { closed string open bool list ui.ListState - // caret is where the field's caret goes as it builds after an insert, 0 for no move. - caret int + // caret is where the field's caret goes as it builds after an insert, 0 for no move; at is + // where the caret was as the field last built. + caret, at int } // SlashQuery is the text after a "/" that starts text, with ok false when text is not a command @@ -86,44 +150,64 @@ func Filter(items []Suggestion, query string) []Suggestion { return append(starts, holds...) } -// suggestOpen tells whether the popup shows for text in terminal id: a command is typed, a source -// is set and Esc did not close it on this text. -func (ch *Chat) suggestOpen(text string) bool { - _, ok := SlashQuery(text) - return ok && ch.Slash != nil && ch.sg.closed != text +// query is the query at the caret of text, with ok false when the popup does not show for it: +// nothing is typed, its source is not set or Esc closed it on this text. +func (ch *Chat) query(text string) (Query, bool) { + q, ok := QueryAt(text, ch.sg.at) + if !ok || ch.sg.closed == text || (q.Mark == '/' && ch.Slash == nil) || (q.Mark == '@' && ch.Path == nil) { + return Query{}, false + } + return q, true } -// suggestions are the items of terminal id that match text. The first call reads the list off -// the UI thread. -func (ch *Chat) suggestions(id, text string) []Suggestion { - q, _ := SlashQuery(text) - items, ok := ch.sg.items[id] - if !ok && !ch.sg.loading[id] && ch.Slash != nil { - ch.sg.loading[id] = true - read := ch.Slash.Suggestions(id) +// listKey is the key of the list of q in the popup's maps: one for "/", one for each folder of "@". +func listKey(id string, q Query) string { + if q.Mark == '@' { + return "@" + id + "\x00" + pathDir(q.Text) + } + return "/" + id +} + +// suggestions are the items of terminal id that match q. The first call for a list reads it off +// the UI thread: once for "/", once for each folder typed after "@". +func (ch *Chat) suggestions(id string, q Query) []Suggestion { + key := listKey(id, q) + items, ok := ch.sg.items[key] + if !ok && !ch.sg.loading[key] { + ch.sg.loading[key] = true + var read func() ([]Suggestion, error) + if q.Mark == '@' { + read = ch.Path.Paths(id, pathDir(q.Text)) + } else { + read = ch.Slash.Suggestions(id) + } ch.Env.Do(func() func() { items, err := read() return func() { - delete(ch.sg.loading, id) - ch.sg.items[id] = items + delete(ch.sg.loading, key) + ch.sg.items[key] = items if err != nil { - ch.sg.errs[id] = err.Error() + ch.sg.errs[key] = err.Error() } ch.Env.Redraw() } }) } - return Filter(items, q) + if q.Mark == '@' { + return FilterPaths(items, q.Text) + } + return Filter(items, q.Text) } // suggestKey takes a key of the field while the popup shows: Up and Down move, Enter and Tab put // the selected item in the field, Esc closes. It returns false for other keys. func (ch *Chat) suggestKey(id string, k ui.TextKey) bool { text := k.Text() - if k.Mods != 0 || !ch.suggestOpen(text) { + q, ok := ch.query(text) + if k.Mods != 0 || !ok { return false } - list := ch.suggestions(id, text) + list := ch.suggestions(id, q) switch k.Key { case ui.KeyUp, ui.KeyDown: if len(list) > 0 { @@ -139,7 +223,7 @@ func (ch *Chat) suggestKey(id string, k ui.TextKey) bool { if ch.sg.sel >= len(list) { return k.Key == ui.KeyTab // Enter with no match sends the text as typed } - k.SetText(ch.insert(list[ch.sg.sel])) + k.SetText(ch.insert(text, q, list[ch.sg.sel])) return true case ui.KeyEscape: ch.sg.closed = text @@ -148,11 +232,19 @@ func (ch *Chat) suggestKey(id string, k ui.TextKey) bool { return false } -// insert is the field's text that picks it, "/name ", with the caret set to go after it. -func (ch *Chat) insert(it Suggestion) string { - text := "/" + it.Name + " " - ch.sg.sel, ch.sg.caret = 0, utf8.RuneCountInString(text) - return text +// insert is text with the query q replaced by the mark and the name of it, "/name " or "@path ", +// with the caret set to go after it. A folder, "@dir/", gets no space, so the popup stays open +// for its content. +func (ch *Chat) insert(text string, q Query, it Suggestion) string { + r := []rune(text) + put := string(q.Mark) + it.Name + if !strings.HasSuffix(it.Name, "/") { + put += " " + } + head := string(r[:q.Start]) + put + ch.sg.sel, ch.sg.caret = 0, utf8.RuneCountInString(head) + ch.sg.at = ch.sg.caret + return head + string(r[q.Caret:]) } // suggestPopup builds the popup above anchor, the card, while it shows for the draft of terminal @@ -161,12 +253,14 @@ func (ch *Chat) insert(it Suggestion) string { // size (D272). func (ch *Chat) suggestPopup(c *ui.Context, id string, anchor ui.Element) { text := *ch.draft(id) - ch.sg.open = ch.suggestOpen(text) + q, open := ch.query(text) + ch.sg.open = open if !ch.sg.open { ch.sg.sel = 0 return } - list := ch.suggestions(id, text) + list := ch.suggestions(id, q) + key := listKey(id, q) if ch.sg.sel >= len(list) { ch.sg.sel = 0 } @@ -183,11 +277,14 @@ func (ch *Chat) suggestPopup(c *ui.Context, id string, anchor ui.Element) { panel.Children(func() { if len(list) == 0 { empty := TextNoMatch + if q.Mark == '@' { + empty = TextNoPath + } switch { - case ch.sg.loading[id]: + case ch.sg.loading[key]: empty = TextLoading - case ch.sg.errs[id] != "": - empty = ch.sg.errs[id] + case ch.sg.errs[key] != "": + empty = ch.sg.errs[key] } kit.Styled(ui.Text(c, empty), detail).SingleLine().Padding(theme.Space6, theme.Space8).TextColor(pal.Ink48) return @@ -195,17 +292,18 @@ func (ch *Chat) suggestPopup(c *ui.Context, id string, anchor ui.Element) { h := float32(min(len(list), suggestRows)) * rowH ui.List(c, &ch.sg.list, len(list), func(i int) { it := list[i] - row := ui.ButtonBase(c).Label("/" + it.Name).Row().FillWidth().Height(rowH).AlignItems(ui.Center).Justify(ui.Start). + shown := string(q.Mark) + it.Name + row := ui.ButtonBase(c).Label(shown).Row().FillWidth().Height(rowH).AlignItems(ui.Center).Justify(ui.Start). Gap(theme.Space8).PaddingX(theme.Space8).Radius(theme.Radius8).FocusRing(false). OnClick(func() { - *ch.draft(id) = ch.insert(it) + *ch.draft(id) = ch.insert(text, q, it) ch.field.Focus() }) if i == ch.sg.sel { row = row.Background(pal.HoverStrong) } row.Children(func() { - kit.Styled(ui.Text(c, "/"+it.Name), name).NoWrap().Shrink(0).TextColor(pal.Ink100) + kit.Styled(ui.Text(c, shown), name).NoWrap().Shrink(0).TextColor(pal.Ink100) kit.Styled(ui.Text(c, it.Detail), detail).SingleLine().Ellipsis("…").MinWidth(0).TextColor(pal.Ink48) }) }).FillWidth().Height(h) diff --git a/internal/review/tree.go b/internal/review/tree.go index c1ed72a2..14e593c1 100644 --- a/internal/review/tree.go +++ b/internal/review/tree.go @@ -173,3 +173,23 @@ func joinRel(rel, name string) string { } return rel + "/" + name } + +// KnownFiles lists the files that git knows under Dir and the new ones that it does not ignore, +// slash-separated and relative to Dir, sorted. ok is false when Dir is not a git work +// tree. +func (r Repo) KnownFiles(ctx context.Context) (files []string, ok bool, err error) { + if !r.IsRepo(ctx) { + return nil, false, nil + } + out, err := r.git(ctx, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--deduplicate") + if err != nil { + return nil, true, err + } + for _, p := range strings.Split(out, "\x00") { + if p != "" { + files = append(files, p) + } + } + sort.Strings(files) + return files, true, nil +}