feat: add interactive diff viewer with user approval workflow

Implement a comprehensive diff viewing system that allows users to review and approve/reject file changes before they're applied. The system includes event-driven architecture for managing diff lifecycle and integrates diff2html for rich visual diffs.

Key changes:
- Add DiffService for managing diff approval workflow with accept/reject/suggest actions
- Create EventService for type-safe event handling (DiffOpened/DiffClosed)
- Add DiffView component with diff2html integration for visual diff rendering
- Modify VaultService to propose changes and require confirmation before file operations
- Update FileSystemService to support optional confirmation for write operations
- Add Event enum for centralized event type definitions
- Import custom styles and diff2html styles for proper diff rendering
- Update ConversationContent validation to support optional toolId field
- Remove FileManager from dependency injection (now accessed directly from app)
- Update .gitignore to track styles.css instead of main.css
This commit is contained in:
Andrew Beal 2025-11-24 21:29:54 +00:00
parent 6ec1c8aeb9
commit 2d5a1b52bf
26 changed files with 4470 additions and 236 deletions

4
.gitignore vendored
View file

@ -15,8 +15,8 @@ main.js
# Exclude sourcemaps
*.map
# Font files generated by esbuild from bundled dependencies
main.css
# CSS and font files generated by esbuild from bundled dependencies
styles.css
*.woff
*.woff2
*.ttf

View file

@ -37,7 +37,6 @@ export class ConversationContent {
"timestamp" in data &&
"isFunctionCall" in data &&
"isFunctionCallResponse" in data &&
"errorType" in data &&
typeof data.role === "string" &&
typeof data.content === "string" &&
typeof data.promptContent === "string" &&
@ -45,7 +44,10 @@ export class ConversationContent {
typeof data.timestamp === "string" &&
typeof data.isFunctionCall === "boolean" &&
typeof data.isFunctionCallResponse === "boolean" &&
(typeof data.errorType === "string" || data.errorType === undefined)
// optional conversation data fields
(!("toolId" in data) || typeof data.toolId === "string") &&
(!("errorType" in data) || typeof data.errorType === "string")
);
}
}

4
Enums/Event.ts Normal file
View file

@ -0,0 +1,4 @@
export enum Event {
DiffOpened = "diffOpened",
DiffClosed = "diffClosed"
}

View file

@ -46,7 +46,7 @@ export class ConversationFileSystemService {
}))
};
const result = await this.fileSystemService.writeObjectToFile(this.currentConversationPath, conversationData, true);
const result = await this.fileSystemService.writeObjectToFile(this.currentConversationPath, conversationData, true, false);
if (result instanceof Error) {
return result;

82
Services/DiffService.ts Normal file
View file

@ -0,0 +1,82 @@
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 { Platform } from 'obsidian';
interface DiffResult {
accepted: boolean;
suggestion?: string;
}
export class DiffService {
private readonly plugin: VaultkeeperAIPlugin;
private readonly eventService: EventService;
private diffResolve?: (result: DiffResult) => void;
public constructor() {
this.plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
this.eventService = Resolve<EventService>(Services.EventService);
}
public async requestDiff(oldFileName: string, newFileName: string, oldContent: string, newContent: string): Promise<DiffResult> {
const diffString = this.createDiffString(oldFileName, newFileName, oldContent, newContent);
const outputFormat: OutputFormatType = (Platform.isMobile || oldContent.trim() === "") ? "line-by-line" : "side-by-side";
const config: Diff2HtmlUIConfig = {
drawFileList: false,
matching: "words",
outputFormat: outputFormat,
highlight: true,
fileListToggle: false,
fileContentToggle: false,
synchronisedScroll: true,
colorScheme: ColorSchemeType.AUTO
};
return new Promise((resolve) => {
this.diffResolve = resolve;
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 createDiffString(oldFileName: string, newFileName: string, oldContent: string, newContent: string): string {
return Diff.createTwoFilesPatch(oldFileName, newFileName, oldContent, newContent);
}
private finishDiff() {
this.diffResolve = undefined;
this.eventService.trigger(Event.DiffClosed);
}
}

13
Services/EventService.ts Normal file
View file

@ -0,0 +1,13 @@
import type { Event } from "Enums/Event";
import { Events } from "obsidian";
export class EventService extends Events {
public trigger(name: Event.DiffOpened, data?: unknown): void;
public trigger(name: Event.DiffClosed, data?: unknown): void;
public trigger(name: string, ...data: unknown[]): void {
super.trigger(name, ...data);
}
}

View file

@ -70,15 +70,15 @@ export class FileSystemService {
return Exception.new(`File not found: ${filePath}`);
}
public async writeObjectToFile(filePath: string, data: object, allowAccessToPluginRoot: boolean = false): Promise<TFile | Error> {
public async writeObjectToFile(filePath: string, data: object, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot);
let result: TFile | Error;
if (file && file instanceof TFile) {
result = await this.vaultService.modify(file, JSON.stringify(data, null, 4), allowAccessToPluginRoot);
result = await this.vaultService.modify(file, JSON.stringify(data, null, 4), allowAccessToPluginRoot, requiresConfirmation);
}
else {
result = await this.vaultService.create(filePath, JSON.stringify(data, null, 4), allowAccessToPluginRoot);
result = await this.vaultService.create(filePath, JSON.stringify(data, null, 4), allowAccessToPluginRoot, requiresConfirmation);
}
return result;

View file

@ -11,7 +11,6 @@ import { StreamingMarkdownService } from "./StreamingMarkdownService";
import { FileSystemService } from "./FileSystemService";
import { ConversationFileSystemService } from "./ConversationFileSystemService";
import { ConversationHistoryModal } from "Modals/ConversationHistoryModal";
import { FileManager } from "obsidian";
import { AIFunctionService } from "./AIFunctionService";
import { StreamingService } from "./StreamingService";
import { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunctionDefinitions";
@ -36,6 +35,8 @@ import { InputService } from "./InputService";
import { HTMLService } from "./HTMLService";
import { SettingsService, type IVaultkeeperAISettings } from "./SettingsService";
import { HelpModal } from "Modals/HelpModal";
import { EventService } from "./EventService";
import { DiffService } from "./DiffService";
export async function RegisterPlugin(plugin: VaultkeeperAIPlugin) {
RegisterSingleton<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin, plugin);
@ -43,12 +44,11 @@ export async function RegisterPlugin(plugin: VaultkeeperAIPlugin) {
}
export function RegisterDependencies() {
const plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
RegisterSingleton<FileManager>(Services.FileManager, plugin.app.fileManager);
RegisterSingleton<EventService>(Services.EventService, new EventService());
RegisterSingleton<StatusBarService>(Services.StatusBarService, new StatusBarService());
RegisterSingleton<HTMLService>(Services.HTMLService, new HTMLService());
RegisterSingleton<SanitiserService>(Services.SanitiserService, new SanitiserService());
RegisterSingleton<DiffService>(Services.DiffService, new DiffService());
RegisterSingleton<VaultService>(Services.VaultService, new VaultService());
RegisterSingleton<VaultCacheService>(Services.VaultCacheService, new VaultCacheService());
RegisterSingleton<SearchStateStore>(Services.SearchStateStore, new SearchStateStore());

View file

@ -1,9 +1,9 @@
export class Services {
static VaultkeeperAIPlugin = Symbol("VaultkeeperAIPlugin");
static SettingsService = Symbol("SettingsService");
static EventService = Symbol("EventService");
static StatusBarService = Symbol("StatusBarService");
static HTMLService = Symbol("HTMLService");
static FileManager = Symbol("FileManager");
static VaultService = Symbol("VaultService");
static VaultCacheService = Symbol("VaultCacheService");
static UserInputService = Symbol("UserInputService");
@ -19,6 +19,7 @@ export class Services {
static ChatService = Symbol("ChatService");
static SanitiserService = Symbol("SanitiserService");
static InputService = Symbol("InputService");
static DiffService = Symbol("DiffService");
// stores
static SearchStateStore = Symbol("SearchStateStore");

View file

@ -10,6 +10,10 @@ import type { SanitiserService } from "./SanitiserService";
import { FileEvent } from "Enums/FileEvent";
import type { SettingsService } from "./SettingsService";
import { Exception } from "Helpers/Exception";
import type { EventService } from "./EventService";
import { DiffService } from "./DiffService";
import * as path from "path-browserify";
import { Event } from "Enums/Event";
interface IFileEventArgs {
oldPath: string;
@ -26,13 +30,19 @@ export class VaultService {
private readonly settingsService: SettingsService;
private readonly fileManager: FileManager;
private readonly sanitiserService: SanitiserService;
private readonly diffService: DiffService;
private readonly eventService: EventService;
public constructor() {
this.plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
this.vault = this.plugin.app.vault;
this.fileManager = this.plugin.app.fileManager
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
this.fileManager = Resolve<FileManager>(Services.FileManager);
this.sanitiserService = Resolve<SanitiserService>(Services.SanitiserService);
this.diffService = Resolve<DiffService>(Services.DiffService);
this.eventService = Resolve<EventService>(Services.EventService);
}
public registerFileEvents(handleFileEvent: (event: FileEvent, file: TAbstractFile, args: IFileEventArgs) => void) {
@ -74,34 +84,31 @@ export class VaultService {
return await this.vault.read(file);
}
public async create(filePath: string, content: string, allowAccessToPluginRoot: boolean = false): Promise<TFile | Error> {
public async create(filePath: string, content: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
filePath = this.sanitiserService.sanitize(filePath);
if (this.isExclusion(filePath, allowAccessToPluginRoot)) {
Exception.log(`Plugin attempted to create a file that is in the exclusion list: ${filePath}`);
return Exception.new(`Failed to create file, permission denied: ${filePath}`);
}
try {
const fileName = path.basename(filePath);
return this.proposeChange(fileName, fileName, "", content, requiresConfirmation, async () => {
await this.createDirectories(filePath, allowAccessToPluginRoot);
return await this.vault.create(filePath, content);
} catch (error) {
Exception.log(error);
return Exception.new(error);
}
});
}
public async modify(file: TFile, content: string, allowAccessToPluginRoot: boolean = false): Promise<TFile | Error> {
public async modify(file: TFile, content: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
const filePath = this.sanitiserService.sanitize(file.path);
if (this.isExclusion(file.path, allowAccessToPluginRoot)) {
Exception.log(`Plugin attempted to modify a file that is in the exclusion list: ${filePath}`);
return Exception.new(`File does not exist: ${filePath}`);
}
try {
return this.proposeChange(file.name, file.name, await this.vault.read(file), content, requiresConfirmation, async () => {
await this.vault.process(file, () => content);
return file;
} catch (error) {
Exception.log(error);
return Exception.new(error);
}
});
}
public async delete(file: TAbstractFile, allowAccessToPluginRoot: boolean = false): Promise<void | Error> {
@ -408,4 +415,26 @@ export class VaultService {
return merged;
}
private async proposeChange<T>(oldFileName: string, newFileName: string, oldContent: string, newContent: string,
requiresConfirmation: boolean = true, performChange: () => Promise<T>): Promise<T | Error> {
try {
const result = requiresConfirmation ?
await this.diffService.requestDiff(oldFileName, newFileName, oldContent, newContent) : { accepted: true };
if (result.accepted) {
return await performChange();
}
let response = "User rejected this change"; // maybe need an event to abort an ongoing function call exchange?
if (result.suggestion) {
response = `User has rejected the input with the following suggestion: ${result.suggestion}`;
}
return Exception.new(response);
} catch (error) {
this.eventService.trigger(Event.DiffClosed);
Exception.log(error);
return Exception.new(error);
}
}
}

1279
Styles/custom_styles.css Normal file

File diff suppressed because it is too large Load diff

400
Styles/diff2html_styles.css Normal file
View file

@ -0,0 +1,400 @@
/**
* Diff2html Obsidian Theme Override
*
* This stylesheet overrides diff2html's default styling to use Obsidian's CSS variables
*
*/
/* ========================================
ROOT VARIABLE OVERRIDES - LIGHT THEME
======================================== */
:root {
/* Background colors */
--d2h-bg-color: var(--background-primary);
--d2h-file-header-bg-color: var(--background-secondary);
--d2h-file-diff-wrapper-bg-color: var(--background-primary);
--d2h-file-list-wrapper-bg-color: var(--background-secondary);
--d2h-file-list-header-bg-color: var(--background-secondary-alt);
/* Border colors */
--d2h-border-color: var(--background-modifier-border);
--d2h-file-border-color: var(--background-modifier-border);
--d2h-line-border-color: var(--background-modifier-border);
/* Text colors */
--d2h-text-color: var(--text-normal);
--d2h-muted-text-color: var(--text-muted);
--d2h-file-name-color: var(--text-normal);
--d2h-file-stats-color: var(--text-muted);
--d2h-line-number-color: var(--text-muted);
/* Insertion colors (additions) */
--d2h-ins-bg-color: color-mix(in srgb, var(--color-green) 15%, var(--background-primary));
--d2h-ins-label-color: var(--color-green);
--d2h-ins-highlight-bg-color: color-mix(in srgb, var(--color-green) 30%, var(--background-primary));
--d2h-ins-border-color: color-mix(in srgb, var(--color-green) 40%, var(--background-modifier-border));
/* Deletion colors (removals) */
--d2h-del-bg-color: color-mix(in srgb, var(--color-red) 15%, var(--background-primary));
--d2h-del-label-color: var(--color-red);
--d2h-del-highlight-bg-color: color-mix(in srgb, var(--color-red) 30%, var(--background-primary));
--d2h-del-border-color: color-mix(in srgb, var(--color-red) 40%, var(--background-modifier-border));
/* Change/modification colors */
--d2h-change-bg-color: color-mix(in srgb, var(--color-yellow) 15%, var(--background-primary));
--d2h-change-label-color: var(--color-yellow);
/* Info colors */
--d2h-info-bg-color: color-mix(in srgb, var(--color-cyan) 10%, var(--background-secondary));
--d2h-info-text-color: var(--text-muted);
/* Link colors */
--d2h-link-color: var(--interactive-accent);
--d2h-link-hover-color: var(--interactive-accent-hover);
/* Code colors */
--d2h-code-bg-color: var(--background-primary-alt);
--d2h-code-text-color: var(--text-normal);
/* Selection and hover */
--d2h-hover-bg-color: var(--background-modifier-hover);
--d2h-selected-bg-color: var(--background-modifier-active-hover);
}
/* ========================================
DARK THEME OVERRIDES
======================================== */
/* Dark theme with explicit class */
.d2h-dark-color-scheme,
.theme-dark {
/* Background colors */
--d2h-dark-bg-color: var(--background-primary);
--d2h-dark-file-header-bg-color: var(--background-secondary);
--d2h-dark-file-diff-wrapper-bg-color: var(--background-primary);
--d2h-dark-file-list-wrapper-bg-color: var(--background-secondary);
--d2h-dark-file-list-header-bg-color: var(--background-secondary-alt);
/* Border colors */
--d2h-dark-border-color: var(--background-modifier-border);
--d2h-dark-file-border-color: var(--background-modifier-border);
--d2h-dark-line-border-color: var(--background-modifier-border);
/* Text colors */
--d2h-dark-text-color: var(--text-normal);
--d2h-dark-muted-text-color: var(--text-muted);
--d2h-dark-file-name-color: var(--text-normal);
--d2h-dark-file-stats-color: var(--text-muted);
--d2h-dark-line-number-color: var(--text-muted);
/* Insertion colors (additions) */
--d2h-dark-ins-bg-color: color-mix(in srgb, var(--color-green) 15%, var(--background-primary));
--d2h-dark-ins-label-color: color-mix(in srgb, var(--color-green) 120%, white);
--d2h-dark-ins-highlight-bg-color: color-mix(in srgb, var(--color-green) 30%, var(--background-primary));
--d2h-dark-ins-border-color: color-mix(in srgb, var(--color-green) 50%, var(--background-modifier-border));
/* Deletion colors (removals) */
--d2h-dark-del-bg-color: color-mix(in srgb, var(--color-red) 20%, var(--background-primary));
--d2h-dark-del-label-color: color-mix(in srgb, var(--color-red) 120%, white);
--d2h-dark-del-highlight-bg-color: color-mix(in srgb, var(--color-red) 35%, var(--background-primary));
--d2h-dark-del-border-color: color-mix(in srgb, var(--color-red) 50%, var(--background-modifier-border));
/* Change/modification colors */
--d2h-dark-change-bg-color: color-mix(in srgb, var(--color-yellow) 20%, var(--background-primary));
--d2h-dark-change-label-color: color-mix(in srgb, var(--color-yellow) 120%, white);
/* Info colors */
--d2h-dark-info-bg-color: color-mix(in srgb, var(--color-cyan) 15%, var(--background-secondary));
--d2h-dark-info-text-color: var(--text-muted);
/* Link colors */
--d2h-dark-link-color: var(--interactive-accent);
--d2h-dark-link-hover-color: var(--interactive-accent-hover);
/* Code colors */
--d2h-dark-code-bg-color: var(--background-primary-alt);
--d2h-dark-code-text-color: var(--text-normal);
/* Selection and hover */
--d2h-dark-hover-bg-color: var(--background-modifier-hover);
--d2h-dark-selected-bg-color: var(--background-modifier-active-hover);
}
/* Auto theme - follows system preference */
@media (prefers-color-scheme: dark) {
.d2h-auto-color-scheme {
/* Apply dark theme variables */
--d2h-bg-color: var(--d2h-dark-bg-color);
--d2h-file-header-bg-color: var(--d2h-dark-file-header-bg-color);
--d2h-border-color: var(--d2h-dark-border-color);
--d2h-text-color: var(--d2h-dark-text-color);
--d2h-ins-bg-color: var(--d2h-dark-ins-bg-color);
--d2h-ins-label-color: var(--d2h-dark-ins-label-color);
--d2h-ins-highlight-bg-color: var(--d2h-dark-ins-highlight-bg-color);
--d2h-del-bg-color: var(--d2h-dark-del-bg-color);
--d2h-del-label-color: var(--d2h-dark-del-label-color);
--d2h-del-highlight-bg-color: var(--d2h-dark-del-highlight-bg-color);
}
}
/* ========================================
COMPONENT-SPECIFIC OVERRIDES
======================================== */
/* File wrapper */
.d2h-wrapper {
background-color: var(--background-primary);
color: var(--text-normal);
font-family: var(--font-interface);
font-size: var(--font-ui-small);
}
/* File header */
.d2h-file-header {
background-color: var(--background-secondary);
border-color: var(--background-modifier-border);
color: var(--text-normal);
}
.d2h-file-name {
color: var(--text-normal);
font-family: var(--font-monospace);
}
.d2h-file-stats {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
}
/* Code lines */
.d2h-code-line {
color: var(--text-normal);
font-family: var(--font-monospace);
font-size: var(--font-ui-small);
}
.d2h-code-line-ctn {
color: var(--text-normal);
}
.d2h-code-line-prefix {
color: var(--text-muted);
}
/* Line numbers */
.d2h-code-linenumber {
background-color: var(--background-secondary-alt);
color: var(--text-muted);
border-color: var(--background-modifier-border);
}
/* Insertion (added) lines */
.d2h-ins {
background-color: var(--d2h-ins-bg-color);
}
.d2h-ins .d2h-code-line-prefix {
color: var(--d2h-ins-label-color);
}
.d2h-ins .d2h-code-line-ctn {
background-color: transparent;
}
.d2h-ins.d2h-change {
background-color: var(--d2h-change-bg-color);
}
/* Deletion (removed) lines */
.d2h-del {
background-color: var(--d2h-del-bg-color);
}
.d2h-del .d2h-code-line-prefix {
color: var(--d2h-del-label-color);
}
.d2h-del .d2h-code-line-ctn {
background-color: transparent;
}
.d2h-del.d2h-change {
background-color: var(--d2h-change-bg-color);
}
/* Word-level highlighting */
.d2h-ins .d2h-change-highlight {
background-color: var(--d2h-ins-highlight-bg-color);
border-radius: 2px;
padding: 0 2px;
}
.d2h-del .d2h-change-highlight {
background-color: var(--d2h-del-highlight-bg-color);
border-radius: 2px;
padding: 0 2px;
}
/* Context (unchanged) lines */
.d2h-cxt {
background-color: var(--background-primary);
color: var(--text-normal);
}
/* Info lines */
.d2h-info {
background-color: var(--d2h-info-bg-color);
color: var(--d2h-info-text-color);
border-color: var(--background-modifier-border);
}
/* File list */
.d2h-file-list-wrapper {
background-color: var(--background-secondary);
border-color: var(--background-modifier-border);
}
.d2h-file-list-header {
background-color: var(--background-secondary-alt);
border-color: var(--background-modifier-border);
color: var(--text-normal);
}
.d2h-file-list-line {
border-color: var(--background-modifier-border);
}
.d2h-file-list-line:hover {
background-color: var(--background-modifier-hover);
}
/* Links */
.d2h-file-name-link,
.d2h-file-link {
color: var(--interactive-accent);
text-decoration: none;
}
.d2h-file-name-link:hover,
.d2h-file-link:hover {
color: var(--interactive-accent-hover);
text-decoration: underline;
}
/* Tag/label styling */
.d2h-tag {
background-color: var(--background-modifier-border);
color: var(--text-normal);
border-radius: var(--radius-s);
padding: 2px 6px;
font-size: var(--font-ui-smaller);
}
/* Diff table */
.d2h-diff-table {
border-color: var(--background-modifier-border);
background-color: var(--background-primary);
}
/* Empty placeholder */
.d2h-empty-placeholder {
color: var(--text-faint);
background-color: var(--background-secondary);
}
/* ========================================
OBSIDIAN-SPECIFIC ENHANCEMENTS
======================================== */
/* Integrate with Obsidian's scrollbar styling */
.d2h-wrapper::-webkit-scrollbar {
width: var(--scrollbar-width);
}
.d2h-wrapper::-webkit-scrollbar-track {
background: var(--background-primary);
}
.d2h-wrapper::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb-bg);
border-radius: var(--radius-s);
}
.d2h-wrapper::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-active-thumb-bg);
}
/* Match Obsidian's border radius */
.d2h-file-wrapper {
border-radius: var(--radius-m);
}
.d2h-file-header {
border-top-left-radius: var(--radius-m);
border-top-right-radius: var(--radius-m);
}
/* Ensure proper contrast in Obsidian's theme */
.theme-light .d2h-code-line,
.theme-light .d2h-code-linenumber {
border-color: var(--background-modifier-border);
}
.theme-dark .d2h-code-line,
.theme-dark .d2h-code-linenumber {
border-color: var(--background-modifier-border);
}
/* Responsive adjustments for mobile */
.is-mobile .d2h-wrapper {
font-size: var(--font-ui-smaller);
}
.is-mobile .d2h-code-line {
font-size: var(--font-ui-smaller);
padding: var(--size-2-1) var(--size-2-2);
}
/* ========================================
ACCESSIBILITY IMPROVEMENTS
======================================== */
/* Ensure focus states are visible */
.d2h-file-list-line:focus,
.d2h-file-name-link:focus {
outline: 2px solid var(--interactive-accent);
outline-offset: 2px;
}
/* Improve contrast for line numbers */
.d2h-code-linenumber {
opacity: 0.7;
}
.d2h-code-line:hover .d2h-code-linenumber {
opacity: 1;
}
/* ========================================
PRINT STYLES
======================================== */
@media print {
.d2h-wrapper {
background: white;
color: black;
}
.d2h-ins {
background-color: #d4ffd4 !important;
}
.d2h-del {
background-color: #ffd4d4 !important;
}
.d2h-code-linenumber {
background-color: #f5f5f5 !important;
}
}

61
Views/DiffView.ts Normal file
View file

@ -0,0 +1,61 @@
import { Diff2HtmlUI, type Diff2HtmlUIConfig } from "diff2html/lib/ui/js/diff2html-ui";
import { ItemView, WorkspaceLeaf, type ViewStateResult } from "obsidian";
export const VIEW_TYPE_DIFF = 'vaultkeeper-ai-diff-view';
interface DiffViewState {
diffString: string;
config: Diff2HtmlUIConfig;
}
export class DiffView extends ItemView {
private diffString: string = "";
private config: Diff2HtmlUIConfig = {};
private diffContainer: HTMLElement | null = null;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
public getViewType(): string {
return VIEW_TYPE_DIFF;
}
public getDisplayText(): string {
return "Vaultkeeper AI diff";
}
public async setState(state: DiffViewState, result: ViewStateResult): Promise<void> {
this.diffString = state.diffString;
this.config = state.config;
this.renderDiff();
return super.setState(state, result);
}
public getState(): Record<string, unknown> {
return {
diffString: this.diffString,
config: this.config
};
}
private renderDiff() {
const container = this.contentEl;
container.empty();
if (this.diffContainer) {
this.diffContainer.remove();
this.diffContainer = null
}
this.diffContainer = container.createDiv({ cls: 'd2h-wrapper' });
const diff2htmlUi = new Diff2HtmlUI(this.diffContainer, this.diffString, this.config);
diff2htmlUi.draw();
}
}

View file

@ -24,20 +24,20 @@ export class MainView extends ItemView {
topBar: ReturnType<typeof TopBar> | undefined;
input: ChatWindowComponent | undefined;
getViewType() {
public getViewType() {
return VIEW_TYPE_MAIN;
}
getDisplayText() {
public getDisplayText() {
return "Vaultkeeper AI";
}
getIcon(): string {
public getIcon(): string {
return 'sparkles';
}
// eslint-disable-next-line @typescript-eslint/require-await -- mount operations are valid but synchronous
async onOpen() {
public async onOpen() {
const container = this.contentEl;
container.empty();
@ -60,7 +60,7 @@ export class MainView extends ItemView {
}) as ChatWindowComponent;
}
async onClose() {
public async onClose() {
if (this.topBar) {
await unmount(this.topBar);
}

View file

@ -66,8 +66,7 @@ describe('Conversation', () => {
functionCall: '',
timestamp: '2024-01-01T00:00:00.000Z',
isFunctionCall: false,
isFunctionCallResponse: false,
errorType: undefined
isFunctionCallResponse: false
}
]
};

View file

@ -122,10 +122,9 @@ describe('ConversationContent', () => {
content: 'Hello',
promptContent: '',
functionCall: '',
timestamp: '2024-01-01T00:00:00.000Z',
timestamp: '2024-01-01T00:00:00:00.000Z',
isFunctionCall: false,
isFunctionCallResponse: false,
errorType: undefined
isFunctionCallResponse: false
};
expect(ConversationContent.isConversationContentData(validData)).toBe(true);
@ -140,8 +139,7 @@ describe('ConversationContent', () => {
timestamp: '2024-01-01T00:00:00.000Z',
isFunctionCall: true,
isFunctionCallResponse: false,
toolId: 'tool-123',
errorType: undefined
toolId: 'tool-123'
};
expect(ConversationContent.isConversationContentData(validData)).toBe(true);
@ -328,8 +326,7 @@ describe('ConversationContent', () => {
functionCall: '',
timestamp: '2024-01-01T00:00:00.000Z',
isFunctionCall: false,
isFunctionCallResponse: false,
errorType: undefined
isFunctionCallResponse: false
};
expect(ConversationContent.isConversationContentData(validData)).toBe(true);
@ -343,8 +340,7 @@ describe('ConversationContent', () => {
functionCall: '',
timestamp: '',
isFunctionCall: false,
isFunctionCallResponse: false,
errorType: undefined
isFunctionCallResponse: false
};
expect(ConversationContent.isConversationContentData(validData)).toBe(true);

View file

@ -127,7 +127,8 @@ describe('ConversationFileSystemService - Integration Tests', () => {
})
])
}),
true
true,
false
);
});
@ -377,8 +378,7 @@ describe('ConversationFileSystemService - Integration Tests', () => {
functionCall: '',
timestamp: '2024-01-01T10:00:00.000Z',
isFunctionCall: false,
isFunctionCallResponse: false,
errorType: undefined
isFunctionCallResponse: false
}
]
})
@ -394,8 +394,7 @@ describe('ConversationFileSystemService - Integration Tests', () => {
functionCall: '',
timestamp: '2024-01-02T10:00:00.000Z',
isFunctionCall: false,
isFunctionCallResponse: false,
errorType: undefined
isFunctionCallResponse: false
}
]
});
@ -428,8 +427,7 @@ describe('ConversationFileSystemService - Integration Tests', () => {
functionCall: '',
timestamp: '2024-01-01T10:00:00.000Z',
isFunctionCall: false,
isFunctionCallResponse: false,
errorType: undefined
isFunctionCallResponse: false
},
{
role: Role.Assistant,
@ -438,8 +436,7 @@ describe('ConversationFileSystemService - Integration Tests', () => {
functionCall: '',
timestamp: '2024-01-01T10:01:00.000Z',
isFunctionCall: false,
isFunctionCallResponse: false,
errorType: undefined
isFunctionCallResponse: false
}
]
});
@ -506,8 +503,7 @@ describe('ConversationFileSystemService - Integration Tests', () => {
timestamp: '2024-01-01T10:00:00.000Z',
isFunctionCall: true,
isFunctionCallResponse: false,
toolId: 'tool_1',
errorType: undefined
toolId: 'tool_1'
},
{
role: Role.User,
@ -517,8 +513,7 @@ describe('ConversationFileSystemService - Integration Tests', () => {
timestamp: '2024-01-01T10:01:00.000Z',
isFunctionCall: false,
isFunctionCallResponse: true,
toolId: 'tool_1',
errorType: undefined
toolId: 'tool_1'
}
]
});
@ -614,7 +609,8 @@ describe('ConversationFileSystemService - Integration Tests', () => {
mockFileSystemService.listFilesInDirectory.mockResolvedValue(mockFiles);
const savedData = mockFileSystemService.writeObjectToFile.mock.calls[0][1];
mockFileSystemService.readObjectFromFile.mockResolvedValue(savedData);
// Simulate JSON serialization/deserialization which removes undefined values
mockFileSystemService.readObjectFromFile.mockResolvedValue(JSON.parse(JSON.stringify(savedData)));
const loaded = await service.getAllConversations();
expect(loaded[0].title).toBe('Original');
@ -677,7 +673,8 @@ describe('ConversationFileSystemService - Integration Tests', () => {
mockFileSystemService.listFilesInDirectory.mockResolvedValue([
createMockFile('Vaultkeeper AI/Conversations/Complete Test.json')
]);
mockFileSystemService.readObjectFromFile.mockResolvedValue(savedData);
// Simulate JSON serialization/deserialization which removes undefined values
mockFileSystemService.readObjectFromFile.mockResolvedValue(JSON.parse(JSON.stringify(savedData)));
const loaded = await service.getAllConversations();
const reconstructed = loaded[0];

View file

@ -489,7 +489,7 @@ describe('FileSystemService', () => {
const result = await fileSystemService.writeObjectToFile('data.json', data);
expect(result).toBe(mockFile);
expect(mockVaultService.create).toHaveBeenCalledWith('data.json', expectedJson, false);
expect(mockVaultService.create).toHaveBeenCalledWith('data.json', expectedJson, false, true);
});
it('should serialize and write object to existing file', async () => {
@ -503,7 +503,7 @@ describe('FileSystemService', () => {
const result = await fileSystemService.writeObjectToFile('existing.json', data);
expect(result).toBe(mockFile);
expect(mockVaultService.modify).toHaveBeenCalledWith(mockFile, expectedJson, false);
expect(mockVaultService.modify).toHaveBeenCalledWith(mockFile, expectedJson, false, true);
});
it('should format JSON with 4-space indentation', async () => {
@ -515,7 +515,7 @@ describe('FileSystemService', () => {
await fileSystemService.writeObjectToFile('formatted.json', data);
const expectedJson = JSON.stringify(data, null, 4);
expect(mockVaultService.create).toHaveBeenCalledWith('formatted.json', expectedJson, false);
expect(mockVaultService.create).toHaveBeenCalledWith('formatted.json', expectedJson, false, true);
// Verify it contains newlines and indentation
expect(expectedJson).toContain('\n');
expect(expectedJson).toContain(' ');
@ -532,7 +532,7 @@ describe('FileSystemService', () => {
const result = await fileSystemService.writeObjectToFile('empty.json', data);
expect(result).toBe(mockFile);
expect(mockVaultService.create).toHaveBeenCalledWith('empty.json', expectedJson, false);
expect(mockVaultService.create).toHaveBeenCalledWith('empty.json', expectedJson, false, true);
});
it('should handle arrays', async () => {
@ -546,7 +546,7 @@ describe('FileSystemService', () => {
const result = await fileSystemService.writeObjectToFile('array.json', data);
expect(result).toBe(mockFile);
expect(mockVaultService.create).toHaveBeenCalledWith('array.json', expectedJson, false);
expect(mockVaultService.create).toHaveBeenCalledWith('array.json', expectedJson, false, true);
});
it('should respect allowAccessToPluginRoot parameter', async () => {
@ -559,7 +559,7 @@ describe('FileSystemService', () => {
await fileSystemService.writeObjectToFile('plugin/config.json', data, true);
expect(mockVaultService.getAbstractFileByPath).toHaveBeenCalledWith('plugin/config.json', true);
expect(mockVaultService.create).toHaveBeenCalledWith('plugin/config.json', expectedJson, true);
expect(mockVaultService.create).toHaveBeenCalledWith('plugin/config.json', expectedJson, true, true);
});
it('should return Error on write error', async () => {

View file

@ -452,9 +452,19 @@ beforeEach(() => {
// Register services
RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin);
RegisterSingleton(Services.FileManager, mockFileManager);
RegisterSingleton(Services.SanitiserService, new SanitiserService());
// Mock EventService and DiffService to avoid Obsidian Events dependency
const mockEventService = { trigger: vi.fn(), on: vi.fn(), off: vi.fn() };
const mockDiffService = {
requestDiff: vi.fn().mockResolvedValue({ accepted: true }),
onAccept: vi.fn(),
onReject: vi.fn(),
onSuggest: vi.fn()
};
RegisterSingleton(Services.EventService, mockEventService as any);
RegisterSingleton(Services.DiffService, mockDiffService as any);
// Create settings service
settingsService = new SettingsService(mockSettings);
RegisterSingleton(Services.SettingsService, settingsService);

View file

@ -353,9 +353,19 @@ beforeEach(() => {
// Register services
RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin);
RegisterSingleton(Services.FileManager, mockFileManager);
RegisterSingleton(Services.SanitiserService, new SanitiserService());
// Mock EventService and DiffService to avoid Obsidian Events dependency
const mockEventService = { trigger: vi.fn(), on: vi.fn(), off: vi.fn() };
const mockDiffService = {
requestDiff: vi.fn().mockResolvedValue({ accepted: true }),
onAccept: vi.fn(),
onReject: vi.fn(),
onSuggest: vi.fn()
};
RegisterSingleton(Services.EventService, mockEventService as any);
RegisterSingleton(Services.DiffService, mockDiffService as any);
// Create settings service with test configuration
settingsService = new SettingsService(mockSettings);
RegisterSingleton(Services.SettingsService, settingsService);

View file

@ -121,9 +121,19 @@ describe('VaultService - Integration Tests', () => {
// Register real dependencies in DependencyService
RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin as any);
RegisterSingleton(Services.FileManager, mockFileManager);
RegisterSingleton(Services.SanitiserService, new SanitiserService());
// Mock EventService and DiffService to avoid Obsidian Events dependency
const mockEventService = { trigger: vi.fn(), on: vi.fn(), off: vi.fn() };
const mockDiffService = {
requestDiff: vi.fn().mockResolvedValue({ accepted: true }),
onAccept: vi.fn(),
onReject: vi.fn(),
onSuggest: vi.fn()
};
RegisterSingleton(Services.EventService, mockEventService as any);
RegisterSingleton(Services.DiffService, mockDiffService as any);
// Create and register SettingsService
settingsService = new SettingsService(mockSettings);
RegisterSingleton(Services.SettingsService, settingsService);

View file

@ -1,9 +1,10 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import { copyFileSync, mkdirSync, existsSync, readdirSync, statSync } from "fs";
import esbuildSvelte from 'esbuild-svelte';
import { sveltePreprocess } from 'svelte-preprocess';
import { copyFileSync, mkdirSync, existsSync, readdirSync, statSync, readFileSync, writeFileSync, unlinkSync } from "fs";
import { join } from "path";
import esbuildSvelte from "esbuild-svelte";
import { sveltePreprocess } from "svelte-preprocess";
const banner =
`/*
@ -14,18 +15,21 @@ if you want to view the source, please visit the github repository of this plugi
const prod = (process.argv[2] === "production");
// Clean up build artifacts that aren"t needed (main.css, main.js, font files)
const CLEANUP_BUILD_ARTIFACTS = true; // Set to false if you need to debug these files
// Function to copy directory recursively
function copyDir(src, dest) {
if (!existsSync(dest)) {
mkdirSync(dest, { recursive: true });
}
const files = readdirSync(src);
for (const file of files) {
const srcPath = join(src, file);
const destPath = join(dest, file);
if (statSync(srcPath).isDirectory()) {
copyDir(srcPath, destPath);
} else {
@ -35,12 +39,76 @@ function copyDir(src, dest) {
}
}
// Plugin to merge CSS files into styles.css and cleanup build artifacts
const cssMergerPlugin = {
name: "css-merger",
setup(build) {
build.onEnd(() => {
// esbuild outputs CSS as main.css (based on outfile: main.js)
const generatedCss = "main.css";
const customCssDir = "Styles";
const outputCss = "styles.css";
let mergedCss = "";
// Read generated CSS from dependencies if it exists
if (existsSync(generatedCss)) {
mergedCss += readFileSync(generatedCss, "utf-8");
mergedCss += "\n\n/* Custom Styles */\n\n";
}
// Append custom CSS files from styles directory
if (existsSync(customCssDir)) {
const cssFiles = readdirSync(customCssDir)
.filter(file => file.endsWith(".css"))
.sort();
for (const cssFile of cssFiles) {
const cssPath = join(customCssDir, cssFile);
mergedCss += readFileSync(cssPath, "utf-8");
mergedCss += "\n\n";
console.log(`📦 Merged: ${cssPath}`);
}
}
// Write merged CSS to styles.css
writeFileSync(outputCss, mergedCss);
console.log(`✅ Generated: ${outputCss}`);
// Clean up build artifacts if enabled
if (CLEANUP_BUILD_ARTIFACTS) {
// Remove main.css (intermediate CSS file)
if (existsSync(generatedCss)) {
unlinkSync(generatedCss);
console.log(`🗑️ Removed: ${generatedCss}`);
}
// Remove KaTeX font files
let anyRemoved = false;
const fontExtensions = [".woff", ".woff2", ".ttf"];
const files = readdirSync(".");
for (const file of files) {
const ext = file.substring(file.lastIndexOf("."));
if (fontExtensions.includes(ext)) {
unlinkSync(file);
anyRemoved = true;
}
}
if (anyRemoved) {
console.log("🗑️ Removed KaTeX Font Files");
}
}
});
}
};
const buildOptions = {
plugins: [
esbuildSvelte({
compilerOptions: { css: 'injected' },
compilerOptions: { css: "injected" },
preprocess: sveltePreprocess(),
}),
cssMergerPlugin,
],
banner: {
js: banner,
@ -70,10 +138,10 @@ const buildOptions = {
outfile: "main.js",
minify: prod,
loader: {
'.css': 'css',
'.ttf': 'file',
'.woff': 'file',
'.woff2': 'file',
".css": "css",
".ttf": "file",
".woff": "file",
".woff2": "file",
},
};
@ -83,5 +151,4 @@ if (prod) {
} else {
const ctx = await esbuild.context(buildOptions);
await ctx.watch();
console.log("👀 Watching for changes...");
}

35
main.ts
View file

@ -2,6 +2,7 @@ import { WorkspaceLeaf, Plugin } from "obsidian";
import { MainView, VIEW_TYPE_MAIN } from "Views/MainView";
import { RegisterDependencies, RegisterPlugin } from "Services/ServiceRegistration";
import { VaultkeeperAISettingTab } from "VaultkeeperAISettingTab";
import { DiffView, VIEW_TYPE_DIFF } from "Views/DiffView";
import { Services } from "Services/Services";
import type { StatusBarService } from "Services/StatusBarService";
import { DeregisterAllServices, Resolve } from "Services/DependencyService";
@ -9,9 +10,12 @@ import type { VaultService } from "Services/VaultService";
import { Path } from "Enums/Path";
import { Copy } from "Enums/Copy";
import type { SettingsService } from "Services/SettingsService";
import type { Diff2HtmlUIConfig } from "diff2html/lib/ui/js/diff2html-ui";
import "katex/dist/katex.min.css";
import "./styles.css";
import 'highlight.js/styles/github.min.css';
import 'diff2html/bundles/css/diff2html.min.css';
import 'diff2html/bundles/js/diff2html-ui.min.js';
export default class VaultkeeperAIPlugin extends Plugin {
@ -23,17 +27,21 @@ export default class VaultkeeperAIPlugin extends Plugin {
VIEW_TYPE_MAIN,
(leaf) => new MainView(leaf)
);
this.registerView(
VIEW_TYPE_DIFF,
(leaf) => new DiffView(leaf)
);
this.addCommand({
id: "open",
name: "Open",
callback: async () => {
await this.activateView();
await this.activateMainView();
}
});
this.addRibbonIcon("sparkles", "Vaultkeeper AI", async () => {
await this.activateView();
await this.activateMainView();
});
this.addSettingTab(new VaultkeeperAISettingTab());
@ -48,7 +56,7 @@ export default class VaultkeeperAIPlugin extends Plugin {
DeregisterAllServices();
}
public async activateView() {
public async activateMainView() {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = null;
@ -66,6 +74,25 @@ export default class VaultkeeperAIPlugin extends Plugin {
}
}
public async activateDiffView(diffString: string, config: Diff2HtmlUIConfig) {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(VIEW_TYPE_DIFF);
leaf = leaves.length > 0 ? leaves[0] : workspace.getLeaf("tab");
await leaf?.setViewState({
type: VIEW_TYPE_DIFF,
active: true,
state: { diffString, config }
});
if (leaf != null) {
await workspace.revealLeaf(leaf);
}
}
// create example user instruction (on first launch only)
private async setup() {
const settingsService = Resolve<SettingsService>(Services.SettingsService);

16
package-lock.json generated
View file

@ -13,6 +13,7 @@
"@google/genai": "^1.30.0",
"@shikijs/rehype": "^3.15.0",
"core-js": "^3.47.0",
"diff": "^8.0.2",
"diff2html": "^3.4.52",
"express": "^5.1.0",
"fuzzysort": "^3.1.0",
@ -3036,9 +3037,9 @@
}
},
"node_modules/diff": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz",
"integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
@ -3060,6 +3061,15 @@
"highlight.js": "11.9.0"
}
},
"node_modules/diff2html/node_modules/diff": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/diff2html/node_modules/highlight.js": {
"version": "11.9.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.9.0.tgz",

View file

@ -48,6 +48,7 @@
"@google/genai": "^1.30.0",
"@shikijs/rehype": "^3.15.0",
"core-js": "^3.47.0",
"diff": "^8.0.2",
"diff2html": "^3.4.52",
"express": "^5.1.0",
"fuzzysort": "^3.1.0",

2538
styles.css

File diff suppressed because one or more lines are too long