From 28772e7d0ef4fda511b7ebfa130ace59ac6b30d5 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Thu, 4 Dec 2025 23:04:20 +0000 Subject: [PATCH] feat: implement centralized abort controller with enhanced cancellation UX Introduce a new AbortService to centralize cancellation logic across all async operations, replacing scattered AbortSignal parameters with a unified singleton service. This improves maintainability and provides consistent cancellation behavior throughout the application. Key changes: - Add AbortService for centralized abort signal management with automatic cleanup - Refactor all AI providers (Claude, Gemini, OpenAI) to use AbortService instead of passing AbortSignal parameters - Update streaming operations to use centralized abort handling - Add CancellationIndicator component to show visual feedback during operation cancellation - Rename ChatAreaThought to ThoughtIndicator for better semantic clarity - Add Environment enum for consistent environment detection - Enhance ChatService lifecycle with proper cancellation state management - Remove scattered abort-related UI selectors and error messages in favor of dedicated indicator - Add safeContinue() factory method to ConversationContent for internal continuations - Update all tests to reflect new abort handling architecture This change simplifies the API surface by removing AbortSignal parameters from method signatures while improving the user experience with clearer cancellation feedback. --- AIClasses/BaseAIClass.ts | 16 +- AIClasses/Claude/Claude.ts | 3 +- .../Claude/ClaudeConversationNamingService.ts | 74 +- AIClasses/Claude/ClaudeTokenService.ts | 3 + AIClasses/Gemini/Gemini.ts | 5 +- .../Gemini/GeminiConversationNamingService.ts | 66 +- AIClasses/Gemini/GeminiTokenService.ts | 3 + AIClasses/IAIClass.ts | 2 +- AIClasses/IConversationNamingService.ts | 2 +- AIClasses/OpenAI/OpenAI.ts | 3 +- .../OpenAI/OpenAIConversationNamingService.ts | 98 ++- Components/CancellationIndicator.svelte | 156 ++++ Components/ChatArea.svelte | 21 +- Components/ChatWindow.svelte | 22 +- Components/StreamingIndicator.svelte | 3 +- ...Thought.svelte => ThoughtIndicator.svelte} | 3 +- Conversations/ConversationContent.ts | 12 + Enums/Copy.ts | 1 - Enums/Environment.ts | 4 + Enums/Selector.ts | 7 - Helpers/Exception.ts | 4 +- Services/AIFunctionService.ts | 173 ++-- Services/AbortService.ts | 68 ++ Services/ChatService.ts | 87 +- Services/ConversationFileSystemService.ts | 10 +- Services/ConversationNamingService.ts | 73 +- Services/DiffService.ts | 29 +- Services/FileSystemService.ts | 44 +- Services/ServiceRegistration.ts | 2 + Services/Services.ts | 1 + Services/StreamingMarkdownService.ts | 2 +- Services/StreamingService.ts | 48 +- Services/VaultService.ts | 18 +- Styles/custom_styles.css | 10 + Styles/diff2html_styles.css | 37 +- __tests__/AIClasses/Claude.test.ts | 29 +- .../ClaudeConversationNamingService.test.ts | 25 +- __tests__/AIClasses/Gemini.test.ts | 19 +- .../GeminiConversationNamingService.test.ts | 25 +- __tests__/AIClasses/OpenAI.test.ts | 10 +- .../OpenAIConversationNamingService.test.ts | 27 +- __tests__/Services/AIFunctionService.test.ts | 20 +- __tests__/Services/AbortService.test.ts | 513 +++++++++++ __tests__/Services/ChatService.test.ts | 65 +- .../ConversationFileSystemService.test.ts | 23 +- .../ConversationNamingService.test.ts | 31 +- __tests__/Services/DiffService.test.ts | 827 ++++++++++++++++++ __tests__/Services/FileSystemService.test.ts | 16 +- __tests__/Services/StreamingService.test.ts | 47 +- .../VaultCacheService.performance.test.ts | 18 + __tests__/Services/VaultCacheService.test.ts | 18 + __tests__/setup.ts | 26 + esbuild.config.mjs | 115 +-- main.ts | 1 - package-lock.json | 744 +++++----------- package.json | 18 +- 56 files changed, 2616 insertions(+), 1111 deletions(-) create mode 100644 Components/CancellationIndicator.svelte rename Components/{ChatAreaThought.svelte => ThoughtIndicator.svelte} (90%) create mode 100644 Enums/Environment.ts create mode 100644 Services/AbortService.ts create mode 100644 __tests__/Services/AbortService.test.ts create mode 100644 __tests__/Services/DiffService.test.ts diff --git a/AIClasses/BaseAIClass.ts b/AIClasses/BaseAIClass.ts index 29f225c..868573b 100644 --- a/AIClasses/BaseAIClass.ts +++ b/AIClasses/BaseAIClass.ts @@ -15,16 +15,24 @@ import { Role } from "Enums/Role"; import { StringTools } from "Helpers/StringTools"; import { Exception } from "Helpers/Exception"; import { ApiErrorType } from "Types/ApiError"; +import type { AbortService } from "Services/AbortService"; export abstract class BaseAIClass implements IAIClass { protected readonly apiKey: string; - protected readonly aiPrompt: IPrompt = Resolve(Services.IPrompt); - protected readonly settingsService: SettingsService = Resolve(Services.SettingsService); - protected readonly streamingService: StreamingService = Resolve(Services.StreamingService); - protected readonly aiFunctionDefinitions: AIFunctionDefinitions = Resolve(Services.AIFunctionDefinitions); + protected readonly aiPrompt: IPrompt; + protected readonly abortService: AbortService; + protected readonly settingsService: SettingsService; + protected readonly streamingService: StreamingService; + protected readonly aiFunctionDefinitions: AIFunctionDefinitions; protected constructor(provider: AIProvider) { + this.aiPrompt = Resolve(Services.IPrompt); + this.abortService = Resolve(Services.AbortService); + this.settingsService = Resolve(Services.SettingsService); + this.streamingService = Resolve(Services.StreamingService); + this.aiFunctionDefinitions = Resolve(Services.AIFunctionDefinitions); + this.apiKey = this.settingsService.getApiKeyForProvider(provider); } diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index c825804..fb75567 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -24,7 +24,7 @@ export class Claude extends BaseAIClass { } public async* streamRequest( - conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal + conversation: Conversation, allowDestructiveActions: boolean ): AsyncGenerator { this.accumulatedFunctionName = null; this.accumulatedFunctionArgs = ""; @@ -62,7 +62,6 @@ export class Claude extends BaseAIClass { AIProviderURL.Claude, requestBody, (chunk: string) => this.parseStreamChunk(chunk), - abortSignal, headers ); } diff --git a/AIClasses/Claude/ClaudeConversationNamingService.ts b/AIClasses/Claude/ClaudeConversationNamingService.ts index 4481612..e1b75e6 100644 --- a/AIClasses/Claude/ClaudeConversationNamingService.ts +++ b/AIClasses/Claude/ClaudeConversationNamingService.ts @@ -7,51 +7,55 @@ import { NamePrompt } from "AIClasses/NamePrompt"; import type { SettingsService } from "Services/SettingsService"; import type Anthropic from '@anthropic-ai/sdk'; import { Exception } from "Helpers/Exception"; +import type { AbortService } from "Services/AbortService"; export class ClaudeConversationNamingService implements IConversationNamingService { private readonly apiKey: string; + private readonly abortService: AbortService; public constructor() { const settingsService = Resolve(Services.SettingsService); this.apiKey = settingsService.getApiKeyForProvider(AIProvider.Claude); + this.abortService = Resolve(Services.AbortService); } - public async generateName(userPrompt: string, abortSignal?: AbortSignal): Promise { - - const requestBody = { - model: AIProviderModel.ClaudeNamer, - max_tokens: 100, - system: NamePrompt, - messages: [{ - role: Role.User, - content: userPrompt - }] - }; - - const response = await fetch(AIProviderURL.Claude, { - method: 'POST', - headers: { - 'x-api-key': this.apiKey, - 'anthropic-version': '2023-06-01', - 'anthropic-dangerous-direct-browser-access': 'true', - 'content-type': 'application/json', - }, - body: JSON.stringify(requestBody), - signal: abortSignal + public async generateName(userPrompt: string): Promise { + return await this.abortService.abortableOperation(async () => { + const requestBody = { + model: AIProviderModel.ClaudeNamer, + max_tokens: 100, + system: NamePrompt, + messages: [{ + role: Role.User, + content: userPrompt + }] + }; + + const response = await fetch(AIProviderURL.Claude, { + method: 'POST', + headers: { + 'x-api-key': this.apiKey, + 'anthropic-version': '2023-06-01', + 'anthropic-dangerous-direct-browser-access': 'true', + 'content-type': 'application/json', + }, + body: JSON.stringify(requestBody), + signal: this.abortService.signal() + }); + + if (!response.ok) { + Exception.throw(`Claude API error: ${response.status} ${response.statusText} - ${await response.text()}`); + } + + const data = await response.json() as Anthropic.Messages.Message; + const firstContent = data.content?.[0]; + + if (!firstContent || firstContent.type !== 'text') { + Exception.throw("Failed to generate conversation name"); + } + + return firstContent.text; }); - - if (!response.ok) { - Exception.throw(`Claude API error: ${response.status} ${response.statusText} - ${await response.text()}`); - } - - const data = await response.json() as Anthropic.Messages.Message; - const firstContent = data.content?.[0]; - - if (!firstContent || firstContent.type !== 'text') { - Exception.throw("Failed to generate conversation name"); - } - - return firstContent.text; } } \ No newline at end of file diff --git a/AIClasses/Claude/ClaudeTokenService.ts b/AIClasses/Claude/ClaudeTokenService.ts index 37a579a..b8df627 100644 --- a/AIClasses/Claude/ClaudeTokenService.ts +++ b/AIClasses/Claude/ClaudeTokenService.ts @@ -5,11 +5,13 @@ import { Services } from "Services/Services"; import { Role } from "Enums/Role"; import { AIProvider } from "Enums/ApiProvider"; import type { SettingsService } from "Services/SettingsService"; +import type { AbortService } from "Services/AbortService"; export class ClaudeTokenService implements ITokenService { private ai: Anthropic; private model: string; + private readonly abortService: AbortService; public constructor() { const settingsService = Resolve(Services.SettingsService); @@ -18,6 +20,7 @@ export class ClaudeTokenService implements ITokenService { dangerouslyAllowBrowser: true }); this.model = settingsService.settings.model; + this.abortService = Resolve(Services.AbortService); } public async countTokens(input: string): Promise { diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index c28a5ab..8bfc0c1 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -22,7 +22,7 @@ export class Gemini extends BaseAIClass { } public async* streamRequest( - conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal + conversation: Conversation, allowDestructiveActions: boolean ): AsyncGenerator { // next request should use web search only (gemini api doesn't support custom tooling and grounding at the same time) const requestWebSearch = this.accumulatedFunctionName == this.REQUEST_WEB_SEARCH; @@ -78,8 +78,7 @@ export class Gemini extends BaseAIClass { yield* this.streamingService.streamRequest( `${AIProviderURL.Gemini}/${this.settingsService.settings.model}:streamGenerateContent?key=${this.apiKey}&alt=sse`, requestBody, - (chunk: string) => this.parseStreamChunk(chunk), - abortSignal + (chunk: string) => this.parseStreamChunk(chunk) ); } diff --git a/AIClasses/Gemini/GeminiConversationNamingService.ts b/AIClasses/Gemini/GeminiConversationNamingService.ts index 98e6c8e..99a1248 100644 --- a/AIClasses/Gemini/GeminiConversationNamingService.ts +++ b/AIClasses/Gemini/GeminiConversationNamingService.ts @@ -7,48 +7,52 @@ import { NamePrompt } from "AIClasses/NamePrompt"; import type { GenerateContentResponse } from "@google/genai"; import type { SettingsService } from "Services/SettingsService"; import { Exception } from "Helpers/Exception"; +import type { AbortService } from "Services/AbortService"; export class GeminiConversationNamingService implements IConversationNamingService { - + private readonly apiKey: string; + private readonly abortService: AbortService; public constructor() { const settingsService = Resolve(Services.SettingsService); this.apiKey = settingsService.getApiKeyForProvider(AIProvider.Gemini); + this.abortService = Resolve(Services.AbortService); } - public async generateName(userPrompt: string, abortSignal?: AbortSignal): Promise { + public async generateName(userPrompt: string): Promise { + return await this.abortService.abortableOperation(async () => { + const requestBody = { + system_instruction: { + parts: [{ text: NamePrompt }] + }, + contents: [{ + role: Role.User, + parts: [{ text: userPrompt }] + }] + }; - const requestBody = { - system_instruction: { - parts: [{ text: NamePrompt }] - }, - contents: [{ - role: Role.User, - parts: [{ text: userPrompt }] - }] - }; + const response = await fetch(`${AIProviderURL.Gemini}/${AIProviderModel.GeminiNamer}:generateContent?key=${this.apiKey}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + signal: this.abortService.signal() + }); - const response = await fetch(`${AIProviderURL.Gemini}/${AIProviderModel.GeminiNamer}:generateContent?key=${this.apiKey}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - signal: abortSignal + if (!response.ok) { + Exception.throw(`Gemini API error: ${response.status} ${response.statusText} - ${await response.text()}`); + } + + const data = await response.json() as GenerateContentResponse; + const generatedName = data.candidates?.[0]?.content?.parts?.[0]?.text; + + if (!generatedName) { + Exception.throw("Failed to generate conversation name"); + } + + return generatedName; }); - - if (!response.ok) { - Exception.throw(`Gemini API error: ${response.status} ${response.statusText} - ${await response.text()}`); - } - - const data = await response.json() as GenerateContentResponse; - const generatedName = data.candidates?.[0]?.content?.parts?.[0]?.text; - - if (!generatedName) { - Exception.throw("Failed to generate conversation name"); - } - - return generatedName; } } diff --git a/AIClasses/Gemini/GeminiTokenService.ts b/AIClasses/Gemini/GeminiTokenService.ts index a122825..2806c81 100644 --- a/AIClasses/Gemini/GeminiTokenService.ts +++ b/AIClasses/Gemini/GeminiTokenService.ts @@ -4,11 +4,13 @@ import { Resolve } from "Services/DependencyService"; import { Services } from "Services/Services"; import { AIProvider } from "Enums/ApiProvider"; import type { SettingsService } from "Services/SettingsService"; +import type { AbortService } from "Services/AbortService"; export class GeminiTokenService implements ITokenService { private readonly ai: GoogleGenAI; private model: string; + private readonly abortService: AbortService; public constructor() { const settingsService = Resolve(Services.SettingsService); @@ -16,6 +18,7 @@ export class GeminiTokenService implements ITokenService { apiKey: settingsService.getApiKeyForProvider(AIProvider.Gemini) }); this.model = settingsService.settings.model; + this.abortService = Resolve(Services.AbortService); } public async countTokens(input: string): Promise { diff --git a/AIClasses/IAIClass.ts b/AIClasses/IAIClass.ts index a4de5ec..496d245 100644 --- a/AIClasses/IAIClass.ts +++ b/AIClasses/IAIClass.ts @@ -2,5 +2,5 @@ import type { IStreamChunk } from "Services/StreamingService"; import type { Conversation } from "Conversations/Conversation"; export interface IAIClass { - streamRequest(conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal): AsyncGenerator; + streamRequest(conversation: Conversation, allowDestructiveActions: boolean): AsyncGenerator; } \ No newline at end of file diff --git a/AIClasses/IConversationNamingService.ts b/AIClasses/IConversationNamingService.ts index f10ea63..aabdbb4 100644 --- a/AIClasses/IConversationNamingService.ts +++ b/AIClasses/IConversationNamingService.ts @@ -1,3 +1,3 @@ export interface IConversationNamingService { - generateName(userPrompt: string, abortSignal?: AbortSignal): Promise; + generateName(userPrompt: string): Promise; } diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts index 14dcb9e..f12acc6 100644 --- a/AIClasses/OpenAI/OpenAI.ts +++ b/AIClasses/OpenAI/OpenAI.ts @@ -16,7 +16,7 @@ export class OpenAI extends BaseAIClass { } public async* streamRequest( - conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal + conversation: Conversation, allowDestructiveActions: boolean ): AsyncGenerator { const systemPrompt = await this.buildSystemPrompt(); @@ -46,7 +46,6 @@ export class OpenAI extends BaseAIClass { AIProviderURL.OpenAI, requestBody, (chunk: string) => this.parseStreamChunk(chunk), - abortSignal, headers ); } diff --git a/AIClasses/OpenAI/OpenAIConversationNamingService.ts b/AIClasses/OpenAI/OpenAIConversationNamingService.ts index 1c6fc7d..e7d09a4 100644 --- a/AIClasses/OpenAI/OpenAIConversationNamingService.ts +++ b/AIClasses/OpenAI/OpenAIConversationNamingService.ts @@ -7,64 +7,68 @@ import { NamePrompt } from "AIClasses/NamePrompt"; import type { SettingsService } from "Services/SettingsService"; import type OpenAI from "openai"; import { Exception } from "Helpers/Exception"; +import type { AbortService } from "Services/AbortService"; export class OpenAIConversationNamingService implements IConversationNamingService { - + private readonly apiKey: string; + private readonly abortService: AbortService; public constructor() { const settingsService = Resolve(Services.SettingsService); this.apiKey = settingsService.getApiKeyForProvider(AIProvider.OpenAI); + this.abortService = Resolve(Services.AbortService); } - public async generateName(userPrompt: string, abortSignal?: AbortSignal): Promise { + public async generateName(userPrompt: string): Promise { + return await this.abortService.abortableOperation(async () => { + const requestBody = { + model: AIProviderModel.OpenAINamer, + max_output_tokens: 100, + instructions: NamePrompt, + input: [ + { + role: Role.User, + content: userPrompt + } + ], + stream: false + }; - const requestBody = { - model: AIProviderModel.OpenAINamer, - max_output_tokens: 100, - instructions: NamePrompt, - input: [ - { - role: Role.User, - content: userPrompt - } - ], - stream: false - }; + const response = await fetch(AIProviderURL.OpenAI, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + signal: this.abortService.signal() + }); - const response = await fetch(AIProviderURL.OpenAI, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${this.apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - signal: abortSignal + if (!response.ok) { + Exception.throw(`OpenAI API error: ${response.status} ${response.statusText} - ${await response.text()}`); + } + + const data = await response.json() as OpenAI.Responses.Response; + + // Try to get the name from output_text first (most common case) + if (data.output_text && data.output_text.trim()) { + return data.output_text.trim(); + } + + // Fall back to checking the output array + const firstOutput = data.output?.[0]; + const generatedName = firstOutput && 'content' in firstOutput + ? firstOutput.content?.[0]?.type === 'output_text' + ? firstOutput.content[0].text + : undefined + : undefined; + + if (!generatedName) { + Exception.throw("Failed to generate conversation name"); + } + + return generatedName; }); - - if (!response.ok) { - Exception.throw(`OpenAI API error: ${response.status} ${response.statusText} - ${await response.text()}`); - } - - const data = await response.json() as OpenAI.Responses.Response; - - // Try to get the name from output_text first (most common case) - if (data.output_text && data.output_text.trim()) { - return data.output_text.trim(); - } - - // Fall back to checking the output array - const firstOutput = data.output?.[0]; - const generatedName = firstOutput && 'content' in firstOutput - ? firstOutput.content?.[0]?.type === 'output_text' - ? firstOutput.content[0].text - : undefined - : undefined; - - if (!generatedName) { - Exception.throw("Failed to generate conversation name"); - } - - return generatedName; } } \ No newline at end of file diff --git a/Components/CancellationIndicator.svelte b/Components/CancellationIndicator.svelte new file mode 100644 index 0000000..f7cfcb6 --- /dev/null +++ b/Components/CancellationIndicator.svelte @@ -0,0 +1,156 @@ + + +
+
+ C + A + N + C + E + L + L + I + N + G + . + . + . +
+
+ + \ No newline at end of file diff --git a/Components/ChatArea.svelte b/Components/ChatArea.svelte index 849c465..3e35cc0 100644 --- a/Components/ChatArea.svelte +++ b/Components/ChatArea.svelte @@ -2,15 +2,16 @@ import { Resolve } from "Services/DependencyService"; import { Services } from "Services/Services"; import type { StreamingMarkdownService } from "Services/StreamingMarkdownService"; - import ChatAreaThought from "./ChatAreaThought.svelte"; + import ThoughtIndicator from "./ThoughtIndicator.svelte"; import StreamingIndicator from "./StreamingIndicator.svelte"; + import CancellationIndicator from "./CancellationIndicator.svelte"; import { Greeting } from "Enums/Greeting"; import { Role } from "Enums/Role"; import type { ConversationContent } from "Conversations/ConversationContent"; import { tick } from "svelte"; - import { Copy } from "Enums/Copy"; import { Selector } from "Enums/Selector"; + export let cancelling: boolean = false; export let messages: ConversationContent[] = []; export let currentThought: string | null = null; export let isSubmitting: boolean = false; @@ -19,6 +20,7 @@ export let editModeActive: boolean = false; export function resetChatArea() { + cancelling = false; messageElements = []; lastProcessedContent.clear(); currentStreamFinalized = false; @@ -67,8 +69,6 @@ let settled: boolean = false; - let thoughtElement: HTMLElement | undefined; - let streamingElement: HTMLElement | undefined; let chatAreaPaddingElement: HTMLElement | undefined; let streamingMarkdownService: StreamingMarkdownService = Resolve(Services.StreamingMarkdownService); @@ -120,11 +120,6 @@ // For assistant messages that aren't streaming, use traditional parsing if (!isCurrentlyStreaming) { - - if (message.content.includes(Selector.ApiRequestAborted)) { - return `${Copy.ApiRequestAborted}`; - } - if (message.errorType) { return `
${message.content}
`; } @@ -204,12 +199,16 @@ {/each} {#if settled} - + {#if isSubmitting} - + {/if} {/if} + {#if cancelling} + + {/if} +
{#if messages.length === 0} diff --git a/Components/ChatWindow.svelte b/Components/ChatWindow.svelte index 0b1ce53..c27c976 100644 --- a/Components/ChatWindow.svelte +++ b/Components/ChatWindow.svelte @@ -24,6 +24,7 @@ let chatArea: ChatArea; let chatInput: ChatInput; + let cancelling = false; let hasNoApiKey = false; let isSubmitting = false; let editModeActive = false; @@ -85,7 +86,6 @@ function handleStop() { chatService.stop(); currentThought = null; - isSubmitting = false; chatArea.scrollChatArea("smooth"); } @@ -108,10 +108,14 @@ onThoughtUpdate: (thought) => { currentThought = thought; }, - onComplete: () => { + onComplete: async () => { + cancelling = false; isSubmitting = false; chatArea.scrollChatArea(undefined); - chatService.updateTokenDisplay(conversation); + await chatService.updateTokenDisplay(conversation); + }, + onCancel: () => { + cancelling = true; } }); } @@ -119,11 +123,21 @@ $: if ($conversationStore.shouldReset) { conversation = new Conversation(); chatService.setStatusBarTokens(0, 0); + + isSubmitting = false; + currentStreamingMessageId = null; + currentThought = null; + conversationStore.clearResetFlag(); } $: if ($conversationStore.conversationToLoad) { conversation.contents = []; + + isSubmitting = false; + currentStreamingMessageId = null; + currentThought = null; + chatArea.resetChatArea(); tick().then(() => { @@ -147,7 +161,7 @@
-
diff --git a/Components/StreamingIndicator.svelte b/Components/StreamingIndicator.svelte index 4195616..05988aa 100644 --- a/Components/StreamingIndicator.svelte +++ b/Components/StreamingIndicator.svelte @@ -1,9 +1,8 @@ -
+
diff --git a/Components/ChatAreaThought.svelte b/Components/ThoughtIndicator.svelte similarity index 90% rename from Components/ChatAreaThought.svelte rename to Components/ThoughtIndicator.svelte index 49a7af6..fd2f0b0 100644 --- a/Components/ChatAreaThought.svelte +++ b/Components/ThoughtIndicator.svelte @@ -1,12 +1,11 @@ {#if isVisible} -
+
{thought}
diff --git a/Conversations/ConversationContent.ts b/Conversations/ConversationContent.ts index 7ad3568..b647d42 100644 --- a/Conversations/ConversationContent.ts +++ b/Conversations/ConversationContent.ts @@ -50,4 +50,16 @@ export class ConversationContent { (!("errorType" in data) || typeof data.errorType === "string") ); } + + public static safeContinue() { + return new ConversationContent( + Role.User, + "Continue", + "Continue", + "", + new Date(), + false, + true // isFunctionCallResponse = true (hides from UI) + ); + } } \ No newline at end of file diff --git a/Enums/Copy.ts b/Enums/Copy.ts index 13735af..903fd50 100644 --- a/Enums/Copy.ts +++ b/Enums/Copy.ts @@ -1,6 +1,5 @@ export enum Copy { // General Copy - ApiRequestAborted = "Request has been cancelled", UserInstructions1 = "You can create custom ", UserInstructions2 = "instructions", UserInstructions3 = " that the AI will follow.", diff --git a/Enums/Environment.ts b/Enums/Environment.ts new file mode 100644 index 0000000..fcb5c3d --- /dev/null +++ b/Enums/Environment.ts @@ -0,0 +1,4 @@ +export enum Environment { + PROD = "production", + DEV = "development" +} \ No newline at end of file diff --git a/Enums/Selector.ts b/Enums/Selector.ts index d6ed2c3..03896e0 100644 --- a/Enums/Selector.ts +++ b/Enums/Selector.ts @@ -7,12 +7,5 @@ export enum Selector { HelpModal = "help-modal", ContextSettingItemDescription = "context-setting-item-description", - ApiRequestAborted = "api-request-aborted", - APIRequestError = "api-request-error", - ErrorSelector = "error-selector" -} - -export function isErrorSelector(selector: Selector) { - return selector === Selector.ApiRequestAborted || selector === Selector.APIRequestError; } \ No newline at end of file diff --git a/Helpers/Exception.ts b/Helpers/Exception.ts index 059bf2d..892eea6 100644 --- a/Helpers/Exception.ts +++ b/Helpers/Exception.ts @@ -1,3 +1,5 @@ +import { Environment } from "Enums/Environment"; + export abstract class Exception { public static throw(error: unknown): never { @@ -10,7 +12,7 @@ export abstract class Exception { } public static log(error: unknown) { - if (process.env.NODE_ENV !== "production") { + if (process.env.NODE_ENV === Environment.DEV) { const e: Error = this.new(error); console.error(e.message, e); } diff --git a/Services/AIFunctionService.ts b/Services/AIFunctionService.ts index f4c5ed4..1a71d95 100644 --- a/Services/AIFunctionService.ts +++ b/Services/AIFunctionService.ts @@ -5,6 +5,7 @@ import { AIFunction } from "Enums/AIFunction"; import { AIFunctionResponse } from "AIClasses/FunctionDefinitions/AIFunctionResponse"; import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; import type { ISearchMatch } from "../Helpers/SearchTypes"; +import { AbortService } from "./AbortService"; import { normalizePath, TAbstractFile, TFile } from "obsidian"; import { SearchVaultFilesArgsSchema, @@ -17,96 +18,104 @@ import { export class AIFunctionService { - private fileSystemService: FileSystemService = Resolve(Services.FileSystemService); + private readonly fileSystemService: FileSystemService; + private readonly abortService: AbortService; + + public constructor() { + this.fileSystemService = Resolve(Services.FileSystemService); + this.abortService = Resolve(Services.AbortService); + } public async performAIFunction(functionCall: AIFunctionCall): Promise { - switch (functionCall.name) { - case AIFunction.SearchVaultFiles: { - const parseResult = SearchVaultFilesArgsSchema.safeParse(functionCall.arguments); - if (!parseResult.success) { + return await this.abortService.abortableOperation(async () => { + switch (functionCall.name) { + case AIFunction.SearchVaultFiles: { + const parseResult = SearchVaultFilesArgsSchema.safeParse(functionCall.arguments); + if (!parseResult.success) { + return new AIFunctionResponse( + functionCall.name, + { error: `Invalid arguments for SearchVaultFiles: ${parseResult.error.message}` }, + functionCall.toolId + ); + } + return new AIFunctionResponse(functionCall.name, await this.searchVaultFiles(parseResult.data.search_terms), functionCall.toolId); + } + + case AIFunction.ReadVaultFiles: { + const parseResult = ReadVaultFilesArgsSchema.safeParse(functionCall.arguments); + if (!parseResult.success) { + return new AIFunctionResponse( + functionCall.name, + { error: `Invalid arguments for ReadVaultFiles: ${parseResult.error.message}` }, + functionCall.toolId + ); + } + return new AIFunctionResponse(functionCall.name, await this.readVaultFiles(parseResult.data.file_paths), functionCall.toolId); + } + + case AIFunction.WriteVaultFile: { + const parseResult = WriteVaultFileArgsSchema.safeParse(functionCall.arguments); + if (!parseResult.success) { + return new AIFunctionResponse( + functionCall.name, + { error: `Invalid arguments for WriteVaultFile: ${parseResult.error.message}` }, + functionCall.toolId + ); + } + return new AIFunctionResponse(functionCall.name, await this.writeVaultFile(parseResult.data.file_path, parseResult.data.content), functionCall.toolId); + } + + case AIFunction.DeleteVaultFiles: { + const parseResult = DeleteVaultFilesArgsSchema.safeParse(functionCall.arguments); + if (!parseResult.success) { + return new AIFunctionResponse( + functionCall.name, + { error: `Invalid arguments for DeleteVaultFiles: ${parseResult.error.message}` }, + functionCall.toolId + ); + } + return new AIFunctionResponse(functionCall.name, await this.deleteVaultFiles(parseResult.data.file_paths, parseResult.data.confirm_deletion), functionCall.toolId); + } + + case AIFunction.MoveVaultFiles: { + const parseResult = MoveVaultFilesArgsSchema.safeParse(functionCall.arguments); + if (!parseResult.success) { + return new AIFunctionResponse( + functionCall.name, + { error: `Invalid arguments for MoveVaultFiles: ${parseResult.error.message}` }, + functionCall.toolId + ); + } + return new AIFunctionResponse(functionCall.name, await this.moveVaultFiles(parseResult.data.source_paths, parseResult.data.destination_paths), functionCall.toolId); + } + + case AIFunction.ListVaultFiles: { + const parseResult = ListVaultFilesArgsSchema.safeParse(functionCall.arguments); + if (!parseResult.success) { + return new AIFunctionResponse( + functionCall.name, + { error: `Invalid arguments for ListVaultFiles: ${parseResult.error.message}` }, + functionCall.toolId + ); + } + return new AIFunctionResponse(functionCall.name, await this.ListVaultFiles(parseResult.data.path, parseResult.data.recursive), functionCall.toolId); + } + + // this is only used by gemini + case AIFunction.RequestWebSearch: + return new AIFunctionResponse(functionCall.name, {}, functionCall.toolId) + + default: { + const error = `Unknown function request ${functionCall.name as string}` + console.error(error); return new AIFunctionResponse( functionCall.name, - { error: `Invalid arguments for SearchVaultFiles: ${parseResult.error.message}` }, + { error: error }, functionCall.toolId ); } - return new AIFunctionResponse(functionCall.name, await this.searchVaultFiles(parseResult.data.search_terms), functionCall.toolId); } - - case AIFunction.ReadVaultFiles: { - const parseResult = ReadVaultFilesArgsSchema.safeParse(functionCall.arguments); - if (!parseResult.success) { - return new AIFunctionResponse( - functionCall.name, - { error: `Invalid arguments for ReadVaultFiles: ${parseResult.error.message}` }, - functionCall.toolId - ); - } - return new AIFunctionResponse(functionCall.name, await this.readVaultFiles(parseResult.data.file_paths), functionCall.toolId); - } - - case AIFunction.WriteVaultFile: { - const parseResult = WriteVaultFileArgsSchema.safeParse(functionCall.arguments); - if (!parseResult.success) { - return new AIFunctionResponse( - functionCall.name, - { error: `Invalid arguments for WriteVaultFile: ${parseResult.error.message}` }, - functionCall.toolId - ); - } - return new AIFunctionResponse(functionCall.name, await this.writeVaultFile(parseResult.data.file_path, parseResult.data.content), functionCall.toolId); - } - - case AIFunction.DeleteVaultFiles: { - const parseResult = DeleteVaultFilesArgsSchema.safeParse(functionCall.arguments); - if (!parseResult.success) { - return new AIFunctionResponse( - functionCall.name, - { error: `Invalid arguments for DeleteVaultFiles: ${parseResult.error.message}` }, - functionCall.toolId - ); - } - return new AIFunctionResponse(functionCall.name, await this.deleteVaultFiles(parseResult.data.file_paths, parseResult.data.confirm_deletion), functionCall.toolId); - } - - case AIFunction.MoveVaultFiles: { - const parseResult = MoveVaultFilesArgsSchema.safeParse(functionCall.arguments); - if (!parseResult.success) { - return new AIFunctionResponse( - functionCall.name, - { error: `Invalid arguments for MoveVaultFiles: ${parseResult.error.message}` }, - functionCall.toolId - ); - } - return new AIFunctionResponse(functionCall.name, await this.moveVaultFiles(parseResult.data.source_paths, parseResult.data.destination_paths), functionCall.toolId); - } - - case AIFunction.ListVaultFiles: { - const parseResult = ListVaultFilesArgsSchema.safeParse(functionCall.arguments); - if (!parseResult.success) { - return new AIFunctionResponse( - functionCall.name, - { error: `Invalid arguments for ListVaultFiles: ${parseResult.error.message}` }, - functionCall.toolId - ); - } - return new AIFunctionResponse(functionCall.name, await this.ListVaultFiles(parseResult.data.path, parseResult.data.recursive), functionCall.toolId); - } - - // this is only used by gemini - case AIFunction.RequestWebSearch: - return new AIFunctionResponse(functionCall.name, {}, functionCall.toolId) - - default: { - const error = `Unknown function request ${functionCall.name as string}` - console.error(error); - return new AIFunctionResponse( - functionCall.name, - { error: error }, - functionCall.toolId - ); - } - } + }); } private async searchVaultFiles(searchTerms: string[]): Promise { diff --git a/Services/AbortService.ts b/Services/AbortService.ts new file mode 100644 index 0000000..250855d --- /dev/null +++ b/Services/AbortService.ts @@ -0,0 +1,68 @@ +// This class is designed to provide a single centralised abort controller +export class AbortService { + private abortController: AbortController = new AbortController(); + + public static isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; + } + + public initialiseAbortController(): void { + this.abortController.abort(); + this.abortController = new AbortController(); + } + + public signal(): AbortSignal { + return this.abortController.signal; + } + + public reason(): Error { + return this.signal().reason instanceof Error ? this.signal().reason as Error : new Error("Aborted"); + } + + public abort(reason?: string): void { + this.abortController.abort( + new DOMException(reason ?? "Aborted", "AbortError") + ); + } + + // useful if you need to rethrow the abort error + public throw(): never { + throw this.reason(); + } + + public async abortableOperation(executor: () => Promise): Promise { + const signal = this.abortController.signal; + + if (signal.aborted) { + this.throw(); + } + + let abortHandler: () => void; + let abortWon = false; + + const executorPromise = executor(); + + const abortPromise = new Promise((_, reject) => { + abortHandler = () => { + abortWon = true; + reject(this.reason()); + }; + signal.addEventListener("abort", abortHandler, { once: true }); + }); + + // Suppress AbortErrors from executor once abort has won the race + executorPromise.catch(error => { + if (abortWon && AbortService.isAbortError(error)) { + return; + } + }); + + abortPromise.catch(() => {}); + + try { + return await Promise.race([executorPromise, abortPromise]); + } finally { + signal.removeEventListener("abort", abortHandler!); + } + } +} \ No newline at end of file diff --git a/Services/ChatService.ts b/Services/ChatService.ts index 0f317a7..54f343a 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -15,12 +15,14 @@ import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; import { Notice } from "obsidian"; import type { EventService } from "./EventService"; import { Event } from "Enums/Event"; +import { AbortService } from "./AbortService"; export interface IChatServiceCallbacks { onSubmit: () => void; onStreamingUpdate: (streamingMessageId: string | null) => void; onThoughtUpdate: (thought: string | null) => void; onComplete: () => void; + onCancel: () => void; } export class ChatService { @@ -32,10 +34,10 @@ export class ChatService { private prompt: IPrompt; private statusBarService: StatusBarService; private eventService: EventService; + private abortService: AbortService; private semaphore: Semaphore; private semaphoreHeld: boolean = false; - private abortController: AbortController | null = null; constructor() { this.conversationService = Resolve(Services.ConversationFileSystemService); @@ -44,6 +46,7 @@ export class ChatService { this.prompt = Resolve(Services.IPrompt); this.statusBarService = Resolve(Services.StatusBarService); this.eventService = Resolve(Services.EventService); + this.abortService = Resolve(Services.AbortService); this.semaphore = new Semaphore(1, false); } @@ -66,43 +69,49 @@ export class ChatService { return; } - this.abortController = new AbortController(); + this.abortService.initialiseAbortController(); - conversation.contents.push(new ConversationContent(Role.User, userRequest, formattedRequest)); - await this.saveConversation(conversation); + await this.abortService.abortableOperation(async () => { + conversation.contents.push(new ConversationContent(Role.User, userRequest, formattedRequest)); + await this.saveConversation(conversation); - callbacks.onSubmit(); - callbacks.onStreamingUpdate(null); + callbacks.onSubmit(); + callbacks.onStreamingUpdate(null); - if (conversation.contents.length === 1) { - this.onNameChanged?.(conversation.title); // on change for initial conversation name - await this.namingService.requestName(conversation, formattedRequest, this.onNameChanged, this.abortController); - } - - // Process AI responses and function calls - let response = await this.streamRequestResponse(conversation, allowDestructiveActions, callbacks); - while (response.functionCall || response.shouldContinue) { - if (response.functionCall) { - const userMessage = response.functionCall.arguments.user_message; - if (userMessage && typeof userMessage === "string") { - callbacks.onThoughtUpdate(userMessage); - } - - const functionResponse = await this.aiFunctionService.performAIFunction(response.functionCall); - - const functionResponseString = functionResponse.toConversationString(); - conversation.contents.push(new ConversationContent( - Role.User, functionResponseString, functionResponseString, "", new Date(), false, true, functionResponse.toolId - )); + if (conversation.contents.length === 1) { + this.onNameChanged?.(conversation.title); // on change for initial conversation name + await this.namingService.requestName(conversation, formattedRequest, this.onNameChanged); } - this.ensureCorrectConversationStructure(conversation); - response = await this.streamRequestResponse(conversation, allowDestructiveActions, callbacks); + // Process AI responses and function calls + let response = await this.streamRequestResponse(conversation, allowDestructiveActions, callbacks); + while (response.functionCall || response.shouldContinue) { + if (response.functionCall) { + const userMessage = response.functionCall.arguments.user_message; + if (userMessage && typeof userMessage === "string") { + callbacks.onThoughtUpdate(userMessage); + } + + const functionResponse = await this.aiFunctionService.performAIFunction(response.functionCall); + + const functionResponseString = functionResponse.toConversationString(); + conversation.contents.push(new ConversationContent( + Role.User, functionResponseString, functionResponseString, "", new Date(), false, true, functionResponse.toolId + )); + } + + this.ensureCorrectConversationStructure(conversation); + response = await this.streamRequestResponse(conversation, allowDestructiveActions, callbacks); + } + }); + } catch (error) { + if (AbortService.isAbortError(error)) { + callbacks.onCancel(); } } finally { + // reset "Cancelling..." flag this needs to get to window (window may actually handle this itself) this.eventService.trigger(Event.DiffClosed); await this.saveConversation(conversation); - this.abortController = null; if (this.semaphoreHeld) { this.semaphoreHeld = false; this.semaphore.release(); @@ -113,11 +122,8 @@ export class ChatService { } public stop() { - if (this.abortController) { - this.abortController.abort(); - this.abortController = null; - } - this.semaphore.release(); + this.abortService.abort("User requested cancellation"); + this.eventService.trigger(Event.DiffClosed); } public async updateTokenDisplay(conversation: Conversation) { @@ -163,15 +169,7 @@ export class ChatService { const lastMessage = conversation.contents[conversation.contents.length - 1]; if (lastMessage.role === Role.Assistant) { // Insert a hidden "Continue" message to maintain proper conversation structure - conversation.contents.push(new ConversationContent( - Role.User, - "Continue", - "Continue", - "", - new Date(), - false, - true // isFunctionCallResponse = true (hides from UI) - )); + conversation.contents.push(ConversationContent.safeContinue()); } } } @@ -191,7 +189,7 @@ export class ChatService { let capturedFunctionCall: AIFunctionCall | null = null; let capturedShouldContinue = false; - for await (const chunk of this.ai.streamRequest(conversation, allowDestructiveActions, this.abortController?.signal)) { + for await (const chunk of this.ai.streamRequest(conversation, allowDestructiveActions)) { if (chunk.error && chunk.errorType) { conversation.setMostRecentError(chunk.error, chunk.errorType); callbacks.onStreamingUpdate(aiMessage.timestamp.getTime().toString()); @@ -208,6 +206,7 @@ export class ChatService { if (chunk.content) { accumulatedContent += chunk.content; + conversation.setMostRecentContent(accumulatedContent); if (accumulatedContent.trim() !== "") { callbacks.onThoughtUpdate(null); diff --git a/Services/ConversationFileSystemService.ts b/Services/ConversationFileSystemService.ts index 9b98e50..1587ff7 100644 --- a/Services/ConversationFileSystemService.ts +++ b/Services/ConversationFileSystemService.ts @@ -4,7 +4,6 @@ import { FileSystemService } from "./FileSystemService"; import { Services } from "./Services"; import { Conversation } from "Conversations/Conversation"; import { ConversationContent } from "Conversations/ConversationContent"; -import { Copy } from "Enums/Copy"; import { Exception } from "Helpers/Exception"; export class ConversationFileSystemService { @@ -23,6 +22,12 @@ export class ConversationFileSystemService { public async saveConversation(conversation: Conversation): Promise { if (!this.currentConversationPath) { this.currentConversationPath = this.generateConversationPath(conversation); + } else { + // can happen if the conversation is deleted during an active request + const fileExists = await this.fileSystemService.exists(this.currentConversationPath, true); + if (!fileExists) { + return this.currentConversationPath; + } } conversation.updated = new Date(); @@ -32,7 +37,6 @@ export class ConversationFileSystemService { created: conversation.created.toISOString(), updated: conversation.updated.toISOString(), contents: conversation.contents - .filter(content => content.content !== Copy.ApiRequestAborted.toString()) .map(content => ({ role: content.role, content: content.content, @@ -72,7 +76,7 @@ export class ConversationFileSystemService { return; } - const result = await this.fileSystemService.deleteFile(this.currentConversationPath, true); + const result = await this.fileSystemService.deleteFile(this.currentConversationPath, true, false); if (result instanceof Error) { return result; diff --git a/Services/ConversationNamingService.ts b/Services/ConversationNamingService.ts index 12e0a87..8997cee 100644 --- a/Services/ConversationNamingService.ts +++ b/Services/ConversationNamingService.ts @@ -7,6 +7,7 @@ import type { VaultService } from "./VaultService"; import { Path } from "Enums/Path"; import { Exception } from "Helpers/Exception"; import { Notice } from "obsidian"; +import { AbortService } from "./AbortService"; export class ConversationNamingService { private readonly stackLimit: number = 1000; @@ -14,54 +15,60 @@ export class ConversationNamingService { private namingProvider: IConversationNamingService | undefined; private conversationService: ConversationFileSystemService; private vaultService: VaultService; + private abortService: AbortService; constructor() { this.conversationService = Resolve(Services.ConversationFileSystemService); this.vaultService = Resolve(Services.VaultService); + this.abortService = Resolve(Services.AbortService); } public resolveNamingProvider() { this.namingProvider = Resolve(Services.IConversationNamingService); } - public async requestName(conversation: Conversation, userPrompt: string, onNameChanged: ((name: string) => void) | undefined, abortController: AbortController) { - if (!this.namingProvider) { - return; - } - - const conversationPath = this.conversationService.getCurrentConversationPath(); - - if (!conversationPath) { - return; - } - - try { - const generatedName: string = await this.namingProvider.generateName(userPrompt, abortController.signal); - const validatedName: string = await this.validateName(generatedName); - - const stillExists = this.conversationService.getCurrentConversationPath() === conversationPath; - if (!stillExists) { + public async requestName(conversation: Conversation, userPrompt: string, onNameChanged: ((name: string) => void) | undefined) { + await this.abortService.abortableOperation(async () => { + if (!this.namingProvider) { return; } - - const updateResult = await this.conversationService.updateConversationTitle(conversationPath, validatedName); + + const conversationPath = this.conversationService.getCurrentConversationPath(); - if (updateResult instanceof Error) { - Exception.throw(updateResult); + if (!conversationPath) { + return; } - - conversation.title = validatedName; - const saveResult = await this.conversationService.saveConversation(conversation); - - if (saveResult instanceof Error) { - Exception.throw(saveResult); + + try { + const generatedName: string = await this.namingProvider.generateName(userPrompt); + const validatedName: string = await this.validateName(generatedName); + + const stillExists = this.conversationService.getCurrentConversationPath() === conversationPath; + if (!stillExists) { + return; + } + + const updateResult = await this.conversationService.updateConversationTitle(conversationPath, validatedName); + + if (updateResult instanceof Error) { + Exception.throw(updateResult); + } + + conversation.title = validatedName; + const saveResult = await this.conversationService.saveConversation(conversation); + + if (saveResult instanceof Error) { + Exception.throw(saveResult); + } + + onNameChanged?.(conversation.title); + } catch (error) { + if (!AbortService.isAbortError(error)) { + Exception.log(error); + new Notice(`Failed to name conversation '${conversation.title}'`); + } } - - onNameChanged?.(conversation.title); - } catch (error) { - Exception.log(error); - new Notice(`Failed to name conversation '${conversation.title}'`); - } + }); } private async validateName(generatedName: string): Promise { diff --git a/Services/DiffService.ts b/Services/DiffService.ts index da021ca..4d57802 100644 --- a/Services/DiffService.ts +++ b/Services/DiffService.ts @@ -6,7 +6,8 @@ import type { EventService } from './EventService'; import { Event } from 'Enums/Event'; import type { Diff2HtmlUIConfig } from 'diff2html/lib/ui/js/diff2html-ui'; import { ColorSchemeType, OutputFormatType } from 'diff2html/lib/types'; -import { Component, Platform } from 'obsidian'; +import { Component } from 'obsidian'; +import { AbortService } from './AbortService'; interface DiffResult { accepted: boolean; @@ -17,6 +18,7 @@ export class DiffService extends Component { private readonly plugin: VaultkeeperAIPlugin; private readonly eventService: EventService; + private readonly abortService: AbortService; private diffResolve?: (result: DiffResult) => void; @@ -26,6 +28,7 @@ export class DiffService extends Component { super(); this.plugin = Resolve(Services.VaultkeeperAIPlugin); this.eventService = Resolve(Services.EventService); + this.abortService = Resolve(Services.AbortService); this.registerEvent(this.eventService.on(Event.DiffClosed, () => { this.cancelPendingDiff(); @@ -35,7 +38,7 @@ export class DiffService extends Component { public async requestDiff(oldFileName: string, newFileName: string, oldContent: string, newContent: string): Promise { const diffString = this.createDiffString(oldFileName, newFileName, oldContent, newContent); - const outputFormat: OutputFormatType = (Platform.isMobile || oldContent.trim() === "") ? "line-by-line" : "side-by-side"; + const outputFormat: OutputFormatType = "line-by-line"; const config: Diff2HtmlUIConfig = { drawFileList: false, @@ -50,8 +53,24 @@ export class DiffService extends Component { this.ongoingDiff = true; - return new Promise((resolve) => { - this.diffResolve = resolve; + const signal = this.abortService.signal(); + return new Promise((resolve, reject) => { + if (signal.aborted) { + this.finishDiff(); + reject(this.abortService.reason()); + return; + } + + const abortHandler = () => { + this.finishDiff(); + reject(this.abortService.reason()); + }; + signal.addEventListener("abort", abortHandler, { once: true }); + + this.diffResolve = (result: DiffResult) => { + signal.removeEventListener("abort", abortHandler); + resolve(result); + }; void this.plugin.activateDiffView(diffString, config); this.eventService.trigger(Event.DiffOpened); @@ -89,7 +108,7 @@ export class DiffService extends Component { } private createDiffString(oldFileName: string, newFileName: string, oldContent: string, newContent: string): string { - return Diff.createTwoFilesPatch(oldFileName, newFileName, oldContent, newContent); + return Diff.createTwoFilesPatch(oldFileName, newFileName, oldContent, newContent, undefined, undefined, { context: Infinity }); } private finishDiff() { diff --git a/Services/FileSystemService.ts b/Services/FileSystemService.ts index 9cd9ccf..745b0e6 100644 --- a/Services/FileSystemService.ts +++ b/Services/FileSystemService.ts @@ -13,6 +13,10 @@ export class FileSystemService { this.vaultService = Resolve(Services.VaultService); } + public async exists(filePath: string, allowAccessToPluginRoot: boolean = false): Promise { + return await this.vaultService.exists(filePath, allowAccessToPluginRoot); + } + public async readFile(filePath: string, allowAccessToPluginRoot: boolean = false): Promise { const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot); if (file && file instanceof TFile) { @@ -21,28 +25,22 @@ export class FileSystemService { return Exception.new(`Path is a folder, not a file: ${filePath}`); } - public async writeFile(filePath: string, content: string, allowAccessToPluginRoot: boolean = false): Promise { - try { - const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot); - if (file == null || !(file instanceof TFile)) { - return await this.vaultService.create(filePath, content, allowAccessToPluginRoot); - } - return await this.vaultService.modify(file, content, allowAccessToPluginRoot); - } - catch (error) { - Exception.log(error); - return Exception.new(error); + public async writeFile(filePath: string, content: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise { + const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot); + if (file == null || !(file instanceof TFile)) { + return await this.vaultService.create(filePath, content, allowAccessToPluginRoot, requiresConfirmation); } + return await this.vaultService.modify(file, content, allowAccessToPluginRoot, requiresConfirmation); } - public async deleteFile(filePath: string, allowAccessToPluginRoot: boolean = false): Promise { + public async deleteFile(filePath: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise { const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot); if (!file) { return Exception.new(`File does not exist: ${filePath}`); } - return await this.vaultService.delete(file, allowAccessToPluginRoot); + return await this.vaultService.delete(file, allowAccessToPluginRoot, requiresConfirmation); } public async moveFile(sourcePath: string, destinationPath: string, allowAccessToPluginRoot: boolean = false): Promise { @@ -61,11 +59,11 @@ export class FileSystemService { return await this.vaultService.listDirectoryContents(dirPath, recursive, allowAccessToPluginRoot); } - public async readObjectFromFile(filePath: string, allowAccessToPluginRoot: boolean = false): Promise { + public async readObjectFromFile(filePath: string, allowAccessToPluginRoot: boolean = false): Promise | Error> { const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot); if (file && file instanceof TFile) { const result = await this.vaultService.read(file, allowAccessToPluginRoot); - return typeof result === "string" ? JSON.parse(result) as object : result; + return typeof result === "string" ? JSON.parse(result) as Record : result; } return Exception.new(`File not found: ${filePath}`); } @@ -73,15 +71,15 @@ export class FileSystemService { public async writeObjectToFile(filePath: string, data: object, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise { const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot); - let result: TFile | Error; - if (file && file instanceof TFile) { - result = await this.vaultService.modify(file, JSON.stringify(data, null, 4), allowAccessToPluginRoot, requiresConfirmation); - } - else { - result = await this.vaultService.create(filePath, JSON.stringify(data, null, 4), allowAccessToPluginRoot, requiresConfirmation); - } + let result: TFile | Error; + if (file && file instanceof TFile) { + result = await this.vaultService.modify(file, JSON.stringify(data, null, 4), allowAccessToPluginRoot, requiresConfirmation); + } + else { + result = await this.vaultService.create(filePath, JSON.stringify(data, null, 4), allowAccessToPluginRoot, requiresConfirmation); + } - return result; + return result; } public async searchVaultFiles(searchTerm: string, allowAccessToPluginRoot: boolean = false): Promise { diff --git a/Services/ServiceRegistration.ts b/Services/ServiceRegistration.ts index 6a0f4e0..74ce8cf 100644 --- a/Services/ServiceRegistration.ts +++ b/Services/ServiceRegistration.ts @@ -37,6 +37,7 @@ import { SettingsService, type IVaultkeeperAISettings } from "./SettingsService" import { HelpModal } from "Modals/HelpModal"; import { EventService } from "./EventService"; import { DiffService } from "./DiffService"; +import { AbortService } from "./AbortService"; export async function RegisterPlugin(plugin: VaultkeeperAIPlugin) { RegisterSingleton(Services.VaultkeeperAIPlugin, plugin); @@ -45,6 +46,7 @@ export async function RegisterPlugin(plugin: VaultkeeperAIPlugin) { export function RegisterDependencies() { RegisterSingleton(Services.EventService, new EventService()); + RegisterSingleton(Services.AbortService, new AbortService()); RegisterSingleton(Services.StatusBarService, new StatusBarService()); RegisterSingleton(Services.HTMLService, new HTMLService()); RegisterSingleton(Services.SanitiserService, new SanitiserService()); diff --git a/Services/Services.ts b/Services/Services.ts index a4581de..ebd9af4 100644 --- a/Services/Services.ts +++ b/Services/Services.ts @@ -2,6 +2,7 @@ export class Services { static VaultkeeperAIPlugin = Symbol("VaultkeeperAIPlugin"); static SettingsService = Symbol("SettingsService"); static EventService = Symbol("EventService"); + static AbortService = Symbol("AbortService"); static StatusBarService = Symbol("StatusBarService"); static HTMLService = Symbol("HTMLService"); static VaultService = Symbol("VaultService"); diff --git a/Services/StreamingMarkdownService.ts b/Services/StreamingMarkdownService.ts index 232c25f..14c2ce5 100644 --- a/Services/StreamingMarkdownService.ts +++ b/Services/StreamingMarkdownService.ts @@ -32,7 +32,7 @@ export class StreamingMarkdownService { constructor() { this.processor = unified() - .use(remarkParse) + .use(remarkParse) .use(remarkGfm) .use(remarkEmoji) .use(remarkMath) diff --git a/Services/StreamingService.ts b/Services/StreamingService.ts index f7dc23d..37f0eeb 100644 --- a/Services/StreamingService.ts +++ b/Services/StreamingService.ts @@ -1,7 +1,9 @@ import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; -import { Selector } from "Enums/Selector"; import { Exception } from "Helpers/Exception"; import { ApiError, ApiErrorType } from "Types/ApiError"; +import { AbortService } from "./AbortService"; +import { Resolve } from "./DependencyService"; +import { Services } from "./Services"; export interface IStreamChunk { content: string; @@ -17,14 +19,20 @@ export class StreamingService { private static readonly MAX_RETRIES = 3; private static readonly RETRY_DELAYS = [1000, 2000, 4000]; // ms + private readonly abortService: AbortService; + + public constructor() { + this.abortService = Resolve(Services.AbortService); + } + public async* streamRequest(url: string, requestBody: unknown, parseStreamChunk: (chunk: string) => IStreamChunk, - abortSignal?: AbortSignal, additionalHeaders?: Record): AsyncGenerator { + additionalHeaders?: Record): AsyncGenerator { let lastError: Error | null = null; for (let attempt = 0; attempt <= StreamingService.MAX_RETRIES; attempt++) { try { - const response = await this.makeRequest(url, requestBody, additionalHeaders, abortSignal); + const response = await this.makeRequest(url, requestBody, additionalHeaders); const reader = response.body?.getReader(); if (!reader) { @@ -42,9 +50,8 @@ export class StreamingService { } catch (error) { lastError = error instanceof Error ? error : Exception.new(error); - if (error instanceof Error && error.name === 'AbortError') { - yield this.createErrorChunk(lastError, true); - return; + if (AbortService.isAbortError(error)) { + throw error; } if (!this.shouldRetry(error, attempt)) { @@ -64,7 +71,7 @@ export class StreamingService { } private async makeRequest(url: string, requestBody: unknown, - additionalHeaders?: Record, abortSignal?: AbortSignal): Promise { + additionalHeaders?: Record): Promise { const response = await fetch(url, { method: "POST", headers: { @@ -72,7 +79,7 @@ export class StreamingService { ...additionalHeaders, }, body: JSON.stringify(requestBody), - signal: abortSignal, + signal: this.abortService.signal(), }); if (!response.ok) { @@ -85,13 +92,16 @@ export class StreamingService { private async* processStream(reader: ReadableStreamDefaultReader, parseStreamChunk: (chunk: string) => IStreamChunk): AsyncGenerator { - let buffer = ""; let lastChunkWasComplete = false; const decoder = new TextDecoder(); while (true) { + if (this.abortService.signal().aborted) { + this.abortService.throw(); + } + const { done, value } = await reader.read(); buffer += decoder.decode(value, { stream: true }); @@ -106,6 +116,9 @@ export class StreamingService { lastChunkWasComplete = chunk.isComplete; yield chunk; } catch (error) { + if (AbortService.isAbortError(error)) { + throw error; + } Exception.log(error); yield { content: "", @@ -125,14 +138,7 @@ export class StreamingService { return lastChunkWasComplete; } - private createErrorChunk(error: Error | ApiError, isAborted = false): IStreamChunk { - if (isAborted) { - return { - content: Selector.ApiRequestAborted, - isComplete: true - }; - } - + private createErrorChunk(error: Error | ApiError): IStreamChunk { if (error instanceof ApiError) { return { content: "", @@ -151,14 +157,12 @@ export class StreamingService { } private shouldRetry(error: unknown, attempt: number): boolean { - // Don't retry abort errors - if (error instanceof Error && error.name === 'AbortError') { - return false; + if (AbortService.isAbortError(error)) { + return false; // Don't retry abort errors } - // Don't retry non-retryable errors if (error instanceof ApiError && !error.info.isRetryable) { - return false; + return false; // Don't retry non-retryable errors } return attempt < StreamingService.MAX_RETRIES; diff --git a/Services/VaultService.ts b/Services/VaultService.ts index 1ba6bc2..52fc50a 100644 --- a/Services/VaultService.ts +++ b/Services/VaultService.ts @@ -14,6 +14,7 @@ import type { EventService } from "./EventService"; import { DiffService } from "./DiffService"; import * as path from "path-browserify"; import { Event } from "Enums/Event"; +import { AbortService } from "./AbortService"; interface IFileEventArgs { oldPath: string; @@ -111,12 +112,21 @@ export class VaultService { }); } - public async delete(file: TAbstractFile, allowAccessToPluginRoot: boolean = false): Promise { + public async delete(file: TAbstractFile, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise { const filePath = this.sanitiserService.sanitize(file.path); if (this.isExclusion(file.path, allowAccessToPluginRoot)) { Exception.log(`Plugin attempted to delete a file that is in the exclusions list: ${filePath}`) return Exception.new(`File does not exist: ${filePath}`); } + + // handle file deletion + if (file instanceof TFile) { + return this.proposeChange(file.name, file.name, await this.vault.read(file), "", requiresConfirmation, async () => { + await this.fileManager.trashFile(file); + }); + } + + // handle folder deletion try { await this.fileManager.trashFile(file); } catch (error) { @@ -428,12 +438,16 @@ export class VaultService { let response = "User rejected this change. Stop all actions and consult with the user"; if (result.suggestion) { - response = `User has rejected the input with the following suggestion: ${result.suggestion}`; + response = `User has rejected the change with the following suggestion: ${result.suggestion}`; } return Exception.new(response); } catch (error) { + if (AbortService.isAbortError(error)) { + throw error; + } this.eventService.trigger(Event.DiffClosed); Exception.log(error); + return Exception.new(error); } } diff --git a/Styles/custom_styles.css b/Styles/custom_styles.css index dcd95dc..3eb0c19 100644 --- a/Styles/custom_styles.css +++ b/Styles/custom_styles.css @@ -13,6 +13,16 @@ padding-top: 0; } +/* ============================== */ +/* Diff View Customization */ +/* ============================== */ + +.workspace-leaf-content[data-type="vaultkeeper-ai-diff-view"] .view-content { + container-type: size; + container-name: diff-container; + overflow: hidden; +} + /* ============================== */ /* Settings Styles */ /* ============================== */ diff --git a/Styles/diff2html_styles.css b/Styles/diff2html_styles.css index 09e5ae1..c9b8d2d 100644 --- a/Styles/diff2html_styles.css +++ b/Styles/diff2html_styles.css @@ -2,6 +2,7 @@ /* Main diff container background */ .d2h-wrapper { + transform: translateZ(0); background-color: var(--background-primary) !important; box-shadow: 0px 0px 4px 1px var(--color-accent) !important; } @@ -17,8 +18,8 @@ background-color: var(--background-primary-alt) !important; } -.d2h-code-line { - padding: 0 5em; +.d2h-code-linenumber { + cursor: default; } /* Line number backgrounds - only override context lines */ @@ -40,15 +41,42 @@ /* File diff background */ .d2h-file-diff { + overflow: auto; background-color: var(--background-primary) !important; border-color: var(--background-modifier-border) !important; } +/* File diff size */ +@container diff-container (max-height: 199px) { + .d2h-file-diff { max-height: 90cqh; } +} + +@container diff-container (min-height: 200px) { + .d2h-file-diff { max-height: 95cqh; } +} + +@container diff-container (min-height: 400px) { + .d2h-file-diff { max-height: 97cqh; } +} + +@container diff-container (min-height: 550px) { + .d2h-file-diff { max-height: 98cqh; } +} + +@container diff-container (min-height: 1000px) { + .d2h-file-diff { max-height: 99cqh; } +} + /* Empty placeholder */ .d2h-emptyplaceholder { background-color: var(--background-secondary-alt) !important; } +.d2h-code-side-linenumber { + position: relative; + display: table-cell; +} + /* Code side backgrounds for side-by-side view - only override context lines */ .d2h-code-side-linenumber.d2h-cntx, .d2h-code-side-line.d2h-cntx { @@ -140,9 +168,12 @@ .d2h-code-linenumber, .d2h-info { color: var(--text-normal) !important; - width: 4.5em; } +.d2h-diff-table { + position: relative; + } + /* Light mode syntax highlighting overrides for better visibility */ .theme-light .d2h-code-side-line { color: var(--text-normal) !important; diff --git a/__tests__/AIClasses/Claude.test.ts b/__tests__/AIClasses/Claude.test.ts index 9c9b409..8ce1589 100644 --- a/__tests__/AIClasses/Claude.test.ts +++ b/__tests__/AIClasses/Claude.test.ts @@ -11,6 +11,8 @@ import { ConversationContent } from '../../Conversations/ConversationContent'; import { Role } from '../../Enums/Role'; import { SettingsService } from '../../Services/SettingsService'; import { AIProvider } from '../../Enums/ApiProvider'; +import { AbortService } from '../../Services/AbortService'; +import { Exception } from '../../Helpers/Exception'; describe('Claude', () => { let claude: Claude; @@ -19,6 +21,7 @@ describe('Claude', () => { let mockPlugin: any; let mockSettingsService: any; let mockFunctionDefinitions: any; + let abortService: AbortService; beforeEach(() => { // Mock IPrompt @@ -52,6 +55,10 @@ describe('Claude', () => { }; RegisterSingleton(Services.SettingsService, mockSettingsService); + // Create real AbortService instance + abortService = new AbortService(); + RegisterSingleton(Services.AbortService, abortService); + // Mock StreamingService mockStreamingService = { streamRequest: vi.fn() @@ -244,7 +251,7 @@ describe('Claude', () => { (claude as any).accumulatedFunctionArgs = 'invalid json {'; (claude as any).accumulatedFunctionId = 'func_789'; - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {}); const chunk = JSON.stringify({ type: 'content_block_stop' @@ -253,9 +260,9 @@ describe('Claude', () => { const result = (claude as any).parseStreamChunk(chunk); expect(result.functionCall).toBeUndefined(); - expect(consoleSpy).toHaveBeenCalled(); + expect(exceptionSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); + exceptionSpy.mockRestore(); }); it('should handle malformed chunk JSON', () => { @@ -341,7 +348,7 @@ describe('Claude', () => { }); it('should handle invalid JSON in function call gracefully', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {}); const invalidContent = new ConversationContent( Role.Assistant, @@ -358,13 +365,13 @@ describe('Claude', () => { expect(result).toHaveLength(1); expect(result[0].content).toHaveLength(1); expect(result[0].content[0].type).toBe('text'); - expect(consoleSpy).toHaveBeenCalled(); + expect(exceptionSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); + exceptionSpy.mockRestore(); }); it('should handle invalid JSON in function response gracefully', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {}); const invalidContent = new ConversationContent( Role.User, @@ -380,9 +387,9 @@ describe('Claude', () => { expect(result[0].content).toHaveLength(1); expect(result[0].content[0].type).toBe('text'); expect(result[0].content[0].text).toBe('invalid json {'); - expect(consoleSpy).toHaveBeenCalled(); + expect(exceptionSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); + exceptionSpy.mockRestore(); }); it('should filter out empty content', () => { @@ -727,8 +734,7 @@ describe('Claude', () => { yield { content: 'response', isComplete: true }; }); - const abortSignal = new AbortController().signal; - const generator = claude.streamRequest(conversation, true, abortSignal); + const generator = claude.streamRequest(conversation, true); // Consume the generator for await (const chunk of generator) { @@ -746,7 +752,6 @@ describe('Claude', () => { stream: true }), expect.any(Function), // parseStreamChunk - abortSignal, expect.objectContaining({ 'x-api-key': 'test-claude-key', 'anthropic-version': '2023-06-01', diff --git a/__tests__/AIClasses/ClaudeConversationNamingService.test.ts b/__tests__/AIClasses/ClaudeConversationNamingService.test.ts index 29f1cf9..6da9215 100644 --- a/__tests__/AIClasses/ClaudeConversationNamingService.test.ts +++ b/__tests__/AIClasses/ClaudeConversationNamingService.test.ts @@ -4,12 +4,12 @@ import { RegisterSingleton, DeregisterAllServices } from '../../Services/Depende import { Services } from '../../Services/Services'; import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider'; import { Role } from '../../Enums/Role'; -import { SettingsService } from '../../Services/SettingsService'; describe('ClaudeConversationNamingService', () => { let service: ClaudeConversationNamingService; let mockPlugin: any; let mockSettingsService: any; + let mockAbortService: any; let fetchMock: any; beforeEach(() => { @@ -36,6 +36,13 @@ describe('ClaudeConversationNamingService', () => { }; RegisterSingleton(Services.SettingsService, mockSettingsService); + // Mock AbortService + mockAbortService = { + signal: vi.fn(() => new AbortController().signal), + abortableOperation: vi.fn((fn) => fn()) + }; + RegisterSingleton(Services.AbortService, mockAbortService); + // Mock global fetch fetchMock = vi.fn(); global.fetch = fetchMock; @@ -58,7 +65,7 @@ describe('ClaudeConversationNamingService', () => { }) }); - await service.generateName('User prompt', undefined); + await service.generateName('User prompt'); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), @@ -91,7 +98,7 @@ describe('ClaudeConversationNamingService', () => { }) }); - const result = await service.generateName('Test prompt', undefined); + const result = await service.generateName('Test prompt'); expect(result).toBe('Generated Name'); }); @@ -104,7 +111,7 @@ describe('ClaudeConversationNamingService', () => { text: async () => 'Invalid API key' }); - await expect(service.generateName('Test', undefined)) + await expect(service.generateName('Test')) .rejects.toThrow('Claude API error: 401 Unauthorized - Invalid API key'); }); @@ -116,13 +123,11 @@ describe('ClaudeConversationNamingService', () => { }) }); - await expect(service.generateName('Test', undefined)) + await expect(service.generateName('Test')) .rejects.toThrow('Failed to generate conversation name'); }); it('should pass abort signal to fetch', async () => { - const abortController = new AbortController(); - fetchMock.mockResolvedValue({ ok: true, json: async () => ({ @@ -130,12 +135,12 @@ describe('ClaudeConversationNamingService', () => { }) }); - await service.generateName('Test', abortController.signal); + await service.generateName('Test'); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - signal: abortController.signal + signal: expect.any(AbortSignal) }) ); }); @@ -149,7 +154,7 @@ describe('ClaudeConversationNamingService', () => { }) }); - await expect(service.generateName('Test', undefined)) + await expect(service.generateName('Test')) .rejects.toThrow('Failed to generate conversation name'); }); }); diff --git a/__tests__/AIClasses/Gemini.test.ts b/__tests__/AIClasses/Gemini.test.ts index 2b5ca08..31ad32b 100644 --- a/__tests__/AIClasses/Gemini.test.ts +++ b/__tests__/AIClasses/Gemini.test.ts @@ -11,6 +11,8 @@ import { ConversationContent } from '../../Conversations/ConversationContent'; import { Role } from '../../Enums/Role'; import { SettingsService } from '../../Services/SettingsService'; import { AIProvider } from '../../Enums/ApiProvider'; +import { AbortService } from '../../Services/AbortService'; +import { Exception } from '../../Helpers/Exception'; describe('Gemini', () => { let gemini: Gemini; @@ -19,6 +21,7 @@ describe('Gemini', () => { let mockPlugin: any; let mockSettingsService: any; let mockFunctionDefinitions: any; + let abortService: AbortService; beforeEach(() => { // Mock IPrompt @@ -52,6 +55,10 @@ describe('Gemini', () => { }; RegisterSingleton(Services.SettingsService, mockSettingsService); + // Create real AbortService instance + abortService = new AbortService(); + RegisterSingleton(Services.AbortService, abortService); + // Mock StreamingService mockStreamingService = { streamRequest: vi.fn() @@ -401,7 +408,7 @@ describe('Gemini', () => { }); it('should handle invalid JSON in function call gracefully', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {}); const invalidContent = new ConversationContent( Role.Assistant, @@ -416,13 +423,13 @@ describe('Gemini', () => { // Should be filtered out since it has no valid parts (no text, invalid function call) expect(result).toHaveLength(0); - expect(consoleSpy).toHaveBeenCalled(); + expect(exceptionSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); + exceptionSpy.mockRestore(); }); it('should handle invalid JSON in function response gracefully', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {}); const invalidContent = new ConversationContent( Role.User, @@ -437,9 +444,9 @@ describe('Gemini', () => { expect(result).toHaveLength(1); expect(result[0].parts).toHaveLength(1); expect(result[0].parts[0]).toEqual({ text: 'invalid json {' }); - expect(consoleSpy).toHaveBeenCalled(); + expect(exceptionSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); + exceptionSpy.mockRestore(); }); it('should filter out empty content', () => { diff --git a/__tests__/AIClasses/GeminiConversationNamingService.test.ts b/__tests__/AIClasses/GeminiConversationNamingService.test.ts index 016c38d..c76ed20 100644 --- a/__tests__/AIClasses/GeminiConversationNamingService.test.ts +++ b/__tests__/AIClasses/GeminiConversationNamingService.test.ts @@ -4,12 +4,12 @@ import { RegisterSingleton, DeregisterAllServices } from '../../Services/Depende import { Services } from '../../Services/Services'; import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider'; import { Role } from '../../Enums/Role'; -import { SettingsService } from '../../Services/SettingsService'; describe('GeminiConversationNamingService', () => { let service: GeminiConversationNamingService; let mockPlugin: any; let mockSettingsService: any; + let mockAbortService: any; let fetchMock: any; beforeEach(() => { @@ -36,6 +36,13 @@ describe('GeminiConversationNamingService', () => { }; RegisterSingleton(Services.SettingsService, mockSettingsService); + // Mock AbortService + mockAbortService = { + signal: vi.fn(() => new AbortController().signal), + abortableOperation: vi.fn((fn) => fn()) + }; + RegisterSingleton(Services.AbortService, mockAbortService); + // Mock global fetch fetchMock = vi.fn(); global.fetch = fetchMock; @@ -58,7 +65,7 @@ describe('GeminiConversationNamingService', () => { }) }); - await service.generateName('User prompt', undefined); + await service.generateName('User prompt'); expect(fetchMock).toHaveBeenCalled(); const fetchUrl = fetchMock.mock.calls[0][0]; @@ -95,7 +102,7 @@ describe('GeminiConversationNamingService', () => { }) }); - const result = await service.generateName('Test prompt', undefined); + const result = await service.generateName('Test prompt'); expect(result).toBe('Generated Name'); }); @@ -108,7 +115,7 @@ describe('GeminiConversationNamingService', () => { text: async () => 'Invalid request' }); - await expect(service.generateName('Test', undefined)) + await expect(service.generateName('Test')) .rejects.toThrow('Gemini API error: 400 Bad Request - Invalid request'); }); @@ -120,13 +127,11 @@ describe('GeminiConversationNamingService', () => { }) }); - await expect(service.generateName('Test', undefined)) + await expect(service.generateName('Test')) .rejects.toThrow('Failed to generate conversation name'); }); it('should pass abort signal to fetch', async () => { - const abortController = new AbortController(); - fetchMock.mockResolvedValue({ ok: true, json: async () => ({ @@ -134,12 +139,12 @@ describe('GeminiConversationNamingService', () => { }) }); - await service.generateName('Test', abortController.signal); + await service.generateName('Test'); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - signal: abortController.signal + signal: expect.any(AbortSignal) }) ); }); @@ -153,7 +158,7 @@ describe('GeminiConversationNamingService', () => { }) }); - await expect(service.generateName('Test', undefined)) + await expect(service.generateName('Test')) .rejects.toThrow('Failed to generate conversation name'); }); }); diff --git a/__tests__/AIClasses/OpenAI.test.ts b/__tests__/AIClasses/OpenAI.test.ts index 1101506..cb1641e 100644 --- a/__tests__/AIClasses/OpenAI.test.ts +++ b/__tests__/AIClasses/OpenAI.test.ts @@ -12,6 +12,7 @@ import { Role } from '../../Enums/Role'; import { SettingsService } from '../../Services/SettingsService'; import { AIProvider } from '../../Enums/ApiProvider'; import { Exception } from '../../Helpers/Exception'; +import { AbortService } from '../../Services/AbortService'; describe('OpenAI', () => { let openai: OpenAI; @@ -20,6 +21,7 @@ describe('OpenAI', () => { let mockPlugin: any; let mockSettingsService: any; let mockFunctionDefinitions: any; + let abortService: AbortService; beforeEach(() => { // Mock Exception methods @@ -56,6 +58,10 @@ describe('OpenAI', () => { }; RegisterSingleton(Services.SettingsService, mockSettingsService); + // Create real AbortService instance + abortService = new AbortService(); + RegisterSingleton(Services.AbortService, abortService); + // Mock StreamingService mockStreamingService = { streamRequest: vi.fn() @@ -727,8 +733,7 @@ describe('OpenAI', () => { yield { content: 'response', isComplete: true }; }); - const abortSignal = new AbortController().signal; - const generator = openai.streamRequest(conversation, true, abortSignal); + const generator = openai.streamRequest(conversation, true); for await (const chunk of generator) { // Just consume @@ -744,7 +749,6 @@ describe('OpenAI', () => { stream: true }), expect.any(Function), // parseStreamChunk - abortSignal, expect.objectContaining({ 'Authorization': 'Bearer test-openai-key', 'Content-Type': 'application/json' diff --git a/__tests__/AIClasses/OpenAIConversationNamingService.test.ts b/__tests__/AIClasses/OpenAIConversationNamingService.test.ts index 812cea9..2d20995 100644 --- a/__tests__/AIClasses/OpenAIConversationNamingService.test.ts +++ b/__tests__/AIClasses/OpenAIConversationNamingService.test.ts @@ -4,12 +4,12 @@ import { RegisterSingleton, DeregisterAllServices } from '../../Services/Depende import { Services } from '../../Services/Services'; import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider'; import { Role } from '../../Enums/Role'; -import { SettingsService } from '../../Services/SettingsService'; describe('OpenAIConversationNamingService', () => { let service: OpenAIConversationNamingService; let mockPlugin: any; let mockSettingsService: any; + let mockAbortService: any; let fetchMock: any; beforeEach(() => { @@ -36,6 +36,13 @@ describe('OpenAIConversationNamingService', () => { }; RegisterSingleton(Services.SettingsService, mockSettingsService); + // Mock AbortService + mockAbortService = { + signal: vi.fn(() => new AbortController().signal), + abortableOperation: vi.fn((fn) => fn()) + }; + RegisterSingleton(Services.AbortService, mockAbortService); + // Mock global fetch fetchMock = vi.fn(); global.fetch = fetchMock; @@ -72,7 +79,7 @@ describe('OpenAIConversationNamingService', () => { }) }); - await service.generateName('User prompt', undefined); + await service.generateName('User prompt'); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), @@ -119,7 +126,7 @@ describe('OpenAIConversationNamingService', () => { }) }); - const result = await service.generateName('Test prompt', undefined); + const result = await service.generateName('Test prompt'); expect(result).toBe('Generated Name'); }); @@ -160,7 +167,7 @@ describe('OpenAIConversationNamingService', () => { }) }); - const result = await service.generateName('Test prompt', undefined); + const result = await service.generateName('Test prompt'); expect(result).toBe('Generated Name'); }); @@ -173,7 +180,7 @@ describe('OpenAIConversationNamingService', () => { text: async () => 'Rate limit exceeded' }); - await expect(service.generateName('Test', undefined)) + await expect(service.generateName('Test')) .rejects.toThrow('OpenAI API error: 429 Too Many Requests - Rate limit exceeded'); }); @@ -187,13 +194,11 @@ describe('OpenAIConversationNamingService', () => { }) }); - await expect(service.generateName('Test', undefined)) + await expect(service.generateName('Test')) .rejects.toThrow('Failed to generate conversation name'); }); it('should pass abort signal to fetch', async () => { - const abortController = new AbortController(); - fetchMock.mockResolvedValue({ ok: true, json: async () => ({ @@ -215,12 +220,12 @@ describe('OpenAIConversationNamingService', () => { }) }); - await service.generateName('Test', abortController.signal); + await service.generateName('Test'); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - signal: abortController.signal + signal: expect.any(AbortSignal) }) ); }); @@ -235,7 +240,7 @@ describe('OpenAIConversationNamingService', () => { }) }); - await expect(service.generateName('Test', undefined)) + await expect(service.generateName('Test')) .rejects.toThrow('Failed to generate conversation name'); }); }); diff --git a/__tests__/Services/AIFunctionService.test.ts b/__tests__/Services/AIFunctionService.test.ts index 4e583f1..382d5e2 100644 --- a/__tests__/Services/AIFunctionService.test.ts +++ b/__tests__/Services/AIFunctionService.test.ts @@ -5,6 +5,7 @@ import { Services } from '../../Services/Services'; import { AIFunction } from '../../Enums/AIFunction'; import { TFile } from 'obsidian'; import { Exception } from '../../Helpers/Exception'; +import { AbortService } from '../../Services/AbortService'; /** * INTEGRATION TESTS - AIFunctionService @@ -24,6 +25,7 @@ import { Exception } from '../../Helpers/Exception'; describe('AIFunctionService - Integration Tests', () => { let service: AIFunctionService; let mockFileSystemService: any; + let abortService: AbortService; beforeEach(() => { // Mock FileSystemService with common operations @@ -36,13 +38,17 @@ describe('AIFunctionService - Integration Tests', () => { moveFile: vi.fn() }; - // Register the mock + // Create real AbortService instance + abortService = new AbortService(); + + // Register the mocks RegisterSingleton(Services.FileSystemService, mockFileSystemService); + RegisterSingleton(Services.AbortService, abortService); // Mock Exception.log vi.spyOn(Exception, 'log').mockImplementation(() => {}); - // Create service - it will resolve the mock FileSystemService + // Create service - it will resolve the mock FileSystemService and real AbortService service = new AIFunctionService(); }); @@ -211,8 +217,8 @@ describe('AIFunctionService - Integration Tests', () => { expect(result.response).toEqual({ results: [ { path: 'exists.md', contents: 'Existing content' }, - { path: 'missing1.md', error: error1 }, - { path: 'missing2.md', error: error2 } + { path: 'missing1.md', error: 'File not found' }, + { path: 'missing2.md', error: 'File not found' } ] }); }); @@ -231,7 +237,7 @@ describe('AIFunctionService - Integration Tests', () => { const results = result.response.results; expect(results[0].contents).toBe('Content A'); - expect(results[1].error).toBeInstanceOf(Error); + expect(results[1].error).toBe('File not found'); expect(results[2].contents).toBe('Content B'); }); @@ -399,7 +405,7 @@ describe('AIFunctionService - Integration Tests', () => { expect(result.response.results).toEqual([ { path: 'a.md', success: true }, - { path: 'missing.md', success: false, error: error }, + { path: 'missing.md', success: false, error: 'File not found' }, { path: 'c.md', success: true } ]); }); @@ -502,7 +508,7 @@ describe('AIFunctionService - Integration Tests', () => { expect(result.response.results).toEqual([ { path: 'new/a.md', success: true }, - { path: 'existing.md', success: false, error: error }, + { path: 'existing.md', success: false, error: 'Destination exists' }, { path: 'new/c.md', success: true } ]); }); diff --git a/__tests__/Services/AbortService.test.ts b/__tests__/Services/AbortService.test.ts new file mode 100644 index 0000000..9883f06 --- /dev/null +++ b/__tests__/Services/AbortService.test.ts @@ -0,0 +1,513 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AbortService } from '../../Services/AbortService'; +import { DeregisterAllServices } from '../../Services/DependencyService'; + +describe('AbortService', () => { + let service: AbortService; + + beforeEach(() => { + service = new AbortService(); + }); + + afterEach(() => { + DeregisterAllServices(); + vi.restoreAllMocks(); + }); + + describe('Constructor & Initialization', () => { + it('should initialize with a non-aborted controller', () => { + expect(service.signal().aborted).toBe(false); + }); + + it('should provide a valid abort signal', () => { + const signal = service.signal(); + expect(signal).toBeInstanceOf(AbortSignal); + }); + + it('should have undefined reason before abort', () => { + expect(service.signal().reason).toBeUndefined(); + }); + }); + + describe('isAbortError - Static Method', () => { + it('should return true for DOMException with name AbortError', () => { + const error = new DOMException('Aborted', 'AbortError'); + expect(AbortService.isAbortError(error)).toBe(true); + }); + + it('should return false for DOMException with different name', () => { + const error = new DOMException('Error', 'NetworkError'); + expect(AbortService.isAbortError(error)).toBe(false); + }); + + it('should return false for generic Error', () => { + const error = new Error('Aborted'); + expect(AbortService.isAbortError(error)).toBe(false); + }); + + it('should return false for null', () => { + expect(AbortService.isAbortError(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(AbortService.isAbortError(undefined)).toBe(false); + }); + + it('should return false for string', () => { + expect(AbortService.isAbortError('AbortError')).toBe(false); + }); + }); + + describe('initialiseAbortController', () => { + it('should abort the current controller', () => { + const originalSignal = service.signal(); + service.initialiseAbortController(); + expect(originalSignal.aborted).toBe(true); + }); + + it('should create a new non-aborted controller', () => { + service.abort(); + expect(service.signal().aborted).toBe(true); + + service.initialiseAbortController(); + expect(service.signal().aborted).toBe(false); + }); + + it('should provide a new signal after initialization', () => { + const oldSignal = service.signal(); + service.initialiseAbortController(); + const newSignal = service.signal(); + + expect(oldSignal).not.toBe(newSignal); + }); + + it('should be callable multiple times in succession', () => { + expect(() => { + service.initialiseAbortController(); + service.initialiseAbortController(); + service.initialiseAbortController(); + }).not.toThrow(); + + expect(service.signal().aborted).toBe(false); + }); + + it('should abort ongoing listeners on old signal', () => { + const listener = vi.fn(); + const oldSignal = service.signal(); + oldSignal.addEventListener('abort', listener); + + service.initialiseAbortController(); + + expect(listener).toHaveBeenCalled(); + expect(oldSignal.aborted).toBe(true); + }); + }); + + describe('signal()', () => { + it('should return the current abort signal', () => { + const signal = service.signal(); + expect(signal).toBeInstanceOf(AbortSignal); + }); + + it('should return the same signal on multiple calls', () => { + const signal1 = service.signal(); + const signal2 = service.signal(); + expect(signal1).toBe(signal2); + }); + + it('should return new signal after re-initialization', () => { + const signal1 = service.signal(); + service.initialiseAbortController(); + const signal2 = service.signal(); + + expect(signal1).not.toBe(signal2); + }); + }); + + describe('abort()', () => { + it('should abort the controller with default reason', () => { + service.abort(); + + expect(service.signal().aborted).toBe(true); + expect(service.signal().reason).toBeDefined(); + expect(service.signal().reason.message).toContain('Aborted'); + }); + + it('should abort with custom reason', () => { + service.abort('Custom abort message'); + + expect(service.signal().aborted).toBe(true); + expect(service.signal().reason.message).toContain('Custom abort message'); + }); + + it('should create DOMException with AbortError name', () => { + service.abort('Test'); + + const reason = service.signal().reason; + expect(reason).toBeInstanceOf(DOMException); + expect(reason.name).toBe('AbortError'); + }); + + it('should trigger abort event on signal', () => { + const listener = vi.fn(); + service.signal().addEventListener('abort', listener); + + service.abort(); + + expect(listener).toHaveBeenCalled(); + }); + + it('should be idempotent - multiple abort calls', () => { + service.abort('First abort'); + const firstReason = service.signal().reason; + + service.abort('Second abort'); + service.abort('Third abort'); + + expect(service.signal().reason).toBe(firstReason); + }); + + it('should work with empty string reason', () => { + service.abort(''); + + expect(service.signal().aborted).toBe(true); + expect(service.signal().reason).toBeDefined(); + }); + + it('should work with very long reason string', () => { + const longReason = 'X'.repeat(1000); + service.abort(longReason); + + expect(service.signal().aborted).toBe(true); + expect(service.signal().reason.message).toContain(longReason); + }); + }); + + describe('reason()', () => { + it('should return Error when signal has Error reason', () => { + service.abort('Test error'); + const reason = service.reason(); + + expect(reason).toBeInstanceOf(Error); + }); + + it('should return default Error before abort', () => { + const reason = service.reason(); + + expect(reason).toBeInstanceOf(Error); + expect(reason.message).toBe('Aborted'); + }); + + it('should return DOMException after abort', () => { + service.abort('Custom'); + const reason = service.reason(); + + expect(reason).toBeInstanceOf(DOMException); + }); + + it('should preserve custom abort message', () => { + service.abort('Custom message'); + const reason = service.reason(); + + expect(reason.message).toContain('Custom message'); + }); + + it('should handle reason() after re-initialization', () => { + service.abort('Original'); + service.initialiseAbortController(); + const reason = service.reason(); + + expect(reason.message).toBe('Aborted'); + }); + }); + + describe('throw()', () => { + it('should throw the abort reason', () => { + service.abort('Test error'); + + expect(() => service.throw()).toThrow('Test error'); + }); + + it('should throw Error type', () => { + service.abort('Test'); + + try { + service.throw(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + } + }); + + it('should throw even before abort', () => { + expect(() => service.throw()).toThrow('Aborted'); + }); + + it('should never return', () => { + service.abort('Test'); + + let didNotReturn = true; + try { + const result = service.throw(); + didNotReturn = false; + // TypeScript ensures result type is 'never' + expect(result).toBeUndefined(); // Should never reach + } catch { + expect(didNotReturn).toBe(true); + } + }); + }); + + describe('abortableOperation - Core Functionality', () => { + it('should execute and return result when not aborted', async () => { + const executor = () => Promise.resolve('success'); + + const result = await service.abortableOperation(executor); + + expect(result).toBe('success'); + }); + + it('should immediately throw if already aborted', async () => { + service.abort('Already aborted'); + + const executor = vi.fn(() => Promise.resolve('test')); + + await expect(service.abortableOperation(executor)).rejects.toThrow(); + expect(executor).not.toHaveBeenCalled(); + }); + + it('should throw when aborted during execution', async () => { + const executor = () => new Promise(resolve => setTimeout(() => resolve('done'), 100)); + + const opPromise = service.abortableOperation(executor); + + setTimeout(() => service.abort('Aborted during execution'), 50); + + await expect(opPromise).rejects.toThrow(); + const error = await opPromise.catch(e => e); + expect(AbortService.isAbortError(error)).toBe(true); + }); + + it('should return executor result when it completes first', async () => { + const executor = () => new Promise(resolve => setTimeout(() => resolve('fast'), 10)); + + const opPromise = service.abortableOperation(executor); + + setTimeout(() => service.abort(), 100); + + const result = await opPromise; + expect(result).toBe('fast'); + }); + + it('should clean up event listener on success', async () => { + const signal = service.signal(); + const removeListenerSpy = vi.spyOn(signal, 'removeEventListener'); + + await service.abortableOperation(() => Promise.resolve('done')); + + expect(removeListenerSpy).toHaveBeenCalled(); + }); + + it('should clean up event listener on abort', async () => { + const signal = service.signal(); + const removeListenerSpy = vi.spyOn(signal, 'removeEventListener'); + + const executor = () => new Promise(resolve => setTimeout(() => resolve('done'), 100)); + const opPromise = service.abortableOperation(executor); + + setTimeout(() => service.abort(), 10); + + await expect(opPromise).rejects.toThrow(); + expect(removeListenerSpy).toHaveBeenCalled(); + }); + + it('should handle executor that rejects with non-abort error', async () => { + const executor = () => Promise.reject(new Error('Network failed')); + + await expect(service.abortableOperation(executor)).rejects.toThrow('Network failed'); + }); + + it('should suppress AbortError from executor after abort wins', async () => { + const executorAbortError = new DOMException('Executor aborted', 'AbortError'); + const executor = () => new Promise((_, reject) => + setTimeout(() => reject(executorAbortError), 100) + ); + + const opPromise = service.abortableOperation(executor); + + setTimeout(() => service.abort('Service abort'), 10); + + await expect(opPromise).rejects.toThrow('Service abort'); + }); + + it('should handle executor that throws synchronously', async () => { + const executor = () => { + throw new Error('Sync error'); + }; + + await expect(service.abortableOperation(executor)).rejects.toThrow('Sync error'); + }); + + it('should handle multiple concurrent operations', async () => { + const executor1 = () => new Promise(resolve => setTimeout(() => resolve('op1'), 100)); + const executor2 = () => new Promise(resolve => setTimeout(() => resolve('op2'), 100)); + + const op1Promise = service.abortableOperation(executor1); + const op2Promise = service.abortableOperation(executor2); + + setTimeout(() => service.abort(), 50); + + await expect(op1Promise).rejects.toThrow(); + await expect(op2Promise).rejects.toThrow(); + }); + + it('should handle executor returning immediately resolved promise', async () => { + const executor = () => Promise.resolve('immediate'); + + const result = await service.abortableOperation(executor); + + expect(result).toBe('immediate'); + }); + + it('should handle executor with zero delay', async () => { + const executor = async () => { + return 'zero-delay'; + }; + + const result = await service.abortableOperation(executor); + + expect(result).toBe('zero-delay'); + }); + }); + + describe('abortableOperation - Timing & Race Conditions', () => { + it('should handle abort triggered immediately after operation starts', async () => { + const executor = () => new Promise(resolve => setTimeout(() => resolve('done'), 50)); + + const opPromise = service.abortableOperation(executor); + service.abort('Immediate abort'); + + await expect(opPromise).rejects.toThrow(); + }); + + it('should handle very fast executor', async () => { + const executor = () => Promise.resolve('instant'); + + const result = await service.abortableOperation(executor); + + expect(result).toBe('instant'); + }); + + it('should handle executor that never resolves with abort', async () => { + const executor = () => new Promise(() => {}); // Never resolves + + const opPromise = service.abortableOperation(executor); + + setTimeout(() => service.abort(), 50); + + await expect(opPromise).rejects.toThrow(); + }); + + it('should handle abort called during Promise.race evaluation', async () => { + const executor = () => new Promise(resolve => setTimeout(() => resolve('done'), 30)); + + const opPromise = service.abortableOperation(executor); + + setTimeout(() => service.abort(), 15); + + await expect(opPromise).rejects.toThrow(); + }); + + it('should handle multiple abort calls during operation', async () => { + const executor = () => new Promise(resolve => setTimeout(() => resolve('done'), 100)); + + const opPromise = service.abortableOperation(executor); + + // Abort multiple times - only first abort matters due to idempotence + setTimeout(() => { + service.abort('First'); + service.abort('Second'); + service.abort('Third'); + }, 10); + + await expect(opPromise).rejects.toThrow('First'); + }); + + it('should handle re-initialization during operation', async () => { + const executor = () => new Promise(resolve => setTimeout(() => resolve('done'), 100)); + + const opPromise = service.abortableOperation(executor); + + setTimeout(() => service.initialiseAbortController(), 50); + + await expect(opPromise).rejects.toThrow(); + }); + }); + + describe('Integration Scenarios', () => { + it('should work with fetch-like operations', async () => { + const mockFetch = vi.fn().mockImplementation((_url, options) => { + return new Promise((resolve, reject) => { + const abortHandler = () => { + reject(new DOMException('Fetch aborted', 'AbortError')); + }; + + if (options.signal.aborted) { + reject(new DOMException('Already aborted', 'AbortError')); + return; + } + + options.signal.addEventListener('abort', abortHandler); + + setTimeout(() => { + options.signal.removeEventListener('abort', abortHandler); + resolve({ data: 'response' }); + }, 100); + }); + }); + + const executor = () => mockFetch('https://api.example.com', { signal: service.signal() }); + const opPromise = service.abortableOperation(executor); + + // Use immediate abort to avoid timing issues with test cleanup + service.abort(); + + await expect(opPromise).rejects.toThrow(); + }); + + it('should chain multiple abortable operations', async () => { + const executor1 = () => Promise.resolve('result1'); + const executor2 = () => Promise.resolve('result2'); + + const result1 = await service.abortableOperation(executor1); + const result2 = await service.abortableOperation(executor2); + + expect(result1).toBe('result1'); + expect(result2).toBe('result2'); + }); + + it('should work with async/await in executor', async () => { + const executor = async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise(resolve => setTimeout(resolve, 10)); + return 'multi-await'; + }; + + const result = await service.abortableOperation(executor); + + expect(result).toBe('multi-await'); + }); + + it('should handle full lifecycle - abort, reset, run operation', async () => { + service.abort('Initial abort'); + expect(service.signal().aborted).toBe(true); + + service.initialiseAbortController(); + expect(service.signal().aborted).toBe(false); + + const result = await service.abortableOperation(() => Promise.resolve('success')); + + expect(result).toBe('success'); + }); + }); +}); diff --git a/__tests__/Services/ChatService.test.ts b/__tests__/Services/ChatService.test.ts index 0eb63f6..227f3fe 100644 --- a/__tests__/Services/ChatService.test.ts +++ b/__tests__/Services/ChatService.test.ts @@ -7,6 +7,7 @@ import { ConversationContent } from '../../Conversations/ConversationContent'; import { Role } from '../../Enums/Role'; import { AIFunctionCall } from '../../AIClasses/AIFunctionCall'; import { AIFunction, fromString } from '../../Enums/AIFunction'; +import { AbortService } from '../../Services/AbortService'; /** * INTEGRATION TESTS - Simplified @@ -26,6 +27,8 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { let mockPrompt: any; let mockStatusBarService: any; let mockTokenService: any; + let mockEventService: any; + let abortService: AbortService; beforeEach(() => { // Setup minimal mocks @@ -54,12 +57,24 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { countTokens: vi.fn().mockResolvedValue(100) }; + // Mock EventService since it extends Obsidian's Events class + mockEventService = { + trigger: vi.fn(), + on: vi.fn(), + off: vi.fn() + }; + + // Create real AbortService instance + abortService = new AbortService(); + // Register dependencies RegisterSingleton(Services.ConversationFileSystemService, mockConversationService); RegisterSingleton(Services.AIFunctionService, mockAIFunctionService); RegisterSingleton(Services.ConversationNamingService, mockNamingService); RegisterSingleton(Services.IPrompt, mockPrompt); RegisterSingleton(Services.StatusBarService, mockStatusBarService); + RegisterSingleton(Services.EventService, mockEventService); + RegisterSingleton(Services.AbortService, abortService); // Create service service = new ChatService(); @@ -227,15 +242,7 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { if (conversation.contents.length > 0) { const lastMessage = conversation.contents[conversation.contents.length - 1]; if (lastMessage.role === Role.Assistant) { - conversation.contents.push(new ConversationContent( - Role.User, - "Continue", - "Continue", - "", - new Date(), - false, - true - )); + conversation.contents.push(ConversationContent.safeContinue()); } } @@ -260,15 +267,7 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { if (conversation.contents.length > 0) { const lastMessage = conversation.contents[conversation.contents.length - 1]; if (lastMessage.role === Role.Assistant) { - conversation.contents.push(new ConversationContent( - Role.User, - "Continue", - "Continue", - "", - new Date(), - false, - true - )); + conversation.contents.push(ConversationContent.safeContinue()); } } @@ -286,15 +285,7 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { if (conversation.contents.length > 0) { const lastMessage = conversation.contents[conversation.contents.length - 1]; if (lastMessage.role === Role.Assistant) { - conversation.contents.push(new ConversationContent( - Role.User, - "Continue", - "Continue", - "", - new Date(), - false, - true - )); + conversation.contents.push(ConversationContent.safeContinue()); } } @@ -311,15 +302,7 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { if (conversation.contents.length > 0) { const lastMessage = conversation.contents[conversation.contents.length - 1]; if (lastMessage.role === Role.Assistant) { - conversation.contents.push(new ConversationContent( - Role.User, - "Continue", - "Continue", - "", - new Date(), - false, - true - )); + conversation.contents.push(ConversationContent.safeContinue()); } } @@ -355,15 +338,7 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { if (conversation.contents.length > 0) { const lastMessage = conversation.contents[conversation.contents.length - 1]; if (lastMessage.role === Role.Assistant) { - conversation.contents.push(new ConversationContent( - Role.User, - "Continue", - "Continue", - "", - new Date(), - false, - true - )); + conversation.contents.push(ConversationContent.safeContinue()); } } diff --git a/__tests__/Services/ConversationFileSystemService.test.ts b/__tests__/Services/ConversationFileSystemService.test.ts index 2730e80..7098315 100644 --- a/__tests__/Services/ConversationFileSystemService.test.ts +++ b/__tests__/Services/ConversationFileSystemService.test.ts @@ -5,7 +5,6 @@ import { Services } from '../../Services/Services'; import { Conversation } from '../../Conversations/Conversation'; import { ConversationContent } from '../../Conversations/ConversationContent'; import { Role } from '../../Enums/Role'; -import { Copy } from '../../Enums/Copy'; import { TFile } from 'obsidian'; import { Exception } from '../../Helpers/Exception'; @@ -20,7 +19,6 @@ import { Exception } from '../../Helpers/Exception'; * - Path management * - Conversation title updates * - Listing all conversations - * - Filtering aborted requests */ describe('ConversationFileSystemService - Integration Tests', () => { @@ -34,7 +32,8 @@ describe('ConversationFileSystemService - Integration Tests', () => { readObjectFromFile: vi.fn(), deleteFile: vi.fn(), moveFile: vi.fn(), - listFilesInDirectory: vi.fn() + listFilesInDirectory: vi.fn(), + exists: vi.fn().mockResolvedValue(true) }; // Register the mock @@ -142,21 +141,6 @@ describe('ConversationFileSystemService - Integration Tests', () => { expect(conversation.updated.getTime()).toBeGreaterThanOrEqual(originalUpdated.getTime()); }); - it('should filter out aborted request messages', async () => { - const conversation = createTestConversation('Test Filter'); - conversation.contents.push( - new ConversationContent(Role.Assistant, Copy.ApiRequestAborted, '', '', new Date()) - ); - - await service.saveConversation(conversation); - - const savedData = mockFileSystemService.writeObjectToFile.mock.calls[0][1]; - const savedContents = savedData.contents; - - // Should have 2 contents (original user and assistant), not 3 - expect(savedContents).toHaveLength(2); - expect(savedContents.every((c: any) => c.content !== Copy.ApiRequestAborted)).toBe(true); - }); it('should set current conversation path on first save', async () => { const conversation = createTestConversation('First Save'); @@ -328,7 +312,8 @@ describe('ConversationFileSystemService - Integration Tests', () => { expect(result).toBeUndefined(); // void = success expect(mockFileSystemService.deleteFile).toHaveBeenCalledWith( 'Vaultkeeper AI/Conversations/To Delete.json', - true + true, + false ); expect(service.getCurrentConversationPath()).toBeNull(); }); diff --git a/__tests__/Services/ConversationNamingService.test.ts b/__tests__/Services/ConversationNamingService.test.ts index e493fd9..028ba4c 100644 --- a/__tests__/Services/ConversationNamingService.test.ts +++ b/__tests__/Services/ConversationNamingService.test.ts @@ -32,6 +32,13 @@ describe('ConversationNamingService', () => { }; RegisterSingleton(Services.VaultService, mockVaultService); + // Mock AbortService + const mockAbortService = { + signal: vi.fn(() => new AbortController().signal), + abortableOperation: vi.fn(async (fn) => await fn()) + }; + RegisterSingleton(Services.AbortService, mockAbortService); + service = new ConversationNamingService(); }); @@ -120,13 +127,11 @@ describe('ConversationNamingService', () => { describe('requestName', () => { let conversation: Conversation; - let abortController: AbortController; let onNameChanged: any; beforeEach(() => { conversation = new Conversation(); conversation.title = 'Old Title'; - abortController = new AbortController(); onNameChanged = vi.fn(); RegisterSingleton(Services.IConversationNamingService, mockNamingProvider); @@ -137,9 +142,9 @@ describe('ConversationNamingService', () => { mockNamingProvider.generateName.mockResolvedValue('New Conversation Title'); mockVaultService.exists.mockReturnValue(false); - await service.requestName(conversation, 'User prompt', onNameChanged, abortController); + await service.requestName(conversation, 'User prompt', onNameChanged); - expect(mockNamingProvider.generateName).toHaveBeenCalledWith('User prompt', abortController.signal); + expect(mockNamingProvider.generateName).toHaveBeenCalledWith('User prompt'); expect(mockConversationService.updateConversationTitle).toHaveBeenCalledWith( 'conversations/test.json', 'New Conversation Title' @@ -152,7 +157,7 @@ describe('ConversationNamingService', () => { it('should return early if naming provider is not resolved', async () => { (service as any).namingProvider = undefined; - await service.requestName(conversation, 'Test', onNameChanged, abortController); + await service.requestName(conversation, 'Test', onNameChanged); expect(mockNamingProvider.generateName).not.toHaveBeenCalled(); expect(onNameChanged).not.toHaveBeenCalled(); @@ -161,7 +166,7 @@ describe('ConversationNamingService', () => { it('should return early if no conversation path exists', async () => { mockConversationService.getCurrentConversationPath.mockReturnValue(null); - await service.requestName(conversation, 'Test', onNameChanged, abortController); + await service.requestName(conversation, 'Test', onNameChanged); expect(mockNamingProvider.generateName).not.toHaveBeenCalled(); expect(onNameChanged).not.toHaveBeenCalled(); @@ -171,7 +176,7 @@ describe('ConversationNamingService', () => { mockNamingProvider.generateName.mockResolvedValue('"Quoted Name"'); mockVaultService.exists.mockReturnValue(false); - await service.requestName(conversation, 'Test', onNameChanged, abortController); + await service.requestName(conversation, 'Test', onNameChanged); expect(conversation.title).toBe('Quoted Name'); // Quotes removed }); @@ -181,7 +186,7 @@ describe('ConversationNamingService', () => { .mockReturnValueOnce('conversations/original.json') .mockReturnValueOnce('conversations/different.json'); // Changed! - await service.requestName(conversation, 'Test', onNameChanged, abortController); + await service.requestName(conversation, 'Test', onNameChanged); expect(mockNamingProvider.generateName).toHaveBeenCalled(); // Should not update or save because path changed @@ -197,7 +202,7 @@ describe('ConversationNamingService', () => { const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {}); - await service.requestName(conversation, 'Test', onNameChanged, abortController); + await service.requestName(conversation, 'Test', onNameChanged); // Should not throw, but will log the error (behavior changed with new error handling) expect(exceptionSpy).toHaveBeenCalledWith(abortError); @@ -212,7 +217,7 @@ describe('ConversationNamingService', () => { const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {}); - await service.requestName(conversation, 'Test', onNameChanged, abortController); + await service.requestName(conversation, 'Test', onNameChanged); expect(exceptionSpy).toHaveBeenCalledWith(error); expect(onNameChanged).not.toHaveBeenCalled(); @@ -225,7 +230,7 @@ describe('ConversationNamingService', () => { mockVaultService.exists.mockReturnValue(false); // Pass undefined for callback - await service.requestName(conversation, 'Test', undefined, abortController); + await service.requestName(conversation, 'Test', undefined); expect(conversation.title).toBe('New Title'); expect(mockConversationService.saveConversation).toHaveBeenCalled(); @@ -238,7 +243,7 @@ describe('ConversationNamingService', () => { return path === `${Path.Conversations}/Popular Name.json`; }); - await service.requestName(conversation, 'Test', onNameChanged, abortController); + await service.requestName(conversation, 'Test', onNameChanged); expect(conversation.title).toBe('Popular Name(1)'); expect(mockConversationService.updateConversationTitle).toHaveBeenCalledWith( @@ -251,7 +256,7 @@ describe('ConversationNamingService', () => { mockNamingProvider.generateName.mockResolvedValue('One Two Three Four Five Six Seven Eight Nine'); mockVaultService.exists.mockReturnValue(false); - await service.requestName(conversation, 'Test', onNameChanged, abortController); + await service.requestName(conversation, 'Test', onNameChanged); expect(conversation.title).toBe('One Two Three Four Five Six'); }); diff --git a/__tests__/Services/DiffService.test.ts b/__tests__/Services/DiffService.test.ts new file mode 100644 index 0000000..ee45d9c --- /dev/null +++ b/__tests__/Services/DiffService.test.ts @@ -0,0 +1,827 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { DiffService } from '../../Services/DiffService'; +import { AbortService } from '../../Services/AbortService'; +import { DeregisterAllServices, RegisterSingleton } from '../../Services/DependencyService'; +import { Services } from '../../Services/Services'; +import { Event } from '../../Enums/Event'; +import type VaultkeeperAIPlugin from '../../main'; +import type { EventService } from '../../Services/EventService'; + +describe('DiffService', () => { + let service: DiffService; + let mockPlugin: any; + let mockEventService: any; + let abortService: AbortService; + let mockEventListeners: Map; + + beforeEach(() => { + DeregisterAllServices(); + + mockEventListeners = new Map(); + + mockPlugin = { + activateDiffView: vi.fn().mockResolvedValue(undefined) + }; + + mockEventService = { + trigger: vi.fn((event: Event, data?: unknown) => { + const listeners = mockEventListeners.get(event) || []; + listeners.forEach(listener => listener(data)); + }), + on: vi.fn((event: Event, callback: Function) => { + if (!mockEventListeners.has(event)) { + mockEventListeners.set(event, []); + } + mockEventListeners.get(event)!.push(callback); + return { listener: callback }; + }), + off: vi.fn() + }; + + abortService = new AbortService(); + + RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin); + RegisterSingleton(Services.EventService, mockEventService); + RegisterSingleton(Services.AbortService, abortService); + + service = new DiffService(); + }); + + afterEach(() => { + DeregisterAllServices(); + vi.restoreAllMocks(); + }); + + describe('Constructor & Initialization', () => { + it('should initialize with all required dependencies', () => { + expect(service).toBeDefined(); + expect(service).toBeInstanceOf(DiffService); + }); + + it('should register DiffClosed event listener', () => { + expect(mockEventService.on).toHaveBeenCalledWith(Event.DiffClosed, expect.any(Function)); + }); + + it('should initialize with ongoingDiff as false', () => { + expect((service as any).ongoingDiff).toBe(false); + }); + + it('should initialize with no diffResolve callback', () => { + expect((service as any).diffResolve).toBeUndefined(); + }); + }); + + describe('requestDiff - Promise Resolution Paths', () => { + it('should create diff string and activate diff view', async () => { + const diffPromise = service.requestDiff('old.md', 'new.md', 'old content', 'new content'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockPlugin.activateDiffView).toHaveBeenCalledWith( + expect.stringContaining('old.md'), + expect.objectContaining({ + outputFormat: 'line-by-line', + highlight: true + }) + ); + + service.onAccept(); + await diffPromise; + }); + + it('should trigger DiffOpened event', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'a', 'b'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockEventService.trigger).toHaveBeenCalledWith(Event.DiffOpened); + + service.onAccept(); + await diffPromise; + }); + + it('should set ongoingDiff to true', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'a', 'b'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect((service as any).ongoingDiff).toBe(true); + + service.onAccept(); + await diffPromise; + }); + + it('should resolve with accepted:true on onAccept()', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onAccept(); + + const result = await diffPromise; + expect(result).toEqual({ accepted: true }); + }); + + it('should resolve with accepted:false on onReject()', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onReject(); + + const result = await diffPromise; + expect(result).toEqual({ accepted: false }); + }); + + it('should resolve with suggestion on onSuggest()', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onSuggest('Please add comments'); + + const result = await diffPromise; + expect(result).toEqual({ accepted: false, suggestion: 'Please add comments' }); + }); + + it('should reject if abort signal already aborted', async () => { + abortService.abort('Already aborted'); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await expect(diffPromise).rejects.toThrow(); + }); + + it('should reject when aborted during pending diff', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + abortService.abort('Aborted during diff'); + + await expect(diffPromise).rejects.toThrow(); + }); + + it('should clean up abort listener on accept', async () => { + const signal = abortService.signal(); + const removeListenerSpy = vi.spyOn(signal, 'removeEventListener'); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onAccept(); + await diffPromise; + + expect(removeListenerSpy).toHaveBeenCalled(); + }); + + it('should clean up abort listener on reject', async () => { + const signal = abortService.signal(); + const removeListenerSpy = vi.spyOn(signal, 'removeEventListener'); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onReject(); + await diffPromise; + + expect(removeListenerSpy).toHaveBeenCalled(); + }); + + it('should clean up abort listener on suggest', async () => { + const signal = abortService.signal(); + const removeListenerSpy = vi.spyOn(signal, 'removeEventListener'); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onSuggest('suggestion'); + await diffPromise; + + expect(removeListenerSpy).toHaveBeenCalled(); + }); + + it('should handle concurrent requestDiff calls', async () => { + const diff1Promise = service.requestDiff('a.md', 'b.md', 'old1', 'new1'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + // Start second diff (may override first) + const diff2Promise = service.requestDiff('c.md', 'd.md', 'old2', 'new2'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onAccept(); + + // Second diff should resolve + const result2 = await diff2Promise; + expect(result2).toEqual({ accepted: true }); + + // First diff callback was overridden, so it won't resolve the same way + // This documents current behavior + }); + }); + + describe('onAccept()', () => { + it('should call diffResolve with accepted:true', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onAccept(); + + const result = await diffPromise; + expect(result).toEqual({ accepted: true }); + }); + + it('should call finishDiff()', async () => { + const finishDiffSpy = vi.spyOn(service as any, 'finishDiff'); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onAccept(); + await diffPromise; + + expect(finishDiffSpy).toHaveBeenCalled(); + }); + + it('should be safe to call when no pending diff', () => { + expect(() => service.onAccept()).not.toThrow(); + }); + + it('should be safe to call multiple times', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onAccept(); + await diffPromise; + + expect(() => service.onAccept()).not.toThrow(); + }); + + it('should set ongoingDiff to false', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect((service as any).ongoingDiff).toBe(true); + + service.onAccept(); + await diffPromise; + + expect((service as any).ongoingDiff).toBe(false); + }); + }); + + describe('onReject()', () => { + it('should call diffResolve with accepted:false', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onReject(); + + const result = await diffPromise; + expect(result).toEqual({ accepted: false }); + }); + + it('should call finishDiff()', async () => { + const finishDiffSpy = vi.spyOn(service as any, 'finishDiff'); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onReject(); + await diffPromise; + + expect(finishDiffSpy).toHaveBeenCalled(); + }); + + it('should be safe to call when no pending diff', () => { + expect(() => service.onReject()).not.toThrow(); + }); + + it('should be safe to call multiple times', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onReject(); + await diffPromise; + + expect(() => service.onReject()).not.toThrow(); + }); + + it('should clear diffResolve callback', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect((service as any).diffResolve).toBeDefined(); + + service.onReject(); + await diffPromise; + + expect((service as any).diffResolve).toBeUndefined(); + }); + }); + + describe('onSuggest()', () => { + it('should call diffResolve with suggestion', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onSuggest('Add error handling'); + + const result = await diffPromise; + expect(result).toEqual({ accepted: false, suggestion: 'Add error handling' }); + }); + + it('should call finishDiff()', async () => { + const finishDiffSpy = vi.spyOn(service as any, 'finishDiff'); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onSuggest('text'); + await diffPromise; + + expect(finishDiffSpy).toHaveBeenCalled(); + }); + + it('should handle empty suggestion string', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onSuggest(''); + + const result = await diffPromise; + expect(result).toEqual({ accepted: false, suggestion: '' }); + }); + + it('should handle very long suggestion', async () => { + const longSuggestion = 'X'.repeat(10000); + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onSuggest(longSuggestion); + + const result = await diffPromise; + expect(result).toEqual({ accepted: false, suggestion: longSuggestion }); + }); + + it('should be safe when no pending diff', () => { + expect(() => service.onSuggest('test')).not.toThrow(); + }); + + it('should handle special characters in suggestion', async () => { + const specialSuggestion = 'Line 1\nLine 2\t"quoted"'; + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onSuggest(specialSuggestion); + + const result = await diffPromise; + expect(result.suggestion).toBe(specialSuggestion); + }); + }); + + describe('cancelPendingDiff - Event Handling', () => { + it('should resolve with accepted:false when diff is ongoing', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + // Trigger DiffClosed event + const listeners = mockEventListeners.get(Event.DiffClosed) || []; + listeners.forEach(listener => listener()); + + const result = await diffPromise; + expect(result).toEqual({ accepted: false }); + }); + + it('should call finishDiff() when ongoing', async () => { + const finishDiffSpy = vi.spyOn(service as any, 'finishDiff'); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + const listeners = mockEventListeners.get(Event.DiffClosed) || []; + listeners.forEach(listener => listener()); + + await diffPromise; + + expect(finishDiffSpy).toHaveBeenCalled(); + }); + + it('should do nothing when no ongoing diff', () => { + const finishDiffSpy = vi.spyOn(service as any, 'finishDiff'); + + const listeners = mockEventListeners.get(Event.DiffClosed) || []; + listeners.forEach(listener => listener()); + + expect(finishDiffSpy).not.toHaveBeenCalled(); + }); + + it('should be called via registered event listener', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockEventListeners.has(Event.DiffClosed)).toBe(true); + + mockEventService.trigger(Event.DiffClosed); + + const result = await diffPromise; + expect(result).toEqual({ accepted: false }); + }); + + it('should handle race with onAccept', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onAccept(); + + // Try to trigger cancel after accept + const listeners = mockEventListeners.get(Event.DiffClosed) || []; + listeners.forEach(listener => listener()); + + const result = await diffPromise; + expect(result).toEqual({ accepted: true }); + }); + + it('should handle multiple DiffClosed events', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + mockEventService.trigger(Event.DiffClosed); + mockEventService.trigger(Event.DiffClosed); + + const result = await diffPromise; + expect(result).toEqual({ accepted: false }); + }); + }); + + describe('createDiffString - Private Method', () => { + const getCreateDiffString = () => (service as any).createDiffString.bind(service); + + it('should return a string', () => { + const createDiffString = getCreateDiffString(); + const result = createDiffString('a.md', 'b.md', 'old', 'new'); + + expect(typeof result).toBe('string'); + }); + + it('should handle identical content', () => { + const createDiffString = getCreateDiffString(); + const result = createDiffString('file.md', 'file.md', 'same', 'same'); + + expect(typeof result).toBe('string'); + }); + + it('should handle empty old content', () => { + const createDiffString = getCreateDiffString(); + const result = createDiffString('a.md', 'b.md', '', 'new content'); + + expect(result).toContain('new content'); + }); + + it('should handle empty new content', () => { + const createDiffString = getCreateDiffString(); + const result = createDiffString('a.md', 'b.md', 'old content', ''); + + expect(result).toContain('old content'); + }); + + it('should handle both empty', () => { + const createDiffString = getCreateDiffString(); + const result = createDiffString('a.md', 'b.md', '', ''); + + expect(typeof result).toBe('string'); + }); + + it('should handle multiline content', () => { + const createDiffString = getCreateDiffString(); + const result = createDiffString('a.md', 'b.md', 'line1\nline2\nline3', 'line1\nMODIFIED\nline3'); + + expect(result).toContain('MODIFIED'); + }); + + it('should handle special characters in content', () => { + const createDiffString = getCreateDiffString(); + const specialContent = 'Content with "quotes" and \\backslashes\\ and \u2713 unicode'; + + expect(() => createDiffString('a.md', 'b.md', specialContent, 'new')).not.toThrow(); + }); + + it('should create valid diff format', () => { + const createDiffString = getCreateDiffString(); + const result = createDiffString('old.md', 'new.md', 'old content', 'new content'); + + expect(result).toContain('---'); + expect(result).toContain('+++'); + expect(result).toContain('@@'); + }); + }); + + describe('finishDiff - Private Method', () => { + const getFinishDiff = () => (service as any).finishDiff.bind(service); + + it('should set ongoingDiff to false', () => { + (service as any).ongoingDiff = true; + + const finishDiff = getFinishDiff(); + finishDiff(); + + expect((service as any).ongoingDiff).toBe(false); + }); + + it('should clear diffResolve callback', () => { + (service as any).diffResolve = vi.fn(); + + const finishDiff = getFinishDiff(); + finishDiff(); + + expect((service as any).diffResolve).toBeUndefined(); + }); + + it('should trigger DiffClosed event', () => { + const finishDiff = getFinishDiff(); + finishDiff(); + + expect(mockEventService.trigger).toHaveBeenCalledWith(Event.DiffClosed); + }); + + it('should be safe to call multiple times', () => { + const finishDiff = getFinishDiff(); + + expect(() => { + finishDiff(); + finishDiff(); + finishDiff(); + }).not.toThrow(); + }); + + it('should complete all cleanup operations in order', () => { + (service as any).ongoingDiff = true; + (service as any).diffResolve = vi.fn(); + + const finishDiff = getFinishDiff(); + finishDiff(); + + expect((service as any).ongoingDiff).toBe(false); + expect((service as any).diffResolve).toBeUndefined(); + expect(mockEventService.trigger).toHaveBeenCalledWith(Event.DiffClosed); + }); + }); + + describe('Diff Configuration', () => { + it('should create config with correct output format', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + const configArg = mockPlugin.activateDiffView.mock.calls[0][1]; + expect(configArg.outputFormat).toBe('line-by-line'); + + service.onAccept(); + await diffPromise; + }); + + it('should enable highlighting in config', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + const configArg = mockPlugin.activateDiffView.mock.calls[0][1]; + expect(configArg.highlight).toBe(true); + + service.onAccept(); + await diffPromise; + }); + + it('should set matching to words', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + const configArg = mockPlugin.activateDiffView.mock.calls[0][1]; + expect(configArg.matching).toBe('words'); + + service.onAccept(); + await diffPromise; + }); + + it('should use auto color scheme', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + const configArg = mockPlugin.activateDiffView.mock.calls[0][1]; + expect(configArg.colorScheme).toBe('auto'); + + service.onAccept(); + await diffPromise; + }); + }); + + describe('Integration & State Management', () => { + it('full lifecycle - request, accept', async () => { + expect((service as any).ongoingDiff).toBe(false); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect((service as any).ongoingDiff).toBe(true); + expect(mockEventService.trigger).toHaveBeenCalledWith(Event.DiffOpened); + + service.onAccept(); + + const result = await diffPromise; + + expect(result).toEqual({ accepted: true }); + expect((service as any).ongoingDiff).toBe(false); + expect((service as any).diffResolve).toBeUndefined(); + }); + + it('full lifecycle - request, reject', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onReject(); + + const result = await diffPromise; + + expect(result).toEqual({ accepted: false }); + expect((service as any).ongoingDiff).toBe(false); + }); + + it('full lifecycle - request, suggest', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onSuggest('Add more tests'); + + const result = await diffPromise; + + expect(result).toEqual({ accepted: false, suggestion: 'Add more tests' }); + expect((service as any).ongoingDiff).toBe(false); + }); + + it('full lifecycle - request, abort', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + abortService.abort('User cancelled'); + + await expect(diffPromise).rejects.toThrow(); + }); + + it('full lifecycle - request, cancel via event', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + mockEventService.trigger(Event.DiffClosed); + + const result = await diffPromise; + + expect(result).toEqual({ accepted: false }); + expect((service as any).ongoingDiff).toBe(false); + }); + + it('sequential diffs - accept first, start second', async () => { + const diff1Promise = service.requestDiff('a.md', 'b.md', 'old1', 'new1'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onAccept(); + + const result1 = await diff1Promise; + expect(result1).toEqual({ accepted: true }); + + // Start second diff + const diff2Promise = service.requestDiff('c.md', 'd.md', 'old2', 'new2'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onReject(); + + const result2 = await diff2Promise; + expect(result2).toEqual({ accepted: false }); + }); + + it('should clean up Component event listeners on unload', () => { + // DiffService extends Component which handles cleanup + // Verify service was created with event registration + expect(mockEventService.on).toHaveBeenCalledWith(Event.DiffClosed, expect.any(Function)); + }); + + it('state consistency after various operation sequences', async () => { + // Request + const diff1 = service.requestDiff('a', 'b', 'old', 'new'); + await new Promise(resolve => setTimeout(resolve, 10)); + + // Cancel + mockEventService.trigger(Event.DiffClosed); + await diff1; + + expect((service as any).ongoingDiff).toBe(false); + + // Request again + const diff2 = service.requestDiff('c', 'd', 'old2', 'new2'); + await new Promise(resolve => setTimeout(resolve, 10)); + + // Accept + service.onAccept(); + await diff2; + + expect((service as any).ongoingDiff).toBe(false); + expect((service as any).diffResolve).toBeUndefined(); + }); + }); + + describe('Error Handling & Edge Cases', () => { + it('should handle activateDiffView rejection', async () => { + mockPlugin.activateDiffView.mockRejectedValue(new Error('View error')); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + // Promise creation succeeds, error happens in background + expect((service as any).ongoingDiff).toBe(true); + + service.onAccept(); + await diffPromise; + }); + + it('should handle very large diff content', () => { + const largeContent = 'X'.repeat(100000); + + expect(() => { + service.requestDiff('a.md', 'b.md', largeContent, 'Y'.repeat(100000)); + }).not.toThrow(); + }); + + it('should handle diff request with same filenames', async () => { + const diffPromise = service.requestDiff('same.md', 'same.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockPlugin.activateDiffView).toHaveBeenCalled(); + + service.onAccept(); + await diffPromise; + }); + + it('should handle abort during diff view activation', async () => { + mockPlugin.activateDiffView.mockImplementation(() => + new Promise(resolve => setTimeout(resolve, 100)) + ); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + abortService.abort('Abort during activation'); + + await expect(diffPromise).rejects.toThrow(); + }); + + it('should handle null content by throwing error', () => { + const createDiffString = (service as any).createDiffString.bind(service); + + // The underlying diff library does not handle null/undefined - it throws + expect(() => { + createDiffString('a.md', 'b.md', null, undefined); + }).toThrow(); + }); + + it('should handle missing dependencies error', () => { + DeregisterAllServices(); + + // Attempting to create service without dependencies should fail + expect(() => new DiffService()).toThrow(); + }); + }); +}); diff --git a/__tests__/Services/FileSystemService.test.ts b/__tests__/Services/FileSystemService.test.ts index fe3404a..f1f86df 100644 --- a/__tests__/Services/FileSystemService.test.ts +++ b/__tests__/Services/FileSystemService.test.ts @@ -134,7 +134,7 @@ describe('FileSystemService', () => { const result = await fileSystemService.writeFile('new.md', 'content'); expect(result).toBe(mockFile); - expect(mockVaultService.create).toHaveBeenCalledWith('new.md', 'content', false); + expect(mockVaultService.create).toHaveBeenCalledWith('new.md', 'content', false, true); expect(mockVaultService.modify).not.toHaveBeenCalled(); }); @@ -147,7 +147,7 @@ describe('FileSystemService', () => { const result = await fileSystemService.writeFile('existing.md', 'new content'); expect(result).toBe(mockFile); - expect(mockVaultService.modify).toHaveBeenCalledWith(mockFile, 'new content', false); + expect(mockVaultService.modify).toHaveBeenCalledWith(mockFile, 'new content', false, true); expect(mockVaultService.create).not.toHaveBeenCalled(); }); @@ -158,14 +158,14 @@ describe('FileSystemService', () => { await fileSystemService.writeFile('plugin/data.json', 'content', true); expect(mockVaultService.getAbstractFileByPath).toHaveBeenCalledWith('plugin/data.json', true); - expect(mockVaultService.create).toHaveBeenCalledWith('plugin/data.json', 'content', true); + expect(mockVaultService.create).toHaveBeenCalledWith('plugin/data.json', 'content', true, true); }); it('should return error object when create fails', async () => { const error = new Error('Create failed'); mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null); - mockVaultService.create = vi.fn().mockRejectedValue(error); + mockVaultService.create = vi.fn().mockResolvedValue(error); const result = await fileSystemService.writeFile('error.md', 'content'); @@ -178,7 +178,7 @@ describe('FileSystemService', () => { const error = new Error('Modify failed'); mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile); - mockVaultService.modify = vi.fn().mockRejectedValue(error); + mockVaultService.modify = vi.fn().mockResolvedValue(error); const result = await fileSystemService.writeFile('existing.md', 'content'); @@ -197,7 +197,7 @@ describe('FileSystemService', () => { const result = await fileSystemService.deleteFile('delete-me.md'); expect(result).toBeUndefined(); - expect(mockVaultService.delete).toHaveBeenCalledWith(mockFile, false); + expect(mockVaultService.delete).toHaveBeenCalledWith(mockFile, false, true); }); it('should return error when file does not exist', async () => { @@ -219,7 +219,7 @@ describe('FileSystemService', () => { await fileSystemService.deleteFile('plugin/temp.json', true); expect(mockVaultService.getAbstractFileByPath).toHaveBeenCalledWith('plugin/temp.json', true); - expect(mockVaultService.delete).toHaveBeenCalledWith(mockFile, true); + expect(mockVaultService.delete).toHaveBeenCalledWith(mockFile, true, true); }); it('should delete folder successfully (supports both files and folders)', async () => { @@ -231,7 +231,7 @@ describe('FileSystemService', () => { const result = await fileSystemService.deleteFile('folder'); expect(result).toBeUndefined(); - expect(mockVaultService.delete).toHaveBeenCalledWith(mockFolder, false); + expect(mockVaultService.delete).toHaveBeenCalledWith(mockFolder, false, true); }); }); diff --git a/__tests__/Services/StreamingService.test.ts b/__tests__/Services/StreamingService.test.ts index a6911a7..fd0cc05 100644 --- a/__tests__/Services/StreamingService.test.ts +++ b/__tests__/Services/StreamingService.test.ts @@ -1,21 +1,27 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { StreamingService, IStreamChunk } from '../../Services/StreamingService'; -import { Selector } from '../../Enums/Selector'; import { Exception } from '../../Helpers/Exception'; +import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService'; +import { Services } from '../../Services/Services'; +import { AbortService } from '../../Services/AbortService'; /** * UNIT TESTS * - * StreamingService has no dependencies, so we use pure unit tests. - * We mock the global fetch API to test streaming behavior. + * StreamingService now depends on AbortService, so we mock that dependency. + * We also mock the global fetch API to test streaming behavior. */ describe('StreamingService', () => { let service: StreamingService; let mockFetch: any; let originalFetch: any; + let abortService: AbortService; beforeEach(() => { + DeregisterAllServices(); + abortService = new AbortService(); + RegisterSingleton(Services.AbortService, abortService); service = new StreamingService(); originalFetch = global.fetch; mockFetch = vi.fn(); @@ -130,7 +136,6 @@ describe('StreamingService', () => { 'https://api.example.com/stream', { prompt: 'test' }, simpleParser, - undefined, { 'Authorization': 'Bearer token123', 'X-Custom': 'value' } )) { // Just consume the stream @@ -436,13 +441,10 @@ describe('StreamingService', () => { body: createMockStream(['data: {"content":"test","done":true}\n']) }); - const abortController = new AbortController(); - for await (const chunk of service.streamRequest( 'https://api.example.com/stream', {}, - simpleParser, - abortController.signal + simpleParser )) { // Just consume the stream } @@ -450,31 +452,28 @@ describe('StreamingService', () => { expect(mockFetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - signal: abortController.signal + signal: abortService.signal() }) ); }); it('should handle abort error gracefully', async () => { - const abortError = new Error('The operation was aborted'); - abortError.name = 'AbortError'; + const abortError = new DOMException('The operation was aborted', 'AbortError'); mockFetch.mockRejectedValue(abortError); const results: IStreamChunk[] = []; - for await (const chunk of service.streamRequest( - 'https://api.example.com/stream', - {}, - simpleParser, - new AbortController().signal - )) { - results.push(chunk); + try { + for await (const chunk of service.streamRequest( + 'https://api.example.com/stream', + {}, + simpleParser + )) { + results.push(chunk); + } + } catch (error) { + // Abort errors are now thrown instead of yielded as chunks + expect(AbortService.isAbortError(error)).toBe(true); } - - expect(results).toHaveLength(1); - expect(results[0]).toEqual({ - content: Selector.ApiRequestAborted, - isComplete: true - }); }); }); diff --git a/__tests__/Services/VaultCacheService.performance.test.ts b/__tests__/Services/VaultCacheService.performance.test.ts index a502032..6255954 100644 --- a/__tests__/Services/VaultCacheService.performance.test.ts +++ b/__tests__/Services/VaultCacheService.performance.test.ts @@ -14,6 +14,24 @@ vi.mock('obsidian', async () => { const actual = await vi.importActual('obsidian'); return { ...actual, + Component: class Component { + public load() {} + public onload() {} + public unload() {} + public onunload() {} + public addChild(component: T): T { + return component; + } + public removeChild(component: T): T { + return component; + } + public register(_cb: () => any): void {} + public registerEvent(_eventRef: any): void {} + public registerDomEvent(_el: any, _type: any, _callback: any, _options?: any): void {} + public registerInterval(id: number): number { + return id; + } + }, getAllTags: vi.fn((metadata: any) => { if (!metadata || !metadata.tags) return null; return metadata.tags.map((t: any) => t.tag); diff --git a/__tests__/Services/VaultCacheService.test.ts b/__tests__/Services/VaultCacheService.test.ts index aaa896c..18f73fc 100644 --- a/__tests__/Services/VaultCacheService.test.ts +++ b/__tests__/Services/VaultCacheService.test.ts @@ -11,6 +11,24 @@ vi.mock('obsidian', async () => { const actual = await vi.importActual('obsidian'); return { ...actual, + Component: class Component { + public load() {} + public onload() {} + public unload() {} + public onunload() {} + public addChild(component: T): T { + return component; + } + public removeChild(component: T): T { + return component; + } + public register(_cb: () => any): void {} + public registerEvent(_eventRef: any): void {} + public registerDomEvent(_el: any, _type: any, _callback: any, _options?: any): void {} + public registerInterval(id: number): number { + return id; + } + }, getAllTags: vi.fn((metadata: any) => { if (!metadata || !metadata.tags) return null; return metadata.tags.map((t: any) => t.tag); diff --git a/__tests__/setup.ts b/__tests__/setup.ts index af42f44..020d629 100644 --- a/__tests__/setup.ts +++ b/__tests__/setup.ts @@ -9,6 +9,32 @@ if (typeof global.window === 'undefined') { global.window = {} as any; } +// Mock Obsidian's Component class +vi.mock('obsidian', async () => { + const actual = await vi.importActual('obsidian'); + return { + ...actual, + Component: class Component { + public load() {} + public onload() {} + public unload() {} + public onunload() {} + public addChild(component: T): T { + return component; + } + public removeChild(component: T): T { + return component; + } + public register(_cb: () => any): void {} + public registerEvent(_eventRef: any): void {} + public registerDomEvent(_el: any, _type: any, _callback: any, _options?: any): void {} + public registerInterval(id: number): number { + return id; + } + } + }; +}); + // Add Obsidian's .empty() method to HTMLElement prototype for testing if (typeof HTMLElement !== 'undefined') { HTMLElement.prototype.empty = function() { diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 00ea1aa..77bcac2 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -1,13 +1,13 @@ import esbuild from "esbuild"; import process from "process"; import builtins from "builtin-modules"; -import { copyFileSync, mkdirSync, existsSync, readdirSync, statSync, readFileSync, writeFileSync, unlinkSync } from "fs"; +import { copyFileSync, mkdirSync, existsSync, readdirSync, statSync, readFileSync, writeFileSync, unlinkSync, watch as fsWatch } from "fs"; import { join } from "path"; import esbuildSvelte from "esbuild-svelte"; import { sveltePreprocess } from "svelte-preprocess"; const banner = -`/* + `/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD if you want to view the source, please visit the github repository of this plugin */ @@ -84,71 +84,84 @@ const cssMergerPlugin = { } // Remove KaTeX font files - let anyRemoved = false; + let anyRemoved = false; const fontExtensions = [".woff", ".woff2", ".ttf"]; const files = readdirSync("."); for (const file of files) { const ext = file.substring(file.lastIndexOf(".")); if (fontExtensions.includes(ext)) { unlinkSync(file); - anyRemoved = true; + anyRemoved = true; } } - if (anyRemoved) { - console.log("🗑️ Removed KaTeX Font Files"); - } + if (anyRemoved) { + console.log("🗑️ Removed KaTeX Font Files"); + } } }); } }; const buildOptions = { - plugins: [ - esbuildSvelte({ - compilerOptions: { css: "injected" }, - preprocess: sveltePreprocess(), - }), - cssMergerPlugin, - ], - banner: { - js: banner, - }, - entryPoints: ["main.ts"], - bundle: true, - external: [ - "obsidian", - "electron", - "@codemirror/autocomplete", - "@codemirror/collab", - "@codemirror/commands", - "@codemirror/language", - "@codemirror/lint", - "@codemirror/search", - "@codemirror/state", - "@codemirror/view", - "@lezer/common", - "@lezer/highlight", - "@lezer/lr", - ...builtins], - format: "cjs", - target: "es2018", - logLevel: "info", - sourcemap: prod ? false : "inline", - treeShaking: true, - outfile: "main.js", - minify: prod, - loader: { - ".css": "css", - ".ttf": "file", - ".woff": "file", - ".woff2": "file", - }, + plugins: [ + esbuildSvelte({ + compilerOptions: { css: "injected" }, + preprocess: sveltePreprocess(), + }), + cssMergerPlugin, + ], + banner: { + js: banner, + }, + entryPoints: ["main.ts"], + bundle: true, + define: { + "process.env.NODE_ENV": JSON.stringify(prod ? "production" : "development"), + }, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + minify: prod, + loader: { + ".css": "css", + ".ttf": "file", + ".woff": "file", + ".woff2": "file", + }, }; if (prod) { - await esbuild.build(buildOptions); - console.log("✅ Production build complete!"); + await esbuild.build(buildOptions); + console.log("✅ Production build complete!"); } else { - const ctx = await esbuild.context(buildOptions); - await ctx.watch(); + const ctx = await esbuild.context(buildOptions); + await ctx.watch(); + + // Watch Styles directory for CSS changes + if (existsSync("Styles")) { + fsWatch("Styles", { recursive: true }, (_eventType, filename) => { + if (filename && filename.endsWith(".css")) { + console.log(`🔄 CSS file changed: ${filename} - Rebuilding...`); + ctx.rebuild(); + } + }); + } } \ No newline at end of file diff --git a/main.ts b/main.ts index 6497202..58e8809 100644 --- a/main.ts +++ b/main.ts @@ -14,7 +14,6 @@ import type { Diff2HtmlUIConfig } from "diff2html/lib/ui/js/diff2html-ui"; import "katex/dist/katex.min.css"; import 'highlight.js/styles/monokai.min.css'; import 'diff2html/bundles/css/diff2html.min.css'; -import 'diff2html/bundles/js/diff2html-ui.min.js'; export default class VaultkeeperAIPlugin extends Plugin { diff --git a/package-lock.json b/package-lock.json index e0836bc..e0ec5ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,9 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.70.1", + "@anthropic-ai/sdk": "^0.71.0", "@google/genai": "^1.30.0", - "@shikijs/rehype": "^3.15.0", + "@shikijs/rehype": "^3.17.0", "core-js": "^3.47.0", "diff": "^8.0.2", "diff2html": "^3.4.52", @@ -35,7 +35,7 @@ "remark-wiki-link": "^2.0.1", "unified": "^11.0.5", "uuid": "^13.0.0", - "zod": "^4.1.12" + "zod": "^4.1.13" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -43,29 +43,29 @@ "@types/express": "^5.0.5", "@types/node": "^24.10.1", "@types/path-browserify": "^1.0.3", - "@typescript-eslint/eslint-plugin": "8.47.0", - "@typescript-eslint/parser": "8.47.0", - "@vitest/ui": "^4.0.13", + "@typescript-eslint/eslint-plugin": "8.48.0", + "@typescript-eslint/parser": "8.48.0", + "@vitest/ui": "^4.0.14", "builtin-modules": "5.0.0", "esbuild": "^0.27.0", "esbuild-svelte": "^0.9.3", "eslint": "^9.39.1", "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-obsidianmd": "^0.1.9", - "happy-dom": "^20.0.10", + "happy-dom": "^20.0.11", "obsidian": "latest", - "svelte": "^5.43.14", + "svelte": "^5.45.2", "svelte-check": "^4.3.4", "svelte-preprocess": "^6.0.3", "tslib": "2.8.1", "typescript": "^5.9.3", - "vitest": "^4.0.13" + "vitest": "^4.0.14" } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.70.1", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.70.1.tgz", - "integrity": "sha512-AGEhifuvE22VxfQ5ROxViTgM8NuVQzEvqcN8bttR4AP24ythmNE/cL/SrOz79xiv7/osrsmCyErjsistJi7Z8A==", + "version": "0.71.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.0.tgz", + "integrity": "sha512-go1XeWXmpxuiTkosSXpb8tokLk2ZLkIRcXpbWVwJM6gH5OBtHOVsfPfGuqI1oW7RRt4qc59EmYbrXRZ0Ng06Jw==", "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.1.1" @@ -986,44 +986,6 @@ "ret": "~0.1.10" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1370,74 +1332,74 @@ "license": "MIT" }, "node_modules/@shikijs/core": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.15.0.tgz", - "integrity": "sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.17.0.tgz", + "integrity": "sha512-/HjeOnbc62C+n33QFNFrAhUlIADKwfuoS50Ht0pxujxP4QjZAlFp5Q+OkDo531SCTzivx5T18khwyBdKoPdkuw==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.15.0", + "@shikijs/types": "3.17.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "node_modules/@shikijs/engine-javascript": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.15.0.tgz", - "integrity": "sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.17.0.tgz", + "integrity": "sha512-WwF99xdP8KfuDrIbT4wxyypfhoIxMeeOCp1AiuvzzZ6JT5B3vIuoclL8xOuuydA6LBeeNXUF/XV5zlwwex1jlA==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.15.0", + "@shikijs/types": "3.17.0", "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.3" + "oniguruma-to-es": "^4.3.4" } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.15.0.tgz", - "integrity": "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.17.0.tgz", + "integrity": "sha512-flSbHZAiOZDNTrEbULY8DLWavu/TyVu/E7RChpLB4WvKX4iHMfj80C6Hi3TjIWaQtHOW0KC6kzMcuB5TO1hZ8Q==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.15.0", + "@shikijs/types": "3.17.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "node_modules/@shikijs/langs": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.15.0.tgz", - "integrity": "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.17.0.tgz", + "integrity": "sha512-icmur2n5Ojb+HAiQu6NEcIIJ8oWDFGGEpiqSCe43539Sabpx7Y829WR3QuUW2zjTM4l6V8Sazgb3rrHO2orEAw==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.15.0" + "@shikijs/types": "3.17.0" } }, "node_modules/@shikijs/rehype": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/rehype/-/rehype-3.15.0.tgz", - "integrity": "sha512-U+tqD1oxL+85N8FaW5XYIlMZ8KAa2g9IdplEZxPWflGRJf2gQRiBMMrpdG1USz3PN350YnMUHWcz9Twt3wJjXQ==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@shikijs/rehype/-/rehype-3.17.0.tgz", + "integrity": "sha512-977PWiZHaJf1ez+Q3ViCjDVNEch+XKDaQbEgsku8p9WfuncbajX1xMGzBax0Az4ATXPQF3p8wpNMPtlRksSbMg==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.15.0", + "@shikijs/types": "3.17.0", "@types/hast": "^3.0.4", "hast-util-to-string": "^3.0.1", - "shiki": "3.15.0", + "shiki": "3.17.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" } }, "node_modules/@shikijs/themes": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.15.0.tgz", - "integrity": "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.17.0.tgz", + "integrity": "sha512-/xEizMHLBmMHwtx4JuOkRf3zwhWD2bmG5BRr0IPjpcWpaq4C3mYEuTk/USAEglN0qPrTwEHwKVpSu/y2jhferA==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.15.0" + "@shikijs/types": "3.17.0" } }, "node_modules/@shikijs/types": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.15.0.tgz", - "integrity": "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.17.0.tgz", + "integrity": "sha512-wjLVfutYWVUnxAjsWEob98xgyaGv0dTEnMZDruU5mRjVN7szcGOfgO+997W2yR6odp+1PtSBNeSITRRTfUzK/g==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", @@ -1779,17 +1741,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz", - "integrity": "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", + "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/type-utils": "8.47.0", - "@typescript-eslint/utils": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/type-utils": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -1803,22 +1765,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.47.0", + "@typescript-eslint/parser": "^8.48.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.47.0.tgz", - "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", + "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4" }, "engines": { @@ -1834,14 +1796,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz", - "integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", + "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.47.0", - "@typescript-eslint/types": "^8.47.0", + "@typescript-eslint/tsconfig-utils": "^8.48.0", + "@typescript-eslint/types": "^8.48.0", "debug": "^4.3.4" }, "engines": { @@ -1856,14 +1818,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz", - "integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", + "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0" + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1874,9 +1836,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz", - "integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", + "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", "dev": true, "license": "MIT", "engines": { @@ -1891,15 +1853,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.47.0.tgz", - "integrity": "sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz", + "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0", - "@typescript-eslint/utils": "8.47.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1916,9 +1878,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz", - "integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", + "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", "dev": true, "license": "MIT", "engines": { @@ -1930,21 +1892,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz", - "integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", + "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.47.0", - "@typescript-eslint/tsconfig-utils": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", + "@typescript-eslint/project-service": "8.48.0", + "@typescript-eslint/tsconfig-utils": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", + "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "engines": { @@ -1959,16 +1920,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz", - "integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", + "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0" + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1983,13 +1944,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz", - "integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", + "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/types": "8.48.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -2020,16 +1981,16 @@ "license": "ISC" }, "node_modules/@vitest/expect": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.13.tgz", - "integrity": "sha512-zYtcnNIBm6yS7Gpr7nFTmq8ncowlMdOJkWLqYvhr/zweY6tFbDkDi8BPPOeHxEtK1rSI69H7Fd4+1sqvEGli6w==", + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.14.tgz", + "integrity": "sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.13", - "@vitest/utils": "4.0.13", + "@vitest/spy": "4.0.14", + "@vitest/utils": "4.0.14", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" }, @@ -2038,13 +1999,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.13.tgz", - "integrity": "sha512-eNCwzrI5djoauklwP1fuslHBjrbR8rqIVbvNlAnkq1OTa6XT+lX68mrtPirNM9TnR69XUPt4puBCx2Wexseylg==", + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.14.tgz", + "integrity": "sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.13", + "@vitest/spy": "4.0.14", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2065,9 +2026,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.13.tgz", - "integrity": "sha512-ooqfze8URWbI2ozOeLDMh8YZxWDpGXoeY3VOgcDnsUxN0jPyPWSUvjPQWqDGCBks+opWlN1E4oP1UYl3C/2EQA==", + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.14.tgz", + "integrity": "sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2078,13 +2039,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.13.tgz", - "integrity": "sha512-9IKlAru58wcVaWy7hz6qWPb2QzJTKt+IOVKjAx5vb5rzEFPTL6H4/R9BMvjZ2ppkxKgTrFONEJFtzvnyEpiT+A==", + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.14.tgz", + "integrity": "sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.13", + "@vitest/utils": "4.0.14", "pathe": "^2.0.3" }, "funding": { @@ -2092,13 +2053,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.13.tgz", - "integrity": "sha512-hb7Usvyika1huG6G6l191qu1urNPsq1iFc2hmdzQY3F5/rTgqQnwwplyf8zoYHkpt7H6rw5UfIw6i/3qf9oSxQ==", + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.14.tgz", + "integrity": "sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.13", + "@vitest/pretty-format": "4.0.14", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2107,9 +2068,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.13.tgz", - "integrity": "sha512-hSu+m4se0lDV5yVIcNWqjuncrmBgwaXa2utFLIrBkQCQkt+pSwyZTPFQAZiiF/63j8jYa8uAeUZ3RSfcdWaYWw==", + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.14.tgz", + "integrity": "sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==", "dev": true, "license": "MIT", "funding": { @@ -2117,13 +2078,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.13.tgz", - "integrity": "sha512-MFV6GhTflgBj194+vowTB2iLI5niMZhqiW7/NV7U4AfWbX/IAtsq4zA+gzCLyGzpsQUdJlX26hrQ1vuWShq2BQ==", + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.14.tgz", + "integrity": "sha512-fvDz8o7SQpFLoSBo6Cudv+fE85/fPCkwTnLAN85M+Jv7k59w2mSIjT9Q5px7XwGrmYqqKBEYxh/09IBGd1E7AQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.13", + "@vitest/utils": "4.0.14", "fflate": "^0.8.2", "flatted": "^3.3.3", "pathe": "^2.0.3", @@ -2135,17 +2096,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.0.13" + "vitest": "4.0.14" } }, "node_modules/@vitest/utils": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.13.tgz", - "integrity": "sha512-ydozWyQ4LZuu8rLp47xFUWis5VOKMdHjXCWhs1LuJsTNKww+pTHQNK4e0assIB9K80TxFyskENL6vCu3j34EYA==", + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.14.tgz", + "integrity": "sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.13", + "@vitest/pretty-format": "4.0.14", "tinyrainbow": "^3.0.3" }, "funding": { @@ -2513,23 +2474,27 @@ } }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/brace-expansion": { @@ -2541,19 +2506,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3023,6 +2975,13 @@ "node": ">=6" } }, + "node_modules/devalue": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.5.0.tgz", + "integrity": "sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==", + "dev": true, + "license": "MIT" + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -4160,9 +4119,9 @@ } }, "node_modules/esrap": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.3.tgz", - "integrity": "sha512-T/Dhhv/QH+yYmiaLz9SA3PW+YyenlnRKDNdtlYJrSOBmNsH4nvPux+mTwx7p+wAedlJrGoZtXNI0a0MjQ2QkVg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.0.tgz", + "integrity": "sha512-WBmtxe7R9C5mvL4n2le8nMUe4mD5V9oiK2vJpQ9I3y20ENPUomPcphBXE8D1x/Bm84oN1V+lOfgXxtqmxTp3Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -4286,36 +4245,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4347,14 +4276,22 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/fetch-blob": { @@ -4400,19 +4337,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/finalhandler": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", @@ -4834,9 +4758,9 @@ } }, "node_modules/happy-dom": { - "version": "20.0.10", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.10.tgz", - "integrity": "sha512-6umCCHcjQrhP5oXhrHQQvLB0bwb1UzHAHdsXy+FjtKoYjUhmNZsQL8NivwM1vDvNEChJabVrUYxUnp/ZdYmy2g==", + "version": "20.0.11", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.11.tgz", + "integrity": "sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g==", "dev": true, "license": "MIT", "dependencies": { @@ -5199,15 +5123,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ignore": { @@ -5550,16 +5478,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", @@ -6320,9 +6238,9 @@ } }, "node_modules/mdast-util-to-hast": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", - "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -6459,16 +6377,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -7060,20 +6968,6 @@ ], "license": "MIT" }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -7426,6 +7320,17 @@ "@codemirror/view": "6.38.6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -7704,13 +7609,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -7860,27 +7765,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -7905,22 +7789,6 @@ "node": ">= 0.10" } }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -8256,17 +8124,6 @@ "node": ">=0.12" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rimraf": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", @@ -8340,30 +8197,6 @@ "node": ">= 18" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -8595,17 +8428,17 @@ } }, "node_modules/shiki": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.15.0.tgz", - "integrity": "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.17.0.tgz", + "integrity": "sha512-lUZfWsyW7czITYTdo/Tb6ZM4VfyXlzmKYBQBjTz+pBzPPkP08RgIt00Ls1Z50Cl3SfwJsue6WbJeF3UgqLVI9Q==", "license": "MIT", "dependencies": { - "@shikijs/core": "3.15.0", - "@shikijs/engine-javascript": "3.15.0", - "@shikijs/engine-oniguruma": "3.15.0", - "@shikijs/langs": "3.15.0", - "@shikijs/themes": "3.15.0", - "@shikijs/types": "3.15.0", + "@shikijs/core": "3.17.0", + "@shikijs/engine-javascript": "3.17.0", + "@shikijs/engine-oniguruma": "3.17.0", + "@shikijs/langs": "3.17.0", + "@shikijs/themes": "3.17.0", + "@shikijs/types": "3.17.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } @@ -9045,9 +8878,9 @@ } }, "node_modules/svelte": { - "version": "5.43.14", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.14.tgz", - "integrity": "sha512-pHeUrp1A5S6RGaXhJB7PtYjL1VVjbVrJ2EfuAoPu9/1LeoMaJa/pcdCsCSb0gS4eUHAHnhCbUDxORZyvGK6kOQ==", + "version": "5.45.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.2.tgz", + "integrity": "sha512-yyXdW2u3H0H/zxxWoGwJoQlRgaSJLp+Vhktv12iRw2WRDlKqUPT54Fi0K/PkXqrdkcQ98aBazpy0AH4BCBVfoA==", "dev": true, "license": "MIT", "dependencies": { @@ -9059,8 +8892,9 @@ "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", + "devalue": "^5.5.0", "esm-env": "^1.2.1", - "esrap": "^2.1.0", + "esrap": "^2.2.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -9094,24 +8928,6 @@ "typescript": ">=5.0.0" } }, - "node_modules/svelte-check/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/svelte-preprocess": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-6.0.3.tgz", @@ -9240,37 +9056,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinyrainbow": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", @@ -9281,19 +9066,6 @@ "node": ">=14.0.0" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -9521,17 +9293,17 @@ } }, "node_modules/typescript-eslint": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.47.0.tgz", - "integrity": "sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz", + "integrity": "sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/eslint-plugin": "8.47.0", - "@typescript-eslint/parser": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0", - "@typescript-eslint/utils": "8.47.0" + "@typescript-eslint/eslint-plugin": "8.48.0", + "@typescript-eslint/parser": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10337,55 +10109,24 @@ "@esbuild/win32-x64": "0.25.12" } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitest": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.13.tgz", - "integrity": "sha512-QSD4I0fN6uZQfftryIXuqvqgBxTvJ3ZNkF6RWECd82YGAYAfhcppBLFXzXJHQAAhVFyYEuFTrq6h0hQqjB7jIQ==", + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.14.tgz", + "integrity": "sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.13", - "@vitest/mocker": "4.0.13", - "@vitest/pretty-format": "4.0.13", - "@vitest/runner": "4.0.13", - "@vitest/snapshot": "4.0.13", - "@vitest/spy": "4.0.13", - "@vitest/utils": "4.0.13", - "debug": "^4.4.3", + "@vitest/expect": "4.0.14", + "@vitest/mocker": "4.0.14", + "@vitest/pretty-format": "4.0.14", + "@vitest/runner": "4.0.14", + "@vitest/snapshot": "4.0.14", + "@vitest/spy": "4.0.14", + "@vitest/utils": "4.0.14", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", @@ -10408,12 +10149,11 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/debug": "^4.1.12", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.13", - "@vitest/browser-preview": "4.0.13", - "@vitest/browser-webdriverio": "4.0.13", - "@vitest/ui": "4.0.13", + "@vitest/browser-playwright": "4.0.14", + "@vitest/browser-preview": "4.0.14", + "@vitest/browser-webdriverio": "4.0.14", + "@vitest/ui": "4.0.14", "happy-dom": "*", "jsdom": "*" }, @@ -10424,9 +10164,6 @@ "@opentelemetry/api": { "optional": true }, - "@types/debug": { - "optional": true - }, "@types/node": { "optional": true }, @@ -10450,19 +10187,6 @@ } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -10751,9 +10475,9 @@ } }, "node_modules/yaml-eslint-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-1.3.0.tgz", - "integrity": "sha512-E/+VitOorXSLiAqtTd7Yqax0/pAS3xaYMP+AUUJGOK1OZG3rhcj9fcJOM5HJ2VrP1FrStVCWr1muTfQCdj4tAA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-1.3.1.tgz", + "integrity": "sha512-MdSgP9YA9QjtAO2+lt4O7V2bnH22LPnfeVLiQqjY3cOyn8dy/Ief8otjIe6SPPTK03nM7O3Yl0LTfWuF7l+9yw==", "dev": true, "license": "MIT", "dependencies": { @@ -10788,9 +10512,9 @@ "license": "MIT" }, "node_modules/zod": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", - "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 08cd58d..f23f472 100644 --- a/package.json +++ b/package.json @@ -25,28 +25,28 @@ "@types/express": "^5.0.5", "@types/node": "^24.10.1", "@types/path-browserify": "^1.0.3", - "@typescript-eslint/eslint-plugin": "8.47.0", - "@typescript-eslint/parser": "8.47.0", - "@vitest/ui": "^4.0.13", + "@typescript-eslint/eslint-plugin": "8.48.0", + "@typescript-eslint/parser": "8.48.0", + "@vitest/ui": "^4.0.14", "builtin-modules": "5.0.0", "esbuild": "^0.27.0", "esbuild-svelte": "^0.9.3", "eslint": "^9.39.1", "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-obsidianmd": "^0.1.9", - "happy-dom": "^20.0.10", + "happy-dom": "^20.0.11", "obsidian": "latest", - "svelte": "^5.43.14", + "svelte": "^5.45.2", "svelte-check": "^4.3.4", "svelte-preprocess": "^6.0.3", "tslib": "2.8.1", "typescript": "^5.9.3", - "vitest": "^4.0.13" + "vitest": "^4.0.14" }, "dependencies": { - "@anthropic-ai/sdk": "^0.70.1", + "@anthropic-ai/sdk": "^0.71.0", "@google/genai": "^1.30.0", - "@shikijs/rehype": "^3.15.0", + "@shikijs/rehype": "^3.17.0", "core-js": "^3.47.0", "diff": "^8.0.2", "diff2html": "^3.4.52", @@ -70,6 +70,6 @@ "remark-wiki-link": "^2.0.1", "unified": "^11.0.5", "uuid": "^13.0.0", - "zod": "^4.1.12" + "zod": "^4.1.13" } }