Support exclude paths in manual context management (#774)

This commit is contained in:
Will Chen
2025-08-05 14:33:39 -07:00
committed by GitHub
parent 74ada7054b
commit 5db0b04400
16 changed files with 1544 additions and 23 deletions

View 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"

View File

@@ -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 }) => ({
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,
};
}

View File

@@ -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;
},
);

View File

@@ -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: [],
};
}
}

View File

@@ -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"]);

View File

@@ -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;