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:
parent
660bf028ac
commit
563ea7accb
2 changed files with 245 additions and 1 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
186
src/main.ts
186
src/main.ts
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue