feat: add context snapshots for saving and replaying context recipes

Snapshots capture the file list, template, and formatting config from a
history entry so the context can be regenerated from current vault state.
Stored as JSON in .context-snapshots/ with no auto-cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Oelfke 2026-02-11 13:45:59 +01:00
parent 06a228847f
commit 40ce34c9b4
5 changed files with 537 additions and 0 deletions

View file

@ -8,6 +8,7 @@ import {
formatRelativeTime, formatRelativeTime,
DiffResult, DiffResult,
} from './history'; } from './history';
import { SaveSnapshotModal } from './snapshot-modal';
export class HistoryModal extends Modal { export class HistoryModal extends Modal {
plugin: PromptfirePlugin; plugin: PromptfirePlugin;
@ -324,6 +325,11 @@ class HistoryDetailModal extends Modal {
new Notice('Copied to clipboard'); new Notice('Copied to clipboard');
}); });
const snapshotBtn = buttonContainer.createEl('button', { text: 'Save as Snapshot' });
snapshotBtn.addEventListener('click', () => {
new SaveSnapshotModal(this.app, this.plugin, this.entry).open();
});
const closeBtn = buttonContainer.createEl('button', { text: 'Close' }); const closeBtn = buttonContainer.createEl('button', { text: 'Close' });
closeBtn.addEventListener('click', () => { closeBtn.addEventListener('click', () => {
this.onUpdate(); this.onUpdate();

View file

@ -6,6 +6,8 @@ import { SourceRegistry, formatSourceOutput } from './sources';
import { TemplateEngine } from './templates'; import { TemplateEngine } from './templates';
import { HistoryManager, HistoryMetadata } from './history'; import { HistoryManager, HistoryMetadata } from './history';
import { HistoryModal } from './history-modal'; import { HistoryModal } from './history-modal';
import { SnapshotManager, ContextSnapshot } from './snapshots';
import { SnapshotListModal } from './snapshot-modal';
import { ContentSelector, FileSelection } from './content-selector'; import { ContentSelector, FileSelection } from './content-selector';
import { FileSelectorModal } from './file-selector-modal'; import { FileSelectorModal } from './file-selector-modal';
import { import {
@ -30,10 +32,12 @@ import { SmartContextModal } from './smart-context-modal';
export default class PromptfirePlugin extends Plugin { export default class PromptfirePlugin extends Plugin {
settings: PromptfireSettings; settings: PromptfireSettings;
historyManager: HistoryManager; historyManager: HistoryManager;
snapshotManager: SnapshotManager;
async onload() { async onload() {
await this.loadSettings(); await this.loadSettings();
this.historyManager = new HistoryManager(this.app, this.settings.history); this.historyManager = new HistoryManager(this.app, this.settings.history);
this.snapshotManager = new SnapshotManager(this.app);
// Ribbon icon // Ribbon icon
this.addRibbonIcon('clipboard-copy', 'Copy Promptfire context', () => { this.addRibbonIcon('clipboard-copy', 'Copy Promptfire context', () => {
@ -64,6 +68,12 @@ export default class PromptfirePlugin extends Plugin {
callback: () => this.openHistory() callback: () => this.openHistory()
}); });
this.addCommand({
id: 'view-snapshots',
name: 'View context snapshots',
callback: () => this.openSnapshots()
});
this.addCommand({ this.addCommand({
id: 'copy-context-selective', id: 'copy-context-selective',
name: 'Copy context (select sections)', name: 'Copy context (select sections)',
@ -120,6 +130,145 @@ export default class PromptfirePlugin extends Plugin {
new HistoryModal(this.app, this, this.historyManager).open(); new HistoryModal(this.app, this, this.historyManager).open();
} }
openSnapshots() {
new SnapshotListModal(this.app, this).open();
}
async replaySnapshot(snapshot: ContextSnapshot) {
// Resolve additional sources
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 parts
const outputParts: string[] = [];
// Add prefix sources
for (const resolved of prefixSources) {
const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels);
if (formatted) outputParts.push(formatted);
}
// Read files and track missing ones
const missingFiles: string[] = [];
const vaultParts: string[] = [];
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) {
const content = await this.app.vault.read(file);
if (snapshot.includeFilenames) {
vaultParts.push(`# === ${file.name} ===\n\n${content}`);
} else {
vaultParts.push(content);
}
} else {
missingFiles.push(cleanPath);
}
}
if (vaultParts.length === 0 && !snapshot.activeNotePath) {
new Notice('All files in this snapshot are missing');
return;
}
if (vaultParts.length > 0) {
outputParts.push(vaultParts.join(`\n\n${snapshot.separator}\n\n`));
}
// Add active note if set
let activeNotePath: string | null = null;
if (snapshot.activeNotePath) {
const activeFile = this.app.vault.getAbstractFileByPath(snapshot.activeNotePath);
if (activeFile instanceof TFile) {
activeNotePath = activeFile.path;
const content = await this.app.vault.read(activeFile);
if (snapshot.includeFilenames) {
outputParts.push(`# === ACTIVE: ${activeFile.name} ===\n\n${content}`);
} else {
outputParts.push(`--- ACTIVE NOTE ---\n\n${content}`);
}
} else {
missingFiles.push(snapshot.activeNotePath);
}
}
// Add suffix sources
for (const resolved of suffixSources) {
const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels);
if (formatted) outputParts.push(formatted);
}
let combined = outputParts.join(`\n\n${snapshot.separator}\n\n`);
// Apply template if set
let templateName: string | null = null;
if (snapshot.templateId) {
const template = this.settings.promptTemplates.find(t => t.id === snapshot.templateId);
if (template) {
const engine = new TemplateEngine(this.app);
const context = await engine.buildContext(combined);
combined = await engine.processTemplate(template.content, context);
templateName = template.name;
} else {
new Notice('Snapshot template no longer exists, continuing without template', 5000);
}
}
// Warn about missing files
if (missingFiles.length > 0) {
new Notice(`${missingFiles.length} file(s) missing, skipped:\n${missingFiles.join('\n')}`, 8000);
}
// Prepare history metadata
const includedFiles = snapshot.notePaths
.map(p => p.replace(/ \(partial\)$/, ''))
.filter(p => !missingFiles.includes(p));
const historyMetadata: Omit<HistoryMetadata, 'charCount' | 'estimatedTokens'> = {
templateId: snapshot.templateId,
templateName,
includedFiles,
includedSources: [
...prefixSources.map(r => r.source.name),
...suffixSources.map(r => r.source.name),
],
activeNote: activeNotePath,
userNote: `Replayed snapshot: ${snapshot.name}`,
};
// Copy and save to history
const copyAndSave = async () => {
await navigator.clipboard.writeText(combined);
const fileCount = includedFiles.length + (activeNotePath ? 1 : 0);
const sourceCount = prefixSources.length + suffixSources.length;
let message = `Replayed snapshot "${snapshot.name}": ${fileCount} files`;
if (sourceCount > 0) message += `, ${sourceCount} sources`;
if (templateName) message += ` using "${templateName}"`;
new Notice(message);
await this.historyManager.saveEntry(combined, historyMetadata);
};
if (this.settings.showPreview) {
const totalCount = includedFiles.length + (activeNotePath ? 1 : 0) + prefixSources.length + suffixSources.length;
new PreviewModal(this.app, combined, totalCount, copyAndSave).open();
} else {
await copyAndSave();
}
}
async runHistoryCleanup(): Promise<number> { async runHistoryCleanup(): Promise<number> {
return await this.historyManager.cleanup(); return await this.historyManager.cleanup();
} }

View file

@ -629,6 +629,19 @@ export class PromptfireSettingTab extends PluginSettingTab {
new Notice(`Cleaned up ${deleted} old entries`); new Notice(`Cleaned up ${deleted} old entries`);
}); });
} }
// Snapshots subsection (independent of history toggle)
const snapshotsSection = new CollapsibleSection(el, 'snapshots', 'Snapshots', this.plugin);
const snc = snapshotsSection.contentEl;
new Setting(snc)
.setName('View snapshots')
.setDesc('Browse, replay, or delete saved context snapshots')
.addButton(button => button
.setButtonText('View Snapshots')
.onClick(() => {
this.plugin.openSnapshots();
}));
} }
// === TAB: Intelligence === // === TAB: Intelligence ===

230
src/snapshot-modal.ts Normal file
View file

@ -0,0 +1,230 @@
import { App, Modal, Notice, Setting } from 'obsidian';
import PromptfirePlugin from './main';
import { HistoryEntry, formatRelativeTime } from './history';
import { ContextSnapshot, SnapshotManager, generateSnapshotId } from './snapshots';
export class SaveSnapshotModal extends Modal {
private plugin: PromptfirePlugin;
private entry: HistoryEntry;
private snapshotManager: SnapshotManager;
private name = '';
private description = '';
constructor(app: App, plugin: PromptfirePlugin, entry: HistoryEntry) {
super(app);
this.plugin = plugin;
this.entry = entry;
this.snapshotManager = plugin.snapshotManager;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('promptfire-save-snapshot');
contentEl.createEl('h2', { text: 'Save as Snapshot' });
// Summary
const summary = contentEl.createDiv();
summary.style.marginBottom = '15px';
summary.style.padding = '10px';
summary.style.backgroundColor = 'var(--background-secondary)';
summary.style.borderRadius = '4px';
summary.style.fontSize = '12px';
const fileCount = this.entry.metadata.includedFiles.length;
const templateName = this.entry.metadata.templateName || 'None';
const activeNote = this.entry.metadata.activeNote || 'None';
summary.createEl('div', { text: `Files: ${fileCount}` });
summary.createEl('div', { text: `Template: ${templateName}` });
summary.createEl('div', { text: `Active note: ${activeNote}` });
// Name field
let nameInput: HTMLInputElement;
new Setting(contentEl)
.setName('Name')
.setDesc('A short name for this snapshot (required)')
.addText(text => {
nameInput = text.inputEl;
text.setPlaceholder('e.g., "Auth refactor context"')
.onChange(value => { this.name = value; });
});
// Description field
new Setting(contentEl)
.setName('Description')
.setDesc('Optional notes about when to use this snapshot')
.addTextArea(text => {
text.setPlaceholder('e.g., "Use when working on auth module"')
.onChange(value => { this.description = value; });
text.inputEl.rows = 2;
text.inputEl.style.width = '100%';
});
// Buttons
const buttonContainer = contentEl.createDiv();
buttonContainer.style.display = 'flex';
buttonContainer.style.justifyContent = 'flex-end';
buttonContainer.style.gap = '10px';
buttonContainer.style.marginTop = '20px';
const saveBtn = buttonContainer.createEl('button', { text: 'Save Snapshot', cls: 'mod-cta' });
saveBtn.addEventListener('click', async () => {
if (!this.name.trim()) {
new Notice('Name is required');
return;
}
const snapshot: ContextSnapshot = {
id: generateSnapshotId(),
name: this.name.trim(),
description: this.description.trim() || undefined,
createdAt: Date.now(),
notePaths: this.entry.metadata.includedFiles,
activeNotePath: this.entry.metadata.activeNote,
templateId: this.entry.metadata.templateId,
includeFilenames: this.plugin.settings.includeFilenames,
separator: this.plugin.settings.separator,
};
const success = await this.snapshotManager.saveSnapshot(snapshot);
if (success) {
new Notice(`Snapshot saved: ${snapshot.name}`);
this.close();
} else {
new Notice('Failed to save snapshot');
}
});
const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel' });
cancelBtn.addEventListener('click', () => this.close());
}
onClose() {
this.contentEl.empty();
}
}
export class SnapshotListModal extends Modal {
private plugin: PromptfirePlugin;
private snapshotManager: SnapshotManager;
constructor(app: App, plugin: PromptfirePlugin) {
super(app);
this.plugin = plugin;
this.snapshotManager = plugin.snapshotManager;
}
async onOpen() {
await this.loadAndRender();
}
async loadAndRender() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('promptfire-snapshot-list');
contentEl.createEl('h2', { text: 'Context Snapshots' });
const snapshots = await this.snapshotManager.loadSnapshots();
if (snapshots.length === 0) {
const notice = contentEl.createEl('p', {
text: 'No snapshots yet. Open a history entry and click "Save as Snapshot" to create one.'
});
notice.style.fontStyle = 'italic';
notice.style.color = 'var(--text-muted)';
return;
}
const countInfo = contentEl.createEl('p', {
text: `${snapshots.length} snapshot${snapshots.length !== 1 ? 's' : ''}`
});
countInfo.style.color = 'var(--text-muted)';
countInfo.style.marginBottom = '10px';
const list = contentEl.createDiv({ cls: 'snapshot-list' });
list.style.maxHeight = '400px';
list.style.overflow = 'auto';
list.style.border = '1px solid var(--background-modifier-border)';
list.style.borderRadius = '4px';
for (const snapshot of snapshots) {
const row = list.createDiv({ cls: 'snapshot-row' });
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.padding = '10px 12px';
row.style.borderBottom = '1px solid var(--background-modifier-border)';
row.style.gap = '10px';
// Info
const infoContainer = row.createDiv();
infoContainer.style.flex = '1';
const header = infoContainer.createDiv();
header.style.display = 'flex';
header.style.alignItems = 'center';
header.style.gap = '8px';
const nameEl = header.createEl('span', { text: snapshot.name });
nameEl.style.fontWeight = '500';
const timeEl = header.createEl('span', { text: formatRelativeTime(snapshot.createdAt) });
timeEl.style.color = 'var(--text-muted)';
timeEl.style.fontSize = '12px';
// Stats
const stats = infoContainer.createDiv();
stats.style.fontSize = '12px';
stats.style.color = 'var(--text-muted)';
stats.style.marginTop = '4px';
const fileCount = snapshot.notePaths.length;
const templateName = snapshot.templateId
? (this.plugin.settings.promptTemplates.find(t => t.id === snapshot.templateId)?.name || 'Unknown template')
: 'No template';
stats.setText(`${fileCount} files · ${templateName}`);
// Description
if (snapshot.description) {
const desc = infoContainer.createDiv({ text: snapshot.description });
desc.style.fontSize = '12px';
desc.style.fontStyle = 'italic';
desc.style.marginTop = '4px';
}
// Actions
const actions = row.createDiv();
actions.style.display = 'flex';
actions.style.gap = '4px';
const replayBtn = actions.createEl('button', { text: '\u25B6' });
replayBtn.title = 'Replay snapshot';
replayBtn.style.padding = '4px 8px';
replayBtn.addEventListener('click', async () => {
this.close();
await this.plugin.replaySnapshot(snapshot);
});
const deleteBtn = actions.createEl('button', { text: '\u2715' });
deleteBtn.title = 'Delete snapshot';
deleteBtn.style.padding = '4px 8px';
deleteBtn.addEventListener('click', async () => {
await this.snapshotManager.deleteSnapshot(snapshot.id);
new Notice('Snapshot deleted');
await this.loadAndRender();
});
}
// Remove bottom border from last row
const lastRow = list.lastElementChild as HTMLElement;
if (lastRow) {
lastRow.style.borderBottom = 'none';
}
}
onClose() {
this.contentEl.empty();
}
}

139
src/snapshots.ts Normal file
View file

@ -0,0 +1,139 @@
import { App, TFile, TFolder } from 'obsidian';
// === Types ===
export interface ContextSnapshot {
id: string;
name: string;
description?: string;
createdAt: number;
notePaths: string[];
activeNotePath: string | null;
templateId: string | null;
includeFilenames: boolean;
separator: string;
}
// === ID Generation ===
export function generateSnapshotId(): string {
return 'snap_' + Date.now().toString(36) + Math.random().toString(36).substring(2, 9);
}
// === Snapshot Manager ===
const SNAPSHOT_FOLDER = '.context-snapshots';
export class SnapshotManager {
private app: App;
constructor(app: App) {
this.app = app;
}
private async ensureFolder(): Promise<TFolder | null> {
const existing = this.app.vault.getAbstractFileByPath(SNAPSHOT_FOLDER);
if (existing instanceof TFolder) {
return existing;
}
try {
await this.app.vault.createFolder(SNAPSHOT_FOLDER);
return this.app.vault.getAbstractFileByPath(SNAPSHOT_FOLDER) as TFolder;
} catch {
return null;
}
}
private getSnapshotFilename(snapshot: ContextSnapshot): string {
const date = new Date(snapshot.createdAt);
const dateStr = date.toISOString().replace(/[:.]/g, '-').substring(0, 19);
return `${dateStr}_${snapshot.id}.json`;
}
async saveSnapshot(snapshot: ContextSnapshot): Promise<boolean> {
const folder = await this.ensureFolder();
if (!folder) {
return false;
}
const filename = this.getSnapshotFilename(snapshot);
const filePath = `${SNAPSHOT_FOLDER}/${filename}`;
try {
await this.app.vault.create(filePath, JSON.stringify(snapshot, null, 2));
return true;
} catch {
return false;
}
}
async loadSnapshots(): Promise<ContextSnapshot[]> {
const folder = this.app.vault.getAbstractFileByPath(SNAPSHOT_FOLDER);
if (!folder || !(folder instanceof TFolder)) {
return [];
}
const snapshots: ContextSnapshot[] = [];
for (const file of folder.children) {
if (file instanceof TFile && file.extension === 'json') {
try {
const content = await this.app.vault.read(file);
const snapshot = JSON.parse(content) as ContextSnapshot;
snapshots.push(snapshot);
} catch {
// Skip invalid files
}
}
}
snapshots.sort((a, b) => b.createdAt - a.createdAt);
return snapshots;
}
async deleteSnapshot(id: string): Promise<boolean> {
const folder = this.app.vault.getAbstractFileByPath(SNAPSHOT_FOLDER);
if (!folder || !(folder instanceof TFolder)) {
return false;
}
for (const file of folder.children) {
if (file instanceof TFile && file.name.includes(id)) {
try {
await this.app.vault.delete(file);
return true;
} catch {
return false;
}
}
}
return false;
}
async updateSnapshot(id: string, updates: Partial<Pick<ContextSnapshot, 'name' | 'description'>>): Promise<boolean> {
const folder = this.app.vault.getAbstractFileByPath(SNAPSHOT_FOLDER);
if (!folder || !(folder instanceof TFolder)) {
return false;
}
for (const file of folder.children) {
if (file instanceof TFile && file.name.includes(id)) {
try {
const content = await this.app.vault.read(file);
const snapshot = JSON.parse(content) as ContextSnapshot;
if (updates.name !== undefined) snapshot.name = updates.name;
if (updates.description !== undefined) snapshot.description = updates.description;
await this.app.vault.modify(file, JSON.stringify(snapshot, null, 2));
return true;
} catch {
return false;
}
}
}
return false;
}
}