mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
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:
parent
6ec1c8aeb9
commit
2d5a1b52bf
26 changed files with 4470 additions and 236 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
4
Enums/Event.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export enum Event {
|
||||
DiffOpened = "diffOpened",
|
||||
DiffClosed = "diffClosed"
|
||||
}
|
||||
|
|
@ -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
82
Services/DiffService.ts
Normal 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
13
Services/EventService.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
1279
Styles/custom_styles.css
Normal file
File diff suppressed because it is too large
Load diff
400
Styles/diff2html_styles.css
Normal file
400
Styles/diff2html_styles.css
Normal 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
61
Views/DiffView.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,8 +66,7 @@ describe('Conversation', () => {
|
|||
functionCall: '',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
isFunctionCall: false,
|
||||
isFunctionCallResponse: false,
|
||||
errorType: undefined
|
||||
isFunctionCallResponse: false
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
35
main.ts
|
|
@ -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
16
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
2538
styles.css
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue