obsidian-promptfire/src/snapshot-modal.ts
luca-tty 40ce34c9b4 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>
2026-02-11 13:45:59 +01:00

230 lines
7 KiB
TypeScript

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();
}
}