feat: add context diff command to copy only changed files since last export

Stores per-file djb2 content hashes in history metadata on every export,
enabling a new "Copy context diff" command that compares against the most
recent baseline and copies only new/modified files with [NEW]/[MODIFIED] tags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Oelfke 2026-02-11 14:43:23 +01:00
parent 660bf028ac
commit 563ea7accb
2 changed files with 245 additions and 1 deletions

View file

@ -18,6 +18,7 @@ export interface HistoryMetadata {
charCount: number;
estimatedTokens: number;
userNote?: string;
fileHashes?: Record<string, string>; // path -> djb2 hash
}
export interface HistorySettings {
@ -48,6 +49,16 @@ export function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
// === Hashing ===
export function djb2Hash(str: string): string {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) & 0xFFFFFFFF;
}
return (hash >>> 0).toString(16).padStart(8, '0');
}
// === History Manager ===
export class HistoryManager {
@ -276,6 +287,55 @@ export function diffEntries(older: HistoryEntry, newer: HistoryEntry): DiffResul
};
}
// === Context Diff ===
export interface ContextDiffResult {
newFiles: string[];
modifiedFiles: string[];
removedFiles: string[];
unchangedFiles: string[];
baselineTimestamp: number;
baselineId: string;
}
export function computeContextDiff(
currentHashes: Record<string, string>,
baseline: HistoryEntry
): ContextDiffResult | null {
const baseHashes = baseline.metadata.fileHashes;
if (!baseHashes) return null;
const newFiles: string[] = [];
const modifiedFiles: string[] = [];
const unchangedFiles: string[] = [];
for (const [path, hash] of Object.entries(currentHashes)) {
if (!(path in baseHashes)) {
newFiles.push(path);
} else if (baseHashes[path] !== hash) {
modifiedFiles.push(path);
} else {
unchangedFiles.push(path);
}
}
const currentPaths = new Set(Object.keys(currentHashes));
const removedFiles = Object.keys(baseHashes).filter(p => !currentPaths.has(p));
return {
newFiles,
modifiedFiles,
removedFiles,
unchangedFiles,
baselineTimestamp: baseline.timestamp,
baselineId: baseline.id,
};
}
export function findBaselineWithHashes(entries: HistoryEntry[]): HistoryEntry | null {
return entries.find(e => e.metadata.fileHashes != null) ?? null;
}
// === Formatting ===
export function formatTimestamp(timestamp: number): string {

View file

@ -4,7 +4,7 @@ import { ContextGeneratorModal } from './generator';
import { PreviewModal } from './preview';
import { SourceRegistry, formatSourceOutput } from './sources';
import { TemplateEngine } from './templates';
import { HistoryManager, HistoryMetadata } from './history';
import { HistoryManager, HistoryMetadata, djb2Hash, ContextDiffResult, computeContextDiff, findBaselineWithHashes } from './history';
import { HistoryModal } from './history-modal';
import { SnapshotManager, ContextSnapshot } from './snapshots';
import { SnapshotListModal } from './snapshot-modal';
@ -99,6 +99,12 @@ export default class PromptfirePlugin extends Plugin {
callback: () => this.exportMultiTarget()
});
this.addCommand({
id: 'copy-context-diff',
name: 'Copy context diff (changes since last export)',
callback: () => this.copyContextDiff()
});
this.addSettingTab(new PromptfireSettingTab(this.app, this));
}
@ -174,12 +180,14 @@ export default class PromptfirePlugin extends Plugin {
// Read files and track missing ones
const missingFiles: string[] = [];
const vaultParts: string[] = [];
const readFiles: TFile[] = [];
for (const notePath of snapshot.notePaths) {
// Strip (partial) suffix — replay always includes full file
const cleanPath = notePath.replace(/ \(partial\)$/, '');
const file = this.app.vault.getAbstractFileByPath(cleanPath);
if (file instanceof TFile) {
readFiles.push(file);
const content = await this.app.vault.read(file);
if (snapshot.includeFilenames) {
vaultParts.push(`# === ${file.name} ===\n\n${content}`);
@ -258,6 +266,7 @@ export default class PromptfirePlugin extends Plugin {
],
activeNote: activeNotePath,
userNote: `Replayed snapshot: ${snapshot.name}`,
fileHashes: await this.computeFileHashes(readFiles),
};
// Copy and save to history
@ -422,6 +431,7 @@ export default class PromptfirePlugin extends Plugin {
...(temporaryFreetext?.trim() ? ['Session Context'] : []),
],
activeNote: activeNotePath,
fileHashes: await this.computeFileHashes(files),
};
// Process targets if provided
@ -612,6 +622,7 @@ export default class PromptfirePlugin extends Plugin {
...suffixSources.map(r => r.source.name),
],
activeNote: null,
fileHashes: await this.computeFileHashes(selectedFiles.map(s => s.file)),
};
// Copy and save
@ -642,6 +653,177 @@ export default class PromptfirePlugin extends Plugin {
return checkAll(selection.headings);
}
private async computeFileHashes(files: TFile[]): Promise<Record<string, string>> {
const hashes: Record<string, string> = {};
for (const file of files) {
const content = await this.app.vault.read(file);
hashes[file.path] = djb2Hash(content);
}
return hashes;
}
async copyContextDiff() {
// Verify history is enabled
if (!this.settings.history.enabled) {
new Notice('Context diff requires history to be enabled. Enable it in Settings > History.');
return;
}
// Find baseline
const entries = await this.historyManager.loadEntries();
const baseline = findBaselineWithHashes(entries);
if (!baseline) {
new Notice('No baseline found. Run a normal context copy first to establish a baseline.');
return;
}
// Read current context files
const folder = this.app.vault.getAbstractFileByPath(this.settings.contextFolder);
if (!folder || !(folder instanceof TFolder)) {
new Notice(`Folder "${this.settings.contextFolder}" not found`);
return;
}
const excludedFiles = this.settings.excludedFiles.map(f => f.toLowerCase());
const files = folder.children
.filter((f): f is TFile =>
f instanceof TFile &&
f.extension === 'md' &&
!excludedFiles.includes(f.name.toLowerCase())
)
.sort((a, b) => {
if (a.basename === 'VAULT') return -1;
if (b.basename === 'VAULT') return 1;
return a.basename.localeCompare(b.basename);
});
// Compute current hashes and cache content
const currentHashes: Record<string, string> = {};
const contentCache: Record<string, string> = {};
for (const file of files) {
const content = await this.app.vault.read(file);
currentHashes[file.path] = djb2Hash(content);
contentCache[file.path] = content;
}
// Compute diff
const diff = computeContextDiff(currentHashes, baseline);
if (!diff) {
new Notice('Baseline entry has no file hashes. Run a normal context copy first.');
return;
}
// Check if there are any changes
if (diff.newFiles.length === 0 && diff.modifiedFiles.length === 0) {
let msg = 'No changes since last export.';
if (diff.removedFiles.length > 0) {
msg += ` (${diff.removedFiles.length} file(s) removed)`;
}
new Notice(msg);
return;
}
// Resolve additional sources (included fully)
const registry = new SourceRegistry();
const enabledSources = this.settings.sources.filter(s => s.enabled);
const resolvedSources = await registry.resolveAll(enabledSources);
const errors = resolvedSources.filter(r => r.error);
if (errors.length > 0) {
const errorNames = errors.map(e => e.source.name).join(', ');
new Notice(`Some sources failed: ${errorNames}`, 5000);
}
const prefixSources = resolvedSources.filter(r => r.source.position === 'prefix' && !r.error);
const suffixSources = resolvedSources.filter(r => r.source.position === 'suffix' && !r.error);
// Build output with only delta files
const outputParts: string[] = [];
// Add prefix sources (fully)
for (const resolved of prefixSources) {
const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels);
if (formatted) outputParts.push(formatted);
}
// Add delta files
const deltaFiles = [...diff.newFiles, ...diff.modifiedFiles];
const deltaFileSet = new Set(deltaFiles);
const vaultParts: string[] = [];
for (const file of files) {
if (!deltaFileSet.has(file.path)) continue;
const content = contentCache[file.path] ?? '';
if (this.settings.includeFilenames) {
const tag = diff.newFiles.includes(file.path) ? ' [NEW]' : ' [MODIFIED]';
vaultParts.push(`# === ${file.name}${tag} ===\n\n${content}`);
} else {
vaultParts.push(content);
}
}
if (vaultParts.length > 0) {
outputParts.push(vaultParts.join(`\n\n${this.settings.separator}\n\n`));
}
// Add suffix sources (fully)
for (const resolved of suffixSources) {
const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels);
if (formatted) outputParts.push(formatted);
}
let combined = outputParts.join(`\n\n${this.settings.separator}\n\n`);
// Apply default template if set
const effectiveTemplateId = this.settings.defaultTemplateId;
let templateName: string | null = null;
if (effectiveTemplateId) {
const template = this.settings.promptTemplates.find(t => t.id === effectiveTemplateId);
if (template) {
const engine = new TemplateEngine(this.app);
const context = await engine.buildContext(combined);
combined = await engine.processTemplate(template.content, context);
templateName = template.name;
}
}
// Build summary
const summary = `Diff: ${diff.newFiles.length} new, ${diff.modifiedFiles.length} modified, ${diff.removedFiles.length} removed (${diff.unchangedFiles.length} unchanged)`;
// Prepare history metadata with ALL current hashes (becomes next baseline)
const historyMetadata: Omit<HistoryMetadata, 'charCount' | 'estimatedTokens'> = {
templateId: effectiveTemplateId || null,
templateName,
includedFiles: deltaFiles,
includedSources: [
...prefixSources.map(r => r.source.name),
...suffixSources.map(r => r.source.name),
],
activeNote: null,
userNote: summary,
fileHashes: currentHashes,
};
// Copy and save
const copyAndSave = async () => {
await navigator.clipboard.writeText(combined);
let message = `Context diff: ${diff.newFiles.length} new, ${diff.modifiedFiles.length} modified, ${diff.removedFiles.length} removed (${diff.unchangedFiles.length} unchanged)`;
if (templateName) message += ` using "${templateName}"`;
new Notice(message, 5000);
await this.historyManager.saveEntry(combined, historyMetadata);
};
if (this.settings.showPreview) {
const totalCount = deltaFiles.length + prefixSources.length + suffixSources.length;
new PreviewModal(this.app, combined, totalCount, copyAndSave).open();
} else {
await copyAndSave();
}
}
async exportMultiTarget() {
const profileId = this.settings.activeProfileId;
if (!profileId) {
@ -814,6 +996,7 @@ export default class PromptfirePlugin extends Plugin {
],
activeNote: this.settings.intelligence.includeActiveNote ? activeFile.path : null,
userNote: `Smart context from: ${activeFile.basename}`,
fileHashes: await this.computeFileHashes(selectedNotes.map(n => n.file)),
};
// Copy and save
@ -1024,6 +1207,7 @@ export default class PromptfirePlugin extends Plugin {
],
activeNote: includeActive ? activeFile.path : null,
userNote: `Preset from: ${activeFile.basename}`,
fileHashes: await this.computeFileHashes(result.files),
};
// Copy and save