feat: Add project new context loading ui. (#1695)

This commit is contained in:
Emt-lin 2025-08-09 00:59:00 +08:00 committed by GitHub
parent eca41a929b
commit fe26c8943b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2064 additions and 322 deletions

63
package-lock.json generated
View file

@ -32,6 +32,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.1.2", "@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
@ -6329,6 +6330,68 @@
} }
} }
}, },
"node_modules/@radix-ui/react-progress": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
"integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": { "node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz",

View file

@ -99,6 +99,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.1.2", "@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",

View file

@ -0,0 +1,279 @@
import {
FailedItem,
ProjectConfig,
projectContextLoadAtom,
setProjectContextLoadState,
updateProjectContextLoadState,
} from "@/aiParams";
import { ContextCache } from "@/cache/projectContextCache";
import { logInfo } from "@/logger";
import { settingsStore } from "@/settings/model";
import { err2String } from "@/utils";
import { App, TFile } from "obsidian";
import { isRateLimitError } from "@/utils/rateLimitUtils";
/**
* ProjectLoadTracker is responsible for managing the progress tracking of project file processing
*/
export class ProjectLoadTracker {
private static instance: ProjectLoadTracker;
private app: App;
private constructor(app: App) {
this.app = app;
}
public static getInstance(app: App): ProjectLoadTracker {
if (!ProjectLoadTracker.instance) {
ProjectLoadTracker.instance = new ProjectLoadTracker(app);
}
return ProjectLoadTracker.instance;
}
/**
* Clear all project context loading states
*/
public clearAllLoadStates(): void {
setProjectContextLoadState({
success: [],
failed: [],
processingFiles: [],
total: [],
});
}
/**
* Wrap an operation and track its execution status
*/
public async executeWithProcessTracking<T>(
key: string,
type: FailedItem["type"],
operation: () => Promise<T>
): Promise<T> {
this.setFileOrUrlStartProcess(key);
try {
const result = await operation();
this.setFileOrUrlProcessSuccessful(key);
return result;
} catch (error) {
const errorMessage = isRateLimitError(error)
? "Rate limit exceeded. (Rate limit: 50 files or 100MB per 3 hours, whichever is reached first)"
: err2String(error);
this.setFileOrUrlProcessFailed(key, type, errorMessage);
throw error; // throw error to outer layer
}
}
/**
* Mark a file or URL as processing started
*/
private setFileOrUrlStartProcess(key: string): void {
settingsStore.set(projectContextLoadAtom, (prev) => {
const newState = { ...prev };
// note: we remove the failed file from the failed list when it starts processing
if (newState.failed.find((item) => item.path === key)) {
newState.failed = newState.failed.filter((file) => file.path !== key);
}
// note: we remove the success file from the success list when it starts processing
// For the case where the file cacheKey still exists, but the actual cached content is missing
if (newState.success.includes(key)) {
newState.success = newState.success.filter((file) => file !== key);
}
// Add to processing files list
if (!newState.processingFiles.includes(key)) {
newState.processingFiles = [...newState.processingFiles, key];
}
// Ensure file is in the total list
if (!newState.total.includes(key)) {
newState.total = [...newState.total, key];
}
return newState;
});
}
/**
* Mark a process as successful
*/
private setFileOrUrlProcessSuccessful(key: string): void {
updateProjectContextLoadState("processingFiles", (prev) => prev.filter((file) => file !== key));
updateProjectContextLoadState("success", (prev) => {
if (!prev.includes(key)) {
return [...prev, key];
}
return prev;
});
}
/**
* Mark a process as failed
*/
private setFileOrUrlProcessFailed(key: string, type: FailedItem["type"], error?: string): void {
updateProjectContextLoadState("processingFiles", (prev) => prev.filter((file) => file !== key));
updateProjectContextLoadState("failed", (prev) => {
const existingFailed = prev.find((item) => item.path === key);
if (!existingFailed) {
const failedItem: FailedItem = {
path: key,
type,
error,
timestamp: Date.now(),
};
return [...prev, failedItem];
}
return prev;
});
}
/**
* Pre-compute all items that need to be processed in the project
*/
public preComputeAllItems(project: ProjectConfig, projectAllFiles: TFile[]): void {
logInfo(`[preComputeAllItems] Starting pre-computation for project: ${project.name}`);
const allItems: string[] = [];
// 1. Count all matching files (markdown and non-markdown)
// Add all matching file paths to the list
allItems.push(...projectAllFiles.map((file: TFile) => file.path));
// 2. Count all Web URLs
const configuredWebUrls = project.contextSource?.webUrls?.trim() || "";
if (configuredWebUrls) {
const webUrls = configuredWebUrls.split("\n").filter((url) => url.trim());
allItems.push(...webUrls);
}
// 3. Count all YouTube URLs
const configuredYoutubeUrls = project.contextSource?.youtubeUrls?.trim() || "";
if (configuredYoutubeUrls) {
const youtubeUrls = configuredYoutubeUrls.split("\n").filter((url) => url.trim());
allItems.push(...youtubeUrls);
}
// Add all items to the total list
if (allItems.length > 0) {
const uniqueItems = [...new Set([...allItems])];
updateProjectContextLoadState("total", (_) => uniqueItems);
logInfo(
`[preComputeAllItems] Project ${project.name}: Added ${allItems.length} items to tracking (${uniqueItems.length} total unique items)`
);
}
}
/**
* Mark all cached items(besides Non-markdown files) as successful
*/
public markAllCachedItemsAsSuccess(
project: ProjectConfig,
contextCache: ContextCache,
projectAllFiles: TFile[]
): void {
logInfo(`[markAllCachedItemsAsSuccess] Starting for project: ${project.name || "default"}`);
// 1. Mark cached Web URLs
const configuredWebUrls = project.contextSource?.webUrls?.trim() || "";
if (configuredWebUrls) {
const urlsInConfig = configuredWebUrls.split("\n").filter((url) => url.trim());
const cachedUrls = urlsInConfig.filter((url) => contextCache.webContexts[url]);
cachedUrls.forEach((url) => {
this.markCachedItemAsSuccess(url);
});
if (cachedUrls.length > 0) {
logInfo(
`[markAllCachedItemsAsSuccess] Project ${project.name}: Marked ${cachedUrls.length} cached Web URLs as successful`
);
}
}
// 2. Mark cached YouTube URLs
const configuredYoutubeUrls = project.contextSource?.youtubeUrls?.trim() || "";
if (configuredYoutubeUrls) {
const urlsInConfig = configuredYoutubeUrls.split("\n").filter((url) => url.trim());
const cachedUrls = urlsInConfig.filter((url) => contextCache.youtubeContexts[url]);
cachedUrls.forEach((url) => {
this.markCachedItemAsSuccess(url);
});
if (cachedUrls.length > 0) {
logInfo(
`[markAllCachedItemsAsSuccess] Project ${project.name}: Marked ${cachedUrls.length} cached YouTube URLs as successful`
);
}
}
// 3. Only mark markdown files present in fileContexts as successful, does not include Non-markdown files.
// because track Non-markdown in the processNonMarkdownFiles method
if (contextCache.fileContexts) {
// only for markdown files
const matchingFilesSet = new Set(
projectAllFiles.filter((file) => file.extension === "md").map((file: TFile) => file.path)
);
const cachedFilesToMark = Object.keys(contextCache.fileContexts).filter((filePath) =>
matchingFilesSet.has(filePath)
);
cachedFilesToMark.forEach((filePath) => {
this.markCachedItemAsSuccess(filePath);
});
if (cachedFilesToMark.length > 0) {
logInfo(
`[markAllCachedItemsAsSuccess] Project ${project.name}: Marked ${
cachedFilesToMark.length
} cached files that match current project patterns as successful.`
);
}
}
}
/**
* Mark a cached item as successful
*/
public markCachedItemAsSuccess(key: string): void {
updateProjectContextLoadState("total", (prev) => {
if (!prev.includes(key)) {
return [...prev, key];
}
return prev;
});
// Mark as successful directly
updateProjectContextLoadState("success", (prev) => {
if (!prev.includes(key)) {
return [...prev, key];
}
return prev;
});
}
public makeItemFailed(key: string, type: FailedItem["type"], error?: string): void {
updateProjectContextLoadState("total", (prev) => {
if (!prev.includes(key)) {
return [...prev, key];
}
return prev;
});
// Check if this item is already in the failed list
updateProjectContextLoadState("failed", (prev) => {
const existingFailed = prev.find((item) => item.path === key);
if (!existingFailed) {
const failedItem: FailedItem = {
path: key,
type,
error,
timestamp: Date.now(),
};
return [...prev, failedItem];
}
return prev;
});
}
}

View file

@ -1,4 +1,5 @@
import { import {
FailedItem,
getChainType, getChainType,
isProjectMode, isProjectMode,
ProjectConfig, ProjectConfig,
@ -23,6 +24,7 @@ import { App, Notice, TFile } from "obsidian";
import VectorStoreManager from "../search/vectorStoreManager"; import VectorStoreManager from "../search/vectorStoreManager";
import { BrevilabsClient } from "./brevilabsClient"; import { BrevilabsClient } from "./brevilabsClient";
import ChainManager from "./chainManager"; import ChainManager from "./chainManager";
import { ProjectLoadTracker } from "./projectLoadTracker";
export default class ProjectManager { export default class ProjectManager {
public static instance: ProjectManager; public static instance: ProjectManager;
@ -32,6 +34,7 @@ export default class ProjectManager {
private readonly chainMangerInstance: ChainManager; private readonly chainMangerInstance: ChainManager;
private readonly projectContextCache: ProjectContextCache; private readonly projectContextCache: ProjectContextCache;
private fileParserManager: FileParserManager; private fileParserManager: FileParserManager;
private loadTracker: ProjectLoadTracker;
private constructor(app: App, vectorStoreManager: VectorStoreManager, plugin: CopilotPlugin) { private constructor(app: App, vectorStoreManager: VectorStoreManager, plugin: CopilotPlugin) {
this.app = app; this.app = app;
@ -45,6 +48,7 @@ export default class ProjectManager {
true, true,
null null
); );
this.loadTracker = ProjectLoadTracker.getInstance(this.app);
// Set up subscriptions // Set up subscriptions
subscribeToModelKeyChange(async () => { subscribeToModelKeyChange(async () => {
@ -124,6 +128,8 @@ export default class ProjectManager {
public async switchProject(project: ProjectConfig | null): Promise<void> { public async switchProject(project: ProjectConfig | null): Promise<void> {
try { try {
// Clear all project context loading states
this.loadTracker.clearAllLoadStates();
setProjectLoading(true); setProjectLoading(true);
logInfo("Project loading started..."); logInfo("Project loading started...");
@ -187,6 +193,10 @@ export default class ProjectManager {
} }
private async loadProjectContext(project: ProjectConfig): Promise<ContextCache | null> { private async loadProjectContext(project: ProjectConfig): Promise<ContextCache | null> {
// for update context condition
this.loadTracker.clearAllLoadStates();
setProjectLoading(true);
try { try {
if (!project.contextSource) { if (!project.contextSource) {
logWarn(`[loadProjectContext] Project ${project.name}: No contextSource. Aborting.`); logWarn(`[loadProjectContext] Project ${project.name}: No contextSource. Aborting.`);
@ -194,86 +204,31 @@ export default class ProjectManager {
} }
logInfo(`[loadProjectContext] Starting for project: ${project.name}`); logInfo(`[loadProjectContext] Starting for project: ${project.name}`);
const initialProjectCache = await this.projectContextCache.get(project); logInfo(
const contextCache = initialProjectCache || { `[loadProjectContext] Project ${project.name}: Cleared all project context load states`
markdownContext: "", );
webContexts: {},
youtubeContexts: {}, const contextCache = await this.projectContextCache.getOrInitializeCache(project);
fileContexts: {},
timestamp: Date.now(), const projectAllFiles = this.getProjectAllFiles(project);
markdownNeedsReload: true,
}; // Pre-count all items that need to be processed
if (!initialProjectCache) { this.loadTracker.preComputeAllItems(project, projectAllFiles);
logInfo( this.loadTracker.markAllCachedItemsAsSuccess(project, contextCache, projectAllFiles);
`[loadProjectContext] Project ${project.name}: No existing cache found, building fresh context.`
);
} else {
logInfo(
`[loadProjectContext] Project ${project.name}: Existing cache found. MarkdownNeedsReload: ${contextCache.markdownNeedsReload}`
);
}
const [updatedContextCacheAfterSources] = await Promise.all([ const [updatedContextCacheAfterSources] = await Promise.all([
this.processMarkdownFiles(project, contextCache), this.processMarkdownFiles(project, contextCache, projectAllFiles),
this.processWebUrls(project, contextCache), this.processWebUrls(project, contextCache),
this.processYoutubeUrls(project, contextCache), this.processYoutubeUrls(project, contextCache),
]); ]);
// After other contexts are processed, ensure all referenced non-markdown files are parsed and cached
if (updatedContextCacheAfterSources.fileContexts) {
const fileContextCount = Object.keys(updatedContextCacheAfterSources.fileContexts).length;
logInfo(
`[loadProjectContext] Project ${project.name}: Checking ${fileContextCount} fileContexts for non-markdown processing.`
);
if (fileContextCount > 0) {
this.fileParserManager = new FileParserManager(
BrevilabsClient.getInstance(),
this.app.vault,
true,
project
);
let processedNonMdCount = 0;
for (const filePath in updatedContextCacheAfterSources.fileContexts) {
const file = this.app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile && file.extension !== "md") {
if (this.fileParserManager.supportsExtension(file.extension)) {
try {
const existingContent = await this.projectContextCache.getFileContext(
project,
filePath
);
if (!existingContent) {
logInfo(
`[loadProjectContext] Project ${project.name}: Parsing/caching new/updated file: ${filePath}`
);
await this.fileParserManager.parseFile(file, this.app.vault);
processedNonMdCount++;
} // else { logInfo for skipped can be too verbose }
} catch (error) {
logError(
`[loadProjectContext] Project ${project.name}: Error parsing file ${filePath}:`,
error
);
// Check if this is a rate limit error and re-throw it to fail the entire operation
if (isRateLimitError(error)) {
throw error; // Re-throw to fail the entire operation
}
}
}
}
}
if (processedNonMdCount > 0) {
logInfo(
`[loadProjectContext] Project ${project.name}: Processed and cached ${processedNonMdCount} non-markdown files.`
);
}
}
}
updatedContextCacheAfterSources.timestamp = Date.now(); updatedContextCacheAfterSources.timestamp = Date.now();
await this.projectContextCache.set(project, updatedContextCacheAfterSources); // Note: Since non-markdown files cannot pass cache parameters , so we need to save the context cache first
await this.projectContextCache.setCacheSafely(project, updatedContextCacheAfterSources);
// After other contexts are processed, ensure all referenced non-markdown files are parsed and cached
await this.processNonMarkdownFiles(project, projectAllFiles);
logInfo(`[loadProjectContext] Completed for project: ${project.name}.`); logInfo(`[loadProjectContext] Completed for project: ${project.name}.`);
return updatedContextCacheAfterSources; return updatedContextCacheAfterSources;
} catch (error) { } catch (error) {
@ -315,11 +270,10 @@ export default class ProjectManager {
const nextUrls = nextWebUrls.split("\n").filter((url) => url.trim()); const nextUrls = nextWebUrls.split("\n").filter((url) => url.trim());
// Remove context for URLs that no longer exist // Remove context for URLs that no longer exist
for (const url of prevUrls) { await this.projectContextCache.removeWebUrls(
if (!nextUrls.includes(url)) { nextProject,
await this.projectContextCache.removeWebUrl(nextProject, url); prevUrls.filter((url) => !nextUrls.includes(url))
} );
}
} }
// Check if YouTube URLs configuration has changed // Check if YouTube URLs configuration has changed
@ -332,11 +286,10 @@ export default class ProjectManager {
const nextUrls = nextYoutubeUrls.split("\n").filter((url) => url.trim()); const nextUrls = nextYoutubeUrls.split("\n").filter((url) => url.trim());
// Remove context for URLs that no longer exist // Remove context for URLs that no longer exist
for (const url of prevUrls) { await this.projectContextCache.removeYoutubeUrls(
if (!nextUrls.includes(url)) { nextProject,
await this.projectContextCache.removeYoutubeUrl(nextProject, url); prevUrls.filter((url) => !nextUrls.includes(url))
} );
}
} }
} catch (error) { } catch (error) {
logError(`Error comparing project configurations: ${error}`); logError(`Error comparing project configurations: ${error}`);
@ -424,7 +377,7 @@ export default class ProjectManager {
// Retrieve file content from FileCache // Retrieve file content from FileCache
const content = const content =
(await this.projectContextCache.getFileContext(project, filePath)) || (await this.projectContextCache.getOrReuseFileContext(project, filePath)) ||
"[Content not available]"; // This is expected for files not processed into FileCache "[Content not available]"; // This is expected for files not processed into FileCache
return `[[${fileName}]]\npath: ${filePath}\ntype: ${fileType}\nmodified: ${new Date(fileContext.timestamp).toISOString()}\n\n${content}`; return `[[${fileName}]]\npath: ${filePath}\ntype: ${fileType}\nmodified: ${new Date(fileContext.timestamp).toISOString()}\n\n${content}`;
@ -497,82 +450,50 @@ ${contextParts.join("\n\n")}
private async processMarkdownFiles( private async processMarkdownFiles(
project: ProjectConfig, project: ProjectConfig,
contextCache: ContextCache contextCache: ContextCache,
projectAllFiles: TFile[]
): Promise<ContextCache> { ): Promise<ContextCache> {
logInfo(`[processMarkdownFiles] Starting for project: ${project.name}`); logInfo(`[processMarkdownFiles] Starting for project: ${project.name}`);
const initialFileContextsCount = Object.keys(contextCache.fileContexts || {}).length;
if (project.contextSource?.inclusions || project.contextSource?.exclusions) { if (
contextCache = await this.projectContextCache.updateProjectFilesFromPatterns( contextCache.markdownNeedsReload ||
!contextCache.markdownContext ||
!contextCache.markdownContext.trim()
) {
logInfo(`[processMarkdownFiles] Project ${project.name}: Processing markdown content.`);
const markdownContent = await this.processMarkdownFileContext(projectAllFiles);
// add context reference to markdown file
this.projectContextCache.updateProjectMarkdownFilesFromPatterns(
project, project,
contextCache contextCache,
projectAllFiles
); );
const newFileContextsCount = Object.keys(contextCache.fileContexts || {}).length;
if (newFileContextsCount > initialFileContextsCount) {
logInfo(
`[processMarkdownFiles] Project ${project.name}: Added ${newFileContextsCount - initialFileContextsCount} new file references via updateProjectFilesFromPatterns.`
);
}
if ( contextCache.markdownContext = markdownContent;
contextCache.markdownNeedsReload || contextCache.markdownNeedsReload = false;
!contextCache.markdownContext ||
!contextCache.markdownContext.trim() logInfo(`[processMarkdownFiles] Project ${project.name}: Markdown content updated.`);
) { } else {
logInfo(`[processMarkdownFiles] Project ${project.name}: Processing markdown content.`); logInfo(
const markdownContent = await this.processFileContext( `[processMarkdownFiles] Project ${project.name}: Markdown content already up-to-date.`
project.contextSource.inclusions, );
project.contextSource.exclusions,
project
);
contextCache.markdownContext = markdownContent;
contextCache.markdownNeedsReload = false;
logInfo(`[processMarkdownFiles] Project ${project.name}: Markdown content updated.`);
} else {
logInfo(
`[processMarkdownFiles] Project ${project.name}: Markdown content already up-to-date.`
);
}
} }
logInfo( logInfo(
`[processMarkdownFiles] Completed for project: ${project.name}. Total fileContexts: ${Object.keys(contextCache.fileContexts || {}).length}` `[processMarkdownFiles] Completed for project: ${project.name}. Total fileContexts: ${Object.keys(contextCache.fileContexts || {}).length}`
); );
return contextCache; return contextCache;
} }
private async processFileContext( private async processMarkdownFileContext(projectAllFiles: TFile[]): Promise<string> {
inclusions?: string,
exclusions?: string,
project?: ProjectConfig
): Promise<string> {
if (!inclusions && !exclusions) {
return "";
}
if (!project) {
return "";
}
// NOTE: Must not fallback to GLOBAL inclusions and exclusions in Copilot settings in Projects!
// This is to avoid project inclusions in the project that conflict with the global ones
// Project UI should be the ONLY source of truth for project inclusions and exclusions
const { inclusions: inclusionPatterns, exclusions: exclusionPatterns } = getMatchingPatterns({
inclusions,
exclusions,
isProject: true,
});
// FileParserManager will be used to process these files when they're accessed, // FileParserManager will be used to process these files when they're accessed,
// either immediately or on-demand when the context is formatted // either immediately or on-demand when the context is formatted
// Get all markdown files that match the inclusion/exclusion patterns // Get all markdown files that match the inclusion/exclusion patterns
// Note: We're only processing markdown files here, other file types // Note: We're only processing markdown files here, other file types
// are handled by FileParserManager and stored in the file cache // are handled by FileParserManager and stored in the file cache
const files = this.app.vault.getFiles().filter((file) => { const files = projectAllFiles.filter((file) => file.extension === "md");
return (
file.extension === "md" && shouldIndexFile(file, inclusionPatterns, exclusionPatterns, true)
);
});
logInfo(`Found ${files.length} markdown files to process for project context`); logInfo(`Found ${files.length} markdown files to process for project context`);
@ -583,15 +504,25 @@ ${contextParts.join("\n\n")}
let metadata = ""; let metadata = "";
try { try {
const stat = await this.app.vault.adapter.stat(file.path); // Only process markdown files here
const [stat, fileContent] = await this.loadTracker.executeWithProcessTracking(
file.path,
"md",
async () => {
return Promise.all([
this.app.vault.adapter.stat(file.path),
this.app.vault.read(file),
]);
}
);
metadata = `[[${file.basename}]] metadata = `[[${file.basename}]]
path: ${file.path} path: ${file.path}
type: ${file.extension} type: ${file.extension}
created: ${stat ? new Date(stat.ctime).toISOString() : "unknown"} created: ${stat ? new Date(stat.ctime).toISOString() : "unknown"}
modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}`; modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}`;
// Only process markdown files here content = fileContent;
content = await this.app.vault.read(file);
logInfo(`Completed processing markdown file: ${file.path}`); logInfo(`Completed processing markdown file: ${file.path}`);
} catch (error) { } catch (error) {
logError(`Error processing file ${file.path}: ${error}`); logError(`Error processing file ${file.path}: ${error}`);
@ -650,8 +581,8 @@ modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}`;
} }
const webContextPromises = urlsToFetch.map(async (url) => { const webContextPromises = urlsToFetch.map(async (url) => {
// processWebUrlsContext itself should log errors if a specific URL fetch fails. // processWebUrlContext itself should log errors if a specific URL fetch fails.
const webContext = await this.processWebUrlsContext(url); const webContext = await this.processWebUrlContext(url);
if (webContext) { if (webContext) {
logInfo( logInfo(
`[processWebUrls] Project ${project.name}: Successfully fetched content for URL: ${url.substring(0, 50)}...` `[processWebUrls] Project ${project.name}: Successfully fetched content for URL: ${url.substring(0, 50)}...`
@ -717,7 +648,7 @@ modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}`;
} }
const youtubeContextPromises = urlsToFetch.map(async (url) => { const youtubeContextPromises = urlsToFetch.map(async (url) => {
const youtubeContext = await this.processYoutubeUrlsContext(url); const youtubeContext = await this.processYoutubeUrlContext(url);
if (youtubeContext) { if (youtubeContext) {
logInfo( logInfo(
`[processYoutubeUrls] Project ${project.name}: Successfully fetched transcript for YouTube URL: ${url.substring(0, 50)}...` `[processYoutubeUrls] Project ${project.name}: Successfully fetched transcript for YouTube URL: ${url.substring(0, 50)}...`
@ -742,44 +673,252 @@ modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}`;
return contextCache; return contextCache;
} }
private async processWebUrlsContext(webUrls?: string): Promise<string> { private async processWebUrlContext(webUrl?: string): Promise<string> {
if (!webUrls?.trim()) { if (!webUrl?.trim()) {
return ""; return "";
} }
try { try {
const mention = Mention.getInstance(); const mention = Mention.getInstance();
const { urlContext } = await mention.processUrls(webUrls); const { urlContext } = await this.loadTracker.executeWithProcessTracking(
webUrl,
"web",
async () => {
const result = await mention.processUrls(webUrl);
if (result.processedErrorUrls[webUrl]) {
throw new Error(result.processedErrorUrls[webUrl]);
}
return result;
}
);
return urlContext || ""; return urlContext || "";
} catch (error) { } catch (error) {
logError(`Failed to process web URLs: ${error}`); logError(`Failed to process web URL: ${error}`);
new Notice(`Failed to process web URLs: ${err2String(error)}`);
return ""; return "";
} }
} }
private async processYoutubeUrlsContext(youtubeUrls?: string): Promise<string> { private async processYoutubeUrlContext(youtubeUrl?: string): Promise<string> {
if (!youtubeUrls?.trim()) { if (!youtubeUrl?.trim()) {
return ""; return "";
} }
const urls = youtubeUrls.split("\n").filter((url) => url.trim()); try {
const processPromises = urls.map(async (url) => { const response = await this.loadTracker.executeWithProcessTracking(
try { youtubeUrl,
const response = await BrevilabsClient.getInstance().youtube4llm(url); "youtube",
if (response.response.transcript) { async () => {
return `\n\nYouTube transcript from ${url}:\n${response.response.transcript}`; return BrevilabsClient.getInstance().youtube4llm(youtubeUrl);
} }
return ""; );
} catch (error) { if (response.response.transcript) {
logError(`Failed to process YouTube URL ${url}: ${error}`); return `\n\nYouTube transcript from ${youtubeUrl}:\n${response.response.transcript}`;
new Notice(`Failed to process YouTube URL ${url}: ${err2String(error)}`);
return "";
} }
return "";
} catch (error) {
logError(`Failed to process YouTube URL ${youtubeUrl}: ${error}`);
new Notice(`Failed to process YouTube URL ${youtubeUrl}: ${err2String(error)}`);
return "";
}
}
private async processNonMarkdownFiles(
project: ProjectConfig,
projectAllFiles: TFile[]
): Promise<void> {
const nonMarkdownFiles = projectAllFiles.filter((file) => file.extension !== "md");
logInfo(
`[loadProjectContext] Project ${project.name}: Checking for non-markdown processing: ${nonMarkdownFiles.length} files .`
);
if (nonMarkdownFiles.length <= 0) {
return;
}
this.fileParserManager = new FileParserManager(
BrevilabsClient.getInstance(),
this.app.vault,
true,
project
);
let processedNonMdCount = 0;
for (const file of nonMarkdownFiles) {
const filePath = file.path;
if (this.fileParserManager.supportsExtension(file.extension)) {
try {
await this.loadTracker.executeWithProcessTracking(filePath, "nonMd", async () => {
const existingContent = await this.projectContextCache.getOrReuseFileContext(
project,
filePath
);
if (existingContent) {
processedNonMdCount++;
} else {
logInfo(
`[loadProjectContext] Project ${project.name}: Parsing/caching new/updated file: ${filePath}`
);
await this.fileParserManager.parseFile(file, this.app.vault);
processedNonMdCount++;
}
});
} catch (error) {
logError(
`[loadProjectContext] Project ${project.name}: Error parsing file ${filePath}:`,
error
);
// Check if this is a rate limit error and re-throw it to fail the entire operation
if (isRateLimitError(error)) {
throw error; // Re-throw to fail the entire operation
}
}
}
}
if (processedNonMdCount > 0) {
logInfo(
`[loadProjectContext] Project ${project.name}: Processed and cached ${processedNonMdCount} non-markdown files.`
);
}
}
/**
* Retry failed item
* @param failedItem Failed item information
*/
public async retryFailedItem(failedItem: FailedItem): Promise<void> {
try {
if (!this.currentProjectId) {
logWarn("[retryFailedItem] No current project, aborting retry");
return;
}
const project = getSettings().projectList.find((p) => p.id === this.currentProjectId);
if (!project) {
logError(`[retryFailedItem] Current project not found: ${this.currentProjectId}`);
return;
}
logInfo(`[retryFailedItem] Starting retry for ${failedItem.type} item: ${failedItem.path}`);
// Handle different retry types
switch (failedItem.type) {
case "web":
await this.retryWebUrl(project, failedItem.path);
break;
case "youtube":
await this.retryYoutubeUrl(project, failedItem.path);
break;
case "md":
await this.retryMarkdownFile(project, failedItem.path);
break;
case "nonMd":
await this.retryNonMarkdownFile(project, failedItem.path);
break;
default:
logWarn(`[retryFailedItem] Unknown item type: ${failedItem.type}`);
return;
}
logInfo(`[retryFailedItem] Successfully retried ${failedItem.type} item: ${failedItem.path}`);
new Notice(`Retry successful: ${failedItem.path}`);
} catch (error) {
logError(
`[retryFailedItem] Failed to retry ${failedItem.type} item ${failedItem.path}:`,
error
);
new Notice(`Retry failed: ${err2String(error)}`);
}
}
private async retryWebUrl(project: ProjectConfig, url: string): Promise<void> {
const webContext = await this.processWebUrlContext(url);
if (!webContext) {
logWarn(`[retryWebUrl] Project ${project.name}: Fetched empty content for Web URL: ${url}`);
return;
}
logInfo(
`[retryWebUrl] Project ${project.name}: Successfully fetched content for URL: ${url.substring(0, 50)}...`
);
await this.projectContextCache.updateWebUrl(project, url, webContext);
}
private async retryYoutubeUrl(project: ProjectConfig, url: string): Promise<void> {
const youtubeContext = await this.processYoutubeUrlContext(url);
if (!youtubeContext) {
logWarn(
`[retryYoutubeUrl] Project ${project.name}: Fetched empty transcript for YouTube URL: ${url}`
);
return;
}
logInfo(
`[retryYoutubeUrl] Project ${project.name}: Successfully fetched transcript for YouTube URL: ${url.substring(0, 50)}...`
);
await this.projectContextCache.updateYoutubeUrl(project, url, youtubeContext);
}
private async retryMarkdownFile(project: ProjectConfig, filePath: string): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile) || file.extension !== "md") {
throw new Error(`File not found or not a markdown file: ${filePath}`);
}
try {
// add flag to track reprocessing of Markdown
await this.loadTracker.executeWithProcessTracking(file.path, "md", async () => {});
logInfo(`[retryMarkdownFile] Successfully reprocessed markdown file: ${filePath}`);
// flag the markdown context as needing a reload
await this.projectContextCache.invalidateMarkdownContext(project);
} catch (error) {
logError(`[retryMarkdownFile] Error processing file ${filePath}: ${error}`);
throw error;
}
}
private async retryNonMarkdownFile(project: ProjectConfig, filePath: string): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile) || file.extension === "md") {
throw new Error(`File not found or is a markdown file: ${filePath}`);
}
if (!this.fileParserManager.supportsExtension(file.extension)) {
throw new Error(`Unsupported file extension: ${file.extension}`);
}
try {
await this.loadTracker.executeWithProcessTracking(filePath, "nonMd", async () => {
return this.fileParserManager.parseFile(file, this.app.vault);
});
logInfo(`[retryNonMarkdownFile] Successfully reprocessed non-markdown file: ${filePath}`);
} catch (error) {
logError(`[retryNonMarkdownFile] Error processing file ${filePath}: ${error}`);
throw error;
}
}
private getProjectAllFiles(project: ProjectConfig) {
// NOTE: Must not fallback to GLOBAL inclusions and exclusions in Copilot settings in Projects!
// This is to avoid project inclusions in the project that conflict with the global ones
// Project UI should be the ONLY source of truth for project inclusions and exclusions
const { inclusions: inclusionPatterns, exclusions: exclusionPatterns } = getMatchingPatterns({
inclusions: project.contextSource.inclusions,
exclusions: project.contextSource.exclusions,
isProject: true,
}); });
const results = await Promise.all(processPromises); return this.app.vault.getFiles().filter((file: TFile) => {
return results.join(""); return shouldIndexFile(file, inclusionPatterns, exclusionPatterns, true);
});
} }
public onunload(): void { public onunload(): void {

View file

@ -38,6 +38,27 @@ const chainTypeAtom = atom(
const currentProjectAtom = atom<ProjectConfig | null>(null); const currentProjectAtom = atom<ProjectConfig | null>(null);
const projectLoadingAtom = atom<boolean>(false); const projectLoadingAtom = atom<boolean>(false);
export interface FailedItem {
path: string;
type: "md" | "web" | "youtube" | "nonMd";
error?: string;
timestamp?: number;
}
interface ProjectContextLoadState {
success: Array<string>;
failed: Array<FailedItem>;
processingFiles: Array<string>;
total: Array<string>;
}
export const projectContextLoadAtom = atom<ProjectContextLoadState>({
success: [],
failed: [],
processingFiles: [],
total: [],
});
const selectedTextContextsAtom = atom<SelectedTextContext[]>([]); const selectedTextContextsAtom = atom<SelectedTextContext[]>([]);
export interface ProjectConfig { export interface ProjectConfig {
@ -51,7 +72,7 @@ export interface ProjectConfig {
maxTokens?: number; maxTokens?: number;
}; };
contextSource: { contextSource: {
inclusions: string; inclusions?: string;
exclusions?: string; exclusions?: string;
webUrls?: string; webUrls?: string;
youtubeUrls?: string; youtubeUrls?: string;
@ -239,3 +260,50 @@ export function useSelectedTextContexts() {
store: settingsStore, store: settingsStore,
}); });
} }
/**
* Gets the project context load state from the atom.
*/
export function getProjectContextLoadState(): Readonly<ProjectContextLoadState> {
return settingsStore.get(projectContextLoadAtom);
}
/**
* Sets the project context load state in the atom.
*/
export function setProjectContextLoadState(state: ProjectContextLoadState) {
settingsStore.set(projectContextLoadAtom, state);
}
/**
* Updates a specific field in the project context load state.
*/
export function updateProjectContextLoadState<K extends keyof ProjectContextLoadState>(
key: K,
valueFn: (prev: ProjectContextLoadState[K]) => ProjectContextLoadState[K]
) {
settingsStore.set(projectContextLoadAtom, (prev) => ({
...prev,
[key]: valueFn(prev[key]),
}));
}
/**
* Subscribes to changes in the project context load state.
*/
export function subscribeToProjectContextLoadChange(
callback: (state: ProjectContextLoadState) => void
): () => void {
return settingsStore.sub(projectContextLoadAtom, () => {
callback(settingsStore.get(projectContextLoadAtom));
});
}
/**
* Hook to get the project context load state from the atom.
*/
export function useProjectContextLoad() {
return useAtom(projectContextLoadAtom, {
store: settingsStore,
});
}

View file

@ -6,6 +6,7 @@ import { getSettings } from "@/settings/model";
import { MD5 } from "crypto-js"; import { MD5 } from "crypto-js";
import { TAbstractFile, TFile, Vault } from "obsidian"; import { TAbstractFile, TFile, Vault } from "obsidian";
import debounce from "lodash.debounce"; import debounce from "lodash.debounce";
import { Mutex } from "async-mutex";
export interface ContextCache { export interface ContextCache {
// Markdown context // Markdown context
@ -38,6 +39,8 @@ export class ProjectContextCache {
private vault: Vault; private vault: Vault;
private fileCache: FileCache<string>; private fileCache: FileCache<string>;
private static readonly DEBOUNCE_DELAY = 5000; // 5 seconds private static readonly DEBOUNCE_DELAY = 5000; // 5 seconds
private projectMutexMap: Map<string, Mutex> = new Map();
private mutexCreationMutex: Mutex = new Mutex(); // Global lock to protect project mutex creation
private constructor() { private constructor() {
this.vault = app.vault; this.vault = app.vault;
@ -65,6 +68,9 @@ export class ProjectContextCache {
this.vault.off("modify", this.handleFileEvent); this.vault.off("modify", this.handleFileEvent);
this.vault.off("delete", this.handleFileEvent); this.vault.off("delete", this.handleFileEvent);
this.vault.off("rename", this.handleFileEvent); this.vault.off("rename", this.handleFileEvent);
// Clean up project mutexes
this.projectMutexMap.clear();
} }
private initializeEventListeners() { private initializeEventListeners() {
@ -143,6 +149,32 @@ export class ProjectContextCache {
return `${this.cacheDir}/${cacheKey}.json`; return `${this.cacheDir}/${cacheKey}.json`;
} }
private async getOrCreateProjectMutex(project: ProjectConfig): Promise<Mutex> {
const projectId = project.id;
// Quick check without lock for performance
const existingMutex = this.projectMutexMap.get(projectId);
if (existingMutex) {
return existingMutex;
}
// Use global lock to ensure atomic creation
return await this.mutexCreationMutex.runExclusive(async () => {
// Double-check inside the lock
const mutex = this.projectMutexMap.get(projectId);
if (mutex) {
return mutex;
}
// Create new mutex safely
const newMutex = new Mutex();
this.projectMutexMap.set(projectId, newMutex);
logInfo(`Created new mutex for project: ${project.name} (ID: ${projectId})`);
return newMutex;
});
}
async get(project: ProjectConfig): Promise<ContextCache | null> { async get(project: ProjectConfig): Promise<ContextCache | null> {
try { try {
const cacheKey = this.getCacheKey(project); const cacheKey = this.getCacheKey(project);
@ -171,6 +203,25 @@ export class ProjectContextCache {
} }
} }
async getOrInitializeCache(project: ProjectConfig): Promise<ContextCache> {
const initialProjectCache = await this.get(project);
if (initialProjectCache) {
logInfo(
`[getOrInitializeCache] Project ${project.name}: Existing cache found. MarkdownNeedsReload: ${initialProjectCache.markdownNeedsReload}`
);
return initialProjectCache;
}
logInfo(
`[getOrInitializeCache] Project ${project.name}: No existing cache found, building fresh context.`
);
const newCache = this.createEmptyCache();
await this.setWithoutMutex(project, newCache);
return newCache;
}
getSync(project: ProjectConfig): ContextCache | null { getSync(project: ProjectConfig): ContextCache | null {
try { try {
const cacheKey = this.getCacheKey(project); const cacheKey = this.getCacheKey(project);
@ -187,18 +238,37 @@ export class ProjectContextCache {
} }
} }
async set(project: ProjectConfig, contextCache: ContextCache): Promise<void> { private async set(project: ProjectConfig, contextCache: ContextCache): Promise<void> {
const mutex = await this.getOrCreateProjectMutex(project);
if (mutex.isLocked()) {
logInfo(`Waiting for project cache lock for project: ${project.name}`);
}
return await mutex.runExclusive(async () => {
logInfo(`Acquired cache lock for project: ${project.name}`);
return await this.setWithoutMutex(project, contextCache);
});
}
private async setWithoutMutex(project: ProjectConfig, contextCache: ContextCache): Promise<void> {
try { try {
await this.ensureCacheDir(); await this.ensureCacheDir();
const cacheKey = this.getCacheKey(project); const cacheKey = this.getCacheKey(project);
const cachePath = this.getCachePath(cacheKey); const cachePath = this.getCachePath(cacheKey);
logInfo("Caching context for project:", project.name); logInfo("Caching context for project:", project.name);
// Create a deep copy to avoid reference issues
const contextCacheCopy = JSON.parse(JSON.stringify(contextCache));
// Store in memory cache // Store in memory cache
this.memoryCache.set(cacheKey, contextCache); this.memoryCache.set(cacheKey, contextCacheCopy);
// Store in file cache // Store in file cache
await this.vault.adapter.write(cachePath, JSON.stringify(contextCache)); await this.vault.adapter.write(cachePath, JSON.stringify(contextCacheCopy));
} catch (error) { } catch (error) {
logError("Error writing to project context cache:", error); logError("Error writing to project context cache:", error);
throw error; // Re-throw to maintain error propagation
} }
} }
@ -209,7 +279,7 @@ export class ProjectContextCache {
youtubeContexts: {}, youtubeContexts: {},
fileContexts: {}, fileContexts: {},
timestamp: Date.now(), timestamp: Date.now(),
markdownNeedsReload: false, markdownNeedsReload: true,
}; };
} }
@ -314,6 +384,10 @@ export class ProjectContextCache {
`[clearForProject] Project ${project.name}: Main project cache file not found (already deleted or never existed): ${cachePath}` `[clearForProject] Project ${project.name}: Main project cache file not found (already deleted or never existed): ${cachePath}`
); );
} }
// Clean up the mutex for this project to prevent memory leaks
this.projectMutexMap.delete(project.id);
logInfo(`[clearForProject] Cleaned up mutex for project: ${project.name}`);
logInfo(`[clearForProject] Completed for project: ${project.name}`); logInfo(`[clearForProject] Completed for project: ${project.name}`);
} catch (error) { } catch (error) {
logError(`[clearForProject] Error for project ${project.name} (ID: ${project.id}):`, error); logError(`[clearForProject] Error for project ${project.name} (ID: ${project.id}):`, error);
@ -332,47 +406,49 @@ export class ProjectContextCache {
project: ProjectConfig, project: ProjectConfig,
forceReloadAllRemotes: boolean = false forceReloadAllRemotes: boolean = false
): Promise<void> { ): Promise<void> {
const cache = await this.get(project); await this.updateCacheSafely(
if (cache) { project,
cache.markdownContext = ""; (cache) => {
cache.markdownNeedsReload = true; cache.markdownContext = "";
cache.markdownNeedsReload = true;
if (forceReloadAllRemotes) { if (forceReloadAllRemotes) {
cache.webContexts = {}; cache.webContexts = {};
cache.youtubeContexts = {}; cache.youtubeContexts = {};
logInfo(`Flagged Web/YouTube contexts for full reload for project ${project.name}`); logInfo(`Flagged Web/YouTube contexts for full reload for project ${project.name}`);
} }
await this.set(project, cache); // Also clean up any file references that no longer match the project's patterns
const cleanedCache = this.cleanupFileReferencesInCache(project, cache);
// Also clean up any file references that no longer match the project's patterns logInfo(`Invalidated markdown context for project ${project.name}`);
await this.cleanupProjectFileReferences(project); return cleanedCache;
},
logInfo(`Invalidated markdown context for project ${project.name}`); true
} );
} }
/** /**
* Update the markdown context for a project * Update the markdown context for a project
*/ */
async updateMarkdownContext(project: ProjectConfig, content: string): Promise<void> { async updateMarkdownContext(project: ProjectConfig, content: string): Promise<void> {
const cache = (await this.get(project)) || this.createEmptyCache(); return await this.updateCacheSafely(project, (cache) => {
cache.markdownContext = content; cache.markdownContext = content;
cache.markdownNeedsReload = false; cache.markdownNeedsReload = false;
await this.set(project, cache); logInfo(`Updated markdown context for project ${project.name}`);
logInfo(`Updated markdown context for project ${project.name}`); return cache;
});
} }
/** /**
* Clear only the markdown context for a project * Clear only the markdown context for a project
*/ */
async clearMarkdownContext(project: ProjectConfig): Promise<void> { async clearMarkdownContext(project: ProjectConfig): Promise<void> {
const cache = await this.get(project); await this.updateCacheSafely(project, (cache) => {
if (cache) {
cache.markdownContext = ""; cache.markdownContext = "";
cache.markdownNeedsReload = true; cache.markdownNeedsReload = true;
await this.set(project, cache); return cache;
} });
} }
//=========================================================================== //===========================================================================
@ -380,10 +456,41 @@ export class ProjectContextCache {
// Non-markdown files cached in fileCache after processing by FileParserManager // Non-markdown files cached in fileCache after processing by FileParserManager
//=========================================================================== //===========================================================================
/**
* Get file content from project cache or reuse from universal cache if available
* This method efficiently searches across all available caches to find file content
*/
async getOrReuseFileContext(project: ProjectConfig, filePath: string): Promise<string | null> {
try {
// 1. Try to get from project cache first
const projectContent = await this.getFileContext(project, filePath);
if (projectContent) {
return projectContent;
}
// 2. Search other projects as fallback
const result = await this.searchOtherProjectsForFile(filePath);
if (result) {
// Associate with current project
await this.associateCacheWithProject(project, filePath, result.cacheKey);
logInfo(
`Reused cached content from other project for: ${filePath} in project ${project.name}`
);
return result.content;
}
// No content found in any cache
return null;
} catch (error) {
logError(`Error in getOrReuseFileContext for ${filePath} in project ${project.name}:`, error);
return null;
}
}
/** /**
* Get content for a specific file in a project * Get content for a specific file in a project
*/ */
async getFileContext(project: ProjectConfig, filePath: string): Promise<string | null> { protected async getFileContext(project: ProjectConfig, filePath: string): Promise<string | null> {
try { try {
// Ensure filePath is valid before proceeding // Ensure filePath is valid before proceeding
if (!filePath || typeof filePath !== "string") { if (!filePath || typeof filePath !== "string") {
@ -445,8 +552,7 @@ export class ProjectContextCache {
* Add or update a file in a project's context * Add or update a file in a project's context
*/ */
async setFileContext(project: ProjectConfig, filePath: string, content: string): Promise<void> { async setFileContext(project: ProjectConfig, filePath: string, content: string): Promise<void> {
try { return await this.updateCacheSafelyAsync(project, async (cache) => {
const cache = (await this.get(project)) || this.createEmptyCache();
if (!cache.fileContexts) { if (!cache.fileContexts) {
cache.fileContexts = {}; cache.fileContexts = {};
} }
@ -469,60 +575,122 @@ export class ProjectContextCache {
cacheKey, cacheKey,
}; };
await this.set(project, cache);
logInfo(`Added/updated file context for ${filePath} in project ${project.name}`); logInfo(`Added/updated file context for ${filePath} in project ${project.name}`);
} catch (error) { return cache;
logError(`Error setting file context for ${filePath}:`, error); });
}
} }
/** /**
* Remove a file from a project's context * Remove a file from a project's context
*/ */
async removeFileContext(project: ProjectConfig, filePath: string): Promise<void> { async removeFileContext(project: ProjectConfig, filePath: string): Promise<void> {
try { return await this.updateCacheSafelyAsync(project, async (cache) => {
const cache = await this.get(project); if (cache.fileContexts && cache.fileContexts[filePath]) {
if (cache && cache.fileContexts[filePath]) {
// Get the cache key before removing from project cache // Get the cache key before removing from project cache
const { cacheKey } = cache.fileContexts[filePath]; const { cacheKey } = cache.fileContexts[filePath];
// Remove from project cache // Remove from project cache
delete cache.fileContexts[filePath]; delete cache.fileContexts[filePath];
await this.set(project, cache);
// Remove from file cache // Remove from file cache
await this.fileCache.remove(cacheKey); await this.fileCache.remove(cacheKey);
logInfo(`Removed file context for ${filePath} in project ${project.name}`); logInfo(`Removed file context for ${filePath} in project ${project.name}`);
} }
return cache;
});
}
/**
* Search all existing projects for cached content of a file
* If found, migrate that content to universal cache and return it
*/
private async searchOtherProjectsForFile(
filePath: string
): Promise<{ cacheKey: string; content: string } | null> {
try {
const settings = getSettings();
const projects = settings.projectList || [];
if (projects.length === 0) {
return null;
}
logInfo(`Searching other projects for file: ${filePath}`);
for (const project of projects) {
// Skip projects without cache
const cache = await this.get(project);
if (!cache || !cache.fileContexts) {
continue;
}
// Check if this project has the file cached
if (cache.fileContexts[filePath]) {
const { cacheKey } = cache.fileContexts[filePath];
if (!cacheKey) continue;
// Try to get content from this project's cache
const content = await this.fileCache.get(cacheKey);
if (content) {
logInfo(`Found content for file ${filePath} in project ${project.name}`);
return { content, cacheKey };
}
}
}
logInfo(`No content found in any project for file: ${filePath}`);
return null;
} catch (error) { } catch (error) {
logError(`Error removing file context for ${filePath}:`, error); logError(`Error searching other projects for file ${filePath}:`, error);
return null;
} }
} }
/** /**
* Update project file references based on inclusion/exclusion patterns. * Associate an existing cache with a specific project, creates the project reference to that cache
* Removes references to files that no longer match patterns, but keeps their content cached.
*/ */
async cleanupProjectFileReferences(project: ProjectConfig): Promise<void> { async associateCacheWithProject(
try { project: ProjectConfig,
const cache = await this.get(project); filePath: string,
if (!cache || !cache.fileContexts) { cacheKey: string
return; ): Promise<void> {
return await this.updateCacheSafelyAsync(project, async (cache) => {
if (!cache.fileContexts) {
cache.fileContexts = {};
} }
const { inclusions, exclusions } = getMatchingPatterns({ // Update project context to reference the other cache key directly
inclusions: project.contextSource.inclusions, cache.fileContexts[filePath] = {
exclusions: project.contextSource.exclusions, timestamp: Date.now(),
isProject: true, cacheKey: cacheKey,
}); };
let removedCount = 0; logInfo(`Associated cache with project ${project.name} for file: ${filePath}`);
const updatedFileContexts: typeof cache.fileContexts = {}; return cache;
});
}
// Check each file against the patterns /**
for (const filePath in cache.fileContexts) { * Helper method to perform file reference cleanup logic on a cache object
const file = this.vault.getAbstractFileByPath(filePath); */
private cleanupFileReferencesInCache(project: ProjectConfig, cache: ContextCache): ContextCache {
if (!cache.fileContexts) {
return cache;
}
const { inclusions, exclusions } = getMatchingPatterns({
inclusions: project.contextSource.inclusions,
exclusions: project.contextSource.exclusions,
isProject: true,
});
let removedCount = 0;
const updatedFileContexts: typeof cache.fileContexts = {};
// Check each file against the patterns
for (const filePath in cache.fileContexts) {
const file = this.vault.getAbstractFileByPath(filePath);
// If file no longer exists or doesn't match patterns, remove its reference // If file no longer exists or doesn't match patterns, remove its reference
if (!(file instanceof TFile) || !shouldIndexFile(file, inclusions, exclusions, true)) { if (!(file instanceof TFile) || !shouldIndexFile(file, inclusions, exclusions, true)) {
@ -534,14 +702,29 @@ export class ProjectContextCache {
} }
} }
// Only update if we actually removed something // Only update if we actually removed something
if (removedCount > 0) { if (removedCount > 0) {
cache.fileContexts = updatedFileContexts; cache.fileContexts = updatedFileContexts;
await this.set(project, cache); logInfo(
logInfo( `Removed ${removedCount} file references from project ${project.name} that no longer match inclusion patterns`
`Removed ${removedCount} file references from project ${project.name} that no longer match inclusion patterns` );
); }
}
return cache;
}
/**
* Update project file references based on inclusion/exclusion patterns.
* Removes references to files that no longer match patterns, but keeps their content cached.
*/
async cleanupProjectFileReferences(project: ProjectConfig): Promise<void> {
logInfo(`[cleanupProjectFileReferences] Starting for project: ${project.name}`);
try {
await this.updateCacheSafely(
project,
(cache) => this.cleanupFileReferencesInCache(project, cache),
true
);
} catch (error) { } catch (error) {
logError(`Error cleaning up project file references for ${project.name}:`, error); logError(`Error cleaning up project file references for ${project.name}:`, error);
} }
@ -598,33 +781,82 @@ export class ProjectContextCache {
return contextCacheToUpdate; return contextCacheToUpdate;
} }
updateProjectMarkdownFilesFromPatterns(
project: ProjectConfig,
contextCacheToUpdate: ContextCache,
projectAllFiles: TFile[]
): ContextCache {
try {
if (!contextCacheToUpdate.fileContexts) {
contextCacheToUpdate.fileContexts = {};
}
const allFiles = projectAllFiles.filter((file) => file.extension === "md");
let addedCount = 0;
for (const file of allFiles) {
if (contextCacheToUpdate.fileContexts[file.path]) {
continue;
}
const cacheKey = this.fileCache.getCacheKey(file, project.id);
contextCacheToUpdate.fileContexts[file.path] = {
timestamp: Date.now(),
cacheKey,
};
addedCount++;
}
if (addedCount > 0) {
logInfo(
`[updateProjectFilesFromPatterns] Project ${project.name}: Added ${addedCount} new file references to context (in memory).`
);
}
logInfo(
`[updateProjectFilesFromPatterns] Completed for project: ${project.name}. Total markdown fileContexts in memory: ${Object.keys(contextCacheToUpdate.fileContexts).length}`
);
} catch (error) {
logError(`[updateProjectFilesFromPatterns] Error for project ${project.name}:`, error);
}
return contextCacheToUpdate;
}
//=========================================================================== //===========================================================================
// WEB CONTEXT OPERATIONS // WEB CONTEXT OPERATIONS
//=========================================================================== //===========================================================================
/** /**
* Remove a web URL from a project's context * Remove a web URLs from a project's context
*/ */
async removeWebUrl(project: ProjectConfig, url: string): Promise<void> {
const cache = await this.get(project); async removeWebUrls(project: ProjectConfig, urls: string[]): Promise<void> {
if (cache?.webContexts?.[url]) { if (!urls.length) return;
delete cache.webContexts[url];
await this.set(project, cache); await this.updateCacheSafely(project, (cache) => {
logInfo(`Removed web context for URL ${url} in project ${project.name}`); if (cache.webContexts) {
} for (const url of urls) {
if (cache.webContexts[url]) {
delete cache.webContexts[url];
}
}
logInfo(`Removed web contexts for URLs ${urls.join(", ")} in project ${project.name}`);
}
return cache;
});
} }
/** /**
* Add or update a web URL in a project's context * Add or update a web URL in a project's context
*/ */
async updateWebUrl(project: ProjectConfig, url: string, content: string): Promise<void> { async updateWebUrl(project: ProjectConfig, url: string, content: string): Promise<void> {
const cache = (await this.get(project)) || this.createEmptyCache(); return await this.updateCacheSafely(project, (cache) => {
if (!cache.webContexts) { if (!cache.webContexts) {
cache.webContexts = {}; cache.webContexts = {};
} }
cache.webContexts[url] = content; cache.webContexts[url] = content;
await this.set(project, cache); logInfo(`Updated web context for URL ${url} in project ${project.name}`);
logInfo(`Updated web context for URL ${url} in project ${project.name}`); return cache;
});
} }
//=========================================================================== //===========================================================================
@ -632,27 +864,123 @@ export class ProjectContextCache {
//=========================================================================== //===========================================================================
/** /**
* Remove a YouTube URL from a project's context * Remove a YouTube URLs from a project's context
*/ */
async removeYoutubeUrl(project: ProjectConfig, url: string): Promise<void> {
const cache = await this.get(project); async removeYoutubeUrls(project: ProjectConfig, urls: string[]): Promise<void> {
if (cache?.youtubeContexts?.[url]) { if (!urls.length) return;
delete cache.youtubeContexts[url];
await this.set(project, cache); await this.updateCacheSafely(project, (cache) => {
logInfo(`Removed YouTube context for URL ${url} in project ${project.name}`); if (cache.youtubeContexts) {
} for (const url of urls) {
if (cache.youtubeContexts[url]) {
delete cache.youtubeContexts[url];
}
}
logInfo(
`removeYoutubeUrls: Removed YouTube contexts for URLs ${urls.join(", ")} in project ${project.name}`
);
}
return cache;
});
} }
/** /**
* Add or update a YouTube URL in a project's context * Add or update a YouTube URL in a project's context
*/ */
async updateYoutubeUrl(project: ProjectConfig, url: string, content: string): Promise<void> { async updateYoutubeUrl(project: ProjectConfig, url: string, content: string): Promise<void> {
const cache = (await this.get(project)) || this.createEmptyCache(); return await this.updateCacheSafely(project, (cache) => {
if (!cache.youtubeContexts) { if (!cache.youtubeContexts) {
cache.youtubeContexts = {}; cache.youtubeContexts = {};
} }
cache.youtubeContexts[url] = content; cache.youtubeContexts[url] = content;
await this.set(project, cache); logInfo(`Updated YouTube context for URL ${url} in project ${project.name}`);
logInfo(`Updated YouTube context for URL ${url} in project ${project.name}`); return cache;
});
}
//===========================================================================
// EXTERNAL SAFE OPERATIONS
//===========================================================================
/**
* Safe external method for bulk cache updates
* This is for external modules that need to perform complex updates safely
* @param project
* @param updateFn
* @param skipIfEmpty - If true, skip the update when cache is empty instead of throwing an error
*/
async updateCacheSafely(
project: ProjectConfig,
updateFn: (cache: ContextCache) => ContextCache,
skipIfEmpty: boolean = false
): Promise<void> {
const mutex = await this.getOrCreateProjectMutex(project);
return await mutex.runExclusive(async () => {
try {
const cache = await this.get(project);
if (!cache) {
if (skipIfEmpty) {
return;
}
throw new Error(
`Project: ${project.name} context cache not found, please invoke getOrInitializeCache method before invoke update context cache`
);
}
const updatedCache = updateFn(cache);
await this.setWithoutMutex(project, updatedCache);
} catch (error) {
logError(`Error updating cache for project ${project.name}:`, error);
throw error;
}
});
}
/**
* Safe external method for async cache updates
* This is for external modules that need to perform async updates safely
* @param project
* @param updateFn
* @param skipIfEmpty - If true, skip the update when cache is empty instead of throwing an error
*/
async updateCacheSafelyAsync(
project: ProjectConfig,
updateFn: (cache: ContextCache) => Promise<ContextCache>,
skipIfEmpty: boolean = false
): Promise<void> {
const mutex = await this.getOrCreateProjectMutex(project);
return await mutex.runExclusive(async () => {
try {
const cache = await this.get(project);
if (!cache) {
if (skipIfEmpty) {
return;
}
throw new Error(
`Project: ${project.name} context cache not found, please invoke getOrInitializeCache method before invoke update context cache`
);
}
const updatedCache = await updateFn(cache);
await this.setWithoutMutex(project, updatedCache);
} catch (error) {
logError(`Error updating cache for project ${project.name}:`, error);
throw error;
}
});
}
/**
* Safe external method for setting complete cache
* Use this instead of direct set() calls from external modules
*/
async setCacheSafely(project: ProjectConfig, contextCache: ContextCache): Promise<void> {
const mutex = await this.getOrCreateProjectMutex(project);
return await mutex.runExclusive(async () => {
logInfo(`External safe set for project: ${project.name}`);
return await this.setWithoutMutex(project, contextCache);
});
} }
} }

View file

@ -8,6 +8,7 @@ import {
useModelKey, useModelKey,
useSelectedTextContexts, useSelectedTextContexts,
} from "@/aiParams"; } from "@/aiParams";
import { useProjectContextStatus } from "@/hooks/useProjectContextStatus";
import { ChainType } from "@/chainFactory"; import { ChainType } from "@/chainFactory";
import { ChatControls, reloadCurrentProject } from "@/components/chat-components/ChatControls"; import { ChatControls, reloadCurrentProject } from "@/components/chat-components/ChatControls";
@ -30,6 +31,7 @@ import { err2String } from "@/utils";
import { Buffer } from "buffer"; import { Buffer } from "buffer";
import { Notice, TFile } from "obsidian"; import { Notice, TFile } from "obsidian";
import React, { useCallback, useContext, useEffect, useRef, useState } from "react"; import React, { useCallback, useContext, useEffect, useRef, useState } from "react";
import ProgressCard from "@/components/project/progress-card";
type ChatMode = "default" | "project"; type ChatMode = "default" | "project";
@ -66,7 +68,29 @@ const Chat: React.FC<ChatProps> = ({
const [includeActiveNote, setIncludeActiveNote] = useState(false); const [includeActiveNote, setIncludeActiveNote] = useState(false);
const [selectedImages, setSelectedImages] = useState<File[]>([]); const [selectedImages, setSelectedImages] = useState<File[]>([]);
const [showChatUI, setShowChatUI] = useState(false); const [showChatUI, setShowChatUI] = useState(false);
// null: keep default behavior; true: show; false: hide
const [progressCardVisible, setProgressCardVisible] = useState<boolean | null>(null);
const [selectedTextContexts] = useSelectedTextContexts(); const [selectedTextContexts] = useSelectedTextContexts();
const projectContextStatus = useProjectContextStatus();
// Calculate whether to show ProgressCard based on status and user preference
const shouldShowProgressCard = () => {
if (selectedChain !== ChainType.PROJECT_CHAIN) return false;
// If user has explicitly set visibility, respect that choice
if (progressCardVisible !== null) {
return progressCardVisible;
}
// Default behavior: show for loading/error, hide for success
return projectContextStatus === "loading" || projectContextStatus === "error";
};
// Reset user preference when status changes to allow default behavior
useEffect(() => {
setProgressCardVisible(null);
}, [projectContextStatus]);
const [previousMode, setPreviousMode] = useState<ChainType | null>(null); const [previousMode, setPreviousMode] = useState<ChainType | null>(null);
const [selectedChain, setSelectedChain] = useChainType(); const [selectedChain, setSelectedChain] = useChainType();
@ -535,38 +559,54 @@ const Chat: React.FC<ChatProps> = ({
onReplaceChat={setInputMessage} onReplaceChat={setInputMessage}
showHelperComponents={selectedChain !== ChainType.PROJECT_CHAIN} showHelperComponents={selectedChain !== ChainType.PROJECT_CHAIN}
/> />
<ChatControls {shouldShowProgressCard() ? (
onNewChat={handleNewChat} <div className="tw-inset-0 tw-z-modal tw-flex tw-items-center tw-justify-center tw-rounded-xl">
onSaveAsNote={() => handleSaveAsNote()} <ProgressCard
onLoadHistory={handleLoadHistory} plugin={plugin}
onModeChange={(newMode) => { setHiddenCard={() => {
setPreviousMode(selectedChain); setProgressCardVisible(false);
// Hide chat UI when switching to project mode }}
if (newMode === ChainType.PROJECT_CHAIN) { />
setShowChatUI(false); </div>
} ) : (
}} <>
/> <ChatControls
<ChatInput onNewChat={handleNewChat}
ref={inputRef} onSaveAsNote={() => handleSaveAsNote()}
inputMessage={inputMessage} onLoadHistory={handleLoadHistory}
setInputMessage={setInputMessage} onModeChange={(newMode) => {
handleSendMessage={handleSendMessage} setPreviousMode(selectedChain);
isGenerating={loading} // Hide chat UI when switching to project mode
onStopGenerating={() => handleStopGenerating(ABORT_REASON.USER_STOPPED)} if (newMode === ChainType.PROJECT_CHAIN) {
app={app} setShowChatUI(false);
contextNotes={contextNotes} }
setContextNotes={setContextNotes} }}
includeActiveNote={includeActiveNote} />
setIncludeActiveNote={setIncludeActiveNote} <ChatInput
mention={mention} ref={inputRef}
selectedImages={selectedImages} inputMessage={inputMessage}
onAddImage={(files: File[]) => setSelectedImages((prev) => [...prev, ...files])} setInputMessage={setInputMessage}
setSelectedImages={setSelectedImages} handleSendMessage={handleSendMessage}
disableModelSwitch={selectedChain === ChainType.PROJECT_CHAIN} isGenerating={loading}
selectedTextContexts={selectedTextContexts} onStopGenerating={() => handleStopGenerating(ABORT_REASON.USER_STOPPED)}
onRemoveSelectedText={handleRemoveSelectedText} app={app}
/> contextNotes={contextNotes}
setContextNotes={setContextNotes}
includeActiveNote={includeActiveNote}
setIncludeActiveNote={setIncludeActiveNote}
mention={mention}
selectedImages={selectedImages}
onAddImage={(files: File[]) => setSelectedImages((prev) => [...prev, ...files])}
setSelectedImages={setSelectedImages}
disableModelSwitch={selectedChain === ChainType.PROJECT_CHAIN}
selectedTextContexts={selectedTextContexts}
onRemoveSelectedText={handleRemoveSelectedText}
showProgressCard={() => {
setProgressCardVisible(true);
}}
/>
</>
)}
</div> </div>
</> </>
); );
@ -597,6 +637,9 @@ const Chat: React.FC<ChatProps> = ({
} }
}} }}
showChatUI={(v) => setShowChatUI(v)} showChatUI={(v) => setShowChatUI(v)}
onProjectClose={() => {
setProgressCardVisible(null);
}}
/> />
</div> </div>
)} )}

View file

@ -1,9 +1,13 @@
import { Plus, X } from "lucide-react"; import { AlertCircle, CheckCircle, CircleDashed, Loader2, Plus, X } from "lucide-react";
import { TFile } from "obsidian"; import { TFile } from "obsidian";
import React from "react"; import React from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { SelectedTextContext } from "@/types/message"; import { SelectedTextContext } from "@/types/message";
import { ChainType } from "@/chainFactory";
import { Separator } from "@/components/ui/separator";
import { useChainType } from "@/aiParams";
import { useProjectContextStatus } from "@/hooks/useProjectContextStatus";
interface ChatContextMenuProps { interface ChatContextMenuProps {
activeNote: TFile | null; activeNote: TFile | null;
@ -14,6 +18,7 @@ interface ChatContextMenuProps {
onRemoveContext: (path: string) => void; onRemoveContext: (path: string) => void;
onRemoveUrl: (url: string) => void; onRemoveUrl: (url: string) => void;
onRemoveSelectedText?: (id: string) => void; onRemoveSelectedText?: (id: string) => void;
showProgressCard: () => void;
} }
function ContextNote({ function ContextNote({
@ -102,7 +107,11 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
onRemoveContext, onRemoveContext,
onRemoveUrl, onRemoveUrl,
onRemoveSelectedText, onRemoveSelectedText,
showProgressCard,
}) => { }) => {
const [currentChain] = useChainType();
const contextStatus = useProjectContextStatus();
const uniqueNotes = React.useMemo(() => { const uniqueNotes = React.useMemo(() => {
const notesMap = new Map(contextNotes.map((note) => [note.path, note])); const notesMap = new Map(contextNotes.map((note) => [note.path, note]));
@ -125,6 +134,20 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
selectedTextContexts.length > 0 || selectedTextContexts.length > 0 ||
!!activeNote; !!activeNote;
// Get contextStatus from the shared hook
const getContextStatusIcon = () => {
switch (contextStatus) {
case "success":
return <CheckCircle className="tw-size-4 tw-text-success" />;
case "loading":
return <Loader2 className="tw-size-4 tw-animate-spin tw-text-loading" />;
case "error":
return <AlertCircle className="tw-size-4 tw-text-error" />;
case "initial":
return <CircleDashed className="tw-size-4 tw-text-faint" />;
}
};
return ( return (
<div className="tw-flex tw-w-full tw-items-center tw-gap-1"> <div className="tw-flex tw-w-full tw-items-center tw-gap-1">
<div className="tw-flex tw-h-full tw-items-start"> <div className="tw-flex tw-h-full tw-items-start">
@ -166,6 +189,22 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
/> />
))} ))}
</div> </div>
{currentChain === ChainType.PROJECT_CHAIN && (
<>
<Separator orientation="vertical" />
<div className="">
<Button
variant="ghost2"
size="fit"
className="tw-text-muted"
onClick={() => showProgressCard()}
>
{getContextStatusIcon()}
</Button>
</div>
</>
)}
</div> </div>
); );
}; };

View file

@ -1,6 +1,6 @@
import { import {
ProjectConfig,
getCurrentProject, getCurrentProject,
ProjectConfig,
subscribeToProjectChange, subscribeToProjectChange,
useChainType, useChainType,
useModelKey, useModelKey,
@ -71,6 +71,7 @@ interface ChatInputProps {
disableModelSwitch?: boolean; disableModelSwitch?: boolean;
selectedTextContexts?: SelectedTextContext[]; selectedTextContexts?: SelectedTextContext[];
onRemoveSelectedText?: (id: string) => void; onRemoveSelectedText?: (id: string) => void;
showProgressCard: () => void;
} }
const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>( const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
@ -93,6 +94,7 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
disableModelSwitch, disableModelSwitch,
selectedTextContexts, selectedTextContexts,
onRemoveSelectedText, onRemoveSelectedText,
showProgressCard,
}, },
ref ref
) => { ) => {
@ -509,6 +511,7 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
onRemoveUrl={(url: string) => setContextUrls((prev) => prev.filter((u) => u !== url))} onRemoveUrl={(url: string) => setContextUrls((prev) => prev.filter((u) => u !== url))}
selectedTextContexts={selectedTextContexts} selectedTextContexts={selectedTextContexts}
onRemoveSelectedText={onRemoveSelectedText} onRemoveSelectedText={onRemoveSelectedText}
showProgressCard={showProgressCard}
/> />
{selectedImages.length > 0 && ( {selectedImages.length > 0 && (

View file

@ -20,6 +20,7 @@ interface ChatControlsProps {
onRemoveUrl: (url: string) => void; onRemoveUrl: (url: string) => void;
selectedTextContexts?: SelectedTextContext[]; selectedTextContexts?: SelectedTextContext[];
onRemoveSelectedText?: (id: string) => void; onRemoveSelectedText?: (id: string) => void;
showProgressCard: () => void;
} }
const ContextControl: React.FC<ChatControlsProps> = ({ const ContextControl: React.FC<ChatControlsProps> = ({
@ -34,6 +35,7 @@ const ContextControl: React.FC<ChatControlsProps> = ({
onRemoveUrl, onRemoveUrl,
selectedTextContexts, selectedTextContexts,
onRemoveSelectedText, onRemoveSelectedText,
showProgressCard,
}) => { }) => {
const [selectedChain] = useChainType(); const [selectedChain] = useChainType();
@ -86,6 +88,7 @@ const ContextControl: React.FC<ChatControlsProps> = ({
onRemoveUrl={onRemoveUrl} onRemoveUrl={onRemoveUrl}
selectedTextContexts={selectedTextContexts} selectedTextContexts={selectedTextContexts}
onRemoveSelectedText={onRemoveSelectedText} onRemoveSelectedText={onRemoveSelectedText}
showProgressCard={showProgressCard}
/> />
); );
}; };

View file

@ -131,6 +131,7 @@ export const ProjectList = memo(
showChatUI, showChatUI,
onClose, onClose,
inputRef, inputRef,
onProjectClose,
}: { }: {
className?: string; className?: string;
projects: ProjectConfig[]; projects: ProjectConfig[];
@ -142,6 +143,7 @@ export const ProjectList = memo(
showChatUI: (v: boolean) => void; showChatUI: (v: boolean) => void;
onClose: () => void; onClose: () => void;
inputRef: React.RefObject<HTMLTextAreaElement>; inputRef: React.RefObject<HTMLTextAreaElement>;
onProjectClose: () => void;
}): React.ReactElement => { }): React.ReactElement => {
const [isOpen, setIsOpen] = useState(defaultOpen); const [isOpen, setIsOpen] = useState(defaultOpen);
const [showChatInput, setShowChatInput] = useState(false); const [showChatInput, setShowChatInput] = useState(false);
@ -285,6 +287,7 @@ export const ProjectList = memo(
size="icon" size="icon"
onClick={() => { onClick={() => {
enableOrDisableProject(false); enableOrDisableProject(false);
onProjectClose();
}} }}
aria-label="Close Current Project" aria-label="Close Current Project"
> >

View file

@ -0,0 +1,218 @@
import * as React from "react";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import {
AlertCircle,
ChevronDown,
ChevronRight,
FileText,
Loader2,
RotateCcw,
X,
} from "lucide-react";
import { FailedItem, useProjectContextLoad } from "@/aiParams";
import { Button } from "@/components/ui/button";
import { TruncatedText } from "@/components/TruncatedText";
import CopilotPlugin from "@/main";
import { logError } from "@/logger";
interface ProgressCardProps {
plugin?: CopilotPlugin;
setHiddenCard: (hidden: boolean) => void;
}
export default function ProgressCard({ plugin, setHiddenCard }: ProgressCardProps) {
const [contextLoadState] = useProjectContextLoad();
const totalFiles = contextLoadState.total;
const successFiles = contextLoadState.success;
const failedFiles = contextLoadState.failed;
const processingFiles = contextLoadState.processingFiles;
// Control file list expand/collapse state
const [isProcessingExpanded, setIsProcessingExpanded] = useState(false);
const [isFailedExpanded, setIsFailedExpanded] = useState(false);
const processedFilesLen = successFiles.length + failedFiles.length;
const progressPercentage =
totalFiles.length > 0 ? Math.round((processedFilesLen / totalFiles.length) * 100) : 0;
const getFailedItemDisplayName = (item: FailedItem): string => {
return item.path;
};
// TODO(emt-lin): maybe use it in the future
/*const handleRetryAllFailed = () => {
console.log("Retrying all failed items");
};*/
const handleRetryFailedItem = async (item: FailedItem) => {
if (!plugin?.projectManager) {
logError("ProjectManager not available");
return;
}
try {
await plugin.projectManager.retryFailedItem(item);
} catch (error) {
logError(`Error retrying failed item: ${error}`);
}
};
return (
<Card className="tw-w-full tw-border tw-border-solid tw-border-border tw-bg-transparent tw-shadow-none">
<CardHeader>
<CardTitle className="tw-flex tw-items-center tw-justify-between tw-gap-2">
<div className="tw-flex tw-items-center tw-gap-2">
<FileText className="tw-size-5" />
Context Loading
</div>
<Button
size="sm"
variant="ghost2"
className="tw-size-6 tw-p-0 tw-text-muted"
title="Close Progress Bar"
onClick={() => setHiddenCard(true)}
>
<X className="tw-size-4" />
</Button>
</CardTitle>
</CardHeader>
<CardContent className="tw-space-y-6">
{/* Total progress display */}
<div className="tw-space-y-2">
<div className="tw-flex tw-items-center tw-justify-between tw-text-sm">
<div className="tw-flex tw-items-center tw-gap-2">
<span className="tw-text-muted">Total progress</span>
<span className="tw-text-xs tw-text-muted">
(Success:{" "}
<span className="tw-font-medium tw-text-success">{successFiles.length}</span>,
Failed: <span className="tw-font-medium tw-text-error">{failedFiles.length}</span>)
</span>
</div>
<span className="tw-font-medium">
{processedFilesLen}/{totalFiles.length} ({progressPercentage}%)
</span>
</div>
<Progress value={progressPercentage} className="tw-h-2" />
</div>
{/* Currently processing file */}
{processingFiles.length > 0 && (
<div className="tw-space-y-3">
<div
className="tw--m-1 tw-flex tw-cursor-pointer tw-items-center tw-gap-2 tw-rounded-md tw-p-1 tw-transition-colors hover:tw-bg-muted/10"
onClick={() => setIsProcessingExpanded(!isProcessingExpanded)}
>
<Loader2 className="tw-size-4 tw-animate-spin tw-text-accent" />
<span className="tw-text-sm tw-font-medium">Processing</span>
{isProcessingExpanded ? (
<ChevronDown className="tw-ml-auto tw-size-4" />
) : (
<ChevronRight className="tw-ml-auto tw-size-4" />
)}
</div>
{isProcessingExpanded && (
<div className="tw-max-h-32 tw-space-y-2 tw-overflow-y-auto">
{processingFiles.map((fileName, index) => (
<div
key={index}
className="tw-flex tw-items-center tw-gap-2 tw-rounded-md tw-p-2 tw-text-sm tw-bg-faint/10"
>
<div className="tw-size-2 tw-animate-pulse tw-rounded-full tw-bg-interactive-accent" />
<TruncatedText className="tw-flex-1" title={fileName}>
{fileName}
</TruncatedText>
</div>
))}
</div>
)}
</div>
)}
{/* Failed to process the file */}
{failedFiles.length > 0 && (
<div className="tw-space-y-3">
<div className="tw-flex tw-items-center tw-gap-2">
<div
className="-tw-m-1 tw-flex tw-flex-1 tw-cursor-pointer tw-items-center tw-gap-2 tw-rounded-md tw-p-1 tw-transition-colors hover:tw-bg-muted/10"
onClick={() => setIsFailedExpanded(!isFailedExpanded)}
>
<AlertCircle className="tw-size-4 tw-text-error" />
<span className="tw-text-sm tw-font-medium">Failed</span>
<Badge variant="destructive" className="tw-text-xs">
{failedFiles.length} files
</Badge>
{isFailedExpanded ? (
<ChevronDown className="tw-ml-auto tw-size-4" />
) : (
<ChevronRight className="tw-ml-auto tw-size-4" />
)}
</div>
{/*todo(emt-lin): in the future, we can add all failed files to retry*/}
{/*<Button
size="sm"
variant="ghost"
className="tw-size-6 tw-bg-transparent tw-p-0"
title="Retry failed files"
onClick={(e) => {
e.stopPropagation();
// Handle retry logic
handleRetryAllFailed()
}}
>
<RotateCcw className="tw-size-3" />
</Button>*/}
</div>
{isFailedExpanded && (
<div className="tw-max-h-32 tw-space-y-2 tw-overflow-y-auto">
{failedFiles.map((failedItem: FailedItem, index: number) => (
<div
key={index}
className="tw-flex tw-items-center tw-gap-2 tw-rounded-md tw-p-2 tw-text-sm tw-bg-faint/10"
>
<div className="tw-flex tw-min-w-0 tw-flex-1 tw-flex-col tw-gap-1">
<div className="tw-flex tw-items-center tw-gap-2">
<div className="tw-size-2 tw-rounded-full tw-bg-error/80" />
<TruncatedText className="tw-flex-1 tw-font-bold" title={failedItem.path}>
{getFailedItemDisplayName(failedItem)}
</TruncatedText>
</div>
<div className="tw-flex tw-items-center tw-gap-2">
<div className="tw-size-2 tw-rounded-full" />
{failedItem.error && (
<TruncatedText
className="tw-flex-1 tw-text-xs tw-text-error/80"
title={failedItem.error}
>
<span className="tw-text-sm tw-text-error">Loading Error: </span>
{failedItem.error}
</TruncatedText>
)}
</div>
</div>
<Button
size="sm"
variant="ghost"
className="tw-size-5 tw-p-0"
title={`Retry ${failedItem.type} item`}
onClick={async (e) => {
e.stopPropagation();
await handleRetryFailedItem(failedItem);
}}
>
<RotateCcw className="tw-size-3" />
</Button>
</div>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,26 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"tw-relative tw-h-2 tw-w-full tw-overflow-hidden tw-rounded-full tw-bg-interactive-accent/20 tw-border-interactive-accent/30",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="tw-size-full tw-flex-1 tw-bg-interactive-accent tw-transition-all"
style={{ transform: `translateX(-${100 - Math.min(Math.max(value || 0, 0), 100)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View file

@ -0,0 +1,50 @@
import { ChainType } from "@/chainFactory";
import { useChainType, useProjectLoading, useProjectContextLoad } from "@/aiParams";
/**
* Hook to calculate the project context status based on project loading state and context load state.
* Returns one of: 'initial', 'loading', 'success', 'error'
*
* Status meanings:
* - 'initial': Project context loading has not started yet or not in project mode
* - 'loading': Project context is currently being loaded or files are being processed
* - 'success': All files have been processed successfully without any errors
* - 'error': One or more files have failed to process during the loading operation
*/
export type ProjectContextStatus = "initial" | "loading" | "success" | "error";
export function useProjectContextStatus(): ProjectContextStatus {
const [currentChain] = useChainType();
const [isProjectLoading] = useProjectLoading();
const [contextLoadState] = useProjectContextLoad();
const contextStatus = (() => {
// Only calculate status for project mode
if (currentChain !== ChainType.PROJECT_CHAIN) {
return "initial";
}
const { total, success, failed, processingFiles } = contextLoadState;
// Loading state: when project is loading or files are being processed
if (isProjectLoading || processingFiles.length > 0) {
return "loading";
}
// Error state: when there are failed files
if (failed.length > 0) {
return "error";
}
// Success state: when all files have been processed successfully
if (total.length > 0 && success.length === total.length) {
return "success";
}
// Initial state: when no context loading has started yet
return "initial";
})();
return contextStatus;
}

View file

@ -1,11 +1,13 @@
import { ImageProcessor } from "@/imageProcessing/imageProcessor"; import { ImageProcessor } from "@/imageProcessing/imageProcessor";
import { BrevilabsClient, Url4llmResponse } from "@/LLMProviders/brevilabsClient"; import { BrevilabsClient, Url4llmResponse } from "@/LLMProviders/brevilabsClient";
import { isYoutubeUrl } from "@/utils"; import { err2String, isYoutubeUrl } from "@/utils";
import { logError } from "@/logger";
export interface MentionData { export interface MentionData {
type: string; type: string;
original: string; original: string;
processed?: string; processed?: string;
error?: string;
} }
export class Mention { export class Mention {
@ -41,24 +43,30 @@ export class Mention {
.filter((url) => !isYoutubeUrl(url)); .filter((url) => !isYoutubeUrl(url));
} }
async processUrl(url: string): Promise<Url4llmResponse> { async processUrl(url: string): Promise<Url4llmResponse & { error?: string }> {
try { try {
return await this.brevilabsClient.url4llm(url); return await this.brevilabsClient.url4llm(url);
} catch (error) { } catch (error) {
console.error(`Error processing URL ${url}:`, error); const msg = err2String(error);
return { response: url, elapsed_time_ms: 0 }; logError(`Error processing URL ${url}: ${msg}`);
return { response: url, elapsed_time_ms: 0, error: msg };
} }
} }
// For non-youtube URLs // For non-youtube URLs
async processUrls(text: string): Promise<{ urlContext: string; imageUrls: string[] }> { async processUrls(text: string): Promise<{
urlContext: string;
imageUrls: string[];
processedErrorUrls: Record<string, string>;
}> {
const urls = this.extractUrls(text); const urls = this.extractUrls(text);
let urlContext = ""; let urlContext = "";
const imageUrls: string[] = []; const imageUrls: string[] = [];
const processedErrorUrls: Record<string, string> = {};
// Return empty string if no URLs to process // Return empty string if no URLs to process
if (urls.length === 0) { if (urls.length === 0) {
return { urlContext: "", imageUrls: [] }; return { urlContext, imageUrls, processedErrorUrls };
} }
// Process all URLs concurrently // Process all URLs concurrently
@ -75,6 +83,7 @@ export class Mention {
type: "url", type: "url",
original: url, original: url,
processed: processed.response, processed: processed.response,
error: processed.error,
}); });
} }
return this.mentions.get(url); return this.mentions.get(url);
@ -87,9 +96,13 @@ export class Mention {
if (urlData?.processed) { if (urlData?.processed) {
urlContext += `\n\n<url_content>\n<url>${urlData.original}</url>\n<content>\n${urlData.processed}\n</content>\n</url_content>`; urlContext += `\n\n<url_content>\n<url>${urlData.original}</url>\n<content>\n${urlData.processed}\n</content>\n</url_content>`;
} }
if (urlData?.error) {
processedErrorUrls[urlData.original] = urlData.error;
}
}); });
return { urlContext, imageUrls }; return { urlContext, imageUrls, processedErrorUrls };
} }
getMentions(): Map<string, MentionData> { getMentions(): Map<string, MentionData> {

View file

@ -147,7 +147,7 @@ export function shouldIndexFile(
return false; return false;
} }
// ProjectOnly the included files need to be processed. // Project: Only the included files need to be processed.
if (isProject && !inclusions) { if (isProject && !inclusions) {
return false; return false;
} }

View file

@ -1,3 +1,7 @@
/*
Copilot Plugin Css
This file is generated by Copilot plugin.
*/
@import "tailwindcss/base"; @import "tailwindcss/base";
@import "tailwindcss/components"; @import "tailwindcss/components";
@import "tailwindcss/utilities"; @import "tailwindcss/utilities";

View file

@ -1,8 +1,25 @@
import { ProjectConfig } from "@/aiParams"; import { ProjectConfig } from "@/aiParams";
import { ProjectContextCache } from "@/cache/projectContextCache"; import { ContextCache, ProjectContextCache } from "@/cache/projectContextCache";
// Mock dependencies // Mock dependencies
jest.mock("obsidian"); jest.mock("obsidian", () => ({
TFile: class MockTFile {
path: string;
extension: string;
basename: string;
stat: any;
constructor(path: string, extension: string, basename: string, stat: any) {
this.path = path;
this.extension = extension;
this.basename = basename;
this.stat = stat;
}
},
TAbstractFile: class MockTAbstractFile {},
Vault: class MockVault {},
}));
jest.mock("@/logger", () => ({ jest.mock("@/logger", () => ({
logInfo: jest.fn(), logInfo: jest.fn(),
logError: jest.fn(), logError: jest.fn(),
@ -94,19 +111,16 @@ describe("ProjectContextCache", () => {
let mockProject: ProjectConfig; let mockProject: ProjectConfig;
// Mock files // Mock files
const mockMarkdownFile = { const { TFile: MockedTFile } = jest.requireMock("obsidian");
path: "test/file.md", const mockMarkdownFile = new MockedTFile("test/file.md", "md", "file", {
extension: "md", mtime: Date.now(),
basename: "file", size: 100,
stat: { mtime: Date.now(), size: 100 }, });
} as any;
const mockPdfFile = { const mockPdfFile = new MockedTFile("test/document.pdf", "pdf", "document", {
path: "test/document.pdf", mtime: Date.now(),
extension: "pdf", size: 200,
basename: "document", });
stat: { mtime: Date.now(), size: 200 },
} as any;
beforeEach(() => { beforeEach(() => {
// Reset mocks // Reset mocks
@ -123,7 +137,12 @@ describe("ProjectContextCache", () => {
return null; return null;
}); });
// Get actual instance // Reset vault adapter mocks to default behavior
mockApp.vault.adapter.exists.mockResolvedValue(false);
mockApp.vault.adapter.read.mockResolvedValue("{}");
mockApp.vault.adapter.write.mockResolvedValue(undefined);
// Get actual instance and clear any existing cache
projectContextCache = ProjectContextCache.getInstance(); projectContextCache = ProjectContextCache.getInstance();
// Mock project with minimal properties for testing // Mock project with minimal properties for testing
@ -141,11 +160,14 @@ describe("ProjectContextCache", () => {
const filePath = "test/document.pdf"; const filePath = "test/document.pdf";
const content = "PDF content"; const content = "PDF content";
// Initialize a default cache for testing
await projectContextCache.getOrInitializeCache(mockProject);
// Store content // Store content
await projectContextCache.setFileContext(mockProject, filePath, content); await projectContextCache.setFileContext(mockProject, filePath, content);
// Get content // Get content
const retrievedContent = await projectContextCache.getFileContext(mockProject, filePath); const retrievedContent = await projectContextCache.getOrReuseFileContext(mockProject, filePath);
// Verify the content was retrieved (note: implementation may return an object instead of string) // Verify the content was retrieved (note: implementation may return an object instead of string)
expect(retrievedContent).toBeDefined(); expect(retrievedContent).toBeDefined();
@ -176,6 +198,9 @@ describe("ProjectContextCache", () => {
}); });
test("should clean up project file references", async () => { test("should clean up project file references", async () => {
// Initialize a default cache for testing
await projectContextCache.getOrInitializeCache(mockProject);
// First add some context // First add some context
await projectContextCache.setFileContext(mockProject, mockPdfFile.path, "PDF content"); await projectContextCache.setFileContext(mockProject, mockPdfFile.path, "PDF content");
@ -187,4 +212,440 @@ describe("ProjectContextCache", () => {
const projectCache = await projectContextCache.get(mockProject); const projectCache = await projectContextCache.get(mockProject);
expect(projectCache).toBeDefined(); expect(projectCache).toBeDefined();
}); });
test("should update project markdown files from patterns", () => {
// Create an empty context cache to update
const contextCache = {
markdownContext: "",
markdownNeedsReload: true,
webContexts: {},
youtubeContexts: {},
fileContexts: {},
timestamp: Date.now(),
};
// Create a test file list containing Markdown and non-Markdown files
const testFiles = [
{
path: "test/file1.md",
extension: "md",
basename: "file1",
stat: { mtime: Date.now(), size: 100 },
},
{
path: "test/file2.md",
extension: "md",
basename: "file2",
stat: { mtime: Date.now(), size: 200 },
},
{
path: "test/document.pdf",
extension: "pdf",
basename: "document",
stat: { mtime: Date.now(), size: 300 },
},
];
// Call the method, passing only Markdown files
const updatedCache = projectContextCache.updateProjectMarkdownFilesFromPatterns(
mockProject,
contextCache,
testFiles as any
);
// Verify that only Markdown files were added to the cache
expect(Object.keys(updatedCache.fileContexts).length).toBe(2);
expect(updatedCache.fileContexts["test/file1.md"]).toBeDefined();
expect(updatedCache.fileContexts["test/file2.md"]).toBeDefined();
expect(updatedCache.fileContexts["test/document.pdf"]).toBeUndefined();
// Verify that each file entry contains the necessary properties
Object.values(updatedCache.fileContexts).forEach((entry) => {
expect(entry).toHaveProperty("timestamp");
expect(entry).toHaveProperty("cacheKey");
expect(typeof entry.timestamp).toBe("number");
expect(typeof entry.cacheKey).toBe("string");
});
});
test("should update and remove web URLs", async () => {
// Create initial context cache
const initialCache = {
markdownContext: "",
markdownNeedsReload: true,
webContexts: {},
youtubeContexts: {},
fileContexts: {},
timestamp: Date.now(),
};
// Mock vault to return our initial cache
mockApp.vault.adapter.exists.mockResolvedValue(true);
mockApp.vault.adapter.read.mockResolvedValue(JSON.stringify(initialCache));
// Update Web URL
const testUrl = "https://example.com/test";
const testContent = "Example web content";
await projectContextCache.updateWebUrl(mockProject, testUrl, testContent);
// Verify write was called
expect(mockApp.vault.adapter.write).toHaveBeenCalled();
// Reset mock to return the updated cache
const updatedCache = {
...initialCache,
webContexts: { [testUrl]: testContent },
};
mockApp.vault.adapter.read.mockResolvedValue(JSON.stringify(updatedCache));
// Add another URL
const testUrl2 = "https://example.com/test2";
const testContent2 = "Another example";
await projectContextCache.updateWebUrl(mockProject, testUrl2, testContent2);
// Remove URL
const urlsToRemove = [testUrl];
await projectContextCache.removeWebUrls(mockProject, urlsToRemove);
// Final cache should only contain the second URL
const finalCache = {
...initialCache,
webContexts: { [testUrl2]: testContent2 },
};
mockApp.vault.adapter.read.mockResolvedValue(JSON.stringify(finalCache));
// Get cache check results
const resultCache = await projectContextCache.get(mockProject);
expect(resultCache).toBeDefined();
expect(resultCache?.webContexts[testUrl]).toBeUndefined();
expect(resultCache?.webContexts[testUrl2]).toBe(testContent2);
});
test("should update and remove YouTube URLs", async () => {
// Create initial context cache
const initialCache = {
markdownContext: "",
markdownNeedsReload: true,
webContexts: {},
youtubeContexts: {},
fileContexts: {},
timestamp: Date.now(),
};
// Mock vault to return our initial cache
mockApp.vault.adapter.exists.mockResolvedValue(true);
mockApp.vault.adapter.read.mockResolvedValue(JSON.stringify(initialCache));
// Update YouTube URL
const testYoutubeUrl = "https://youtube.com/watch?v=test123";
const testYoutubeContent = "Test YouTube transcript";
await projectContextCache.updateYoutubeUrl(mockProject, testYoutubeUrl, testYoutubeContent);
// Verify write was called
expect(mockApp.vault.adapter.write).toHaveBeenCalled();
// Reset mock to return the updated cache
const updatedCache = {
...initialCache,
youtubeContexts: { [testYoutubeUrl]: testYoutubeContent },
};
mockApp.vault.adapter.read.mockResolvedValue(JSON.stringify(updatedCache));
// Add another URL
const testYoutubeUrl2 = "https://youtube.com/watch?v=test456";
const testYoutubeContent2 = "Another YouTube transcript";
await projectContextCache.updateYoutubeUrl(mockProject, testYoutubeUrl2, testYoutubeContent2);
// Remove URL
const urlsToRemove = [testYoutubeUrl];
await projectContextCache.removeYoutubeUrls(mockProject, urlsToRemove);
// Final cache should only contain the second URL
const finalCache = {
...initialCache,
youtubeContexts: { [testYoutubeUrl2]: testYoutubeContent2 },
};
mockApp.vault.adapter.read.mockResolvedValue(JSON.stringify(finalCache));
// Get cache check results
const resultCache = await projectContextCache.get(mockProject);
expect(resultCache).toBeDefined();
expect(resultCache?.youtubeContexts[testYoutubeUrl]).toBeUndefined();
expect(resultCache?.youtubeContexts[testYoutubeUrl2]).toBe(testYoutubeContent2);
});
test("should safely update cache with updateCacheSafely", async () => {
// Create initial context cache
const initialCache = {
markdownContext: "Initial markdown content",
markdownNeedsReload: false,
webContexts: {},
youtubeContexts: {},
fileContexts: {},
timestamp: Date.now(),
};
// Mock cache existence
mockApp.vault.adapter.exists.mockResolvedValue(true);
mockApp.vault.adapter.read.mockResolvedValue(JSON.stringify(initialCache));
// Define update function
const updateFn = (cache: any) => {
cache.markdownContext = "Updated markdown content";
cache.markdownNeedsReload = true;
return cache;
};
// Execute safe update
await projectContextCache.updateCacheSafely(mockProject, updateFn);
// Verify write was called
expect(mockApp.vault.adapter.write).toHaveBeenCalled();
// Check that written content contains updated values
const writeCall = mockApp.vault.adapter.write.mock.calls[0];
const writtenContent = JSON.parse(writeCall[1]);
expect(writtenContent.markdownContext).toBe("Updated markdown content");
expect(writtenContent.markdownNeedsReload).toBe(true);
});
test("should safely update cache with updateCacheSafelyAsync", async () => {
// Create initial context cache
const initialCache = {
markdownContext: "Initial markdown content",
markdownNeedsReload: false,
webContexts: {},
youtubeContexts: {},
fileContexts: {},
timestamp: Date.now(),
};
// Mock cache existence
mockApp.vault.adapter.exists.mockResolvedValue(true);
mockApp.vault.adapter.read.mockResolvedValue(JSON.stringify(initialCache));
// Define async update function
const asyncUpdateFn = async (cache: any) => {
// Simulate async operation
await new Promise((resolve) => setTimeout(resolve, 10));
cache.markdownContext = "Async updated content";
cache.webContexts = { "https://example.com": "Async web content" };
return cache;
};
// Execute async safe update
await projectContextCache.updateCacheSafelyAsync(mockProject, asyncUpdateFn);
// Verify write was called
expect(mockApp.vault.adapter.write).toHaveBeenCalled();
// Check that written content contains updated values
const writeCall = mockApp.vault.adapter.write.mock.calls[0];
const writtenContent = JSON.parse(writeCall[1]);
expect(writtenContent.markdownContext).toBe("Async updated content");
expect(writtenContent.webContexts["https://example.com"]).toBe("Async web content");
});
test("should handle skipIfEmpty parameter correctly in updateCacheSafely", async () => {
// Create a new project instance to ensure cache isolation
const isolatedProject = {
id: "isolated-test-project-id",
name: "Isolated Test Project",
contextSource: {
inclusions: "**/*.md, **/*.pdf",
exclusions: "",
},
} as any;
// Mock cache non-existence for this isolated project
mockApp.vault.adapter.exists.mockImplementation((path) => {
if (path.includes("isolated-test-project-id")) {
return Promise.resolve(false);
}
return Promise.resolve(true);
});
// Define update function
const updateFn = jest.fn((cache: any) => {
cache.markdownContext = "Should not be called";
return cache;
});
// Execute safe update with skipIfEmpty=true
await projectContextCache.updateCacheSafely(isolatedProject, updateFn, true);
// Verify updateFn was not called
expect(updateFn).not.toHaveBeenCalled();
// Should throw error when skipIfEmpty=false
await expect(
projectContextCache.updateCacheSafely(isolatedProject, updateFn, false)
).rejects.toThrow();
});
test("should handle concurrent updates with updateCacheSafely", async () => {
// Create initial context cache with proper types
const initialCache: ContextCache = {
markdownContext: "Initial markdown content",
markdownNeedsReload: false,
webContexts: {},
youtubeContexts: {},
fileContexts: {},
timestamp: Date.now(),
};
// Mock cache storage to simulate real persistence
let currentCache = initialCache;
mockApp.vault.adapter.exists.mockResolvedValue(true);
mockApp.vault.adapter.read.mockImplementation(() => {
return Promise.resolve(JSON.stringify(currentCache));
});
mockApp.vault.adapter.write.mockImplementation((path, content) => {
currentCache = JSON.parse(content);
return Promise.resolve();
});
// Track execution order and update timing
const executionOrder: string[] = [];
const startTime = Date.now();
// Create update functions to modify different parts of the cache
const updateMarkdown = (cache: any) => {
executionOrder.push(`markdown-${Date.now() - startTime}`);
cache.markdownContext = "Updated markdown content";
return cache;
};
const updateWeb = (cache: any) => {
executionOrder.push(`web-${Date.now() - startTime}`);
cache.webContexts = { "https://example.com": "Web content" };
return cache;
};
const updateYoutube = (cache: any) => {
executionOrder.push(`youtube-${Date.now() - startTime}`);
cache.youtubeContexts = { "https://youtube.com/test": "YouTube content" };
return cache;
};
// Execute concurrent updates
await Promise.all([
projectContextCache.updateCacheSafely(mockProject, updateMarkdown),
projectContextCache.updateCacheSafely(mockProject, updateWeb),
projectContextCache.updateCacheSafely(mockProject, updateYoutube),
]);
// Verify write was called three times
expect(mockApp.vault.adapter.write).toHaveBeenCalledTimes(3);
// Verify that all update functions were executed
expect(executionOrder.length).toBe(3);
// Verify all updates were applied to the final cache
expect(currentCache.markdownContext).toBe("Updated markdown content");
expect(currentCache.webContexts && currentCache.webContexts["https://example.com"]).toBe(
"Web content"
);
expect(
currentCache.youtubeContexts && currentCache.youtubeContexts["https://youtube.com/test"]
).toBe("YouTube content");
});
test("should handle concurrent updates with updateCacheSafelyAsync", async () => {
// Create initial context cache with proper types
const initialCache: ContextCache = {
markdownContext: "Initial markdown content",
markdownNeedsReload: false,
webContexts: {},
youtubeContexts: {},
fileContexts: {},
timestamp: Date.now(),
};
// Mock cache storage to simulate real persistence
let currentCache = initialCache;
mockApp.vault.adapter.exists.mockResolvedValue(true);
mockApp.vault.adapter.read.mockImplementation(() => {
return Promise.resolve(JSON.stringify(currentCache));
});
// Track execution order, write calls and timing
const executionOrder: string[] = [];
const writeOrder: string[] = [];
const startTime = Date.now();
mockApp.vault.adapter.write.mockImplementation((path, content) => {
const parsed = JSON.parse(content);
currentCache = parsed;
// Track what was written and when
if (parsed.markdownContext === "Async updated markdown") {
writeOrder.push(`markdown-${Date.now() - startTime}`);
} else if (
parsed.webContexts &&
parsed.webContexts["https://example.com"] === "Async web content"
) {
writeOrder.push(`web-${Date.now() - startTime}`);
} else if (
parsed.youtubeContexts &&
parsed.youtubeContexts["https://youtube.com/test"] === "Async YouTube content"
) {
writeOrder.push(`youtube-${Date.now() - startTime}`);
}
return Promise.resolve();
});
// Create async update functions with different delays
const updateMarkdownAsync = async (cache: any) => {
const startMark = Date.now() - startTime;
executionOrder.push(`markdown-start-${startMark}`);
await new Promise((resolve) => setTimeout(resolve, 30));
cache.markdownContext = "Async updated markdown";
executionOrder.push(`markdown-end-${Date.now() - startTime}`);
return cache;
};
const updateWebAsync = async (cache: any) => {
const startMark = Date.now() - startTime;
executionOrder.push(`web-start-${startMark}`);
await new Promise((resolve) => setTimeout(resolve, 10));
cache.webContexts = { "https://example.com": "Async web content" };
executionOrder.push(`web-end-${Date.now() - startTime}`);
return cache;
};
const updateYoutubeAsync = async (cache: any) => {
const startMark = Date.now() - startTime;
executionOrder.push(`youtube-start-${startMark}`);
await new Promise((resolve) => setTimeout(resolve, 20));
cache.youtubeContexts = { "https://youtube.com/test": "Async YouTube content" };
executionOrder.push(`youtube-end-${Date.now() - startTime}`);
return cache;
};
// Execute concurrent async updates
await Promise.all([
projectContextCache.updateCacheSafelyAsync(mockProject, updateMarkdownAsync),
projectContextCache.updateCacheSafelyAsync(mockProject, updateWebAsync),
projectContextCache.updateCacheSafelyAsync(mockProject, updateYoutubeAsync),
]);
// Verify all writes occurred
expect(mockApp.vault.adapter.write).toHaveBeenCalledTimes(3);
expect(writeOrder.length).toBe(3);
// Verify all updates were applied to the final cache
expect(currentCache.markdownContext).toBe("Async updated markdown");
expect(currentCache.webContexts && currentCache.webContexts["https://example.com"]).toBe(
"Async web content"
);
expect(
currentCache.youtubeContexts && currentCache.youtubeContexts["https://youtube.com/test"]
).toBe("Async YouTube content");
// Log order details for debugging
// console.log("Execution order:", executionOrder);
// console.log("Write order:", writeOrder);
});
}); });

View file

@ -210,7 +210,7 @@ export class Docs4LLMParser implements FileParser {
throw new Error("No project context provided for file parsing"); throw new Error("No project context provided for file parsing");
} }
const cachedContent = await this.projectContextCache.getFileContext( const cachedContent = await this.projectContextCache.getOrReuseFileContext(
this.currentProject, this.currentProject,
file.path file.path
); );

View file

@ -28,6 +28,7 @@ module.exports = {
"on-accent-inverted": "var(--text-on-accent-inverted)", "on-accent-inverted": "var(--text-on-accent-inverted)",
success: "var(--text-success)", success: "var(--text-success)",
warning: "var(--text-warning)", warning: "var(--text-warning)",
loading: "var(--color-blue)",
error: "var(--text-error)", error: "var(--text-error)",
accent: "var(--text-accent)", accent: "var(--text-accent)",
"accent-hover": "var(--text-accent-hover)", "accent-hover": "var(--text-accent-hover)",