mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
feat(agents): complete typed paths outside the project after @
@../, @./ and @/ list the directory typed so far instead of searching the project index. Folders descend in place; files resolve to absolute paths.
This commit is contained in:
@@ -16,6 +16,8 @@ import {
|
||||
MAX_IMAGE_BYTES,
|
||||
applyCompletion,
|
||||
atEdgeLine,
|
||||
isPathQuery,
|
||||
resolveTyped,
|
||||
completionAt,
|
||||
imageFromDataUrl,
|
||||
matchCommands,
|
||||
@@ -34,7 +36,7 @@ import { isImeKey } from "@/platform/ime";
|
||||
const MAX_PROMPT_HEIGHT = 280;
|
||||
const MAX_ITEMS = 12;
|
||||
|
||||
type Item = { kind: "file"; path: string; rel: string } | { kind: "command"; command: AgentCommand };
|
||||
type Item = { kind: "file"; path: string; rel: string; dir?: boolean } | { kind: "command"; command: AgentCommand };
|
||||
|
||||
export interface ComposerProps {
|
||||
/** Store key of the draft. */
|
||||
@@ -100,8 +102,30 @@ export function Composer(props: ComposerProps) {
|
||||
useEffect(() => {
|
||||
if (completion?.kind !== "file") return;
|
||||
let alive = true;
|
||||
const query = completion.query;
|
||||
const timer = setTimeout(() => {
|
||||
call(commands.findFiles(completion.query, MAX_ITEMS))
|
||||
if (isPathQuery(query)) {
|
||||
// Outside the index: list the directory typed so far.
|
||||
const typed = /(^|\/)\.\.?$/.test(query) ? `${query}/` : query;
|
||||
const dirPart = typed.slice(0, typed.lastIndexOf("/") + 1);
|
||||
const last = typed.slice(dirPart.length);
|
||||
call(commands.pathCompletions(`${typed.startsWith("/") ? "" : root}/_`, typed))
|
||||
.then((found) => {
|
||||
if (!alive) return;
|
||||
setFiles(
|
||||
found
|
||||
.filter((f) => f.name.toLowerCase().startsWith(last.toLowerCase()))
|
||||
.slice(0, MAX_ITEMS)
|
||||
.map((f) => {
|
||||
const rel = `${dirPart}${f.name}`;
|
||||
return { kind: "file", rel, dir: f.dir, path: resolveTyped(root, rel) };
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(() => alive && setFiles([]));
|
||||
return;
|
||||
}
|
||||
call(commands.findFiles(query, MAX_ITEMS))
|
||||
.then((found) => {
|
||||
if (!alive) return;
|
||||
setFiles(
|
||||
@@ -145,6 +169,12 @@ export function Composer(props: ComposerProps) {
|
||||
|
||||
const accept = (item: Item | undefined) => {
|
||||
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);
|
||||
@@ -311,7 +341,7 @@ export function Composer(props: ComposerProps) {
|
||||
>
|
||||
{item.kind === "file" ? (
|
||||
<>
|
||||
<Icon name={fileIconName(basename(item.rel))} size={16} />
|
||||
<Icon name={item.dir ? "folder" : fileIconName(basename(item.rel))} size={16} />
|
||||
<span className={styles.itemName}>{basename(item.rel)}</span>
|
||||
<span className={styles.itemDetail}>{item.rel}</span>
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
atEdgeLine,
|
||||
canSend,
|
||||
completionAt,
|
||||
isPathQuery,
|
||||
resolveTyped,
|
||||
imageFromDataUrl,
|
||||
mainOptions,
|
||||
matchCommands,
|
||||
@@ -275,3 +277,16 @@ describe("retryParts", () => {
|
||||
expect(retryParts({ ...retry, maxRetries: null }, 9_000)).toEqual({ attempt: "3", wait: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("typed path references", () => {
|
||||
it("tells a path from a search", () => {
|
||||
for (const q of ["../", "..", "./a", "/etc", "../x/y"]) expect(isPathQuery(q)).toBe(true);
|
||||
for (const q of ["", "src/le", ".gitignore", "a/../b"]) expect(isPathQuery(q)).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves against the task root", () => {
|
||||
expect(resolveTyped("/r/idea", "../kalo/a.ts")).toBe("/r/kalo/a.ts");
|
||||
expect(resolveTyped("/r/idea", "./src")).toBe("/r/idea/src");
|
||||
expect(resolveTyped("/r/idea", "/etc/hosts")).toBe("/etc/hosts");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,19 @@ function inOrder(text: string, query: string): boolean {
|
||||
return i >= query.length;
|
||||
}
|
||||
|
||||
/** Whether an `@` query is a typed-out path (`../x`, `./x`, `/x`) rather than a project file search. */
|
||||
export const isPathQuery = (query: string) => /^(\.\.?(\/|$)|\/)/.test(query);
|
||||
|
||||
/** `rel` (as typed after `@`) resolved against `root`, `.` and `..` folded lexically. */
|
||||
export function resolveTyped(root: string, rel: string): string {
|
||||
const out: string[] = [];
|
||||
for (const part of (rel.startsWith("/") ? rel : `${root}/${rel}`).split("/")) {
|
||||
if (part === "..") out.pop();
|
||||
else if (part && part !== ".") out.push(part);
|
||||
}
|
||||
return `/${out.join("/")}`;
|
||||
}
|
||||
|
||||
/** `@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}`;
|
||||
|
||||
Reference in New Issue
Block a user