дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(agents): reference folders from @ completion (#1210)
Folders now appear in the composer's `@` completion next to files, from a new `find_dirs` index query over the folders that hold indexed files (kept incrementally in the index, ~0.5 ms on 100k files). Enter or a click references the folder as `@dir/` and sends it as a resource link; Tab keeps completing inside it. Closes #1202
Цей коміт міститься в:
1 джерело
d7886bab45
коміт
b473876d59
19 files changed
+331
-24
No files matched your search
@@ -36,6 +36,10 @@ pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeRe
|
||||
Ok(host.project(window)?.index.find_files(&query, limit as usize))
|
||||
}
|
||||
|
||||
pub fn find_dirs(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
|
||||
Ok(host.project(window)?.index.find_dirs(&query, limit as usize))
|
||||
}
|
||||
|
||||
pub fn find_symbols(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -157,6 +161,7 @@ pub async fn web_unresolved_references(host: &Host, window: &str, doc: DocId) ->
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
|
||||
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
|
||||
d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
|
||||
d.add("find_symbols", |c| async move {
|
||||
find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?)
|
||||
});
|
||||
|
||||
@@ -226,6 +226,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "deploy_coverage"
|
||||
| "deploy_test_connection"
|
||||
| "find_files"
|
||||
| "find_dirs"
|
||||
| "find_symbols"
|
||||
| "mark_recent"
|
||||
| "remove_recent"
|
||||
|
||||
@@ -14,6 +14,9 @@ fn bench(c: &mut Criterion) {
|
||||
for q in ["ordcon", "src/user", "PaymentService", "zzzz", "a"] {
|
||||
g.bench_function(format!("find_files/{q}"), |b| b.iter(|| black_box(index.find_files(q, 50).len())));
|
||||
}
|
||||
for q in ["util", "src/ui", "a"] {
|
||||
g.bench_function(format!("find_dirs/{q}"), |b| b.iter(|| black_box(index.find_dirs(q, 50).len())));
|
||||
}
|
||||
for q in ["OCo", "invoiceRepo", "renderView", "q"] {
|
||||
g.bench_function(format!("find_symbols/{q}"), |b| {
|
||||
b.iter(|| black_box(index.find_symbols(q, SymbolFilter::All, 50).len()))
|
||||
|
||||
@@ -134,6 +134,9 @@ impl FileEntry {
|
||||
pub(crate) struct IndexData {
|
||||
pub files: Vec<Option<FileEntry>>,
|
||||
by_path: HashMap<PathBuf, u32>,
|
||||
/// Every folder holding an indexed file at any depth, keyed by its `rel` with a
|
||||
/// trailing `/`: its absolute path and the number of files below it.
|
||||
pub dirs: HashMap<Box<str>, (PathBuf, u32)>,
|
||||
free: Vec<u32>,
|
||||
pub file_count: u32,
|
||||
pub symbol_count: u32,
|
||||
@@ -167,6 +170,11 @@ impl IndexData {
|
||||
}
|
||||
};
|
||||
let path = self.files[slot as usize].as_ref().map(|e| e.path.clone()).unwrap_or_default();
|
||||
if let Some(e) = &self.files[slot as usize] {
|
||||
for (rel, abs) in ancestors(e) {
|
||||
self.dirs.entry(rel.into()).or_insert_with(|| (abs.to_owned(), 0)).1 += 1;
|
||||
}
|
||||
}
|
||||
self.by_path.insert(path, slot);
|
||||
self.file_count += 1;
|
||||
self.symbol_count += symbols;
|
||||
@@ -176,6 +184,14 @@ impl IndexData {
|
||||
let slot = self.by_path.remove(path)?;
|
||||
let old = self.files[slot as usize].take()?;
|
||||
self.free.push(slot);
|
||||
for (rel, _) in ancestors(&old) {
|
||||
if let Some(d) = self.dirs.get_mut(rel) {
|
||||
d.1 -= 1;
|
||||
if d.1 == 0 {
|
||||
self.dirs.remove(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.file_count -= 1;
|
||||
self.symbol_count -= old.symbols.len() as u32;
|
||||
Some(old)
|
||||
@@ -197,6 +213,7 @@ impl IndexData {
|
||||
self.files.shrink_to_fit();
|
||||
self.free.shrink_to_fit();
|
||||
self.by_path.shrink_to_fit();
|
||||
self.dirs.shrink_to_fit();
|
||||
}
|
||||
|
||||
pub(crate) fn paths(&self) -> impl Iterator<Item = &PathBuf> {
|
||||
@@ -204,6 +221,19 @@ impl IndexData {
|
||||
}
|
||||
}
|
||||
|
||||
/// The folders holding `e` (`rel` with a trailing `/`, absolute path), innermost first.
|
||||
fn ancestors(e: &FileEntry) -> impl Iterator<Item = (&str, &Path)> {
|
||||
let mut rel = e.dir();
|
||||
let mut abs = e.path.parent();
|
||||
std::iter::from_fn(move || {
|
||||
let path = abs.filter(|_| !rel.is_empty())?;
|
||||
let here = rel;
|
||||
rel = rel[..rel.len() - 1].rfind('/').map_or("", |i| &rel[..=i]);
|
||||
abs = path.parent();
|
||||
Some((here, path))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -223,6 +253,23 @@ mod tests {
|
||||
FileEntry::new(PathBuf::from("/r").join(path), path.to_owned(), record)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirs_follow_inserts_and_removals() {
|
||||
let mut d = IndexData::default();
|
||||
d.insert(entry("a/b/c.rs", 0));
|
||||
d.insert(entry("a/d.rs", 0));
|
||||
d.insert(entry("a/d.rs", 0));
|
||||
d.insert(entry("top.rs", 0));
|
||||
let mut dirs: Vec<_> = d.dirs.iter().map(|(k, v)| (k.to_string(), v.1)).collect();
|
||||
dirs.sort();
|
||||
assert_eq!(dirs, [("a/".into(), 2), ("a/b/".into(), 1)]);
|
||||
assert_eq!(d.dirs["a/b/"].0, PathBuf::from("/r/a/b"));
|
||||
d.remove(Path::new("/r/a/b/c.rs"));
|
||||
assert!(!d.dirs.contains_key("a/b/"));
|
||||
d.remove_tree(Path::new("/r/a"));
|
||||
assert!(d.dirs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_replace_remove_keep_counts() {
|
||||
let mut d = IndexData::default();
|
||||
|
||||
@@ -167,6 +167,11 @@ impl ProjectIndex {
|
||||
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
|
||||
}
|
||||
|
||||
/// Fuzzy search for folders that hold indexed files; `rel_path` ends in `/`.
|
||||
pub fn find_dirs(&self, query: &str, limit: usize) -> Vec<FileMatch> {
|
||||
matching::find_dirs(&self.shared.data.read(), query, limit)
|
||||
}
|
||||
|
||||
/// Fuzzy symbol search; CamelHump aware (`OCo` → `OrderController`).
|
||||
pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec<SymbolMatch> {
|
||||
let recent = self.shared.recent.lock().clone();
|
||||
@@ -366,6 +371,10 @@ pub mod bench_support {
|
||||
matching::find_files(&self.data, &matching::Recents { paths: &self.recent }, query, limit)
|
||||
}
|
||||
|
||||
pub fn find_dirs(&self, query: &str, limit: usize) -> Vec<FileMatch> {
|
||||
matching::find_dirs(&self.data, query, limit)
|
||||
}
|
||||
|
||||
pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec<SymbolMatch> {
|
||||
matching::find_symbols(&self.data, &matching::Recents { paths: &self.recent }, query, filter, limit)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use nucleo_matcher::{Config, Matcher, Utf32Str, Utf32String};
|
||||
use rayon::prelude::*;
|
||||
use std::cell::RefCell;
|
||||
use std::cmp::Ordering;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const TIER_NAME: u32 = 1 << 20;
|
||||
const EXACT: u32 = 4000;
|
||||
@@ -251,6 +251,76 @@ pub(crate) fn find_files(data: &IndexData, recents: &Recents, query: &str, limit
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fuzzy folder search over the folders holding indexed files: folder-name matches beat
|
||||
/// path matches; `/` in the query matches the whole path. `rel_path` ends in `/`.
|
||||
pub(crate) fn find_dirs(data: &IndexData, query: &str, limit: usize) -> Vec<FileMatch> {
|
||||
let q = normalize_file_query(query);
|
||||
if limit == 0 || q.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let by_path = q.contains('/');
|
||||
let needle = Needle::new(q.trim_end_matches('/'));
|
||||
// (score, rel without the trailing `/`, char offset of the name when it matched by name)
|
||||
let score = |m: &mut Matcher, buf: &mut Vec<char>, rel: &str| -> Option<(u32, Option<u32>)> {
|
||||
let trimmed = &rel[..rel.len() - 1];
|
||||
let start = trimmed.rfind('/').map_or(0, |i| i + 1);
|
||||
let name = &trimmed[start..];
|
||||
if !by_path
|
||||
&& needle.mask & char_mask(name) == needle.mask
|
||||
&& let Some(s) = needle.score(m, name, buf)
|
||||
{
|
||||
return Some((TIER_NAME + s * 4 + needle.name_boost(name), Some(trimmed[..start].chars().count() as u32)));
|
||||
}
|
||||
if needle.mask & char_mask(trimmed) != needle.mask {
|
||||
return None;
|
||||
}
|
||||
needle.score(m, trimmed, buf).map(|s| (s, None))
|
||||
};
|
||||
let mut best: Vec<(u32, &str, &Path, Option<u32>)> = data
|
||||
.dirs
|
||||
.par_iter()
|
||||
.filter_map(|(rel, (path, _))| {
|
||||
let mut buf = Vec::new();
|
||||
with_matcher(|m| score(m, &mut buf, rel)).map(|(s, at)| (s, &**rel, path.as_path(), at))
|
||||
})
|
||||
.collect();
|
||||
let order = |a: &(u32, &str, &Path, Option<u32>), b: &(u32, &str, &Path, Option<u32>)| {
|
||||
b.0.cmp(&a.0).then_with(|| a.1.len().cmp(&b.1.len())).then_with(|| a.1.cmp(b.1))
|
||||
};
|
||||
if best.len() > limit {
|
||||
best.select_nth_unstable_by(limit, order);
|
||||
best.truncate(limit);
|
||||
}
|
||||
// Match indices and the CamelHump bonus `find_files` gives, so equal matches rank alike.
|
||||
let mut out: Vec<FileMatch> = with_matcher(|m| {
|
||||
let (mut buf, mut idx) = (Vec::new(), Vec::new());
|
||||
best.into_iter()
|
||||
.map(|(score, rel, path, name_at)| {
|
||||
let trimmed = &rel[..rel.len() - 1];
|
||||
let (bonus, indices) = match name_at {
|
||||
Some(at) => {
|
||||
let name = &trimmed[trimmed.rfind('/').map_or(0, |i| i + 1)..];
|
||||
needle.indices(m, name, &mut buf, &mut idx);
|
||||
(hump_bonus(name, &idx), idx.iter().map(|i| i + at).collect())
|
||||
}
|
||||
None => {
|
||||
needle.indices(m, trimmed, &mut buf, &mut idx);
|
||||
(hump_bonus(trimmed, &idx), idx.clone())
|
||||
}
|
||||
};
|
||||
FileMatch { path: path.to_path_buf().into(), rel_path: rel.to_owned(), score: score + bonus, indices }
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
out.sort_by(|a, b| {
|
||||
b.score
|
||||
.cmp(&a.score)
|
||||
.then_with(|| a.rel_path.len().cmp(&b.rel_path.len()))
|
||||
.then_with(|| a.rel_path.cmp(&b.rel_path))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn file(data: &IndexData, slot: u32) -> &FileEntry {
|
||||
data.files[slot as usize].as_ref().expect("candidate slots are live")
|
||||
}
|
||||
|
||||
@@ -138,6 +138,23 @@ fn file_ranking_is_idea_like() {
|
||||
assert_eq!(shown.to_lowercase(), "uibtn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_dirs_ranks_folder_names() {
|
||||
let f = sample();
|
||||
let idx = f.open();
|
||||
let dirs = |q: &str| idx.find_dirs(q, 20).into_iter().map(|m| m.rel_path).collect::<Vec<_>>();
|
||||
assert_eq!(dirs("order"), ["src/order/"]);
|
||||
assert_eq!(dirs("src")[0], "src/");
|
||||
assert!(dirs("src").contains(&"src/ui/".to_owned()), "{:?}", dirs("src"));
|
||||
assert_eq!(dirs("src/ui/"), ["src/ui/"]);
|
||||
assert!(dirs("").is_empty());
|
||||
assert!(dirs("main").is_empty());
|
||||
|
||||
let m = &idx.find_dirs("ui", 1)[0];
|
||||
assert_eq!(m.path.0, f.root.join("src/ui"));
|
||||
assert_eq!(m.indices, vec![4, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorter_paths_win_ties_and_recent_files_boost() {
|
||||
let fx = Fixture::new(&[("a/b/c/util.rs", ""), ("x/util.rs", ""), ("y/util.rs", "")]);
|
||||
|
||||
@@ -76,6 +76,9 @@
|
||||
"index_100k/find_files/ordcon": 10000,
|
||||
"index_100k/find_files/src/user": 10000,
|
||||
"index_100k/find_files/PaymentService": 10000,
|
||||
"index_100k/find_dirs/util": 5000,
|
||||
"index_100k/find_dirs/src/ui": 5000,
|
||||
"index_100k/find_dirs/a": 5000,
|
||||
"index_100k/find_symbols/OCo": 15000,
|
||||
"index_100k/find_symbols/q": 15000,
|
||||
"index_100k/find_symbols/invoiceRepo": 15000,
|
||||
|
||||
@@ -188,6 +188,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
nav::index_status,
|
||||
nav::spring_route_sources,
|
||||
nav::find_files,
|
||||
nav::find_dirs,
|
||||
nav::find_symbols,
|
||||
nav::mark_recent,
|
||||
nav::recent_files,
|
||||
|
||||
@@ -22,6 +22,13 @@ pub fn find_files(window: Window, state: State<'_, AppState>, query: String, lim
|
||||
ide_host::ops::nav::find_files(&state.host, window.label(), query, limit)
|
||||
}
|
||||
|
||||
/// Folders holding indexed files, for `@` references.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn find_dirs(window: Window, state: State<'_, AppState>, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
|
||||
ide_host::ops::nav::find_dirs(&state.host, window.label(), query, limit)
|
||||
}
|
||||
|
||||
/// Spring Web mapping sources for the request-path index of Search Everywhere.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -327,6 +327,8 @@ export const commands = {
|
||||
/** Spring Web mapping sources for the request-path index of Search Everywhere. */
|
||||
springRouteSources: () => typedError<RouteSource[], IdeError>(__TAURI_INVOKE("spring_route_sources")),
|
||||
findFiles: (query: string, limit: number) => typedError<FileMatch[], IdeError>(__TAURI_INVOKE("find_files", { query, limit })),
|
||||
/** Folders holding indexed files, for `@` references. */
|
||||
findDirs: (query: string, limit: number) => typedError<FileMatch[], IdeError>(__TAURI_INVOKE("find_dirs", { query, limit })),
|
||||
findSymbols: (query: string, filter: SymbolFilter, limit: number) => typedError<SymbolMatch[], IdeError>(__TAURI_INVOKE("find_symbols", { query, filter, limit })),
|
||||
markRecent: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("mark_recent", { path })),
|
||||
/** Recently opened files, most recent first (⌘E). */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The message box of the New Task page and of sessions: `@` file references (project file
|
||||
* search), `/` agent commands, ↑ / ↓ history, pasted or dropped images, Shift+Tab to cycle
|
||||
* The message box of the New Task page and of sessions: `@` file and folder references
|
||||
* (project index search), `/` agent commands, ↑ / ↓ history, pasted or dropped images, Shift+Tab to cycle
|
||||
* the agent's mode. Its draft lives in the store under `id`.
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
applyCompletion,
|
||||
atEdgeLine,
|
||||
isPathQuery,
|
||||
mergeMatches,
|
||||
resolveTyped,
|
||||
completionAt,
|
||||
imageFromDataUrl,
|
||||
@@ -129,14 +130,15 @@ export function Composer(props: ComposerProps) {
|
||||
.catch(() => alive && setFiles([]));
|
||||
return;
|
||||
}
|
||||
call(commands.findFiles(query, MAX_ITEMS))
|
||||
.then((found) => {
|
||||
Promise.all([call(commands.findFiles(query, MAX_ITEMS)), call(commands.findDirs(query, MAX_ITEMS))])
|
||||
.then(([found, dirs]) => {
|
||||
if (!alive) return;
|
||||
setFiles(
|
||||
found.map((f) => ({
|
||||
mergeMatches(found, dirs, MAX_ITEMS).map((f) => ({
|
||||
kind: "file",
|
||||
rel: f.relPath,
|
||||
path: root === projectRoot ? f.path : `${root}/${f.relPath}`,
|
||||
rel: f.rel,
|
||||
dir: f.dir,
|
||||
path: root === projectRoot ? f.path : `${root}/${f.rel}`,
|
||||
})),
|
||||
);
|
||||
})
|
||||
@@ -171,19 +173,20 @@ export function Composer(props: ComposerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const accept = (item: Item | undefined) => {
|
||||
/** Enter or a click references the item; Tab on a folder keeps completing inside it. */
|
||||
const accept = (item: Item | undefined, descend = false) => {
|
||||
if (!item || !completion) return;
|
||||
if (item.kind === "file" && item.dir) {
|
||||
// Descend: keep completing inside the folder.
|
||||
const insert = `${mentionOf(item.rel)}/`;
|
||||
setText(text.slice(0, completion.from) + insert + text.slice(completion.to), completion.from + insert.length);
|
||||
return;
|
||||
}
|
||||
if (item.kind === "file") {
|
||||
const mention = mentionOf(item.rel);
|
||||
const next = applyCompletion(text, completion, mention);
|
||||
const rel = item.dir ? `${item.rel}/` : item.rel;
|
||||
// A folder stays referenced while its `@folder/` is left as typed (`toPrompt`).
|
||||
const current = composerDraft(id);
|
||||
updateComposer(id, { files: [...current.files, { path: item.path, rel: item.rel } satisfies FileRef] });
|
||||
updateComposer(id, { files: [...current.files, { path: item.path, rel } satisfies FileRef] });
|
||||
if (item.dir && descend) {
|
||||
const insert = mentionOf(rel);
|
||||
setText(text.slice(0, completion.from) + insert + text.slice(completion.to), completion.from + insert.length);
|
||||
return;
|
||||
}
|
||||
const next = applyCompletion(text, completion, mentionOf(rel));
|
||||
setText(next.text, next.caret);
|
||||
} else {
|
||||
const next = applyCompletion(text, completion, `/${item.command.name}`);
|
||||
@@ -266,7 +269,7 @@ export function Composer(props: ComposerProps) {
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
accept(items[active]);
|
||||
accept(items[active], e.key === "Tab");
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
@@ -345,7 +348,10 @@ export function Composer(props: ComposerProps) {
|
||||
{item.kind === "file" ? (
|
||||
<>
|
||||
<Icon name={item.dir ? "folder" : fileIconName(basename(item.rel), item.rel)} size={16} />
|
||||
<span className={styles.itemName}>{basename(item.rel)}</span>
|
||||
<span className={styles.itemName}>
|
||||
{basename(item.rel)}
|
||||
{item.dir && "/"}
|
||||
</span>
|
||||
<span className={styles.itemDetail}>{item.rel}</span>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -99,7 +99,9 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
|
||||
id={HOME}
|
||||
autoFocus
|
||||
placeholder={
|
||||
docked ? "Describe a task…" : "Describe a task for the agent. @ references a file, / runs a command"
|
||||
docked
|
||||
? "Describe a task…"
|
||||
: "Describe a task for the agent. @ references a file or folder, / runs a command"
|
||||
}
|
||||
root={root}
|
||||
projectRoot={root}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
nextValue,
|
||||
recall,
|
||||
toPrompt,
|
||||
mergeMatches,
|
||||
retryParts,
|
||||
usageLabel,
|
||||
limitLabel,
|
||||
@@ -67,6 +68,27 @@ const option = (category: string, values: string[], current: string | null): Age
|
||||
via: "option",
|
||||
});
|
||||
|
||||
describe("mergeMatches", () => {
|
||||
it("ranks folders among files by score, files first on a tie", () => {
|
||||
const merged = mergeMatches(
|
||||
[
|
||||
{ path: "/p/src/agents.ts", relPath: "src/agents.ts", score: 50 },
|
||||
{ path: "/p/x.ts", relPath: "x.ts", score: 10 },
|
||||
],
|
||||
[
|
||||
{ path: "/p/src/agents", relPath: "src/agents/", score: 50 },
|
||||
{ path: "/p/docs", relPath: "docs/", score: 70 },
|
||||
],
|
||||
3,
|
||||
);
|
||||
expect(merged).toEqual([
|
||||
{ path: "/p/docs", rel: "docs", dir: true, score: 70 },
|
||||
{ path: "/p/src/agents.ts", rel: "src/agents.ts", dir: false, score: 50 },
|
||||
{ path: "/p/src/agents", rel: "src/agents", dir: true, score: 50 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent composer", () => {
|
||||
it("completes @files anywhere and /commands as the first word", () => {
|
||||
expect(completionAt("fix @src/le", 11)).toEqual({ kind: "file", query: "src/le", from: 4, to: 11 });
|
||||
@@ -111,6 +133,11 @@ describe("agent composer", () => {
|
||||
});
|
||||
expect(prompt).toEqual({ text: "look at @src/a.rs:3-5 and @b.rs", files: ["/w/src/a.rs", "/w/b.rs"], images: [] });
|
||||
expect(mentionOf("a.rs", { from: 3, to: 3 })).toBe("@a.rs:3");
|
||||
// A folder counts while `@src/` is left as typed, not once a file inside it was picked.
|
||||
const folder = { path: "/w/src", rel: "src/" };
|
||||
expect(toPrompt({ text: "explain @src/ please", files: [folder], images: [] }).files).toEqual(["/w/src"]);
|
||||
expect(toPrompt({ text: "explain @src/", files: [folder], images: [] }).files).toEqual(["/w/src"]);
|
||||
expect(toPrompt({ text: "explain @src/a.rs", files: [folder], images: [] }).files).toEqual([]);
|
||||
expect(canSend({ text: " ", files: [], images: [] })).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const findFiles = vi.fn();
|
||||
const findDirs = vi.fn();
|
||||
const pathCompletions = vi.fn();
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
isTauri: vi.fn(() => false),
|
||||
commands: {
|
||||
...real.commands,
|
||||
findFiles: (query: string, limit: number) => findFiles(query, limit),
|
||||
findDirs: (query: string, limit: number) => findDirs(query, limit),
|
||||
pathCompletions: (base: string, typed: string) => pathCompletions(base, typed),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { Composer } from "./Composer";
|
||||
import { composerDraft, updateComposer } from "./store";
|
||||
import { emptyDraft, toPrompt } from "./composerModel";
|
||||
|
||||
const ID = "folders-test";
|
||||
const ok = <T,>(data: T) => Promise.resolve({ status: "ok", data });
|
||||
|
||||
function renderComposer() {
|
||||
render(
|
||||
<Composer
|
||||
id={ID}
|
||||
placeholder="Ask"
|
||||
root="/p"
|
||||
projectRoot="/p"
|
||||
commands={[]}
|
||||
history={[]}
|
||||
config={[]}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
return screen.getByPlaceholderText("Ask") as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
updateComposer(ID, emptyDraft());
|
||||
const match = (path: string, relPath: string, score: number) => ({ path, relPath, score, indices: [] });
|
||||
findFiles.mockReset().mockReturnValue(ok([match("/p/src/agents.ts", "src/agents.ts", 40)]));
|
||||
findDirs.mockReset().mockReturnValue(ok([match("/p/src/agents", "src/agents/", 90)]));
|
||||
pathCompletions.mockReset().mockReturnValue(ok([{ name: "lib", dir: true }]));
|
||||
});
|
||||
|
||||
describe("Composer @ folders", () => {
|
||||
it("lists index folders among files and references one on Enter", async () => {
|
||||
const box = renderComposer();
|
||||
await userEvent.type(box, "see @agen");
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((o) => o.textContent)).toEqual(["agents/src/agents", "agents.tssrc/agents.ts"]);
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(box.value).toBe("see @src/agents/ ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/agents"]);
|
||||
});
|
||||
|
||||
it("keeps completing inside a folder on Tab, which drops the folder once a file is picked", async () => {
|
||||
const box = renderComposer();
|
||||
await userEvent.type(box, "@agen");
|
||||
await screen.findAllByRole("option");
|
||||
await userEvent.keyboard("{Tab}");
|
||||
expect(box.value).toBe("@src/agents/");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/agents"]);
|
||||
await userEvent.type(box, "x.ts ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual([]);
|
||||
});
|
||||
|
||||
it("references a folder of a typed path", async () => {
|
||||
const box = renderComposer();
|
||||
await userEvent.type(box, "@./");
|
||||
await screen.findByRole("option");
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(box.value).toBe("@./lib/ ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/lib"]);
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,29 @@ export function resolveTyped(root: string, rel: string): string {
|
||||
return isDrive(out[0]) ? out.join("/") : `/${out.join("/")}`;
|
||||
}
|
||||
|
||||
interface IndexMatch {
|
||||
path: string;
|
||||
relPath: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index file and folder matches as one list, best first (files first on a tie); folders'
|
||||
* `rel` loses the trailing `/` the index gives them.
|
||||
*/
|
||||
export function mergeMatches(
|
||||
files: readonly IndexMatch[],
|
||||
dirs: readonly IndexMatch[],
|
||||
limit: number,
|
||||
): { path: string; rel: string; dir: boolean; score: number }[] {
|
||||
return [
|
||||
...files.map((f) => ({ path: f.path, rel: f.relPath, dir: false, score: f.score })),
|
||||
...dirs.map((d) => ({ path: d.path, rel: d.relPath.replace(/\/$/, ""), dir: true, score: d.score })),
|
||||
]
|
||||
.sort((a, b) => b.score - a.score || Number(a.dir) - Number(b.dir))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/** `@rel` with a line range suffix when given ("@src/a.rs:10-20"). */
|
||||
export function mentionOf(rel: string, lines?: { from: number; to: number }): string {
|
||||
if (!lines) return `@${rel}`;
|
||||
|
||||
@@ -3385,7 +3385,7 @@
|
||||
"Not installed": "Non installé",
|
||||
"{command} was not found on PATH": "{command} est introuvable dans le PATH",
|
||||
"Agent tasks": "Tâches d'agent",
|
||||
"Describe a task for the agent. @ references a file, / runs a command": "Décrivez une tâche pour l'agent. @ référence un fichier, / lance une commande",
|
||||
"Describe a task for the agent. @ references a file or folder, / runs a command": "Décrivez une tâche pour l'agent. @ référence un fichier ou un dossier, / lance une commande",
|
||||
"away from your changes": "à l'écart de vos modifications",
|
||||
"edits your files directly": "modifie directement vos fichiers",
|
||||
"from": "depuis",
|
||||
|
||||
@@ -3492,7 +3492,7 @@
|
||||
"Not installed": "설치되지 않음",
|
||||
"{command} was not found on PATH": "PATH에서 {command}을(를) 찾을 수 없습니다",
|
||||
"Agent tasks": "에이전트 작업",
|
||||
"Describe a task for the agent. @ references a file, / runs a command": "에이전트에게 맡길 작업을 설명하세요. @는 파일 참조, /는 명령 실행",
|
||||
"Describe a task for the agent. @ references a file or folder, / runs a command": "에이전트에게 맡길 작업을 설명하세요. @는 파일·폴더 참조, /는 명령 실행",
|
||||
"away from your changes": "변경 사항과 분리됨",
|
||||
"edits your files directly": "파일을 직접 편집함",
|
||||
"from": "기준",
|
||||
|
||||
@@ -3498,7 +3498,7 @@
|
||||
"Not installed": "未安装",
|
||||
"{command} was not found on PATH": "在 PATH 中找不到 {command}",
|
||||
"Agent tasks": "Agent 任务",
|
||||
"Describe a task for the agent. @ references a file, / runs a command": "描述要交给 agent 的任务。@ 引用文件,/ 运行命令",
|
||||
"Describe a task for the agent. @ references a file or folder, / runs a command": "描述要交给 agent 的任务。@ 引用文件或文件夹,/ 运行命令",
|
||||
"away from your changes": "不影响你的改动",
|
||||
"edits your files directly": "直接修改你的文件",
|
||||
"from": "基于",
|
||||
|
||||
Reference in new issue
Заблокувати користувача