Unverified 提交 5db0b044 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Support exclude paths in manual context management (#774)

上级 74ada705
......@@ -74,3 +74,64 @@ test("manage context - smart context - auto-includes only", async ({ po }) => {
await po.snapshotServerDump("request");
});
test("manage context - exclude paths", async ({ po }) => {
await po.setUp();
await po.importApp("context-manage");
const dialog = await po.openContextFilesPicker();
await po.snapshotDialog();
// Add some include paths first
await dialog.addManualContextFile("src/**/*.ts");
await dialog.addManualContextFile("manual/**");
// Add exclude paths
await dialog.addExcludeContextFile("src/components/**");
await dialog.addExcludeContextFile("manual/exclude/**");
await po.snapshotDialog();
await dialog.close();
await po.sendPrompt("[dump]");
await po.snapshotServerDump("all-messages", { name: "exclude-paths-basic" });
// Test that exclude paths take precedence over include paths
const dialog2 = await po.openContextFilesPicker();
await dialog2.removeExcludeContextFile(); // Remove src/components/**
await dialog2.addExcludeContextFile("src/**"); // This should exclude everything from src
await po.snapshotDialog();
await dialog2.close();
await po.sendPrompt("[dump]");
await po.snapshotServerDump("all-messages", {
name: "exclude-paths-precedence",
});
});
test("manage context - exclude paths with smart context", async ({ po }) => {
await po.setUpDyadPro();
await po.selectModel({ provider: "Google", model: "Gemini 2.5 Pro" });
await po.importApp("context-manage");
const dialog = await po.openContextFilesPicker();
await po.snapshotDialog();
// Add manual context files
await dialog.addManualContextFile("src/**/*.ts");
await dialog.addManualContextFile("manual/**");
// Add smart context auto-includes
await dialog.addAutoIncludeContextFile("a.ts");
await dialog.addAutoIncludeContextFile("exclude/**");
// Add exclude paths that should filter out some of the above
await dialog.addExcludeContextFile("src/components/**");
await dialog.addExcludeContextFile("exclude/exclude.ts");
await po.snapshotDialog();
await dialog.close();
await po.sendPrompt("[dump]");
await po.snapshotServerDump("all-messages", {
name: "exclude-paths-with-smart-context",
});
});
......@@ -47,6 +47,18 @@ export class ContextFilesPickerDialog {
.first()
.click();
}
async addExcludeContextFile(path: string) {
await this.page.getByTestId("exclude-context-files-input").fill(path);
await this.page.getByTestId("exclude-context-files-add-button").click();
}
async removeExcludeContextFile() {
await this.page
.getByTestId("exclude-context-files-remove-button")
.first()
.click();
}
}
class ProModesDialog {
......
- dialog:
- heading "Codebase Context" [level=3]
- paragraph:
- text: Select the files to use as context.
- img
- textbox "src/**/*.tsx"
- button "Add"
- paragraph: Dyad will use the entire codebase as context.
- heading "Exclude Paths" [level=3]
- paragraph:
- text: These files will be excluded from the context.
- img
- textbox "node_modules/**/*"
- button "Add"
\ No newline at end of file
- dialog:
- heading "Codebase Context" [level=3]
- paragraph:
- text: Select the files to use as context.
- img
- textbox "src/**/*.tsx"
- button "Add"
- text: /src\/\*\*\/\*\.ts 4 files, ~\d+ tokens/
- button:
- img
- text: /manual\/\*\* 3 files, ~\d+ tokens/
- button:
- img
- heading "Exclude Paths" [level=3]
- paragraph:
- text: These files will be excluded from the context.
- img
- textbox "node_modules/**/*"
- button "Add"
- text: /src\/components\/\*\* 2 files, ~\d+ tokens/
- button:
- img
- text: manual/exclude/** 0 files, ~0 tokens
- button:
- img
\ No newline at end of file
- dialog:
- heading "Codebase Context" [level=3]
- paragraph:
- text: Select the files to use as context.
- img
- textbox "src/**/*.tsx"
- button "Add"
- text: /src\/\*\*\/\*\.ts 4 files, ~\d+ tokens/
- button:
- img
- text: /manual\/\*\* 3 files, ~\d+ tokens/
- button:
- img
- heading "Exclude Paths" [level=3]
- paragraph:
- text: These files will be excluded from the context.
- img
- textbox "node_modules/**/*"
- button "Add"
- text: manual/exclude/** 0 files, ~0 tokens
- button:
- img
- text: /src\/\*\* 7 files, ~\d+ tokens/
- button:
- img
\ No newline at end of file
- dialog:
- heading "Codebase Context" [level=3]
- paragraph:
- text: Select the files to use as context.
- img
- textbox "src/**/*.tsx"
- button "Add"
- paragraph: Dyad will use Smart Context to automatically find the most relevant files to use as context.
- heading "Exclude Paths" [level=3]
- paragraph:
- text: These files will be excluded from the context.
- img
- textbox "node_modules/**/*"
- button "Add"
- heading "Smart Context Auto-includes" [level=3]
- paragraph:
- text: These files will always be included in the context.
- img
- textbox "src/**/*.config.ts"
- button "Add"
\ No newline at end of file
- dialog:
- heading "Codebase Context" [level=3]
- paragraph:
- text: Select the files to use as context.
- img
- textbox "src/**/*.tsx"
- button "Add"
- text: /src\/\*\*\/\*\.ts 4 files, ~\d+ tokens/
- button:
- img
- text: /manual\/\*\* 3 files, ~\d+ tokens/
- button:
- img
- heading "Exclude Paths" [level=3]
- paragraph:
- text: These files will be excluded from the context.
- img
- textbox "node_modules/**/*"
- button "Add"
- text: /src\/components\/\*\* 2 files, ~\d+ tokens/
- button:
- img
- text: /exclude\/exclude\.ts 1 files, ~\d+ tokens/
- button:
- img
- heading "Smart Context Auto-includes" [level=3]
- paragraph:
- text: These files will always be included in the context.
- img
- textbox "src/**/*.config.ts"
- button "Add"
- text: /a\.ts 1 files, ~\d+ tokens/
- button:
- img
- text: /exclude\/\*\* 2 files, ~\d+ tokens/
- button:
- img
\ No newline at end of file
......@@ -16,29 +16,33 @@ import {
} from "./ui/tooltip";
import { useSettings } from "@/hooks/useSettings";
import { useContextPaths } from "@/hooks/useContextPaths";
import type { ContextPathResult } from "@/lib/schemas";
export function ContextFilesPicker() {
const { settings } = useSettings();
const {
contextPaths,
smartContextAutoIncludes,
excludePaths,
updateContextPaths,
updateSmartContextAutoIncludes,
updateExcludePaths,
} = useContextPaths();
const [isOpen, setIsOpen] = useState(false);
const [newPath, setNewPath] = useState("");
const [newAutoIncludePath, setNewAutoIncludePath] = useState("");
const [newExcludePath, setNewExcludePath] = useState("");
const addPath = () => {
if (
newPath.trim() === "" ||
contextPaths.find((p) => p.globPath === newPath)
contextPaths.find((p: ContextPathResult) => p.globPath === newPath)
) {
setNewPath("");
return;
}
const newPaths = [
...contextPaths.map(({ globPath }) => ({ globPath })),
...contextPaths.map(({ globPath }: ContextPathResult) => ({ globPath })),
{
globPath: newPath,
},
......@@ -49,21 +53,25 @@ export function ContextFilesPicker() {
const removePath = (pathToRemove: string) => {
const newPaths = contextPaths
.filter((p) => p.globPath !== pathToRemove)
.map(({ globPath }) => ({ globPath }));
.filter((p: ContextPathResult) => p.globPath !== pathToRemove)
.map(({ globPath }: ContextPathResult) => ({ globPath }));
updateContextPaths(newPaths);
};
const addAutoIncludePath = () => {
if (
newAutoIncludePath.trim() === "" ||
smartContextAutoIncludes.find((p) => p.globPath === newAutoIncludePath)
smartContextAutoIncludes.find(
(p: ContextPathResult) => p.globPath === newAutoIncludePath,
)
) {
setNewAutoIncludePath("");
return;
}
const newPaths = [
...smartContextAutoIncludes.map(({ globPath }) => ({ globPath })),
...smartContextAutoIncludes.map(({ globPath }: ContextPathResult) => ({
globPath,
})),
{
globPath: newAutoIncludePath,
},
......@@ -74,11 +82,36 @@ export function ContextFilesPicker() {
const removeAutoIncludePath = (pathToRemove: string) => {
const newPaths = smartContextAutoIncludes
.filter((p) => p.globPath !== pathToRemove)
.map(({ globPath }) => ({ globPath }));
.filter((p: ContextPathResult) => p.globPath !== pathToRemove)
.map(({ globPath }: ContextPathResult) => ({ globPath }));
updateSmartContextAutoIncludes(newPaths);
};
const addExcludePath = () => {
if (
newExcludePath.trim() === "" ||
excludePaths.find((p: ContextPathResult) => p.globPath === newExcludePath)
) {
setNewExcludePath("");
return;
}
const newPaths = [
...excludePaths.map(({ globPath }: ContextPathResult) => ({ globPath })),
{
globPath: newExcludePath,
},
];
updateExcludePaths(newPaths);
setNewExcludePath("");
};
const removeExcludePath = (pathToRemove: string) => {
const newPaths = excludePaths
.filter((p: ContextPathResult) => p.globPath !== pathToRemove)
.map(({ globPath }: ContextPathResult) => ({ globPath }));
updateExcludePaths(newPaths);
};
const isSmartContextEnabled =
settings?.enableDyadPro && settings?.enableProSmartFilesContextMode;
......@@ -100,7 +133,10 @@ export function ContextFilesPicker() {
<TooltipContent>Codebase Context</TooltipContent>
</Tooltip>
<PopoverContent className="w-96" align="start">
<PopoverContent
className="w-96 max-h-[80vh] overflow-y-auto"
align="start"
>
<div className="relative space-y-4">
<div>
<h3 className="font-medium">Codebase Context</h3>
......@@ -153,7 +189,7 @@ export function ContextFilesPicker() {
<TooltipProvider>
{contextPaths.length > 0 ? (
<div className="space-y-2">
{contextPaths.map((p) => (
{contextPaths.map((p: ContextPathResult) => (
<div
key={p.globPath}
className="flex items-center justify-between gap-2 rounded-md border p-2"
......@@ -197,6 +233,91 @@ export function ContextFilesPicker() {
)}
</TooltipProvider>
<div className="pt-2">
<div>
<h3 className="font-medium">Exclude Paths</h3>
<p className="text-sm text-muted-foreground">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1 cursor-help">
These files will be excluded from the context.{" "}
<InfoIcon className="ml-2 size-4" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>
Exclude paths take precedence - files that match both
include and exclude patterns will be excluded.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</p>
</div>
<div className="flex w-full max-w-sm items-center space-x-2 mt-4">
<Input
data-testid="exclude-context-files-input"
type="text"
placeholder="node_modules/**/*"
value={newExcludePath}
onChange={(e) => setNewExcludePath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
addExcludePath();
}
}}
/>
<Button
type="submit"
onClick={addExcludePath}
data-testid="exclude-context-files-add-button"
>
Add
</Button>
</div>
<TooltipProvider>
{excludePaths.length > 0 && (
<div className="space-y-2 mt-4">
{excludePaths.map((p: ContextPathResult) => (
<div
key={p.globPath}
className="flex items-center justify-between gap-2 rounded-md border p-2 border-red-200"
>
<div className="flex flex-1 flex-col overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate font-mono text-sm text-red-600">
{p.globPath}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{p.globPath}</p>
</TooltipContent>
</Tooltip>
<span className="text-xs text-muted-foreground">
{p.files} files, ~{p.tokens} tokens
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => removeExcludePath(p.globPath)}
data-testid="exclude-context-files-remove-button"
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
))}
</div>
)}
</TooltipProvider>
</div>
{isSmartContextEnabled && (
<div className="pt-2">
<div>
......@@ -247,7 +368,7 @@ export function ContextFilesPicker() {
<TooltipProvider>
{smartContextAutoIncludes.length > 0 && (
<div className="space-y-2 mt-4">
{smartContextAutoIncludes.map((p) => (
{smartContextAutoIncludes.map((p: ContextPathResult) => (
<div
key={p.globPath}
className="flex items-center justify-between gap-2 rounded-md border p-2"
......
......@@ -15,7 +15,12 @@ export function useContextPaths() {
} = useQuery<ContextPathResults, Error>({
queryKey: ["context-paths", appId],
queryFn: async () => {
if (!appId) return { contextPaths: [], smartContextAutoIncludes: [] };
if (!appId)
return {
contextPaths: [],
smartContextAutoIncludes: [],
excludePaths: [],
};
const ipcClient = IpcClient.getInstance();
return ipcClient.getChatContextResults({ appId });
},
......@@ -25,9 +30,17 @@ export function useContextPaths() {
const updateContextPathsMutation = useMutation<
unknown,
Error,
{ contextPaths: GlobPath[]; smartContextAutoIncludes?: GlobPath[] }
{
contextPaths: GlobPath[];
smartContextAutoIncludes?: GlobPath[];
excludePaths?: GlobPath[];
}
>({
mutationFn: async ({ contextPaths, smartContextAutoIncludes }) => {
mutationFn: async ({
contextPaths,
smartContextAutoIncludes,
excludePaths,
}) => {
if (!appId) throw new Error("No app selected");
const ipcClient = IpcClient.getInstance();
return ipcClient.setChatContext({
......@@ -35,6 +48,7 @@ export function useContextPaths() {
chatContext: {
contextPaths,
smartContextAutoIncludes: smartContextAutoIncludes || [],
excludePaths: excludePaths || [],
},
});
},
......@@ -46,28 +60,63 @@ export function useContextPaths() {
const updateContextPaths = async (paths: GlobPath[]) => {
const currentAutoIncludes =
contextPathsData?.smartContextAutoIncludes || [];
const currentExcludePaths = contextPathsData?.excludePaths || [];
return updateContextPathsMutation.mutateAsync({
contextPaths: paths,
smartContextAutoIncludes: currentAutoIncludes.map(({ globPath }) => ({
smartContextAutoIncludes: currentAutoIncludes.map(
({ globPath }: { globPath: string }) => ({
globPath,
}),
),
excludePaths: currentExcludePaths.map(
({ globPath }: { globPath: string }) => ({
globPath,
})),
}),
),
});
};
const updateSmartContextAutoIncludes = async (paths: GlobPath[]) => {
const currentContextPaths = contextPathsData?.contextPaths || [];
const currentExcludePaths = contextPathsData?.excludePaths || [];
return updateContextPathsMutation.mutateAsync({
contextPaths: currentContextPaths.map(({ globPath }) => ({ globPath })),
contextPaths: currentContextPaths.map(
({ globPath }: { globPath: string }) => ({ globPath }),
),
smartContextAutoIncludes: paths,
excludePaths: currentExcludePaths.map(
({ globPath }: { globPath: string }) => ({
globPath,
}),
),
});
};
const updateExcludePaths = async (paths: GlobPath[]) => {
const currentContextPaths = contextPathsData?.contextPaths || [];
const currentAutoIncludes =
contextPathsData?.smartContextAutoIncludes || [];
return updateContextPathsMutation.mutateAsync({
contextPaths: currentContextPaths.map(
({ globPath }: { globPath: string }) => ({ globPath }),
),
smartContextAutoIncludes: currentAutoIncludes.map(
({ globPath }: { globPath: string }) => ({
globPath,
}),
),
excludePaths: paths,
});
};
return {
contextPaths: contextPathsData?.contextPaths || [],
smartContextAutoIncludes: contextPathsData?.smartContextAutoIncludes || [],
excludePaths: contextPathsData?.excludePaths || [],
isLoading,
error,
updateContextPaths,
updateSmartContextAutoIncludes,
updateExcludePaths,
};
}
......@@ -39,10 +39,10 @@ export function registerContextPathsHandlers() {
const results: ContextPathResults = {
contextPaths: [],
smartContextAutoIncludes: [],
excludePaths: [],
};
const { contextPaths, smartContextAutoIncludes } = validateChatContext(
app.chatContext,
);
const { contextPaths, smartContextAutoIncludes, excludePaths } =
validateChatContext(app.chatContext);
for (const contextPath of contextPaths) {
const { formattedOutput, files } = await extractCodebase({
appPath,
......@@ -76,6 +76,23 @@ export function registerContextPathsHandlers() {
tokens: totalTokens,
});
}
for (const excludePath of excludePaths || []) {
const { formattedOutput, files } = await extractCodebase({
appPath,
chatContext: {
contextPaths: [excludePath],
smartContextAutoIncludes: [],
},
});
const totalTokens = estimateTokens(formattedOutput);
results.excludePaths.push({
...excludePath,
files: files.length,
tokens: totalTokens,
});
}
return results;
},
);
......
......@@ -8,6 +8,7 @@ export function validateChatContext(chatContext: unknown): AppChatContext {
return {
contextPaths: [],
smartContextAutoIncludes: [],
excludePaths: [],
};
}
......@@ -20,6 +21,7 @@ export function validateChatContext(chatContext: unknown): AppChatContext {
return {
contextPaths: [],
smartContextAutoIncludes: [],
excludePaths: [],
};
}
}
......@@ -120,6 +120,7 @@ export type GlobPath = z.infer<typeof GlobPathSchema>;
export const AppChatContextSchema = z.object({
contextPaths: z.array(GlobPathSchema),
smartContextAutoIncludes: z.array(GlobPathSchema),
excludePaths: z.array(GlobPathSchema).optional(),
});
export type AppChatContext = z.infer<typeof AppChatContextSchema>;
......@@ -131,6 +132,7 @@ export type ContextPathResult = GlobPath & {
export type ContextPathResults = {
contextPaths: ContextPathResult[];
smartContextAutoIncludes: ContextPathResult[];
excludePaths: ContextPathResult[];
};
export const ReleaseChannelSchema = z.enum(["stable", "beta"]);
......
......@@ -472,9 +472,10 @@ export async function extractCodebase({
}
// Collect files from contextPaths and smartContextAutoIncludes
const { contextPaths, smartContextAutoIncludes } = chatContext;
const { contextPaths, smartContextAutoIncludes, excludePaths } = chatContext;
const includedFiles = new Set<string>();
const autoIncludedFiles = new Set<string>();
const excludedFiles = new Set<string>();
// Add files from contextPaths
if (contextPaths && contextPaths.length > 0) {
......@@ -509,6 +510,7 @@ export async function extractCodebase({
const matches = await glob(pattern, {
nodir: true,
absolute: true,
ignore: "**/node_modules/**",
});
matches.forEach((file) => {
const normalizedFile = path.normalize(file);
......@@ -518,12 +520,36 @@ export async function extractCodebase({
}
}
// Add files from excludePaths
if (excludePaths && excludePaths.length > 0) {
for (const p of excludePaths) {
const pattern = createFullGlobPath({
appPath,
globPath: p.globPath,
});
const matches = await glob(pattern, {
nodir: true,
absolute: true,
ignore: "**/node_modules/**",
});
matches.forEach((file) => {
const normalizedFile = path.normalize(file);
excludedFiles.add(normalizedFile);
});
}
}
// Only filter files if contextPaths are provided
// If only smartContextAutoIncludes are provided, keep all files and just mark auto-includes as forced
if (contextPaths && contextPaths.length > 0) {
files = files.filter((file) => includedFiles.has(path.normalize(file)));
}
// Filter out excluded files (this takes precedence over include paths)
if (excludedFiles.size > 0) {
files = files.filter((file) => !excludedFiles.has(path.normalize(file)));
}
// Sort files by modification time (oldest first)
// This is important for cache-ability.
const sortedFiles = await sortFilesByModificationTime([...new Set(files)]);
......@@ -543,7 +569,9 @@ export async function extractCodebase({
virtualFileSystem,
});
const isForced = autoIncludedFiles.has(path.normalize(file));
const isForced =
autoIncludedFiles.has(path.normalize(file)) &&
!excludedFiles.has(path.normalize(file));
// Determine file content based on whether we should read it
let fileContent: string;
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论