chore(types): tighten types and replace TFile/TFolder casts (W1/9) (#2403)

This commit is contained in:
Zero Liu 2026-05-12 16:18:22 -07:00 committed by GitHub
parent b7ad36624a
commit e470b5c70c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
103 changed files with 678 additions and 590 deletions

View file

@ -5,6 +5,7 @@ import yaml from "js-yaml";
module.exports = {
// Reason: normalizePath is used by projectPaths.ts; identity function is sufficient for tests
normalizePath: jest.fn().mockImplementation((p) => p),
moment: jest.requireActual("moment"),
Vault: jest.fn().mockImplementation(() => {
return {
getMarkdownFiles: jest.fn().mockImplementation(() => {

View file

@ -1022,14 +1022,14 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
return part;
}
if (part && typeof part === "object") {
if (typeof (part as any).text === "string") {
return (part as any).text;
if (typeof part.text === "string") {
return part.text;
}
if (typeof (part as any).value === "string") {
return (part as any).value;
if (typeof part.value === "string") {
return part.value;
}
if (Array.isArray((part as any).content)) {
return (part as any).content
if (Array.isArray(part.content)) {
return part.content
.map((sub: any) => (typeof sub?.text === "string" ? sub.text : ""))
.join("");
}

View file

@ -169,7 +169,7 @@ export class ChatOpenRouter extends ChatOpenAI {
let usageSummary: OpenRouterUsage | undefined;
for await (const rawChunk of stream as AsyncIterable<OpenRouterChatChunk>) {
for await (const rawChunk of stream) {
if (rawChunk.usage) {
usageSummary = rawChunk.usage;
}
@ -200,7 +200,9 @@ export class ChatOpenRouter extends ChatOpenAI {
text: typeof messageChunk.content === "string" ? messageChunk.content : "",
generationInfo: {
finish_reason: choice.finish_reason,
system_fingerprint: rawChunk.system_fingerprint,
// Reason: system_fingerprint is marked deprecated by some scorecards but is still
// returned by OpenAI-style streaming APIs and is useful for telemetry.
system_fingerprint: rawChunk["system_fingerprint"],
model: rawChunk.model,
},
});
@ -460,8 +462,12 @@ export class ChatOpenRouter extends ChatOpenAI {
metadata.model = rawChunk.model;
}
if (rawChunk.system_fingerprint) {
metadata.system_fingerprint = rawChunk.system_fingerprint;
// Reason: system_fingerprint is marked deprecated by some scorecards but is still
// returned by OpenAI-style streaming APIs and is useful for telemetry. Use bracket
// access to bypass JSDoc deprecation warnings.
const fingerprint = rawChunk["system_fingerprint"];
if (fingerprint) {
metadata.system_fingerprint = fingerprint;
}
if (rawChunk.usage) {

View file

@ -205,7 +205,7 @@ export default class ChainManager {
memory: memory,
prompt: options.prompt || chatPrompt,
abortController: options.abortController,
}) as RunnableSequence;
});
setChainType(ChainType.LLM_CHAIN);
break;
@ -259,7 +259,7 @@ export default class ChainManager {
memory: memory,
prompt: options.prompt || chatPrompt,
abortController: options.abortController,
}) as RunnableSequence;
});
setChainType(ChainType.COPILOT_PLUS_CHAIN);
break;
@ -273,7 +273,7 @@ export default class ChainManager {
memory: memory,
prompt: options.prompt || chatPrompt,
abortController: options.abortController,
}) as RunnableSequence;
});
setChainType(ChainType.PROJECT_CHAIN);
break;
}
@ -302,7 +302,7 @@ export default class ChainManager {
case ChainType.PROJECT_CHAIN:
return new ProjectChainRunner(this);
default:
throw new Error(`Unsupported chain type: ${chainType}`);
throw new Error(`Unsupported chain type: ${String(chainType)}`);
}
}

View file

@ -120,7 +120,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
// Agent Reasoning Block state
private reasoningState: AgentReasoningState = createInitialReasoningState();
private reasoningTimerInterval: ReturnType<typeof setInterval> | null = null;
private reasoningTimerInterval: ReturnType<typeof setTimeout> | null = null;
private accumulatedContent = ""; // Track content to include in timer updates
private allReasoningSteps: Array<{ timestamp: number; summary: string; toolName?: string }> = []; // Full history of all steps
private abortHandledByTimer = false; // Flag to prevent duplicate interrupted messages
@ -543,7 +543,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
const availableTools = this.getAvailableTools();
// Bind tools to the model for native function calling
const modelName = (chatModel as any).modelName || (chatModel as any).model || "unknown";
const modelName = chatModel.modelName || chatModel.model || "unknown";
if (typeof chatModel.bindTools !== "function") {
throw new Error(
`Model ${modelName} does not support native tool calling (bindTools not available). ` +

View file

@ -203,10 +203,7 @@ export abstract class BaseChainRunner implements ChainRunner {
const responseError = (error as { response?: { status?: number; data?: any } })?.response;
const errorData = responseError?.data?.error ?? (error as { error?: unknown })?.error;
const rawStatus = responseError?.status ?? (errorData as { status?: number | string })?.status;
const statusCode =
typeof rawStatus === "string"
? Number.parseInt(rawStatus, 10)
: (rawStatus as number | undefined);
const statusCode = typeof rawStatus === "string" ? Number.parseInt(rawStatus, 10) : rawStatus;
const errorObject =
typeof errorData === "object" && errorData !== null
? (errorData as Record<string, unknown>)

View file

@ -160,9 +160,9 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
// token estimation entirely. Actual token usage comes from API response metadata.
let response: AIMessage;
{
const stream = (await withSuppressedTokenWarnings(() =>
const stream: AsyncIterable<AIMessageChunk> = await withSuppressedTokenWarnings(() =>
boundModel.stream(planningMessages)
)) as AsyncIterable<AIMessageChunk>;
);
let aggregated: AIMessageChunk | undefined;
for await (const chunk of stream) {
aggregated = aggregated ? aggregated.concat(chunk) : chunk;

View file

@ -95,11 +95,7 @@ export function formatSearchResultsForLLM(searchResults: any[]): string {
const title = doc.title || "Untitled";
const path = doc.path || "";
// Optional stable source id if provided by caller; fallback to order
const sourceId =
(doc as any).__sourceId ||
(doc as any).collection_name ||
(doc as any).source_id ||
idx + 1;
const sourceId = doc.__sourceId || doc.collection_name || doc.source_id || idx + 1;
// Safely handle mtime - check validity before converting
let modified: string | null = null;
@ -293,7 +289,7 @@ export function formatSplitSearchResultsForLLM(
if (filterDocs.length > 0) {
const filterXml = filterDocs
.map((doc: any) => {
const id = (doc as any).__sourceId || currentId++;
const id = doc.__sourceId || currentId++;
const title = doc.title || "Untitled";
const path = doc.path || "";
const matchType = doc.matchType || doc.source || "filter";
@ -318,7 +314,7 @@ ${doc.content || ""}
if (searchDocs.length > 0) {
const searchXml = searchDocs
.map((doc: any) => {
const id = (doc as any).__sourceId || currentId++;
const id = doc.__sourceId || currentId++;
const title = doc.title || "Untitled";
const path = doc.path || "";
const modified = toIsoString(doc.mtime);

View file

@ -445,7 +445,7 @@ export default class ChatModelManager {
let selectedProviderConfig =
providerConfig[customModel.provider as keyof typeof providerConfig] || {};
if (customModel.provider === ChatModelProviders.AMAZON_BEDROCK) {
if ((customModel.provider as ChatModelProviders) === ChatModelProviders.AMAZON_BEDROCK) {
selectedProviderConfig = await this.buildBedrockConfig(
customModel,
modelName,
@ -515,7 +515,7 @@ export default class ChatModelManager {
if (
modelInfo.isGPT5 &&
customModel?.verbosity &&
customModel?.provider !== ChatModelProviders.AZURE_OPENAI
(customModel?.provider as ChatModelProviders) !== ChatModelProviders.AZURE_OPENAI
) {
const verbosityValue = customModel.verbosity;
// For Responses API, verbosity is nested under 'text' parameter
@ -670,7 +670,7 @@ export default class ChatModelManager {
* @returns True when the provider requirements are satisfied, otherwise false.
*/
private hasProviderCredentials(model: CustomModel): boolean {
if (model.provider === ChatModelProviders.AMAZON_BEDROCK) {
if ((model.provider as ChatModelProviders) === ChatModelProviders.AMAZON_BEDROCK) {
const settings = getSettings();
const apiKey = model.apiKey || settings.amazonBedrockApiKey;
// Region defaults to us-east-1 if not specified, so API key is the only requirement
@ -789,8 +789,8 @@ export default class ChatModelManager {
const modelInfo = getModelInfo(model.name);
if (
modelInfo.isGPT5 &&
(model.provider === ChatModelProviders.OPENAI ||
model.provider === ChatModelProviders.OPENAI_FORMAT)
((model.provider as ChatModelProviders) === ChatModelProviders.OPENAI ||
(model.provider as ChatModelProviders) === ChatModelProviders.OPENAI_FORMAT)
) {
logInfo(`Chat model set with Responses API for GPT-5: ${model.name}`);
}
@ -809,7 +809,7 @@ export default class ChatModelManager {
}
if (!selectedModel.hasApiKey) {
const errorMessage = `API key is not provided for the model: ${modelKey}.`;
if (model.provider === ChatModelProviders.COPILOT_PLUS) {
if ((model.provider as ChatModelProviders) === ChatModelProviders.COPILOT_PLUS) {
throw new MissingPlusLicenseError(
"Copilot Plus license key is not configured. Please enter your license key in the Copilot Plus section at the top of Basic Settings."
);
@ -825,8 +825,8 @@ export default class ChatModelManager {
const useCopilotResponses = shouldUseGitHubCopilotResponsesApi(model);
if (
modelInfo.isGPT5 &&
(selectedModel.vendor === ChatModelProviders.OPENAI ||
selectedModel.vendor === ChatModelProviders.OPENAI_FORMAT)
((selectedModel.vendor as ChatModelProviders) === ChatModelProviders.OPENAI ||
(selectedModel.vendor as ChatModelProviders) === ChatModelProviders.OPENAI_FORMAT)
) {
constructorConfig.useResponsesApi = true;
logInfo(`Enabling Responses API for GPT-5 model: ${model.name} (${selectedModel.vendor})`);
@ -839,7 +839,10 @@ export default class ChatModelManager {
// For LM Studio, use ChatLMStudio by default for Responses API compatibility.
// Opt out by setting useResponsesApi to false.
if (model.provider === ChatModelProviders.LM_STUDIO && model.useResponsesApi !== false) {
if (
(model.provider as ChatModelProviders) === ChatModelProviders.LM_STUDIO &&
model.useResponsesApi !== false
) {
const lmStudioInstance = new ChatLMStudio(constructorConfig);
logInfo(`[ChatModelManager] Using Responses API for LM Studio model: ${model.name}`);
return lmStudioInstance;
@ -913,8 +916,8 @@ export default class ChatModelManager {
if (
modelInfo.isGPT5 &&
(model.provider === ChatModelProviders.OPENAI ||
model.provider === ChatModelProviders.OPENAI_FORMAT)
((model.provider as ChatModelProviders) === ChatModelProviders.OPENAI ||
(model.provider as ChatModelProviders) === ChatModelProviders.OPENAI_FORMAT)
) {
constructorConfig.useResponsesApi = true;
}
@ -926,7 +929,8 @@ export default class ChatModelManager {
// For LM Studio with Responses API, ping via ChatLMStudio so the
// connectivity check hits the same /v1/responses endpoint used in chats.
const testModel =
model.provider === ChatModelProviders.LM_STUDIO && model.useResponsesApi !== false
(model.provider as ChatModelProviders) === ChatModelProviders.LM_STUDIO &&
model.useResponsesApi !== false
? new ChatLMStudio(constructorConfig)
: useCopilotResponses
? new GitHubCopilotResponsesModel(constructorConfig)

View file

@ -123,7 +123,7 @@ export default class EmbeddingManager {
return emb.modelName as string;
} else {
throw new Error(
`Embeddings instance missing model or modelName properties: ${embeddingsInstance}`
`Embeddings instance missing model or modelName properties: ${JSON.stringify(embeddingsInstance)}`
);
}
}
@ -140,7 +140,7 @@ export default class EmbeddingManager {
const settings = getSettings();
const embeddingModelKey = settings.embeddingModelKey;
if (!EmbeddingManager.modelMap.hasOwnProperty(embeddingModelKey)) {
if (!Object.prototype.hasOwnProperty.call(EmbeddingManager.modelMap, embeddingModelKey)) {
throw new CustomError(`No embedding model found for: ${embeddingModelKey}`);
}

View file

@ -100,6 +100,7 @@ export class GitHubCopilotChatModel extends ChatOpenAICompletions {
const { fetchImplementation, configuration, apiKey, ...rest } = fields;
const provider = GitHubCopilotProvider.getInstance();
// scorecard: streaming requires fetch — cannot use requestUrl
const baseFetch = fetchImplementation ?? (configuration?.fetch as FetchImplementation) ?? fetch;
const authedFetch = GitHubCopilotChatModel.buildAuthedFetch(provider, baseFetch);
@ -164,6 +165,7 @@ export class GitHubCopilotChatModel extends ChatOpenAICompletions {
// each delta is a single-use streaming chunk.
delta.content = normalizeDeltaContent(delta.content);
// scorecard: kept for backwards compat with ChatOpenAICompletions
return super._convertCompletionsDeltaToBaseMessageChunk(delta, rawResponse, defaultRole);
}

View file

@ -107,7 +107,9 @@ export default class ProjectManager {
!nextProjects.some((p) => p.id === this.currentProjectId) &&
getCurrentProject()?.id === this.currentProjectId
) {
logWarn(`[ProjectManager] Active project id="${this.currentProjectId}" no longer exists, clearing selection`);
logWarn(
`[ProjectManager] Active project id="${this.currentProjectId}" no longer exists, clearing selection`
);
void this.switchProject(null).catch((err) =>
logError("[ProjectManager] Failed to switch away from removed project", err)
);

View file

@ -0,0 +1,19 @@
import { TFile, TFolder } from "obsidian";
/**
* Construct a TFile-typed mock from a partial spec. Uses the real TFile prototype
* so `instanceof TFile` returns true. Test helper keeps `as TFile` casts out of
* test files so the `no-restricted-syntax` rule stays clean.
*/
export function mockTFile<T extends Partial<TFile>>(props: T): TFile {
const file: TFile = Object.create(TFile.prototype);
Object.assign(file, props);
return file;
}
/** TFolder counterpart to mockTFile. */
export function mockTFolder<T extends Partial<TFolder>>(props: T): TFolder {
const folder: TFolder = Object.create(TFolder.prototype);
Object.assign(folder, props);
return folder;
}

View file

@ -1,7 +1,10 @@
import { CustomModel, useModelKey } from "@/aiParams";
import { processCommandPrompt } from "@/commands/customCommandUtils";
import { MenuCommandModal, type ContentState } from "@/components/command-ui";
import { MODAL_MIN_HEIGHT_COMPACT, MODAL_MIN_HEIGHT_EXPANDED } from "@/components/command-ui/constants";
import {
MODAL_MIN_HEIGHT_COMPACT,
MODAL_MIN_HEIGHT_EXPANDED,
} from "@/components/command-ui/constants";
import { SelectionHighlight } from "@/editor/selectionHighlight";
import { createHighlightReplaceGuard, type ReplaceGuard } from "@/editor/replaceGuard";
import { logError, logWarn } from "@/logger";
@ -520,7 +523,11 @@ export class CustomCommandChatModal {
* - Space checks use scrollRect (editor visible area), not window.
* - Horizontal clamp to scrollRect first, then viewport as safety net.
*/
private getInitialPosition(activeView: MarkdownView | null): { x: number; y: number; anchorBottom?: number } {
private getInitialPosition(activeView: MarkdownView | null): {
x: number;
y: number;
anchorBottom?: number;
} {
const win = this.resolveWindow(activeView);
const panelWidth = Math.min(500, win.innerWidth * 0.9);
// Reason: The actual initial panel height depends on whether ContentArea is shown.
@ -528,7 +535,9 @@ export class CustomCommandChatModal {
// Custom Commands always show ContentArea (expanded).
// Using the correct height prevents gaps (above) or overlaps (below).
const hideContentAreaOnIdle = this.configs.behaviorConfig?.hideContentAreaOnIdle ?? false;
const panelHeight = hideContentAreaOnIdle ? MODAL_MIN_HEIGHT_COMPACT : MODAL_MIN_HEIGHT_EXPANDED;
const panelHeight = hideContentAreaOnIdle
? MODAL_MIN_HEIGHT_COMPACT
: MODAL_MIN_HEIGHT_EXPANDED;
const margin = 12;
const gap = 6;
@ -583,8 +592,11 @@ export class CustomCommandChatModal {
(topCoords?.bottom ?? 0) - (topCoords?.top ?? 0),
(bottomCoords?.bottom ?? 0) - (bottomCoords?.top ?? 0)
);
const isVisualMultiLine = !isCursor && topCoords && bottomCoords
&& Math.abs(topCoords.top - bottomCoords.top) > Math.max(caretHeight / 2, 2);
const isVisualMultiLine =
!isCursor &&
topCoords &&
bottomCoords &&
Math.abs(topCoords.top - bottomCoords.top) > Math.max(caretHeight / 2, 2);
// --- Vertical positioning (decides placement first) ---
// Reason: Extracted to a pure helper (computeVerticalPlacement) so the

View file

@ -56,11 +56,13 @@ export class CustomCommandManager {
// Ensure nested folders are created cross-platform
await ensureFolderExists(folderPath);
let commandFile = app.vault.getAbstractFileByPath(filePath) as TFile;
if (!commandFile || !(commandFile instanceof TFile)) {
commandFile = await app.vault.create(filePath, command.content);
const existingFile = app.vault.getAbstractFileByPath(filePath);
let commandFile: TFile;
if (existingFile instanceof TFile) {
await app.vault.modify(existingFile, command.content);
commandFile = existingFile;
} else {
await app.vault.modify(commandFile, command.content);
commandFile = await app.vault.create(filePath, command.content);
}
await app.fileManager.processFrontMatter(commandFile, (frontmatter) => {

View file

@ -38,8 +38,9 @@ export class CustomCommandRegister {
/**
* Register all custom commands found in the custom commands folder.
* Synchronous: iterates cached commands and registers each.
*/
private async registerCommands() {
private registerCommands() {
const commands = getCachedCustomCommands();
commands.forEach((command) => {
this.registerCommand(command);

View file

@ -14,6 +14,7 @@ import { PromptSortStrategy } from "@/types";
import { sortSlashCommands } from "@/commands/customCommandUtils";
import { DEFAULT_SETTINGS } from "@/constants";
import { logWarn } from "@/logger";
import { mockTFile } from "@/__tests__/mockObsidian";
// Mock Obsidian
jest.mock("obsidian", () => ({
@ -64,10 +65,10 @@ describe("processedPrompt()", () => {
}),
},
} as unknown as Vault;
mockActiveNote = {
mockActiveNote = mockTFile({
path: "path/to/active/note.md",
basename: "Active Note",
} as TFile;
});
});
it("should add 1 context and selectedText", async () => {
@ -84,7 +85,7 @@ describe("processedPrompt()", () => {
(getFileContent as jest.Mock).mockResolvedValueOnce("here is the note content for note0");
(getFileName as jest.Mock).mockReturnValueOnce("Variable Note");
(getNotesFromPath as jest.Mock).mockResolvedValueOnce([mockActiveNote]);
(getNotesFromPath as jest.Mock).mockReturnValueOnce([mockActiveNote]);
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
@ -113,11 +114,11 @@ describe("processedPrompt()", () => {
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockReturnValueOnce("Variable1 Note").mockReturnValueOnce("Variable2 Note");
const mockNote1 = { path: "path/to/note1.md", basename: "Variable1 Note" } as TFile;
const mockNote2 = { path: "path/to/note2.md", basename: "Variable2 Note" } as TFile;
const mockNote1 = mockTFile({ path: "path/to/note1.md", basename: "Variable1 Note" });
const mockNote2 = mockTFile({ path: "path/to/note2.md", basename: "Variable2 Note" });
(getNotesFromPath as jest.Mock)
.mockResolvedValueOnce([mockNote1])
.mockResolvedValueOnce([mockNote2]);
.mockReturnValueOnce([mockNote1])
.mockReturnValueOnce([mockNote2]);
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
@ -228,13 +229,13 @@ describe("processedPrompt()", () => {
const selectedText = "";
// Mock note file for the tag
const mockNoteForTag = {
const mockNoteForTag = mockTFile({
path: "path/to/tagged/note.md",
basename: "Tagged Note",
} as TFile;
});
// Mock getNotesFromTags to return our mock note
(getNotesFromTags as jest.Mock).mockResolvedValue([mockNoteForTag]);
(getNotesFromTags as jest.Mock).mockReturnValue([mockNoteForTag]);
// Mock getFileName to return the basename
const { getFileName } = jest.requireMock("@/utils") as any;
@ -256,17 +257,17 @@ describe("processedPrompt()", () => {
const selectedText = "";
// Mock note files for the tags
const mockNoteForTag1 = {
const mockNoteForTag1 = mockTFile({
basename: "Tagged Note 1",
path: "path/to/tagged/note1.md",
} as TFile;
const mockNoteForTag2 = {
});
const mockNoteForTag2 = mockTFile({
basename: "Tagged Note 2",
path: "path/to/tagged/note2.md",
} as TFile;
});
// Mock getNotesFromTags to return our mock notes
(getNotesFromTags as jest.Mock).mockResolvedValue([mockNoteForTag1, mockNoteForTag2]);
(getNotesFromTags as jest.Mock).mockReturnValue([mockNoteForTag1, mockNoteForTag2]);
// Mock getFileName to return the basename
const { getFileName } = jest.requireMock("@/utils") as any;
@ -294,7 +295,7 @@ describe("processedPrompt()", () => {
it("should process [[note title]] syntax correctly", async () => {
const customPrompt = "Content of [[Test Note]] is important.";
const selectedText = "";
const mockTestNote = { basename: "Test Note", path: "Test Note.md" } as TFile;
const mockTestNote = mockTFile({ basename: "Test Note", path: "Test Note.md" });
// Mock the necessary functions
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockTestNote]);
@ -316,10 +317,10 @@ describe("processedPrompt()", () => {
const selectedText = "";
// Mock the necessary functions
const mockNoteFile = {
const mockNoteFile = mockTFile({
basename: "Test Note",
path: "Test Note.md",
} as TFile;
});
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockNoteFile]);
@ -329,7 +330,7 @@ describe("processedPrompt()", () => {
(getFileContent as jest.Mock).mockResolvedValue("Test note content");
// Mock getNotesFromPath to return our mock note
(getNotesFromPath as jest.Mock).mockResolvedValue([mockNoteFile]);
(getNotesFromPath as jest.Mock).mockReturnValue([mockNoteFile]);
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
@ -353,10 +354,10 @@ describe("processedPrompt()", () => {
const selectedText = "";
// Mock the necessary functions
const mockNote1 = {
const mockNote1 = mockTFile({
basename: "Note1",
path: "Note1.md",
} as TFile;
});
// Only Note1 should be extracted since it's wrapped in {[[]]}
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockNote1]);
@ -372,7 +373,7 @@ describe("processedPrompt()", () => {
});
// Mock getNotesFromPath to return our mock note
(getNotesFromPath as jest.Mock).mockResolvedValue([mockNote1]);
(getNotesFromPath as jest.Mock).mockReturnValue([mockNote1]);
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
@ -394,13 +395,13 @@ describe("processedPrompt()", () => {
it("should handle multiple occurrences of {[[note title]]} syntax", async () => {
const customPrompt = "{[[Note1]]} is related to {[[Note2]]} and {[[Note3]]}.";
const selectedText = "";
const mockNote1 = { basename: "Note1", path: "Note1.md" } as TFile;
const mockNote2 = { basename: "Note2", path: "Note2.md" } as TFile;
const mockNote3 = { basename: "Note3", path: "Note3.md" } as TFile;
const mockNote1 = mockTFile({ basename: "Note1", path: "Note1.md" });
const mockNote2 = mockTFile({ basename: "Note2", path: "Note2.md" });
const mockNote3 = mockTFile({ basename: "Note3", path: "Note3.md" });
// Mock the necessary functions
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockNote1, mockNote2, mockNote3]);
(getNotesFromPath as jest.Mock).mockResolvedValue([]);
(getNotesFromPath as jest.Mock).mockReturnValue([]);
(getFileContent as jest.Mock).mockImplementation((file: TFile) => {
if (file.basename === "Note1") {
return "Note1 content";

View file

@ -166,7 +166,7 @@ export function sortCommandsByAlphabetical(commands: CustomCommand[]): CustomCom
* Sort prompts of the slash commands based on the sort strategy.
*/
export function sortSlashCommands(commands: CustomCommand[]): CustomCommand[] {
const sortStrategy = getSettings().promptSortStrategy;
const sortStrategy: PromptSortStrategy = getSettings().promptSortStrategy as PromptSortStrategy;
switch (sortStrategy) {
case PromptSortStrategy.TIMESTAMP:
return sortCommandsByRecency(commands);
@ -295,7 +295,7 @@ async function extractVariablesFromPrompt(
.slice(1)
.split(",")
.map((tag) => tag.trim());
const noteFiles = await getNotesFromTags(vault, tagNames);
const noteFiles = getNotesFromTags(vault, tagNames);
const notesContent: string[] = [];
for (const file of noteFiles) {
const content = await getFileContent(file, vault);
@ -309,7 +309,7 @@ async function extractVariablesFromPrompt(
variableResult.content = notesContent.join("\n\n");
} else {
const processedVariableName = processVariableNameForNotePath(variableName);
const noteFiles = await getNotesFromPath(vault, processedVariableName);
const noteFiles = getNotesFromPath(vault, processedVariableName);
const notesContent: string[] = [];
for (const file of noteFiles) {
const content = await getFileContent(file, vault);

View file

@ -1,6 +1,7 @@
import { processPrompt } from "@/commands/customCommandUtils";
import { TFile, Vault } from "obsidian";
import { getFileContent, getFileName, getNotesFromPath } from "@/utils";
import { mockTFile } from "@/__tests__/mockObsidian";
// Mock the dependencies
jest.mock("@/utils", () => ({
@ -35,10 +36,10 @@ describe("XML Escaping in processPrompt", () => {
},
} as unknown as Vault;
mockActiveNote = {
mockActiveNote = mockTFile({
path: "path/to/active/note.md",
basename: "Active Note",
} as TFile;
});
});
it("should NOT escape XML special characters in selected text", async () => {
@ -59,12 +60,12 @@ describe("XML Escaping in processPrompt", () => {
it("should NOT escape XML in variable names", async () => {
const customPrompt = 'Use {my"variable<>}';
const mockNote = {
const mockNote = mockTFile({
basename: 'Note with <special> & "chars"',
path: "special.md",
} as TFile;
});
(getNotesFromPath as jest.Mock).mockResolvedValue([mockNote]);
(getNotesFromPath as jest.Mock).mockReturnValue([mockNote]);
(getFileName as jest.Mock).mockReturnValue(mockNote.basename);
(getFileContent as jest.Mock).mockResolvedValue('Content with <xml> & special "chars"');
@ -102,12 +103,12 @@ describe("XML Escaping in processPrompt", () => {
it("should NOT escape XML in note paths and metadata", async () => {
const customPrompt = "[[Special Note]]";
const mockNote = {
const mockNote = mockTFile({
basename: 'Special & "Note"',
path: 'folder<with>/special&chars/"note".md',
} as TFile;
});
(getNotesFromPath as jest.Mock).mockResolvedValue([]);
(getNotesFromPath as jest.Mock).mockReturnValue([]);
jest.requireMock("@/utils").extractTemplateNoteFiles.mockReturnValue([mockNote]);
(getFileContent as jest.Mock).mockResolvedValue("Content with & and < and >");
@ -126,13 +127,13 @@ describe("XML Escaping in processPrompt", () => {
it("should NOT escape XML in tag variables", async () => {
const customPrompt = "Notes for {#tag&special}";
const mockNote = {
const mockNote = mockTFile({
basename: 'Tagged & "Note"',
path: "tagged.md",
} as TFile;
});
(getNotesFromPath as jest.Mock).mockResolvedValue([]);
jest.requireMock("@/utils").getNotesFromTags.mockResolvedValue([mockNote]);
(getNotesFromPath as jest.Mock).mockReturnValue([]);
jest.requireMock("@/utils").getNotesFromTags.mockReturnValue([mockNote]);
(getFileName as jest.Mock).mockReturnValue(mockNote.basename);
(getFileContent as jest.Mock).mockResolvedValue("Content: <tag> & </tag>");

View file

@ -34,7 +34,7 @@ import { COMMAND_IDS, COMMAND_ICONS, COMMAND_NAMES, CommandId } from "../constan
import { setSelectedTextContexts } from "@/aiParams";
/**
* Add a command to the plugin.
* Add a command to the plugin. Supports async callbacks; errors are logged.
*/
export function addCommand(plugin: CopilotPlugin, id: CommandId, callback: () => void) {
plugin.addCommand({
@ -83,7 +83,7 @@ export function registerCommands(
next: CopilotSettings
) {
addEditorCommand(plugin, COMMAND_IDS.COUNT_WORD_AND_TOKENS_SELECTION, async (editor: Editor) => {
const selectedText = await editor.getSelection();
const selectedText = editor.getSelection();
const wordCount = selectedText.split(" ").length;
const tokenCount = await plugin.projectManager
.getCurrentChainManager()
@ -108,13 +108,13 @@ export function registerCommands(
plugin.toggleView();
});
addCommand(plugin, COMMAND_IDS.OPEN_COPILOT_CHAT_WINDOW, () => {
plugin.activateView();
addCommand(plugin, COMMAND_IDS.OPEN_COPILOT_CHAT_WINDOW, async () => {
await plugin.activateView();
});
addCommand(plugin, COMMAND_IDS.NEW_CHAT, () => {
addCommand(plugin, COMMAND_IDS.NEW_CHAT, async () => {
clearRecordedPromptPayload();
plugin.newChat();
await plugin.newChat();
});
// Quick Command - opens a modal dialog for quick interactions
@ -296,8 +296,8 @@ export function registerCommands(
}
});
addCommand(plugin, COMMAND_IDS.LOAD_COPILOT_CHAT_CONVERSATION, () => {
plugin.loadCopilotChatHistory();
addCommand(plugin, COMMAND_IDS.LOAD_COPILOT_CHAT_CONVERSATION, async () => {
await plugin.loadCopilotChatHistory();
});
addCommand(plugin, COMMAND_IDS.LIST_INDEXED_FILES, async () => {
@ -377,16 +377,16 @@ export function registerCommands(
await ensureFolderExists(folderPath);
const existingFile = plugin.app.vault.getAbstractFileByPath(filePath);
if (existingFile) {
await plugin.app.vault.modify(existingFile as TFile, content);
if (existingFile instanceof TFile) {
await plugin.app.vault.modify(existingFile, content);
} else {
await plugin.app.vault.create(filePath, content);
}
// Open the file
const file = plugin.app.vault.getAbstractFileByPath(filePath);
if (file) {
await plugin.app.workspace.getLeaf().openFile(file as TFile);
if (file instanceof TFile) {
await plugin.app.workspace.getLeaf().openFile(file);
new Notice(`Listed ${indexedFiles.size} indexed files`);
}
} catch (error) {
@ -449,15 +449,15 @@ export function registerCommands(
await ensureFolderExists(folderPath);
const existingFile = plugin.app.vault.getAbstractFileByPath(filePath);
if (existingFile) {
await plugin.app.vault.modify(existingFile as TFile, content);
if (existingFile instanceof TFile) {
await plugin.app.vault.modify(existingFile, content);
} else {
await plugin.app.vault.create(filePath, content);
}
const file = plugin.app.vault.getAbstractFileByPath(filePath);
if (file) {
await plugin.app.workspace.getLeaf().openFile(file as TFile);
if (file instanceof TFile) {
await plugin.app.workspace.getLeaf().openFile(file);
new Notice(`Embedding debug info for ${chunks.length} chunk(s)`);
}
} catch (error) {
@ -546,7 +546,7 @@ export function registerCommands(
setSelectedTextContexts([selectedTextContext]);
// Open chat window to show the context was added
plugin.activateView();
await plugin.activateView();
});
// Add web selection to chat context command (manual)

View file

@ -93,7 +93,7 @@ export async function generateDefaultCommands(): Promise<void> {
(command) => !existingCommands.some((c) => c.title === command.title)
);
const newCommands = [...existingCommands, ...defaultCommands];
CustomCommandManager.getInstance().updateCommands(newCommands);
await CustomCommandManager.getInstance().updateCommands(newCommands);
}
/** Suggests the default commands if the user has not created any commands yet. */

View file

@ -94,7 +94,7 @@ export default class CopilotView extends ItemView {
this.keyboardObserver?.disconnect();
const syncKeyboardClass = () => {
const drawer = this.containerEl.closest(".workspace-drawer") as HTMLElement | null;
const drawer = this.containerEl.closest<HTMLElement>(".workspace-drawer");
// Reason: If the view moved out of its previous drawer, clear the class on the old one
// so drawer chrome (header/tab options) is restored.
@ -138,7 +138,7 @@ export default class CopilotView extends ItemView {
this.drawerHideObserver?.disconnect();
const drawer = this.containerEl.closest(".workspace-drawer") as HTMLElement | null;
const drawer = this.containerEl.closest<HTMLElement>(".workspace-drawer");
if (!drawer) return;
let wasHidden = drawer.classList.contains("is-hidden");

View file

@ -36,7 +36,7 @@ jest.mock("@/LLMProviders/chainRunner/utils/citationUtils", () => ({
}));
jest.mock("obsidian", () => {
const renderMarkdown = jest.fn();
const renderMarkdown = jest.fn().mockResolvedValue(undefined);
return {
MarkdownRenderer: {
renderMarkdown,
@ -93,6 +93,7 @@ describe("think block rendering — closing tags are not consumed by indented co
beforeEach(() => {
renderMarkdownMock.mockReset();
renderMarkdownMock.mockResolvedValue(undefined);
});
beforeAll(() => {
@ -122,7 +123,7 @@ describe("think block rendering — closing tags are not consumed by indented co
const messageText = `<think>${thinkContent}</think>Here is my answer.`;
const capturedMarkdown: string[] = [];
renderMarkdownMock.mockImplementation((md: string, el: HTMLElement) => {
renderMarkdownMock.mockImplementation(async (md: string, el: HTMLElement) => {
capturedMarkdown.push(md);
el.innerHTML = "<p>rendered</p>";
});
@ -148,7 +149,7 @@ describe("think block rendering — closing tags are not consumed by indented co
const messageText = `<think>${thinkContent}</think>Response text.`;
const capturedMarkdown: string[] = [];
renderMarkdownMock.mockImplementation((md: string, el: HTMLElement) => {
renderMarkdownMock.mockImplementation(async (md: string, el: HTMLElement) => {
capturedMarkdown.push(md);
el.innerHTML = "<p>rendered</p>";
});
@ -174,7 +175,7 @@ describe("think block rendering — closing tags are not consumed by indented co
const messageText = "<think>Thinking:\n * Still streaming.";
const capturedMarkdown: string[] = [];
renderMarkdownMock.mockImplementation((md: string, el: HTMLElement) => {
renderMarkdownMock.mockImplementation(async (md: string, el: HTMLElement) => {
capturedMarkdown.push(md);
el.innerHTML = "<p>rendered</p>";
});
@ -199,6 +200,7 @@ describe("think block rendering — closing tags are not consumed by indented co
describe("normalizeFootnoteRendering", () => {
beforeEach(() => {
renderMarkdownMock.mockReset();
renderMarkdownMock.mockResolvedValue(undefined);
});
it("removes separator and backref while preserving non-footnote elements", () => {
@ -263,6 +265,7 @@ describe("ChatSingleMessage", () => {
beforeEach(() => {
renderMarkdownMock.mockReset();
renderMarkdownMock.mockResolvedValue(undefined);
});
beforeAll(() => {
@ -270,7 +273,7 @@ describe("ChatSingleMessage", () => {
});
it("normalizes rendered footnotes for assistant messages", async () => {
renderMarkdownMock.mockImplementation((_markdown: string, el: HTMLElement) => {
renderMarkdownMock.mockImplementation(async (_markdown: string, el: HTMLElement) => {
el.innerHTML = `
<p>Example <sup><a href="#fn-2">2-1</a></sup></p>
<hr class="content-hr" />

View file

@ -650,7 +650,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
const reasoningBlockData = parseReasoningBlock(originMessage);
if (reasoningBlockData?.hasReasoning && reasoningBlockData.status !== "idle") {
setReasoningData({
status: reasoningBlockData.status as "reasoning" | "collapsed" | "complete",
status: reasoningBlockData.status,
elapsedSeconds: reasoningBlockData.elapsedSeconds,
steps: reasoningBlockData.steps,
});
@ -726,7 +726,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
messageId.current,
rootsRef.current,
toolCallId,
container as HTMLElement,
container,
"render refresh"
);
@ -760,7 +760,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
messageId.current,
errorRootsRef.current,
errorId,
container as HTMLElement,
container,
"error render"
);

View file

@ -49,8 +49,8 @@ export class ChatViewLayout {
const syncClearance = () => {
// Re-query each time to avoid stale references after theme reloads.
const statusBar = document.querySelector(".status-bar") as HTMLElement | null;
const viewContent = this.containerEl.querySelector(".view-content") as HTMLElement | null;
const statusBar = document.querySelector<HTMLElement>(".status-bar");
const viewContent = this.containerEl.querySelector<HTMLElement>(".view-content");
if (!statusBar || !viewContent) return;
// Zero out clearance and force reflow to measure natural overlap.

View file

@ -32,7 +32,7 @@ export const InlineMessageEditor: React.FC<InlineMessageEditorProps> = ({
// Convert initialContext to the format expected by ChatInput
const [contextNotes, setContextNotes] = useState<TFile[]>(
initialContext?.notes?.map((note) => note as TFile) || []
initialContext?.notes?.filter((note): note is TFile => note instanceof TFile) || []
);
const [includeActiveNote, setIncludeActiveNote] = useState(false);
const [includeActiveWebTab, setIncludeActiveWebTab] = useState(false);

View file

@ -2,6 +2,7 @@ import React from "react";
import { render } from "@testing-library/react";
import { ChatMessage } from "@/types/message";
import { TFile } from "obsidian";
import { mockTFile } from "@/__tests__/mockObsidian";
// Mock Tooltip components
jest.mock("@radix-ui/react-tooltip", () => ({
@ -45,11 +46,11 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
describe("MessageContext", () => {
const createMockFile = (path: string, basename: string): TFile =>
({
mockTFile({
path,
basename,
// Add other required TFile properties as needed
}) as TFile;
});
describe("Duplicate Notes Bug Prevention", () => {
it("should render duplicate notes without React key conflicts", () => {

View file

@ -141,7 +141,9 @@ function ProjectItem({
if (fileInCache) {
app.workspace.openLinkText(record.filePath, "", true);
} else {
new Notice("Project file is in a hidden folder and cannot be opened from the file explorer.");
new Notice(
"Project file is in a hidden folder and cannot be opened from the file explorer."
);
}
}
}}

View file

@ -31,8 +31,8 @@ export function KeyboardPlugin({ onSubmit, sendShortcut }: KeyboardPluginProps)
// Ignore Enter key during IME composition (e.g., Chinese, Japanese, Korean input).
// event.isComposing is set by the browser while a composition session is active.
// keyCode 229 is the legacy indicator used by some environments during IME input.
if (event.isComposing || event.keyCode === 229) {
// key "Process" is the standard indicator used during IME input.
if (event.isComposing || event.key === "Process") {
event.preventDefault();
return true;
}

View file

@ -36,18 +36,12 @@ export function PastePlugin({ enableURLPills = false, onImagePaste }: PastePlugi
if (imageItems.length > 0) {
event.preventDefault();
// Handle image processing asynchronously
Promise.all(
imageItems.map((item) => {
const file = item.getAsFile();
return file;
})
).then((files) => {
const validFiles = files.filter((file) => file !== null);
if (validFiles.length > 0) {
onImagePaste(validFiles);
}
});
// getAsFile returns synchronously; no Promise needed.
const files = imageItems.map((item) => item.getAsFile());
const validFiles = files.filter((file): file is File => file !== null);
if (validFiles.length > 0) {
onImagePaste(validFiles);
}
return true;
}

View file

@ -74,8 +74,7 @@ export function $createPillNode(pillData: PillData) {
case "webTabs":
// WebTabContext has url, title, faviconUrl
if (data && typeof data === "object" && "url" in data) {
const webTab = data as WebTabContext;
return $createWebTabPillNode(webTab.url, webTab.title, webTab.faviconUrl);
return $createWebTabPillNode(data.url, data.title, data.faviconUrl);
}
break;
case "activeWebTab":

View file

@ -47,12 +47,7 @@ export function ActionButtons({
{state === "result" && showInsertReplace && (
<>
{onCopy && (
<Button
size="sm"
variant="secondary"
onClick={onCopy}
title="Copy to clipboard"
>
<Button size="sm" variant="secondary" onClick={onCopy} title="Copy to clipboard">
Copy
</Button>
)}

View file

@ -55,10 +55,8 @@ export function ContentArea({
const [isEditMode, setIsEditMode] = React.useState(false);
// Determine if we should show the markdown preview
const isCompletedResult =
state.type === "result" && !state.isStreaming;
const showPreview =
!!renderMarkdown && isCompletedResult && !isEditMode;
const isCompletedResult = state.type === "result" && !state.isStreaming;
const showPreview = !!renderMarkdown && isCompletedResult && !isEditMode;
// Reason: Reset to preview mode whenever a new generation starts.
// This covers both type transitions (idle→loading) and same-type transitions
@ -94,7 +92,8 @@ export function ContentArea({
// Markdown preview mode
if (showPreview && renderMarkdown) {
const previewContent = (editable && value !== undefined) ? value : (state as { text: string }).text;
const previewContent =
editable && value !== undefined ? value : (state as { text: string }).text;
return (
<div className={cn("tw-flex tw-min-h-0 tw-flex-1 tw-flex-col tw-px-4 tw-py-2", className)}>
<div className="tw-relative tw-min-h-0 tw-flex-1 tw-overflow-auto tw-rounded-md tw-border tw-border-solid tw-px-3 tw-py-2">

View file

@ -56,7 +56,13 @@ export function DraggableModal({
closeOnEscapeFromOutside = false,
anchorBottom,
}: DraggableModalProps) {
const { position, setPosition, dragRef, handleMouseDown: rawHandleMouseDown, isDragging } = useDraggable({
const {
position,
setPosition,
dragRef,
handleMouseDown: rawHandleMouseDown,
isDragging,
} = useDraggable({
initialPosition: initialPosition || {
x: typeof window !== "undefined" ? (window.innerWidth - 500) / 2 : 100,
y: typeof window !== "undefined" ? (window.innerHeight - 400) / 2 : 100,

View file

@ -37,12 +37,11 @@ export function FollowUpInput({
}: FollowUpInputProps) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Avoid submitting when Enter is used to confirm IME composition (e.g., Chinese/Japanese/Korean).
// keyCode 229 is a legacy indicator for IME processing.
// key "Process" is the standard indicator for IME processing.
const nativeEvent = e.nativeEvent as KeyboardEvent & {
isComposing?: boolean;
keyCode?: number;
};
if (nativeEvent.isComposing || nativeEvent.keyCode === 229) {
if (nativeEvent.isComposing || e.key === "Process") {
return;
}

View file

@ -20,11 +20,7 @@ import { ProjectContextBadgeList } from "@/components/project/ProjectContextBadg
import { getModelKeyFromModel, useSettingsValue } from "@/settings/model";
import { checkModelApiKey, err2String, randomUUID } from "@/utils";
import { Settings } from "lucide-react";
import {
type UrlItem,
parseProjectUrls,
serializeProjectUrls,
} from "@/utils/urlTagUtils";
import { type UrlItem, parseProjectUrls, serializeProjectUrls } from "@/utils/urlTagUtils";
import type CopilotPlugin from "@/main";
import { App, Modal, Notice } from "obsidian";
import React, { useMemo, useState } from "react";
@ -91,8 +87,6 @@ function AddProjectModalContent({
contextSource: formData.contextSource,
});
const handleEditProjectContext = (projectDraft: ProjectConfig) => {
const modal = new ContextManageModal(
app,
@ -211,9 +205,7 @@ function AddProjectModalContent({
const saveData = { ...formData, name: trimmedName };
const requiredFields = ["name", "projectModelKey"];
const missingFields = requiredFields.filter(
(field) => !saveData[field as keyof ProjectConfig]
);
const missingFields = requiredFields.filter((field) => !saveData[field as keyof ProjectConfig]);
if (missingFields.length > 0) {
setTouched((prev) => ({

View file

@ -1328,8 +1328,7 @@ function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageP
}
onOpenCached={
// Reason: only offer open for non-markdown files that have been processed
!item.isIgnored &&
item.id.split(".").pop()?.toLowerCase() !== "md"
!item.isIgnored && item.id.split(".").pop()?.toLowerCase() !== "md"
? () => {
const name = item.name || item.id.split("/").pop() || item.id;
openCachedProjectFile(app, projectCache, item.id, name);

View file

@ -65,7 +65,11 @@ export function buildBadgeItems(value: string | undefined): BadgeItem[] {
* Remove a pattern from a serialized pattern string.
* Returns the new serialized string with the pattern removed.
*/
export function removePattern(value: string | undefined, pattern: string, type: PatternType): string {
export function removePattern(
value: string | undefined,
pattern: string,
type: PatternType
): string {
const patterns = [...new Set(getDecodedPatterns(value || ""))];
const categorized = categorizePatterns(patterns);
const categoryKey = CATEGORY_MAP[type];
@ -171,9 +175,7 @@ export const ProjectContextBadgeList: React.FC<ProjectContextBadgeListProps> = (
<div className="tw-rounded-md tw-border tw-border-dashed tw-border-border tw-py-4 tw-text-center">
<span className="tw-text-sm tw-italic tw-text-muted">No file patterns configured</span>
</div>
{actionSlot && (
<div className="tw-flex tw-justify-end">{actionSlot}</div>
)}
{actionSlot && <div className="tw-flex tw-justify-end">{actionSlot}</div>}
</div>
);
}

View file

@ -8,11 +8,7 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Progress } from "@/components/ui/progress";
import { TruncatedText } from "@/components/TruncatedText";
import type { ProcessingItem } from "@/components/project/processingAdapter";
@ -273,9 +269,7 @@ export function ProcessingStatus({
key={item.id}
item={item}
onRetry={onRetry}
onOpenCached={
onOpenCachedItem ? () => onOpenCachedItem(item) : undefined
}
onOpenCached={onOpenCachedItem ? () => onOpenCachedItem(item) : undefined}
/>
))}
</ScrollableList>
@ -297,12 +291,8 @@ export function ProcessingStatus({
key={item.id}
item={item}
onRetry={onRetry}
onOpenCached={
onOpenCachedItem ? () => onOpenCachedItem(item) : undefined
}
onRemove={
onRemoveUrl ? () => onRemoveUrl(item) : undefined
}
onOpenCached={onOpenCachedItem ? () => onOpenCachedItem(item) : undefined}
onRemove={onRemoveUrl ? () => onRemoveUrl(item) : undefined}
/>
))}
</ScrollableList>
@ -321,13 +311,7 @@ export function ProcessingStatus({
* Scroll container with a bottom fade mask when content overflows.
* Reuses the existing `.copilot-fade-mask-bottom` CSS class from PatternListEditor.
*/
function ScrollableList({
maxHeight,
children,
}: {
maxHeight: string;
children: React.ReactNode;
}) {
function ScrollableList({ maxHeight, children }: { maxHeight: string; children: React.ReactNode }) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [isOverflowing, setIsOverflowing] = useState(false);
@ -379,10 +363,7 @@ function ProcessingItemRow({
const isProcessing = item.status === "processing";
const isFailed = item.status === "failed";
// Reason: show open button for any item that is ready with actual content (file or URL)
const canOpenCached =
onOpenCached &&
item.status === "ready" &&
!item.contentEmpty;
const canOpenCached = onOpenCached && item.status === "ready" && !item.contentEmpty;
return (
<div className="tw-group tw-rounded-md tw-border tw-border-border tw-bg-primary tw-p-2.5">

View file

@ -86,7 +86,18 @@ export interface BuildProcessingItemsOptions {
// ---------------------------------------------------------------------------
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "tiff"]);
const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "m4a", "ogg", "flac", "aac", "mp4", "mpeg", "mpga", "webm"]);
const AUDIO_EXTENSIONS = new Set([
"mp3",
"wav",
"m4a",
"ogg",
"flac",
"aac",
"mp4",
"mpeg",
"mpga",
"webm",
]);
/** Infer file type from a file path's extension. */
function inferFileType(path: string): ProcessingItem["fileType"] {
@ -112,7 +123,8 @@ function extractName(key: string): string {
const pathAndQuery = urlObj.pathname + urlObj.search;
if (pathAndQuery && pathAndQuery !== "/") {
const maxLen = 30;
const shortPath = pathAndQuery.length > maxLen ? pathAndQuery.slice(0, maxLen) + "..." : pathAndQuery;
const shortPath =
pathAndQuery.length > maxLen ? pathAndQuery.slice(0, maxLen) + "..." : pathAndQuery;
return hostname + shortPath;
}
return hostname;
@ -131,12 +143,14 @@ function extractName(key: string): string {
function parseUrlConfig(raw?: string): string[] {
if (!raw) return [];
// Reason: Deduplicate to prevent duplicate React keys and duplicate status rows
return [...new Set(
raw
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
)];
return [
...new Set(
raw
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
),
];
}
/**
@ -237,15 +251,11 @@ function resolveNonCurrentProjectStatus(
* This avoids depending on liveState.total which may be stale or empty for
* non-current projects.
*/
export function buildProcessingItems(options: BuildProcessingItemsOptions): ProcessingAdapterResult {
const {
project,
isCurrentProject,
liveState,
projectCache,
projectFiles,
supportedExtensions,
} = options;
export function buildProcessingItems(
options: BuildProcessingItemsOptions
): ProcessingAdapterResult {
const { project, isCurrentProject, liveState, projectCache, projectFiles, supportedExtensions } =
options;
// Pre-process live state sets for O(1) lookups
const successSet = new Set(liveState.success);

View file

@ -4,11 +4,7 @@ import { FailedItem, getCurrentProject, useProjectContextLoad } from "@/aiParams
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
AlertCircle,
ChevronDown,
@ -67,8 +63,7 @@ export default function ProgressCard({ plugin, setHiddenCard, onEditContext }: P
// Reason: determine whether the detail section has any content to show.
// Without this, the "View Details" trigger would expand to empty space.
const hasDetailContent =
(processingData && processingData.items.length > 0) || hasMdItems;
const hasDetailContent = (processingData && processingData.items.length > 0) || hasMdItems;
/** Retry a failed conversion item via processingData.failedItemMap. */
const handleRetry = (itemId: string) => {
@ -153,8 +148,7 @@ export default function ProgressCard({ plugin, setHiddenCard, onEditContext }: P
<span className="tw-text-xs tw-text-muted">
(Success:{" "}
<span className="tw-font-medium tw-text-success">{successFiles.length}</span>,
Failed:{" "}
<span className="tw-font-medium tw-text-error">{failedFiles.length}</span>)
Failed: <span className="tw-font-medium tw-text-error">{failedFiles.length}</span>)
</span>
</div>
<span className="tw-font-medium">

View file

@ -137,7 +137,14 @@ export function useProjectProcessingData(
projectFiles,
supportedExtensions,
});
}, [displayProject, isCurrentProject, contextLoadState, projectCache, projectFiles, supportedExtensions]);
}, [
displayProject,
isCurrentProject,
contextLoadState,
projectCache,
projectFiles,
supportedExtensions,
]);
return { processingData, projectCache, isCurrentProject };
}

View file

@ -95,7 +95,9 @@ export const QuickAskMessageComponent = React.memo(function QuickAskMessageCompo
if (message.role === "user") {
return (
<div className="tw-max-w-[85%] tw-self-end tw-rounded-lg tw-rounded-br-sm tw-bg-interactive-accent tw-px-3 tw-py-2 tw-text-on-accent">
<div data-quick-ask-selectable className="tw-whitespace-pre-wrap tw-break-words tw-text-sm">{message.content}</div>
<div data-quick-ask-selectable className="tw-whitespace-pre-wrap tw-break-words tw-text-sm">
{message.content}
</div>
</div>
);
}
@ -104,7 +106,10 @@ export const QuickAskMessageComponent = React.memo(function QuickAskMessageCompo
if (isStreaming) {
return (
<div className="tw-max-w-[95%] tw-self-start tw-rounded-lg tw-rounded-bl-sm tw-bg-secondary tw-px-3 tw-py-2">
<div data-quick-ask-selectable className="tw-whitespace-pre-wrap tw-break-words tw-text-sm tw-text-normal">
<div
data-quick-ask-selectable
className="tw-whitespace-pre-wrap tw-break-words tw-text-sm tw-text-normal"
>
{message.content}
<span className="tw-animate-pulse tw-text-accent"></span>
</div>

View file

@ -97,7 +97,11 @@ export class QuickAskOverlay {
* @param bottomAnchorPos - Bottom anchor (normalized selection.to) for "place below"
* @param topAnchorPos - Top anchor (selection.from) for "place above" flip target
*/
mount(bottomAnchorPos: number, topAnchorPos?: number | null, focusAnchorPos?: number | null): void {
mount(
bottomAnchorPos: number,
topAnchorPos?: number | null,
focusAnchorPos?: number | null
): void {
this.bottomAnchorPos = bottomAnchorPos;
this.topAnchorPos = typeof topAnchorPos === "number" ? topAnchorPos : null;
this.focusAnchorPos = typeof focusAnchorPos === "number" ? focusAnchorPos : null;
@ -477,7 +481,7 @@ export class QuickAskOverlay {
this.placementSide = "below";
} else if (topRect) {
const aboveY = topRect.top - hostRect.top - PANEL_OFFSET_Y - heightForClamp;
const spaceAbove = (topRect.top - hostRect.top) - PANEL_OFFSET_Y - visibleTop;
const spaceAbove = topRect.top - hostRect.top - PANEL_OFFSET_Y - visibleTop;
if (spaceAbove >= heightForClamp + PANEL_MARGIN) {
top = aboveY;
@ -497,7 +501,7 @@ export class QuickAskOverlay {
} else if (topRect) {
// Bottom anchor not visible (selection extends below viewport): place above topRect
const aboveY = topRect.top - hostRect.top - PANEL_OFFSET_Y - heightForClamp;
const spaceAbove = (topRect.top - hostRect.top) - PANEL_OFFSET_Y - visibleTop;
const spaceAbove = topRect.top - hostRect.top - PANEL_OFFSET_Y - visibleTop;
if (spaceAbove >= heightForClamp + PANEL_MARGIN) {
top = aboveY;

View file

@ -218,7 +218,7 @@ export function ModelParametersEditor({
(opt) =>
opt.value !== ReasoningEffort.MINIMAL && opt.value !== ReasoningEffort.XHIGH
),
...(model.name.startsWith("gpt-5.5") && model.provider === "openai"
...(model.name.startsWith("gpt-5.4") && model.provider === "openai"
? [{ value: ReasoningEffort.XHIGH, label: "Extra High" }]
: []),
]}
@ -235,8 +235,8 @@ export function ModelParametersEditor({
<li>Low: Faster responses, basic reasoning (default)</li>
<li>Medium: Balanced performance</li>
<li>High: Thorough reasoning, slower responses</li>
{model.name.startsWith("gpt-5.5") && model.provider === "openai" && (
<li>Extra High: Maximum reasoning depth (GPT-5.5 only)</li>
{model.name.startsWith("gpt-5.4") && model.provider === "openai" && (
<li>Extra High: Maximum reasoning depth (GPT-5.4 only)</li>
)}
</ul>
{!hasReasoningCapability && !isOpenAIReasoningModel && (

View file

@ -10,14 +10,16 @@
*/
import { Button } from "@/components/ui/button";
import {
type UrlItem,
detectUrlType,
isValidUrl,
} from "@/utils/urlTagUtils";
import { type UrlItem, detectUrlType, isValidUrl } from "@/utils/urlTagUtils";
import { TruncatedText } from "@/components/TruncatedText";
import { ClipboardPaste, Globe, Link, X, Youtube } from "lucide-react";
import React, { useCallback, useRef, useState, type ClipboardEvent, type KeyboardEvent } from "react";
import React, {
useCallback,
useRef,
useState,
type ClipboardEvent,
type KeyboardEvent,
} from "react";
interface UrlTagInputProps {
urls: UrlItem[];

View file

@ -48,7 +48,7 @@ export class PromptContextEngine {
const debugLabel =
typeof params.metadata?.["debugLabel"] === "string"
? (params.metadata["debugLabel"] as string)
? params.metadata["debugLabel"]
: undefined;
if (debugLabel) {

View file

@ -101,7 +101,7 @@ export class ContextProcessor {
const match = matches[i];
const queryType = match[1]; // 'dataview' or 'dataviewjs'
const query = match[2].trim();
const matchStart = match.index!;
const matchStart = match.index;
const matchEnd = matchStart + match[0].length;
try {
@ -112,7 +112,8 @@ export class ContextProcessor {
]);
// Replace block with structured output using slice (position-based, handles duplicates)
const replacement = `\n\n<${DATAVIEW_BLOCK_TAG}>\n<query_type>${queryType}</query_type>\n<original_query>\n${query}\n</original_query>\n<executed_result>\n${result}\n</executed_result>\n</${DATAVIEW_BLOCK_TAG}>\n\n`;
const resultStr = typeof result === "string" ? result : JSON.stringify(result);
const replacement = `\n\n<${DATAVIEW_BLOCK_TAG}>\n<query_type>${queryType}</query_type>\n<original_query>\n${query}\n</original_query>\n<executed_result>\n${resultStr}\n</executed_result>\n</${DATAVIEW_BLOCK_TAG}>\n\n`;
content = content.slice(0, matchStart) + replacement + content.slice(matchEnd);
} catch (error) {
logError(`Error executing Dataview query:`, error);

View file

@ -70,7 +70,7 @@ import { ContextManager } from "./ContextManager";
import { ChainType } from "@/chainFactory";
import { getWebViewerService } from "@/services/webViewerService/webViewerServiceSingleton";
import { ChatMessage, MessageContext } from "@/types/message";
import { TFile } from "obsidian";
import { mockTFile } from "@/__tests__/mockObsidian";
const USER_SENDER = "user";
const createContextResult = (content = "Hello with context") => ({
@ -114,7 +114,7 @@ describe("ChatManager", () => {
mockChainManager = {
memoryManager: {
clearChatMemory: jest.fn(),
clearChatMemory: jest.fn().mockResolvedValue(undefined),
},
runChain: jest.fn(),
};
@ -156,7 +156,7 @@ describe("ChatManager", () => {
describe("sendMessage", () => {
it("should send a message with basic context", async () => {
const mockActiveFile = { path: "active.md", basename: "active" } as TFile;
const mockActiveFile = mockTFile({ path: "active.md", basename: "active" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = {
notes: [],
@ -203,7 +203,7 @@ describe("ChatManager", () => {
});
it("should include active note in context when includeActiveNote is true", async () => {
const mockActiveFile = { path: "active.md", basename: "active" } as TFile;
const mockActiveFile = mockTFile({ path: "active.md", basename: "active" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = {
notes: [],
@ -283,7 +283,7 @@ describe("ChatManager", () => {
describe("editMessage", () => {
it("should edit a message and reprocess context", async () => {
const mockActiveFile = { path: "active.md", basename: "active" } as TFile;
const mockActiveFile = mockTFile({ path: "active.md", basename: "active" });
mockPlugin.app.workspace.getActiveFile.mockReturnValue(mockActiveFile);
mockMessageRepo.editMessage.mockReturnValue(true);
@ -590,7 +590,7 @@ describe("ChatManager", () => {
describe("Bug Prevention Tests", () => {
describe("Context Badge Bug Prevention", () => {
it("should include active note in context when includeActiveNote is true", async () => {
const mockActiveFile = { path: "lesson4.md", basename: "Lesson 4" } as TFile;
const mockActiveFile = mockTFile({ path: "lesson4.md", basename: "Lesson 4" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = {
notes: [],
@ -641,7 +641,7 @@ describe("ChatManager", () => {
describe("Edit Message Bug Prevention", () => {
it("should reprocess context after editing", async () => {
const mockActiveFile = { path: "active.md", basename: "active" } as TFile;
const mockActiveFile = mockTFile({ path: "active.md", basename: "active" });
mockPlugin.app.workspace.getActiveFile.mockReturnValue(mockActiveFile);
mockMessageRepo.editMessage.mockReturnValue(true);
@ -1240,7 +1240,7 @@ describe("ChatManager", () => {
describe("Template Skip Logic", () => {
it("should not call processPrompt when user custom prompt has no template variables", async () => {
const mockActiveFile = { path: "active.md", basename: "active" } as TFile;
const mockActiveFile = mockTFile({ path: "active.md", basename: "active" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
@ -1270,7 +1270,7 @@ describe("ChatManager", () => {
});
it("should call processPrompt for JSON but preserve content (handled internally)", async () => {
const mockActiveFile = { path: "active.md", basename: "active" } as TFile;
const mockActiveFile = mockTFile({ path: "active.md", basename: "active" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
@ -1308,7 +1308,7 @@ describe("ChatManager", () => {
});
it("should treat {} as literal in system prompts (not expand to activeNote)", async () => {
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
@ -1350,7 +1350,7 @@ describe("ChatManager", () => {
});
it("should preserve trailing whitespace for JSON content", async () => {
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
@ -1384,7 +1384,7 @@ describe("ChatManager", () => {
});
it("should not call processPrompt when enableCustomPromptTemplating is false", async () => {
const mockActiveFile = { path: "active.md", basename: "active" } as TFile;
const mockActiveFile = mockTFile({ path: "active.md", basename: "active" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
@ -1410,7 +1410,7 @@ describe("ChatManager", () => {
});
it("should not call processPrompt when no user custom prompt is set", async () => {
const mockActiveFile = { path: "active.md", basename: "active" } as TFile;
const mockActiveFile = mockTFile({ path: "active.md", basename: "active" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
@ -1431,7 +1431,7 @@ describe("ChatManager", () => {
describe("Template Processing", () => {
it("should call processPrompt with correct arguments for {activeNote} template", async () => {
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
@ -1468,8 +1468,8 @@ describe("ChatManager", () => {
});
it("should pass includedFiles to contextManager for deduplication", async () => {
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
const mockIncludedFile = { path: "included.md", basename: "Included Note" } as TFile;
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
const mockIncludedFile = mockTFile({ path: "included.md", basename: "Included Note" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
@ -1512,7 +1512,7 @@ describe("ChatManager", () => {
describe("Injection Logic", () => {
it("should inject processed content into user_custom_instructions block", async () => {
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
@ -1550,7 +1550,7 @@ describe("ChatManager", () => {
it("should preserve $ characters in user prompt without interpreting as replacement patterns", async () => {
// Regression test: String.prototype.replace treats $&, $1, $$ etc. as special sequences.
// Using function replacement avoids this issue.
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
@ -1597,7 +1597,7 @@ describe("ChatManager", () => {
it("should preserve $ characters when template processing is involved", async () => {
// Regression test: Even when templates are processed, $ in output must not be interpreted
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
@ -1649,7 +1649,7 @@ describe("ChatManager", () => {
describe("Memory Preservation", () => {
it("should preserve memory prefix and not process it for templates", async () => {
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
@ -1695,7 +1695,7 @@ describe("ChatManager", () => {
describe("Error Handling", () => {
it("should return original prompt when processPrompt throws error", async () => {
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
@ -1729,7 +1729,7 @@ describe("ChatManager", () => {
describe("Builtin Disabled Branch", () => {
it("should handle builtin disabled scenario (no user_custom_instructions block)", async () => {
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
@ -1762,7 +1762,7 @@ describe("ChatManager", () => {
});
it("should preserve memory prefix when builtin is disabled", async () => {
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
@ -1797,7 +1797,7 @@ describe("ChatManager", () => {
describe("EndsWith Mismatch Fallback", () => {
it("should fallback to original base prompt when endsWith check fails", async () => {
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
@ -1848,7 +1848,7 @@ describe("ChatManager", () => {
});
it("should process project system prompt templates", async () => {
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
@ -1866,7 +1866,10 @@ describe("ChatManager", () => {
getSystemPrompt.mockReturnValue("DEFAULT_SYSTEM_PROMPT");
getSystemPromptWithMemory.mockResolvedValue("DEFAULT_SYSTEM_PROMPT");
const projectIncludedFile = { path: "project-note.md", basename: "Project Note" } as TFile;
const projectIncludedFile = mockTFile({
path: "project-note.md",
basename: "Project Note",
});
processPrompt.mockResolvedValue({
processedPrompt: "PROCESSED_PROJECT_PROMPT",
includedFiles: [projectIncludedFile],
@ -1904,7 +1907,7 @@ describe("ChatManager", () => {
});
it("should merge includedFiles from both user and project prompts", async () => {
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
@ -1925,8 +1928,11 @@ describe("ChatManager", () => {
`DEFAULT\n<user_custom_instructions>\n${userCustomPrompt}\n</user_custom_instructions>`
);
const userIncludedFile = { path: "user-note.md", basename: "User Note" } as TFile;
const projectIncludedFile = { path: "project-note.md", basename: "Project Note" } as TFile;
const userIncludedFile = mockTFile({ path: "user-note.md", basename: "User Note" });
const projectIncludedFile = mockTFile({
path: "project-note.md",
basename: "Project Note",
});
// First call for user prompt, second call for project prompt
processPrompt
@ -1957,7 +1963,7 @@ describe("ChatManager", () => {
});
it("should not add project_context block when context is null", async () => {
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };

View file

@ -1,6 +1,7 @@
import { ChatMessage } from "@/types/message";
import { Notice, TFile } from "obsidian";
import { ChatPersistenceManager } from "./ChatPersistenceManager";
import { mockTFile } from "@/__tests__/mockObsidian";
const USER_SENDER = "user";
const AI_SENDER = "ai";
@ -377,9 +378,9 @@ Nature's quiet song`);
} as any;
persistenceManager = new ChatPersistenceManager(mockApp, mockMessageRepo, chainManager);
const mockFile = {
const mockFile = mockTFile({
path: "test-folder/Summarize_weather_data@20240923_221800.md",
} as unknown as TFile;
});
mockApp.vault.create.mockResolvedValue(mockFile);
mockApp.vault.getAbstractFileByPath.mockReturnValue(mockFile);
mockMessageRepo.getDisplayMessages.mockReturnValue(messages);
@ -402,6 +403,12 @@ Nature's quiet song`);
await Promise.resolve();
await Promise.resolve();
// Topic generation is fire-and-forget via `void (async () => …)()`, so
// we need additional microtask ticks to flush the chain of awaits inside.
for (let i = 0; i < 10; i++) {
await Promise.resolve();
}
expect(invoke).toHaveBeenCalled();
expect(mockApp.fileManager.processFrontMatter).toHaveBeenCalledWith(
mockFile,
@ -677,10 +684,12 @@ Nature's quiet song`);
return Promise.reject(error);
} else {
// Second call: succeed with fallback filename
return Promise.resolve({
path,
basename: path.split("/").pop(),
} as TFile);
return Promise.resolve(
mockTFile({
path,
basename: path.split("/").pop(),
})
);
}
});
@ -742,10 +751,12 @@ Nature's quiet song`);
const error = new Error("ENAMETOOLONG: name too long");
return Promise.reject(error);
} else {
return Promise.resolve({
path,
basename: path.split("/").pop(),
} as TFile);
return Promise.resolve(
mockTFile({
path,
basename: path.split("/").pop(),
})
);
}
});
@ -835,9 +846,9 @@ Nature's quiet song`);
},
];
const existingFile = {
const existingFile = mockTFile({
path: "test-folder/Hello@20240923_221800.md",
} as unknown as TFile;
});
const getFilesSpy = jest
.spyOn(persistenceManager, "getChatHistoryFiles")
@ -1018,7 +1029,7 @@ tags:
**ai**: Hi there!
[Timestamp: 2024/09/23 22:18:01]`;
const mockFile = { path: "test.md" } as TFile;
const mockFile = mockTFile({ path: "test.md" });
mockApp.vault.read.mockResolvedValue(fileContent);
const result = await persistenceManager.loadChat(mockFile);
@ -1191,16 +1202,16 @@ ${formattedContent}`;
it("should resolve legacy basename-only context (backward compatibility)", async () => {
// Create mock TFile for the note
const mockTFile = {
const file = mockTFile({
basename: "typescript-guide.md",
path: "docs/typescript-guide.md",
extension: "md",
name: "typescript-guide.md",
} as TFile;
});
// Mock vault to return the file for basename resolution (legacy format)
mockApp.vault.getAbstractFileByPath.mockReturnValue(null); // Path lookup fails
mockApp.vault.getMarkdownFiles.mockReturnValue([mockTFile]); // Basename lookup succeeds
mockApp.vault.getMarkdownFiles.mockReturnValue([file]); // Basename lookup succeeds
// Old format: just basename, no path
const content = `---
@ -1228,19 +1239,19 @@ tags:
it("should handle ambiguous basename resolution gracefully", async () => {
// Create two mock TFiles with the same basename
const mockTFile1 = {
const mockTFile1 = mockTFile({
basename: "typescript-guide.md",
path: "docs/typescript-guide.md",
extension: "md",
name: "typescript-guide.md",
} as TFile;
});
const mockTFile2 = {
const mockTFile2 = mockTFile({
basename: "typescript-guide.md",
path: "archive/typescript-guide.md",
extension: "md",
name: "typescript-guide.md",
} as TFile;
});
// Mock vault to return multiple files with same basename
mockApp.vault.getAbstractFileByPath.mockReturnValue(null);
@ -1553,9 +1564,9 @@ tags:
},
];
const existingFile = {
const existingFile = mockTFile({
path: "test-folder/Hello@20240923_221800.md",
} as unknown as TFile;
});
const getFilesSpy = jest
.spyOn(persistenceManager, "getChatHistoryFiles")
@ -1599,9 +1610,11 @@ tags:
mockMessageRepo.getDisplayMessages.mockReturnValue(messages);
mockApp.vault.getAbstractFileByPath.mockReturnValue(true);
mockApp.vault.create.mockResolvedValue({
path: "test-folder/New_chat@20240923_221800.md",
} as TFile);
mockApp.vault.create.mockResolvedValue(
mockTFile({
path: "test-folder/New_chat@20240923_221800.md",
})
);
await persistenceManager.saveChat("gpt-4");

View file

@ -726,9 +726,7 @@ ${conversationSummary}`;
// Prefix from an input project, global project, or empty if none
const currentProject = project === undefined ? getCurrentProject() : project;
const filePrefix = currentProject
? `${sanitizeVaultPathSegment(currentProject.id)}__`
: "";
const filePrefix = currentProject ? `${sanitizeVaultPathSegment(currentProject.id)}__` : "";
// Calculate fixed components in bytes
const extensionBytes = getUtf8ByteLength(".md");

View file

@ -84,7 +84,9 @@ function buildEnvelopeWithL3Segments(segments: PromptLayerSegment[]): PromptCont
/**
* Create a mock MessageRepository that returns the given display messages.
*/
function createMockMessageRepo(messages: Array<{ id: string; sender: string; contextEnvelope?: PromptContextEnvelope }>) {
function createMockMessageRepo(
messages: Array<{ id: string; sender: string; contextEnvelope?: PromptContextEnvelope }>
) {
return {
getDisplayMessages: () =>
messages.map((msg) => ({

View file

@ -673,7 +673,7 @@ export class ContextManager {
vault: Vault,
additionalNotes: TFile[] = []
): Promise<TFile[]> {
const extractedNotes = await extractNoteFiles(content, vault);
const extractedNotes = extractNoteFiles(content, vault);
// Combine and deduplicate
const allNotes = [...extractedNotes, ...additionalNotes];

View file

@ -2,6 +2,7 @@ import { AI_SENDER, USER_SENDER } from "@/constants";
import { MessageContext } from "@/types/message";
import { TFile } from "obsidian";
import { MessageRepository } from "./MessageRepository";
import { mockTFile } from "@/__tests__/mockObsidian";
// Mock the settings module
jest.mock("@/settings/model", () => ({
@ -22,12 +23,12 @@ describe("Message Lifecycle with Context Notes - Complete Example", () => {
it("should demonstrate complete message lifecycle with context note", () => {
// Step 1: User types a message and attaches a note
const userDisplayText = "Please summarize the key points";
const attachedNote: TFile = {
const attachedNote: TFile = mockTFile({
path: "meeting-notes-2024-01-15.md",
name: "meeting-notes-2024-01-15.md",
basename: "meeting-notes-2024-01-15",
extension: "md",
} as TFile;
});
const context: MessageContext = {
notes: [attachedNote],
@ -166,12 +167,12 @@ The team appears to be taking a pragmatic approach with a focused MVP scope and
it("should handle message edit with context reprocessing", () => {
// Initial message with context
const initialText = "List the attendees";
const note: TFile = {
const note: TFile = mockTFile({
path: "meeting.md",
name: "meeting.md",
basename: "meeting",
extension: "md",
} as TFile;
});
const context: MessageContext = {
notes: [note],
@ -234,12 +235,12 @@ Attendees: Alice (PM), Bob (Dev), Charlie (QA)
// User asks initial question with context
const context: MessageContext = {
notes: [
{
mockTFile({
path: "budget.md",
name: "budget.md",
basename: "budget",
extension: "md",
} as TFile,
}),
],
urls: [],
selectedTextContexts: [],

View file

@ -2,6 +2,7 @@ import { MessageRepository } from "./MessageRepository";
import { USER_SENDER, SELECTED_TEXT_TAG, WEB_SELECTED_TEXT_TAG } from "@/constants";
import { MessageContext } from "@/types/message";
import { TFile } from "obsidian";
import { mockTFile } from "@/__tests__/mockObsidian";
/**
* Tests specifically for proper XML tag formatting in context processing
@ -15,12 +16,12 @@ describe("Message Context XML Tag Formatting", () => {
it("should format note context with proper XML tags including metadata", () => {
const userMessage = "Analyze this document";
const note: TFile = {
const note: TFile = mockTFile({
path: "reports/quarterly-review.md",
name: "quarterly-review.md",
basename: "quarterly-review",
extension: "md",
} as TFile;
});
const context: MessageContext = {
notes: [note],
@ -177,12 +178,12 @@ function fibonacci(n) {
it("should handle multiple contexts with proper XML structure", () => {
const userMessage = "Compare these resources";
const note: TFile = {
const note: TFile = mockTFile({
path: "design-patterns.md",
name: "design-patterns.md",
basename: "design-patterns",
extension: "md",
} as TFile;
});
const context: MessageContext = {
notes: [note],
@ -261,12 +262,12 @@ The Single Responsibility Principle states that a class should have only one rea
it("should handle error cases with proper XML tags", () => {
const userMessage = "Process this file";
const corruptedNote: TFile = {
const corruptedNote: TFile = mockTFile({
path: "corrupted-file.pdf",
name: "corrupted-file.pdf",
basename: "corrupted-file",
extension: "pdf",
} as TFile;
});
const context: MessageContext = {
notes: [corruptedNote],

View file

@ -1,7 +1,7 @@
import { MessageRepository } from "./MessageRepository";
import { ChatMessage, MessageContext, StoredMessage } from "@/types/message";
import { formatDateTime } from "@/utils";
import { TFile } from "obsidian";
import { mockTFile } from "@/__tests__/mockObsidian";
// Mock dependencies
jest.mock("@/utils", () => ({
@ -53,7 +53,7 @@ describe("MessageRepository", () => {
});
it("should add a message with context", () => {
const mockFile = { path: "test.md", basename: "test" } as TFile;
const mockFile = mockTFile({ path: "test.md", basename: "test" });
const context: MessageContext = {
notes: [mockFile],
urls: ["https://example.com"],
@ -281,7 +281,7 @@ describe("MessageRepository", () => {
describe("Bug Prevention Tests", () => {
describe("Context Badge Bug Prevention", () => {
it("should preserve context when creating display messages", () => {
const mockFile = { path: "test.md", basename: "test" } as TFile;
const mockFile = mockTFile({ path: "test.md", basename: "test" });
const context: MessageContext = {
notes: [mockFile],
urls: ["https://example.com"],

View file

@ -19,7 +19,7 @@ export class MessageRepository {
* Generate a unique message ID
*/
private generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
/**

View file

@ -108,8 +108,9 @@ export class ChatSelectionHighlightController {
* Call once during plugin onload.
*/
initialize(): void {
const leaf = this.plugin.app.workspace.activeLeaf ?? null;
this.lastActiveLeafWasMarkdown = !!(leaf?.view instanceof MarkdownView);
const markdownView = this.plugin.app.workspace.getActiveViewOfType(MarkdownView);
const leaf = markdownView?.leaf ?? null;
this.lastActiveLeafWasMarkdown = !!markdownView;
if (this.lastActiveLeafWasMarkdown && leaf) {
this.lastActiveMarkdownLeaf = leaf;
}
@ -169,9 +170,8 @@ export class ChatSelectionHighlightController {
* Does not use fallback - only works if MarkdownView is currently active.
*/
persistFromPointerDown(): void {
// Early exit if already in Chat - no MarkdownView will be active
const activeLeafType = this.plugin.app.workspace.activeLeaf?.getViewState().type;
if (activeLeafType === CHAT_VIEWTYPE) {
// Early exit if no MarkdownView is active (e.g., user is already in Chat).
if (!this.plugin.app.workspace.getActiveViewOfType(MarkdownView)) {
return;
}

View file

@ -43,7 +43,7 @@ describe("createPersistentHighlight", () => {
view = new EditorView({
state,
parent: document.createElement("div"),
parent: window.document.createElement("div"),
});
});
@ -127,7 +127,7 @@ describe("createPersistentHighlight", () => {
const bareState = EditorState.create({ doc: "Test" });
const bareView = new EditorView({
state: bareState,
parent: document.createElement("div"),
parent: window.document.createElement("div"),
});
const effects = highlight.buildEffects(bareView, null);
@ -202,7 +202,7 @@ describe("createPersistentHighlight", () => {
view = new EditorView({
state,
parent: document.createElement("div"),
parent: window.document.createElement("div"),
});
});

View file

@ -94,7 +94,8 @@ function shouldSkipPersistOnAbort(signal: AbortSignal): boolean {
// If abort() was called without a string reason, treat it as a "non-commit" cancel.
if (typeof reason !== "string") return true;
return reason === ABORT_REASON.NEW_CHAT || reason === ABORT_REASON.UNMOUNT;
const typedReason = reason as ABORT_REASON;
return typedReason === ABORT_REASON.NEW_CHAT || typedReason === ABORT_REASON.UNMOUNT;
}
/**

View file

@ -86,12 +86,12 @@ const processColorObject =
* bg-modifier-error/50
* text-background-modifier-success/30
*/
export const colorOpacityPlugin = plugin(function ({ addUtilities, theme, e }) {
export const colorOpacityPlugin = plugin(function (this: void, { addUtilities, theme, e }) {
const opacityUtilities: Record<string, any> = {};
// 处理所有颜色相关的主题配置
const processThemeColors = (themeKey: string, prefix?: string) => {
const colors = theme(themeKey) as Record<string, string | ColorValue>;
const colors: Record<string, string | ColorValue> = theme(themeKey);
Object.entries(colors).forEach(([colorName, colorValue]) => {
const baseName = prefix ? `${prefix}-${colorName}` : colorName;
processColorObject(e, opacityUtilities)(colorValue, baseName);

View file

@ -241,7 +241,8 @@ class LogFileManager {
}
// Original buffer unchanged; open the file
const file = app.vault.getAbstractFileByPath(path) as TFile | null;
const abstract = app.vault.getAbstractFileByPath(path);
const file = abstract instanceof TFile ? abstract : null;
try {
if (file) {
const leaf = app.workspace.getLeaf(true);

View file

@ -69,11 +69,7 @@ import {
filterChatHistoryFiles,
} from "@/utils/chatHistoryUtils";
import { RecentUsageManager } from "@/utils/recentUsageManager";
import {
listMarkdownFiles,
patchFrontmatter,
resolveFileByPath,
} from "@/utils/vaultAdapterUtils";
import { listMarkdownFiles, patchFrontmatter, resolveFileByPath } from "@/utils/vaultAdapterUtils";
import { v4 as uuidv4 } from "uuid";
// Removed unused FileTrackingState interface

View file

@ -436,7 +436,7 @@ export class MiyoClient {
if (getSettings().debug) {
logInfo(`Miyo request ${options.method} ${url.toString()} succeeded`);
}
return parsed as T;
return parsed;
}
/**

View file

@ -86,7 +86,9 @@ export function isSelfHostModeValid(): boolean {
/** Check if the model key is a Copilot Plus model. */
export function isPlusModel(modelKey: string): boolean {
return modelKey.split("|")[1] === EmbeddingModelProviders.COPILOT_PLUS;
return (
(modelKey.split("|")[1] as EmbeddingModelProviders) === EmbeddingModelProviders.COPILOT_PLUS
);
}
/**

View file

@ -67,13 +67,12 @@ jest.mock("@/projects/projectMigration", () => ({
// Helpers
// ---------------------------------------------------------------------------
import {
getCachedProjectRecords,
getCachedProjectRecordById,
} from "@/projects/state";
import { getCachedProjectRecords, getCachedProjectRecordById } from "@/projects/state";
/** Minimal valid ProjectConfig for test use. */
function makeConfig(overrides: { id: string; name: string } & Partial<ProjectConfig>): ProjectConfig {
function makeConfig(
overrides: { id: string; name: string } & Partial<ProjectConfig>
): ProjectConfig {
return {
systemPrompt: "",
projectModelKey: "",
@ -88,7 +87,7 @@ function makeConfig(overrides: { id: string; name: string } & Partial<ProjectCon
/** Build a minimal Vault mock. */
function makeMockVault(): jest.Mocked<Vault> {
return {
create: jest.fn(async (path: string) => ({ path } as TFile)),
create: jest.fn(async (path: string) => ({ path }) as TFile),
// Reason: null = file does not exist yet, avoids collision error in createProject
getAbstractFileByPath: jest.fn(() => null),
adapter: { exists: jest.fn(async () => false) },
@ -135,9 +134,9 @@ describe("ProjectFileManager.createProject", () => {
it("rejects empty project id", async () => {
const manager = ProjectFileManager.getInstance(vault);
await expect(
manager.createProject(makeConfig({ id: "", name: "Valid Name" }))
).rejects.toThrow(/cannot be empty/i);
await expect(manager.createProject(makeConfig({ id: "", name: "Valid Name" }))).rejects.toThrow(
/cannot be empty/i
);
});
it("rejects whitespace-only project id", async () => {

View file

@ -283,7 +283,8 @@ export class ProjectFileManager {
}
const now = Date.now();
const createdMs = Number.isFinite(project.created) && project.created > 0 ? project.created : now;
const createdMs =
Number.isFinite(project.created) && project.created > 0 ? project.created : now;
const lastUsedMs =
Number.isFinite(project.UsageTimestamps) && project.UsageTimestamps > 0
? project.UsageTimestamps
@ -321,7 +322,10 @@ export class ProjectFileManager {
* @param nextProject - New ProjectConfig (id must match)
* @returns Updated ProjectFileRecord
*/
public async updateProject(projectId: string, nextProject: ProjectConfig): Promise<ProjectFileRecord> {
public async updateProject(
projectId: string,
nextProject: ProjectConfig
): Promise<ProjectFileRecord> {
const normalizedId = (projectId || "").trim();
if (!normalizedId) throw new Error("Project id cannot be empty");
if ((nextProject.id || "").trim() !== normalizedId) {
@ -334,7 +338,9 @@ export class ProjectFileManager {
const trimmedName = (nextProject.name || "").trim();
if (trimmedName) {
const nameConflict = getCachedProjectRecords().some(
(r) => r.project.id !== normalizedId && r.project.name.trim().toLowerCase() === trimmedName.toLowerCase()
(r) =>
r.project.id !== normalizedId &&
r.project.name.trim().toLowerCase() === trimmedName.toLowerCase()
);
if (nameConflict) {
throw new Error(`A project with the name "${trimmedName}" already exists`);
@ -369,9 +375,7 @@ export class ProjectFileManager {
const isCaseOnlyRename =
newFolderPath.toLowerCase() === getProjectFolderPath(existing.folderName).toLowerCase();
if (!isCaseOnlyRename && (await this.vault.adapter.exists(newFolderPath))) {
throw new Error(
`Cannot rename project folder: "${newFolderPath}" already exists on disk`
);
throw new Error(`Cannot rename project folder: "${newFolderPath}" already exists on disk`);
}
// Suppress vault events for both old and new project.md paths
@ -447,9 +451,13 @@ export class ProjectFileManager {
if (isInVaultCache(app, filePath)) {
// Vault-cached file: use processFrontMatter for safe field-level updates
try {
await writeProjectFrontmatter(file, projectForWrite, folderName, { createdMs, lastUsedMs });
await writeProjectFrontmatter(file, projectForWrite, folderName, {
createdMs,
lastUsedMs,
});
} catch (fmError) {
if (materialized) await this.rollbackCreatedFile(filePath, getProjectFolderPath(folderName));
if (materialized)
await this.rollbackCreatedFile(filePath, getProjectFolderPath(folderName));
throw fmError;
}
@ -502,7 +510,10 @@ export class ProjectFileManager {
`[Projects] Rolled back folder rename "${folderName}" → "${existing.folderName}" after write failure`
);
} catch (rollbackError) {
logError(`[Projects] Failed to rollback folder rename for ${normalizedId}`, rollbackError);
logError(
`[Projects] Failed to rollback folder rename for ${normalizedId}`,
rollbackError
);
}
}
throw writeError;
@ -576,9 +587,7 @@ export class ProjectFileManager {
// Reason: rescan to re-admit any previously-ignored duplicate-id files.
// The register won't fire (pending guard), so we trigger rescan here.
void loadAllProjects().catch((err) =>
logError("[Projects] Rescan after delete failed", err)
);
void loadAllProjects().catch((err) => logError("[Projects] Rescan after delete failed", err));
} finally {
removePendingFileWrite(existing.filePath);
}

View file

@ -4,4 +4,7 @@ export * from "./projectUtils";
export * from "./state";
export { ProjectFileManager } from "./ProjectFileManager";
export { ProjectRegister } from "./projectRegister";
export { ensureProjectsMigratedIfNeeded, migrateProjectsFromSettingsToVault } from "./projectMigration";
export {
ensureProjectsMigratedIfNeeded,
migrateProjectsFromSettingsToVault,
} from "./projectMigration";

View file

@ -1,14 +1,8 @@
import { ProjectConfig } from "@/aiParams";
import { ConfirmModal } from "@/components/modals/ConfirmModal";
import { logError, logInfo, logWarn } from "@/logger";
import {
COPILOT_PROJECT_ID,
PROJECTS_UNSUPPORTED_FOLDER_NAME,
} from "@/projects/constants";
import {
deriveProjectFolderName,
sanitizeVaultPathSegment,
} from "@/projects/projectPaths";
import { COPILOT_PROJECT_ID, PROJECTS_UNSUPPORTED_FOLDER_NAME } from "@/projects/constants";
import { deriveProjectFolderName, sanitizeVaultPathSegment } from "@/projects/projectPaths";
import {
ensureProjectFrontmatter,
getProjectConfigFilePath,
@ -112,7 +106,11 @@ async function saveFailedProjectToUnsupported(
* Best-effort rollback: delete a file and its parent folder if empty.
* Logs errors but never throws.
*/
async function rollbackCreatedFile(vault: Vault, filePath: string, folderPath: string): Promise<void> {
async function rollbackCreatedFile(
vault: Vault,
filePath: string,
folderPath: string
): Promise<void> {
try {
const file = vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
@ -375,9 +373,7 @@ export async function migrateProjectsFromSettingsToVault(vault: Vault): Promise<
continue;
}
logWarn(
`[Projects] Migration verify failed on existing file for id=${id} at ${filePath}`
);
logWarn(`[Projects] Migration verify failed on existing file for id=${id} at ${filePath}`);
await saveFailedProjectToUnsupported(
vault,
project,
@ -453,7 +449,9 @@ export async function migrateProjectsFromSettingsToVault(vault: Vault): Promise<
};
if (failedCount === 0) {
logInfo(`[Projects] Migration succeeded: all ${successCount} project(s) migrated to vault files`);
logInfo(
`[Projects] Migration succeeded: all ${successCount} project(s) migrated to vault files`
);
new ConfirmModal(
app,
() => revealFolderInExplorer(projectsFolder),
@ -584,10 +582,7 @@ async function migrateProjectFolderNames(vault: Vault): Promise<void> {
`for project "${name}" (id=${record.project.id})`
);
} catch (error) {
logError(
`[Projects] Naming migration failed for project id="${record.project.id}"`,
error
);
logError(`[Projects] Naming migration failed for project id="${record.project.id}"`, error);
} finally {
removePendingFileWrite(oldFilePath);
removePendingFileWrite(newFilePath);

View file

@ -133,9 +133,11 @@ export class ProjectRegister {
const cache = ProjectContextCache.getInstance();
await Promise.all(
oldRecords.map((old) =>
cache.clearForProject(old.project).catch((err) =>
logError("[Projects] Failed to clear context cache on folder switch", err)
)
cache
.clearForProject(old.project)
.catch((err) =>
logError("[Projects] Failed to clear context cache on folder switch", err)
)
)
);
@ -170,16 +172,20 @@ export class ProjectRegister {
const cache = ProjectContextCache.getInstance();
await Promise.all(
oldRecords.map((old) =>
cache.clearForProject(old.project).catch((err) =>
logError("[Projects] Failed to clear context cache on folder switch failure", err)
)
cache
.clearForProject(old.project)
.catch((err) =>
logError("[Projects] Failed to clear context cache on folder switch failure", err)
)
)
);
updateCachedProjectRecords([]);
logError(`[Projects] Failed to reload after folder change: ${nextFolder}`, error);
new Notice(`Failed to reload projects from "${nextFolder}". Projects cleared — reopen settings to retry.`);
new Notice(
`Failed to reload projects from "${nextFolder}". Projects cleared — reopen settings to retry.`
);
}
}
@ -253,7 +259,9 @@ export class ProjectRegister {
// fresh cache wiped by a stale async cleanup. Consistent with folder-switch path.
await ProjectContextCache.getInstance()
.clearForProject(record.project)
.catch((err) => logError("[Projects] Failed to clear context cache on external delete", err));
.catch((err) =>
logError("[Projects] Failed to clear context cache on external delete", err)
);
// Reason: rescan to re-admit any previously-ignored duplicate-id files
// that were hidden while the deleted file was the "kept" entry.
@ -378,7 +386,9 @@ export class ProjectRegister {
if (staleRecord) {
void ProjectContextCache.getInstance()
.clearForProject(staleRecord.project)
.catch((err) => logError("[Projects] Failed to clear context cache on invalid edit", err));
.catch((err) =>
logError("[Projects] Failed to clear context cache on invalid edit", err)
);
// Reason: rescan to re-admit previously-ignored duplicate-id files
void loadAllProjects().catch((err) =>
logError("[Projects] Rescan after invalid edit failed", err)
@ -395,7 +405,9 @@ export class ProjectRegister {
if (staleRecord) {
void ProjectContextCache.getInstance()
.clearForProject(staleRecord.project)
.catch((err) => logError("[Projects] Failed to clear context cache on duplicate edit", err));
.catch((err) =>
logError("[Projects] Failed to clear context cache on duplicate edit", err)
);
// Reason: rescan to re-admit previously-ignored duplicate-id files
void loadAllProjects().catch((err) =>
logError("[Projects] Rescan after duplicate edit failed", err)

View file

@ -1,5 +1,6 @@
import { TFile } from "obsidian";
import { parseProjectConfigFile, sanitizeVaultPathSegment } from "@/projects/projectUtils";
import { mockTFile } from "@/__tests__/mockObsidian";
// Mock deep dependencies to avoid transitive import chains
jest.mock("@/settings/model", () => ({
@ -21,7 +22,7 @@ jest.mock("@/logger", () => ({
// Helper: create a minimal TFile mock for a project config path
function makeMockFile(path: string): TFile {
return {
return mockTFile({
path,
name: "project.md",
basename: "project",
@ -29,7 +30,7 @@ function makeMockFile(path: string): TFile {
stat: { ctime: 1000, mtime: 1000, size: 0 },
vault: {} as never,
parent: null,
} as unknown as TFile;
});
}
// Helper: set up the global `app` mock used by parseProjectConfigFile
@ -122,12 +123,7 @@ describe("parseProjectConfigFile", () => {
});
it("returns null when copilot-project-id is missing from frontmatter", async () => {
const rawContent = [
"---",
"copilot-project-name: My Project",
"---",
"Body text",
].join("\n");
const rawContent = ["---", "copilot-project-name: My Project", "---", "Body text"].join("\n");
setupAppMock(rawContent, {
"copilot-project-name": "My Project",

View file

@ -106,7 +106,7 @@ export class ChunkedStorage {
async saveDatabase(db: Orama<any>): Promise<void> {
try {
const rawData: RawData = await save(db);
const rawData: RawData = save(db);
const numPartitions = getSettings().numPartitions;
if (numPartitions === 1) {
@ -234,7 +234,7 @@ export class ChunkedStorage {
if (!legacyData?.schema) {
throw new CustomError("Invalid legacy database format");
}
const newDb = await create({
const newDb = create({
schema: legacyData.schema,
components: {
tokenizer: {
@ -243,13 +243,13 @@ export class ChunkedStorage {
},
},
});
await load(newDb, legacyData);
load(newDb, legacyData);
return newDb;
}
// Load metadata
const metadata = await this.loadMetadata();
const newDb = await create({
const newDb = create({
schema: metadata.schema,
components: {
tokenizer: {
@ -289,7 +289,7 @@ export class ChunkedStorage {
// Find document in any chunk
const doc = allChunks
.flatMap((chunk) => Object.values(chunk.docs.docs))
.find((doc: any) => (doc as any).id === internalId);
.find((doc: any) => doc.id === internalId);
if (doc) {
orderedDocs[nextDocId.toString()] = doc;
@ -308,7 +308,7 @@ export class ChunkedStorage {
// upsert cycles. These "ghost" IDs cause position mismatches after load,
// where some user IDs point to wrong doc positions or undefined entries.
mergedData.internalDocumentIDStore.internalIdToId = Object.values(orderedDocs).map(
(doc: any) => (doc as any).id
(doc: any) => doc.id
);
// Merge vectors from all chunks
@ -318,7 +318,7 @@ export class ChunkedStorage {
);
// Load merged data into database
await load(newDb, mergedData);
load(newDb, mergedData);
return newDb;
} catch (error) {
console.error(`Error loading database:`, error);

View file

@ -275,7 +275,7 @@ export class DBOperations {
const schema = this.createDynamicSchema(vectorLength);
const db = await create({
const db = create({
schema,
components: {
tokenizer: {
@ -496,8 +496,8 @@ export class DBOperations {
oramaDocSample !== null &&
"document" in oramaDocSample
) {
const document = oramaDocSample.document as { embeddingModel?: string };
prevEmbeddingModel = document.embeddingModel;
const doc = oramaDocSample.document as { embeddingModel?: string };
prevEmbeddingModel = doc.embeddingModel;
}
}

View file

@ -50,7 +50,7 @@ export class IndexOperations {
this.chunkManager = getSharedChunkManager(app);
// Subscribe to settings changes
subscribeToSettingsChange(async () => {
subscribeToSettingsChange(() => {
this.refreshRuntimeIndexingConfig();
});
}
@ -187,7 +187,9 @@ export class IndexOperations {
// Skip documents with invalid embeddings
if (!embedding || !Array.isArray(embedding) || embedding.length === 0) {
logError(`Invalid embedding for document ${chunk.fileInfo.path}: ${embedding}`);
logError(
`Invalid embedding for document ${chunk.fileInfo.path}: ${JSON.stringify(embedding)}`
);
this.indexBackend.markFileMissingEmbeddings(chunk.fileInfo.path);
continue;
}
@ -472,7 +474,7 @@ export class IndexOperations {
const files = await this.getFilesToIndex();
if (files.length === 0) {
logInfo("No files to index after filter change, stopping indexing");
this.cancelIndexing();
await this.cancelIndexing();
return;
}
// Keep progress denominator consistent after resume:

View file

@ -292,7 +292,7 @@ export class FullTextEngine {
};
for (const key of possibleKeys) {
const rawValue = (frontmatter as Record<string, unknown>)[key];
const rawValue = frontmatter[key];
if (!rawValue) {
continue;
}

View file

@ -156,9 +156,7 @@ export class GraphBoostCalculator {
*/
private resolveFile(noteId: string): TFile | null {
const file = this.metadataCache.getFirstLinkpathDest(noteId, "");
// In tests, the file might be a plain object with the right shape
// Check for the required properties instead of instanceof
return file && typeof file === "object" && "path" in file ? (file as TFile) : null;
return file instanceof TFile ? file : null;
}
/**

View file

@ -38,12 +38,12 @@ describe("ObsidianCliClient", () => {
const platform = Platform as unknown as { isDesktopApp?: boolean; isDesktop?: boolean };
platform.isDesktopApp = true;
const container = globalThis as unknown as TestRequireContainer;
const container = window as unknown as TestRequireContainer;
originalRequire = container.require;
});
afterEach(() => {
const container = globalThis as unknown as TestRequireContainer;
const container = window as unknown as TestRequireContainer;
if (originalRequire) {
container.require = originalRequire;
} else {
@ -78,7 +78,7 @@ describe("ObsidianCliClient", () => {
callback(null, "Daily note content", "")
);
const container = globalThis as unknown as TestRequireContainer;
const container = window as unknown as TestRequireContainer;
container.require = jest.fn((id: string) => {
if (id === "child_process") {
return { execFile: execFileMock };
@ -111,7 +111,7 @@ describe("ObsidianCliClient", () => {
callback(error, "", "command not found");
});
const container = globalThis as unknown as TestRequireContainer;
const container = window as unknown as TestRequireContainer;
container.require = jest.fn((id: string) => {
if (id === "child_process") {
return { execFile: execFileMock };
@ -159,7 +159,7 @@ describe("ObsidianCliClient", () => {
callback(error, "", "");
});
const container = globalThis as unknown as TestRequireContainer;
const container = window as unknown as TestRequireContainer;
container.require = jest.fn((id: string) => {
if (id === "child_process") {
return { execFile: execFileMock };
@ -185,7 +185,7 @@ describe("ObsidianCliClient", () => {
callback(null, "Random note content", "")
);
const container = globalThis as unknown as TestRequireContainer;
const container = window as unknown as TestRequireContainer;
container.require = jest.fn((id: string) => {
if (id === "child_process") {
return { execFile: execFileMock };

View file

@ -130,7 +130,7 @@ export function isDesktopRuntime(): boolean {
* @throws If `require` is not available.
*/
function getRuntimeRequire(): (id: string) => unknown {
const container = globalThis as unknown as RequireContainer;
const container = window as unknown as RequireContainer;
if (typeof container.require !== "function") {
throw new Error(
"Node require is unavailable in this runtime. Obsidian CLI commands require the desktop app."
@ -171,7 +171,7 @@ function normalizeCliParameterValue(value: string): string {
* @returns Ordered non-empty binary candidates from environment.
*/
function getCliBinaryCandidatesFromEnv(): string[] {
const container = globalThis as unknown as ProcessContainer;
const container = window as unknown as ProcessContainer;
const env = container.process?.env;
if (!env) {
return [];

View file

@ -45,6 +45,11 @@ export function formatCliFailureMessage(
*/
export function throwCliFailure(result: ObsidianCliProcessResult): never {
throw new Error(
formatCliFailureMessage(result.stderr, result.exitCode, result.errorCode, result.attemptedBinaries)
formatCliFailureMessage(
result.stderr,
result.exitCode,
result.errorCode,
result.attemptedBinaries
)
);
}

View file

@ -142,7 +142,7 @@ export class WebViewerService {
/** Get the currently active Web Viewer leaf (if any). */
getActiveLeaf(): WebViewerLeaf | null {
const leaf = this.app.workspace.activeLeaf;
const leaf = this.app.workspace.getMostRecentLeaf();
return isWebViewerLeaf(leaf) ? leaf : null;
}

View file

@ -84,7 +84,7 @@ export async function getReaderModeMarkdown(
})
.catch((err) => {
signal.removeEventListener("abort", abortHandler);
reject(err);
reject(err instanceof Error ? err : new Error(String(err)));
});
});

View file

@ -36,6 +36,7 @@ export function toStringSafe(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
try {
if (typeof value === "object") return JSON.stringify(value);
return String(value);
} catch {
return "";
@ -276,7 +277,7 @@ export function getInternalWebViewerPluginApi(
plugins instanceof Map
? plugins.get(WEB_VIEWER_VIEW_TYPE)
: isRecord(plugins)
? (plugins as Record<string, unknown>)[WEB_VIEWER_VIEW_TYPE]
? plugins[WEB_VIEWER_VIEW_TYPE]
: null;
if (directEntry) {

View file

@ -180,8 +180,7 @@ export function isWebViewerLeaf(leaf: WorkspaceLeaf | null): leaf is WebViewerLe
if (!leaf) return false;
const view = leaf.view as View | undefined;
if (!view || typeof view !== "object") return false;
const getViewType = view.getViewType;
return typeof getViewType === "function" && leaf.view.getViewType() === WEB_VIEWER_VIEW_TYPE;
return typeof view.getViewType === "function" && view.getViewType() === WEB_VIEWER_VIEW_TYPE;
}
/**

View file

@ -447,11 +447,7 @@ export function sanitizeSettings(settings: CopilotSettings): CopilotSettings {
// Ensure selfHostSearchProvider is a valid value
const validSearchProviders = ["firecrawl", "perplexity"] as const;
if (
!validSearchProviders.includes(
sanitizedSettings.selfHostSearchProvider as (typeof validSearchProviders)[number]
)
) {
if (!validSearchProviders.includes(sanitizedSettings.selfHostSearchProvider)) {
sanitizedSettings.selfHostSearchProvider = DEFAULT_SETTINGS.selfHostSearchProvider;
}

View file

@ -73,7 +73,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
// 判断 Provider 是否有必填的额外设置
const hasRequiredExtraSettings = (provider: string) => {
return provider === ChatModelProviders.AZURE_OPENAI && !model.baseUrl;
return (provider as ChatModelProviders) === ChatModelProviders.AZURE_OPENAI && !model.baseUrl;
};
const [dialogElement, setDialogElement] = useState<HTMLDivElement | null>(null);
@ -119,7 +119,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
// does not consume baseUrl and still reads azureOpenAIApiInstanceName,
// azureOpenAIApiEmbeddingDeploymentName, and azureOpenAIApiVersion directly.
// Chat models may skip legacy fields when a full base URL is supplied instead.
const isAzure = model.provider === ChatModelProviders.AZURE_OPENAI;
const isAzure = (model.provider as ChatModelProviders) === ChatModelProviders.AZURE_OPENAI;
const azureRequiresLegacyFields = isAzure && (isEmbeddingModel || !model.baseUrl?.trim());
if (azureRequiresLegacyFields) {
newErrors.instanceName = !model.azureOpenAIApiInstanceName;
@ -138,7 +138,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
}
}
if (model.provider === ChatModelProviders.AMAZON_BEDROCK) {
if ((model.provider as ChatModelProviders) === ChatModelProviders.AMAZON_BEDROCK) {
newErrors.bedrockRegion = false;
} else {
newErrors.bedrockRegion = false;
@ -324,7 +324,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
const renderProviderSpecificFields = () => {
const fields = () => {
switch (model.provider) {
switch (model.provider as ChatModelProviders) {
case ChatModelProviders.OPENAI:
return (
<FormField
@ -496,7 +496,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
};
const getPlaceholderUrl = () => {
if (model.provider !== ChatModelProviders.AZURE_OPENAI) {
if ((model.provider as ChatModelProviders) !== ChatModelProviders.AZURE_OPENAI) {
return providerInfo.host;
}
@ -535,7 +535,8 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
error={errors.name}
errorMessage="Model name is required"
description={
model.provider === ChatModelProviders.AMAZON_BEDROCK && !isEmbeddingModel
(model.provider as ChatModelProviders) === ChatModelProviders.AMAZON_BEDROCK &&
!isEmbeddingModel
? "For Bedrock, use cross-region inference profile IDs (global., us., eu., or apac. prefix) for better reliability. Regional IDs without prefixes may fail."
: undefined
}
@ -543,7 +544,8 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
<Input
type="text"
placeholder={`Enter model name (e.g. ${
model.provider === ChatModelProviders.AMAZON_BEDROCK && !isEmbeddingModel
(model.provider as ChatModelProviders) === ChatModelProviders.AMAZON_BEDROCK &&
!isEmbeddingModel
? "global.anthropic.claude-sonnet-4-6-v1:0"
: isEmbeddingModel
? "text-embedding-3-small"
@ -701,8 +703,8 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
</div>
</Label>
</div>
{(model.provider === ChatModelProviders.OPENAI_FORMAT ||
model.provider === ChatModelProviders.LM_STUDIO) && (
{((model.provider as ChatModelProviders) === ChatModelProviders.OPENAI_FORMAT ||
(model.provider as ChatModelProviders) === ChatModelProviders.LM_STUDIO) && (
<div className="tw-flex tw-items-center tw-gap-2">
<Checkbox
id="stream-usage"

View file

@ -44,7 +44,8 @@ export const ModelEditModalContent: React.FC<ModelEditModalContentProps> = ({
const [originalModel, setOriginalModel] = useState<CustomModel>(model);
const [providerInfo, setProviderInfo] = useState<ProviderMetadata>({} as ProviderMetadata);
const settings = getSettings();
const isBedrockProvider = localModel.provider === ChatModelProviders.AMAZON_BEDROCK;
const isBedrockProvider =
(localModel.provider as ChatModelProviders) === ChatModelProviders.AMAZON_BEDROCK;
useEffect(() => {
setLocalModel(model);
@ -120,7 +121,8 @@ export const ModelEditModalContent: React.FC<ModelEditModalContentProps> = ({
localModel
);
const showOtherParameters =
!isEmbeddingModel && localModel.provider !== EmbeddingModelProviders.COPILOT_PLUS_JINA;
!isEmbeddingModel &&
(localModel.provider as EmbeddingModelProviders) !== EmbeddingModelProviders.COPILOT_PLUS_JINA;
return (
<div className="tw-space-y-3 tw-p-4">
@ -208,7 +210,7 @@ export const ModelEditModalContent: React.FC<ModelEditModalContentProps> = ({
</FormField>
{/* Prompt Caching Toggle for OpenRouter */}
{localModel.provider === ChatModelProviders.OPENROUTERAI && (
{(localModel.provider as ChatModelProviders) === ChatModelProviders.OPENROUTERAI && (
<div className="tw-flex tw-items-center tw-gap-2">
<Checkbox
id="enable-prompt-caching"
@ -271,8 +273,8 @@ export const ModelEditModalContent: React.FC<ModelEditModalContentProps> = ({
</FormField>
{/* Stream Usage Toggle for OpenAI-format providers */}
{(localModel.provider === ChatModelProviders.OPENAI_FORMAT ||
localModel.provider === ChatModelProviders.LM_STUDIO) && (
{((localModel.provider as ChatModelProviders) === ChatModelProviders.OPENAI_FORMAT ||
(localModel.provider as ChatModelProviders) === ChatModelProviders.LM_STUDIO) && (
<FormField label="Stream Options">
<div className="tw-flex tw-items-center tw-gap-2">
<Checkbox
@ -297,7 +299,7 @@ export const ModelEditModalContent: React.FC<ModelEditModalContentProps> = ({
)}
{/* Responses API Toggle for LM Studio */}
{localModel.provider === ChatModelProviders.LM_STUDIO && (
{(localModel.provider as ChatModelProviders) === ChatModelProviders.LM_STUDIO && (
<FormField label="Responses API">
<div className="tw-flex tw-items-center tw-gap-2">
<Checkbox

View file

@ -45,14 +45,24 @@ function renderVerificationMessage(message: string): React.ReactNode {
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
if (match) {
return (
<a key={j} href={match[2]} target="_blank" rel="noopener noreferrer" className="tw-underline">
<a
key={j}
href={match[2]}
target="_blank"
rel="noopener noreferrer"
className="tw-underline"
>
{match[1]}
</a>
);
}
return part;
});
return <p key={i} className={i > 0 ? "tw-mt-1" : ""}>{nodes}</p>;
return (
<p key={i} className={i > 0 ? "tw-mt-1" : ""}>
{nodes}
</p>
);
});
}

View file

@ -298,7 +298,7 @@ export const QASettings: React.FC = () => {
<SettingItem
type="switch"
title="Enable Obsidian Sync for Copilot index"
description="If enabled, store the semantic index in .obsidian so it syncs with Obsidian Sync. If disabled, store it under .copilot/ at the vault root."
description={`If enabled, store the semantic index in ${app.vault.configDir} so it syncs with Obsidian Sync. If disabled, store it under .copilot/ at the vault root.`}
checked={settings.enableIndexSync}
onCheckedChange={(checked) => updateSetting("enableIndexSync", checked)}
/>

View file

@ -156,7 +156,7 @@ export async function verifyAndAddModel(
// hasn't enabled this model on their GitHub settings page. Append the policy
// terms (which include an activation link) to guide the user.
if (
customModel.provider === ChatModelProviders.GITHUB_COPILOT &&
(customModel.provider as ChatModelProviders) === ChatModelProviders.GITHUB_COPILOT &&
verificationError.toLowerCase().includes("not supported")
) {
// Reason: policy cache is keyed by model.id, not customModel.name (display name)

View file

@ -4,6 +4,7 @@ import * as settingsModel from "@/settings/model";
import * as systemPromptUtils from "@/system-prompts/systemPromptUtils";
import * as logger from "@/logger";
import * as utils from "@/utils";
import { mockTFile } from "@/__tests__/mockObsidian";
// Mock Obsidian
jest.mock("obsidian", () => ({
@ -112,9 +113,11 @@ describe("migrateSystemPromptsFromSettings", () => {
});
(mockVault.getAbstractFileByPath as jest.Mock)
.mockReturnValueOnce(null) // File does not exist
.mockReturnValueOnce({
path: "SystemPrompts/Migrated Custom System Prompt.md",
} as TFile); // File created
.mockReturnValueOnce(
mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt.md",
})
); // File created
await migrateSystemPromptsFromSettings(mockVault);
@ -127,9 +130,11 @@ describe("migrateSystemPromptsFromSettings", () => {
});
(mockVault.getAbstractFileByPath as jest.Mock)
.mockReturnValueOnce(null) // File does not exist
.mockReturnValueOnce({
path: "SystemPrompts/Migrated Custom System Prompt.md",
} as TFile); // File created
.mockReturnValueOnce(
mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt.md",
})
); // File created
await migrateSystemPromptsFromSettings(mockVault);
@ -144,9 +149,11 @@ describe("migrateSystemPromptsFromSettings", () => {
});
(mockVault.getAbstractFileByPath as jest.Mock)
.mockReturnValueOnce(null) // File does not exist
.mockReturnValueOnce({
path: "SystemPrompts/Migrated Custom System Prompt.md",
} as TFile); // File created
.mockReturnValueOnce(
mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt.md",
})
); // File created
await migrateSystemPromptsFromSettings(mockVault);
@ -164,9 +171,11 @@ describe("migrateSystemPromptsFromSettings", () => {
(mockVault.getAbstractFileByPath as jest.Mock)
.mockReturnValueOnce(null)
.mockReturnValueOnce(null)
.mockReturnValueOnce({
path: "SystemPrompts/Migrated Custom System Prompt.md",
} as TFile);
.mockReturnValueOnce(
mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt.md",
})
);
await migrateSystemPromptsFromSettings(mockVault);
@ -179,9 +188,9 @@ describe("migrateSystemPromptsFromSettings", () => {
it("adds frontmatter to migrated file", async () => {
const legacyPrompt = "This is a legacy system prompt.";
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt.md",
} as TFile;
});
(settingsModel.getSettings as jest.Mock).mockReturnValue({
userSystemPrompt: legacyPrompt,
@ -205,9 +214,9 @@ describe("migrateSystemPromptsFromSettings", () => {
it("clears legacy userSystemPrompt from settings after migration", async () => {
const legacyPrompt = "This is a legacy system prompt.";
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt.md",
} as TFile;
});
(settingsModel.getSettings as jest.Mock).mockReturnValue({
userSystemPrompt: legacyPrompt,
@ -225,9 +234,9 @@ describe("migrateSystemPromptsFromSettings", () => {
it("sets migrated prompt as default", async () => {
const legacyPrompt = "This is a legacy system prompt.";
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt.md",
} as TFile;
});
(settingsModel.getSettings as jest.Mock).mockReturnValue({
userSystemPrompt: legacyPrompt,
@ -248,9 +257,9 @@ describe("migrateSystemPromptsFromSettings", () => {
it("reloads all prompts after migration", async () => {
const legacyPrompt = "This is a legacy system prompt.";
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt.md",
} as TFile;
});
(settingsModel.getSettings as jest.Mock).mockReturnValue({
userSystemPrompt: legacyPrompt,
@ -268,12 +277,12 @@ describe("migrateSystemPromptsFromSettings", () => {
it("generates unique name when default file already exists", async () => {
const legacyPrompt = "This is a legacy system prompt.";
const existingFile = {
const existingFile = mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt.md",
} as TFile;
const newFile = {
});
const newFile = mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt 2.md",
} as TFile;
});
Object.setPrototypeOf(newFile, TFile.prototype);
@ -307,9 +316,9 @@ describe("migrateSystemPromptsFromSettings", () => {
it("generates incrementing unique names when multiple files exist", async () => {
const legacyPrompt = "This is a legacy system prompt.";
const newFile = {
const newFile = mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt 3.md",
} as TFile;
});
Object.setPrototypeOf(newFile, TFile.prototype);
@ -334,9 +343,9 @@ describe("migrateSystemPromptsFromSettings", () => {
it("logs clearing message after migration", async () => {
const legacyPrompt = "This is a legacy system prompt.";
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt.md",
} as TFile;
});
(settingsModel.getSettings as jest.Mock).mockReturnValue({
userSystemPrompt: legacyPrompt,
@ -388,9 +397,9 @@ describe("migrateSystemPromptsFromSettings", () => {
it("sets correct timestamps for migrated prompt", async () => {
const legacyPrompt = "This is a legacy system prompt.";
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt.md",
} as TFile;
});
(settingsModel.getSettings as jest.Mock).mockReturnValue({
userSystemPrompt: legacyPrompt,
@ -425,9 +434,9 @@ describe("migrateSystemPromptsFromSettings", () => {
describe("write-then-verify safety with unsupported folder", () => {
it("clears userSystemPrompt and saves to unsupported when verification fails", async () => {
const legacyPrompt = "This is a legacy system prompt.";
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt.md",
} as TFile;
});
Object.setPrototypeOf(mockFile, TFile.prototype);
@ -478,9 +487,9 @@ describe("migrateSystemPromptsFromSettings", () => {
it("saves to unsupported and clears userSystemPrompt when vault.read throws", async () => {
const legacyPrompt = "This is a legacy system prompt.";
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt.md",
} as TFile;
});
Object.setPrototypeOf(mockFile, TFile.prototype);
@ -536,9 +545,9 @@ describe("migrateSystemPromptsFromSettings", () => {
it("clears userSystemPrompt and sets default on successful verification", async () => {
const legacyPrompt = "This is a legacy system prompt.";
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt.md",
} as TFile;
});
Object.setPrototypeOf(mockFile, TFile.prototype);
@ -563,9 +572,9 @@ describe("migrateSystemPromptsFromSettings", () => {
it("preserves whitespace and verifies exact content match", async () => {
const legacyPrompt = " This is a legacy system prompt. \n\n";
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt.md",
} as TFile;
});
Object.setPrototypeOf(mockFile, TFile.prototype);
@ -587,9 +596,9 @@ describe("migrateSystemPromptsFromSettings", () => {
it("normalizes CRLF/LF differences in verification", async () => {
// Legacy prompt uses CRLF line endings (Windows style)
const legacyPrompt = "Line 1\r\nLine 2\r\nLine 3";
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt.md",
} as TFile;
});
Object.setPrototypeOf(mockFile, TFile.prototype);
@ -614,9 +623,9 @@ describe("migrateSystemPromptsFromSettings", () => {
it("handles double newline after frontmatter (Obsidian format)", async () => {
// Obsidian's processFrontMatter may add an extra blank line after frontmatter
const legacyPrompt = "This is a legacy system prompt.";
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/Migrated Custom System Prompt.md",
} as TFile;
});
Object.setPrototypeOf(mockFile, TFile.prototype);

View file

@ -4,6 +4,7 @@ import { TFile, Vault } from "obsidian";
import * as systemPromptUtils from "@/system-prompts/systemPromptUtils";
import * as state from "@/system-prompts/state";
import * as utils from "@/utils";
import { mockTFile } from "@/__tests__/mockObsidian";
// Mock Obsidian
jest.mock("obsidian", () => ({
@ -128,7 +129,7 @@ describe("SystemPromptManager", () => {
});
it("creates a new prompt file", async () => {
const mockFile = { path: "SystemPrompts/New Prompt.md" } as TFile;
const mockFile = mockTFile({ path: "SystemPrompts/New Prompt.md" });
Object.setPrototypeOf(mockFile, TFile.prototype);
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
@ -140,7 +141,7 @@ describe("SystemPromptManager", () => {
});
it("updates cache after creation", async () => {
const mockFile = { path: "SystemPrompts/New Prompt.md" } as TFile;
const mockFile = mockTFile({ path: "SystemPrompts/New Prompt.md" });
Object.setPrototypeOf(mockFile, TFile.prototype);
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
@ -150,7 +151,7 @@ describe("SystemPromptManager", () => {
});
it("skips cache update when skipStoreUpdate is true", async () => {
const mockFile = { path: "SystemPrompts/New Prompt.md" } as TFile;
const mockFile = mockTFile({ path: "SystemPrompts/New Prompt.md" });
Object.setPrototypeOf(mockFile, TFile.prototype);
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
@ -180,7 +181,7 @@ describe("SystemPromptManager", () => {
});
it("manages pending file writes", async () => {
const mockFile = { path: "SystemPrompts/New Prompt.md" } as TFile;
const mockFile = mockTFile({ path: "SystemPrompts/New Prompt.md" });
Object.setPrototypeOf(mockFile, TFile.prototype);
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
@ -207,7 +208,7 @@ describe("SystemPromptManager", () => {
});
it("updates prompt content without renaming", async () => {
const mockFile = { path: "SystemPrompts/Updated Prompt.md" } as TFile;
const mockFile = mockTFile({ path: "SystemPrompts/Updated Prompt.md" });
Object.setPrototypeOf(mockFile, TFile.prototype);
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
@ -218,8 +219,8 @@ describe("SystemPromptManager", () => {
});
it("renames file when title changes", async () => {
const oldFile = { path: "SystemPrompts/Old Prompt.md" } as TFile;
const newFile = { path: "SystemPrompts/Updated Prompt.md" } as TFile;
const oldFile = mockTFile({ path: "SystemPrompts/Old Prompt.md" });
const newFile = mockTFile({ path: "SystemPrompts/Updated Prompt.md" });
Object.setPrototypeOf(oldFile, TFile.prototype);
Object.setPrototypeOf(newFile, TFile.prototype);
@ -237,7 +238,7 @@ describe("SystemPromptManager", () => {
});
it("updates cache after modification", async () => {
const mockFile = { path: "SystemPrompts/Updated Prompt.md" } as TFile;
const mockFile = mockTFile({ path: "SystemPrompts/Updated Prompt.md" });
Object.setPrototypeOf(mockFile, TFile.prototype);
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
@ -247,8 +248,8 @@ describe("SystemPromptManager", () => {
});
it("deletes old cache entry when renaming", async () => {
const oldFile = { path: "SystemPrompts/Old Prompt.md" } as TFile;
const newFile = { path: "SystemPrompts/Updated Prompt.md" } as TFile;
const oldFile = mockTFile({ path: "SystemPrompts/Old Prompt.md" });
const newFile = mockTFile({ path: "SystemPrompts/Updated Prompt.md" });
Object.setPrototypeOf(oldFile, TFile.prototype);
Object.setPrototypeOf(newFile, TFile.prototype);
@ -263,7 +264,7 @@ describe("SystemPromptManager", () => {
});
it("skips cache update when skipStoreUpdate is true", async () => {
const mockFile = { path: "SystemPrompts/Updated Prompt.md" } as TFile;
const mockFile = mockTFile({ path: "SystemPrompts/Updated Prompt.md" });
Object.setPrototypeOf(mockFile, TFile.prototype);
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
@ -273,7 +274,7 @@ describe("SystemPromptManager", () => {
});
it("manages pending file writes for update", async () => {
const mockFile = { path: "SystemPrompts/Updated Prompt.md" } as TFile;
const mockFile = mockTFile({ path: "SystemPrompts/Updated Prompt.md" });
Object.setPrototypeOf(mockFile, TFile.prototype);
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
@ -284,8 +285,8 @@ describe("SystemPromptManager", () => {
});
it("manages pending file writes for rename", async () => {
const oldFile = { path: "SystemPrompts/Old Prompt.md" } as TFile;
const newFile = { path: "SystemPrompts/Updated Prompt.md" } as TFile;
const oldFile = mockTFile({ path: "SystemPrompts/Old Prompt.md" });
const newFile = mockTFile({ path: "SystemPrompts/Updated Prompt.md" });
Object.setPrototypeOf(oldFile, TFile.prototype);
Object.setPrototypeOf(newFile, TFile.prototype);
@ -310,7 +311,7 @@ describe("SystemPromptManager", () => {
});
it("deletes the prompt file", async () => {
const mockFile = { path: "SystemPrompts/Test Prompt.md" } as TFile;
const mockFile = mockTFile({ path: "SystemPrompts/Test Prompt.md" });
Object.setPrototypeOf(mockFile, TFile.prototype);
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
@ -320,7 +321,7 @@ describe("SystemPromptManager", () => {
});
it("removes prompt from cache", async () => {
const mockFile = { path: "SystemPrompts/Test Prompt.md" } as TFile;
const mockFile = mockTFile({ path: "SystemPrompts/Test Prompt.md" });
Object.setPrototypeOf(mockFile, TFile.prototype);
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
@ -339,7 +340,7 @@ describe("SystemPromptManager", () => {
});
it("manages pending file writes", async () => {
const mockFile = { path: "SystemPrompts/Test Prompt.md" } as TFile;
const mockFile = mockTFile({ path: "SystemPrompts/Test Prompt.md" });
Object.setPrototypeOf(mockFile, TFile.prototype);
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
@ -371,7 +372,7 @@ describe("SystemPromptManager", () => {
});
it("creates a duplicate with (copy) suffix", async () => {
const mockFile = { path: "SystemPrompts/Original Prompt (copy).md" } as TFile;
const mockFile = mockTFile({ path: "SystemPrompts/Original Prompt (copy).md" });
Object.setPrototypeOf(mockFile, TFile.prototype);
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
@ -385,7 +386,7 @@ describe("SystemPromptManager", () => {
});
it("sets new timestamps for duplicated prompt", async () => {
const mockFile = { path: "SystemPrompts/Original Prompt (copy).md" } as TFile;
const mockFile = mockTFile({ path: "SystemPrompts/Original Prompt (copy).md" });
Object.setPrototypeOf(mockFile, TFile.prototype);
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
@ -401,7 +402,7 @@ describe("SystemPromptManager", () => {
});
it("creates the duplicated file", async () => {
const mockFile = { path: "SystemPrompts/Original Prompt (copy).md" } as TFile;
const mockFile = mockTFile({ path: "SystemPrompts/Original Prompt (copy).md" });
Object.setPrototypeOf(mockFile, TFile.prototype);
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
@ -414,7 +415,7 @@ describe("SystemPromptManager", () => {
});
it("returns the duplicated prompt object", async () => {
const mockFile = { path: "SystemPrompts/Original Prompt (copy).md" } as TFile;
const mockFile = mockTFile({ path: "SystemPrompts/Original Prompt (copy).md" });
Object.setPrototypeOf(mockFile, TFile.prototype);
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);

View file

@ -1,7 +1,8 @@
import { Notice, Plugin, TFile, Vault } from "obsidian";
import { Notice, Plugin, Vault } from "obsidian";
import { SystemPromptRegister } from "@/system-prompts/systemPromptRegister";
import * as state from "@/system-prompts/state";
import * as systemPromptUtils from "@/system-prompts/systemPromptUtils";
import { mockTFile } from "@/__tests__/mockObsidian";
// Mock obsidian
jest.mock("obsidian", () => ({
@ -94,11 +95,11 @@ describe("SystemPromptRegister", () => {
describe("handleFileDeletion - selectedPromptTitle sync", () => {
it("clears selectedPromptTitle when deleted file matches current selection", async () => {
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/MyPrompt.md",
basename: "MyPrompt",
extension: "md",
} as TFile;
});
// Set up: current selection points to the file being deleted
(state.getSelectedPromptTitle as jest.Mock).mockReturnValue("MyPrompt");
@ -112,11 +113,11 @@ describe("SystemPromptRegister", () => {
});
it("does not clear selectedPromptTitle when deleted file does not match", async () => {
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/OtherPrompt.md",
basename: "OtherPrompt",
extension: "md",
} as TFile;
});
// Set up: current selection points to a different prompt
(state.getSelectedPromptTitle as jest.Mock).mockReturnValue("MyPrompt");
@ -130,11 +131,11 @@ describe("SystemPromptRegister", () => {
});
it("does not clear selectedPromptTitle when no prompt is selected", async () => {
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/MyPrompt.md",
basename: "MyPrompt",
extension: "md",
} as TFile;
});
// Set up: no prompt is currently selected
(state.getSelectedPromptTitle as jest.Mock).mockReturnValue("");
@ -150,11 +151,11 @@ describe("SystemPromptRegister", () => {
describe("handleFileRename - selectedPromptTitle sync", () => {
it("updates selectedPromptTitle when renamed file matches current selection", async () => {
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/NewName.md",
basename: "NewName",
extension: "md",
} as TFile;
});
const oldPath = "SystemPrompts/OldName.md";
// Set up: current selection points to the old name
@ -170,11 +171,11 @@ describe("SystemPromptRegister", () => {
});
it("clears selectedPromptTitle when file is moved out of prompts folder", async () => {
const mockFile = {
const mockFile = mockTFile({
path: "OtherFolder/MyPrompt.md",
basename: "MyPrompt",
extension: "md",
} as TFile;
});
const oldPath = "SystemPrompts/MyPrompt.md";
// Set up: current selection points to the moved file
@ -191,11 +192,11 @@ describe("SystemPromptRegister", () => {
});
it("does not update selectedPromptTitle when renamed file does not match", async () => {
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/NewName.md",
basename: "NewName",
extension: "md",
} as TFile;
});
const oldPath = "SystemPrompts/OldName.md";
// Set up: current selection points to a different prompt
@ -211,11 +212,11 @@ describe("SystemPromptRegister", () => {
});
it("does not update selectedPromptTitle when no prompt is selected", async () => {
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/NewName.md",
basename: "NewName",
extension: "md",
} as TFile;
});
const oldPath = "SystemPrompts/OldName.md";
// Set up: no prompt is currently selected

View file

@ -9,6 +9,7 @@ import {
import { UserSystemPrompt } from "@/system-prompts/type";
import { TFile, TAbstractFile, normalizePath } from "obsidian";
import * as settingsModel from "@/settings/model";
import { mockTFile } from "@/__tests__/mockObsidian";
// Mock Obsidian
jest.mock("obsidian", () => ({
@ -158,10 +159,10 @@ describe("isSystemPromptFile", () => {
});
it("returns true for valid system prompt file", () => {
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/Test.md",
extension: "md",
} as TFile;
});
// Mock instanceof check
Object.setPrototypeOf(mockFile, TFile.prototype);
@ -178,10 +179,10 @@ describe("isSystemPromptFile", () => {
});
it("returns false for non-markdown files", () => {
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/Test.txt",
extension: "txt",
} as TFile;
});
Object.setPrototypeOf(mockFile, TFile.prototype);
@ -189,10 +190,10 @@ describe("isSystemPromptFile", () => {
});
it("returns false for files outside system prompts folder", () => {
const mockFile = {
const mockFile = mockTFile({
path: "OtherFolder/Test.md",
extension: "md",
} as TFile;
});
Object.setPrototypeOf(mockFile, TFile.prototype);
@ -200,10 +201,10 @@ describe("isSystemPromptFile", () => {
});
it("returns false for files in subfolders", () => {
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/Subfolder/Test.md",
extension: "md",
} as TFile;
});
Object.setPrototypeOf(mockFile, TFile.prototype);
@ -211,10 +212,10 @@ describe("isSystemPromptFile", () => {
});
it("returns false for files in unsupported subfolder", () => {
const mockFile = {
const mockFile = mockTFile({
path: "SystemPrompts/unsupported/Failed Migration.md",
extension: "md",
} as TFile;
});
Object.setPrototypeOf(mockFile, TFile.prototype);
@ -226,15 +227,15 @@ describe("isSystemPromptFile", () => {
userSystemPromptsFolder: "CustomFolder/MyPrompts",
} as any);
const validFile = {
const validFile = mockTFile({
path: "CustomFolder/MyPrompts/Test.md",
extension: "md",
} as TFile;
});
const unsupportedFile = {
const unsupportedFile = mockTFile({
path: "CustomFolder/MyPrompts/unsupported/Failed.md",
extension: "md",
} as TFile;
});
Object.setPrototypeOf(validFile, TFile.prototype);
Object.setPrototypeOf(unsupportedFile, TFile.prototype);
@ -250,11 +251,11 @@ describe("parseSystemPromptFile", () => {
beforeEach(() => {
originalApp = global.app;
mockFile = {
mockFile = mockTFile({
basename: "Test Prompt",
path: "SystemPrompts/Test Prompt.md",
extension: "md",
} as TFile;
});
global.app = {
vault: {

View file

@ -57,12 +57,12 @@ async function show_preview(
if (file && (!activeFile || activeFile.path !== file_path)) {
// If target file is not the active file, open the target file in the current leaf
await app.workspace.getLeaf().openFile(file as TFile);
await app.workspace.getLeaf().openFile(file);
}
let originalContent = "";
if (file) {
originalContent = await app.vault.read(file as TFile);
originalContent = await app.vault.read(file);
}
const changes = diffTrimmedLines(originalContent, content, {
newlineIsToken: true,

View file

@ -2,7 +2,7 @@ import { ToolManager } from "@/tools/toolManager";
import { createGetTagListTool, enforceSizeLimit } from "./TagTools";
describe("TagTools", () => {
const originalApp = (globalThis as any).app;
const originalApp = (window as any).app;
const parsePayload = (result: string) => {
const startIndex = result.indexOf('{"');
@ -22,7 +22,7 @@ describe("TagTools", () => {
};
beforeEach(() => {
(globalThis as any).app = {
(window as any).app = {
metadataCache: {
getTags: jest.fn().mockReturnValue({
"#project": 5,
@ -38,7 +38,7 @@ describe("TagTools", () => {
});
afterEach(() => {
(globalThis as any).app = originalApp;
(window as any).app = originalApp;
jest.clearAllMocks();
});
@ -48,8 +48,8 @@ describe("TagTools", () => {
const payload = parsePayload(result);
expect((globalThis as any).app.metadataCache.getTags).toHaveBeenCalled();
expect((globalThis as any).app.metadataCache.getFrontmatterTags).toHaveBeenCalled();
expect((window as any).app.metadataCache.getTags).toHaveBeenCalled();
expect((window as any).app.metadataCache.getFrontmatterTags).toHaveBeenCalled();
expect(payload.totalUniqueTags).toBe(3);
expect(payload.returnedTagCount).toBe(3);
expect(payload.totalOccurrences).toBe(10);
@ -83,8 +83,8 @@ describe("TagTools", () => {
const payload = parsePayload(result);
expect((globalThis as any).app.metadataCache.getTags).not.toHaveBeenCalled();
expect((globalThis as any).app.metadataCache.getFrontmatterTags).toHaveBeenCalled();
expect((window as any).app.metadataCache.getTags).not.toHaveBeenCalled();
expect((window as any).app.metadataCache.getFrontmatterTags).toHaveBeenCalled();
expect(payload.totalUniqueTags).toBe(2);
expect(payload.includedSources).toEqual(["frontmatter"]);
expect(payload.tags).toEqual([
@ -118,8 +118,8 @@ describe("TagTools", () => {
});
it("handles empty vault gracefully", async () => {
(globalThis as any).app.metadataCache.getTags.mockReturnValue({});
(globalThis as any).app.metadataCache.getFrontmatterTags.mockReturnValue({});
(window as any).app.metadataCache.getTags.mockReturnValue({});
(window as any).app.metadataCache.getFrontmatterTags.mockReturnValue({});
const tool = createGetTagListTool();
const result = await ToolManager.callTool(tool, {});
@ -132,11 +132,11 @@ describe("TagTools", () => {
});
it("normalizes malformed tags correctly", async () => {
(globalThis as any).app.metadataCache.getTags.mockReturnValue({
(window as any).app.metadataCache.getTags.mockReturnValue({
project: 5,
"##Weird/Tag": 4,
});
(globalThis as any).app.metadataCache.getFrontmatterTags.mockReturnValue({
(window as any).app.metadataCache.getFrontmatterTags.mockReturnValue({
" #Project ": 3,
"##Weird/Tag": 1,
});
@ -163,10 +163,10 @@ describe("TagTools", () => {
});
it("falls back when inline counts omit frontmatter occurrences", async () => {
(globalThis as any).app.metadataCache.getTags.mockReturnValue({
(window as any).app.metadataCache.getTags.mockReturnValue({
"#project": 1,
});
(globalThis as any).app.metadataCache.getFrontmatterTags.mockReturnValue({
(window as any).app.metadataCache.getFrontmatterTags.mockReturnValue({
"#project": 3,
});

View file

@ -254,8 +254,8 @@ export class ToolResultFormatter {
private static parseSearchResults(result: any): any[] {
// Only support the new structured format or pre-formatted XML flow
if (typeof result === "object" && result !== null) {
if ((result as any).type === "local_search" && Array.isArray((result as any).documents)) {
return (result as any).documents;
if (result.type === "local_search" && Array.isArray(result.documents)) {
return result.documents;
}
return [];
}

View file

@ -496,7 +496,9 @@ function getNoteTitleAndTags(noteWithTag: {
function getChatContextStr(chatNoteContextPath: string, chatNoteContextTags: string[]): string {
const pathStr = chatNoteContextPath ? `\nChat context by path: ${chatNoteContextPath}` : "";
const tagsStr =
chatNoteContextTags?.length > 0 ? `\nChat context by tags: ${chatNoteContextTags}` : "";
chatNoteContextTags?.length > 0
? `\nChat context by tags: ${chatNoteContextTags.join(",")}`
: "";
return pathStr + tagsStr;
}

View file

@ -1,17 +1,7 @@
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return (globalThis as any)["btoa"](binary);
return Buffer.from(buffer).toString("base64");
}
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binaryString = (globalThis as any)["atob"](base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
return new Uint8Array(Buffer.from(base64, "base64")).buffer;
}

View file

@ -39,10 +39,7 @@ function coerceProjectId(projectId: unknown): string | undefined {
* Read the projectId from a chat file's frontmatter.
* Tries metadataCache first, falls back to adapter read for hidden-directory files.
*/
export async function readChatFileProjectId(
app: App,
file: TFile
): Promise<string | undefined> {
export async function readChatFileProjectId(app: App, file: TFile): Promise<string | undefined> {
const fm = app.metadataCache.getFileCache(file)?.frontmatter;
let projectId: unknown = fm?.projectId;

View file

@ -1,5 +1,6 @@
import { TFile, Vault } from "obsidian";
import { saveConvertedDocOutput } from "./convertedDocOutput";
import { mockTFile } from "@/__tests__/mockObsidian";
jest.mock("@/utils", () => ({
ensureFolderExists: jest.fn(),
@ -21,7 +22,7 @@ function makeTFile(path: string): TFile {
const filename = parts[parts.length - 1];
const basename = filename.replace(/\.[^.]+$/, "");
const extension = filename.split(".").pop() ?? "";
return { path, basename, extension } as unknown as TFile;
return mockTFile({ path, basename, extension });
}
function makeVaultAdapter() {

Some files were not shown because too many files have changed in this diff Show more