andy-stack_vaultkeeper-ai/Services/DiffService.ts
Andrew Beal 065d475d74 Refactor file patching from unified diff to direct string replace
This change simplifies the patch mechanism by replacing the unified diff format with a straightforward find-and-replace approach. The PatchVaultFile function now accepts oldContent and newContent parameters instead of a diff patch string, making it more intuitive and reliable for AI agents to use.

Key changes:
- Update PatchVaultFile function signature and documentation
- Replace diff.applyPatch() with String.replace()
- Simplify error handling for content matching
- Update all tests to reflect new API
- Remove unused applyPatch method from DiffService
- Fix diff container height calculation in CSS
- Fix error notification logic in ChatService
- Fix conversation deletion order in TopBar component
2025-12-05 23:02:55 +00:00

120 lines
No EOL
3.8 KiB
TypeScript

import * as Diff from 'diff';
import type VaultkeeperAIPlugin from 'main';
import { Resolve } from './DependencyService';
import { Services } from './Services';
import type { EventService } from './EventService';
import { Event } from 'Enums/Event';
import type { Diff2HtmlUIConfig } from 'diff2html/lib/ui/js/diff2html-ui';
import { ColorSchemeType, OutputFormatType } from 'diff2html/lib/types';
import { Component } from 'obsidian';
import { AbortService } from './AbortService';
interface DiffResult {
accepted: boolean;
suggestion?: string;
}
export class DiffService extends Component {
private readonly plugin: VaultkeeperAIPlugin;
private readonly eventService: EventService;
private readonly abortService: AbortService;
private diffResolve?: (result: DiffResult) => void;
private ongoingDiff: boolean = false;
public constructor() {
super();
this.plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
this.eventService = Resolve<EventService>(Services.EventService);
this.abortService = Resolve<AbortService>(Services.AbortService);
this.registerEvent(this.eventService.on(Event.DiffClosed, () => {
this.cancelPendingDiff();
}));
}
public async requestDiff(oldFileName: string, newFileName: string, oldContent: string, newContent: string): Promise<DiffResult> {
const diffString = this.createDiffString(oldFileName, newFileName, oldContent, newContent);
const outputFormat: OutputFormatType = "line-by-line";
const config: Diff2HtmlUIConfig = {
drawFileList: false,
matching: "words",
outputFormat: outputFormat,
highlight: true,
fileListToggle: false,
fileContentToggle: false,
synchronisedScroll: true,
colorScheme: ColorSchemeType.AUTO
};
this.ongoingDiff = true;
const signal = this.abortService.signal();
return new Promise<DiffResult>((resolve, reject) => {
if (signal.aborted) {
this.finishDiff();
reject(this.abortService.reason());
return;
}
const abortHandler = () => {
this.finishDiff();
reject(this.abortService.reason());
};
signal.addEventListener("abort", abortHandler, { once: true });
this.diffResolve = (result: DiffResult) => {
signal.removeEventListener("abort", abortHandler);
resolve(result);
};
void this.plugin.activateDiffView(diffString, config);
this.eventService.trigger(Event.DiffOpened);
});
}
public onAccept() {
if (this.diffResolve) {
this.diffResolve({ accepted: true });
}
this.finishDiff();
}
public onReject() {
if (this.diffResolve) {
this.diffResolve({ accepted: false });
}
this.finishDiff();
}
public onSuggest(suggestion: string) {
if (this.diffResolve) {
this.diffResolve({ accepted: false, suggestion: suggestion });
}
this.finishDiff();
}
private cancelPendingDiff() {
if (this.ongoingDiff) {
if (this.diffResolve) {
this.diffResolve({ accepted: false });
}
this.finishDiff();
}
}
private createDiffString(oldFileName: string, newFileName: string, oldContent: string, newContent: string): string {
return Diff.createTwoFilesPatch(oldFileName, newFileName, oldContent, newContent, undefined, undefined, { context: Infinity });
}
private finishDiff() {
this.ongoingDiff = false;
this.diffResolve = undefined;
this.eventService.trigger(Event.DiffClosed);
}
}