diff --git a/src/history-modal.ts b/src/history-modal.ts index 38d9a74..6659265 100644 --- a/src/history-modal.ts +++ b/src/history-modal.ts @@ -8,6 +8,7 @@ import { formatRelativeTime, DiffResult, } from './history'; +import { SaveSnapshotModal } from './snapshot-modal'; export class HistoryModal extends Modal { plugin: PromptfirePlugin; @@ -324,6 +325,11 @@ class HistoryDetailModal extends Modal { 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' }); closeBtn.addEventListener('click', () => { this.onUpdate(); diff --git a/src/main.ts b/src/main.ts index 4b171e5..164c65f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,8 @@ import { SourceRegistry, formatSourceOutput } from './sources'; import { TemplateEngine } from './templates'; import { HistoryManager, HistoryMetadata } from './history'; import { HistoryModal } from './history-modal'; +import { SnapshotManager, ContextSnapshot } from './snapshots'; +import { SnapshotListModal } from './snapshot-modal'; import { ContentSelector, FileSelection } from './content-selector'; import { FileSelectorModal } from './file-selector-modal'; import { @@ -30,10 +32,12 @@ import { SmartContextModal } from './smart-context-modal'; export default class PromptfirePlugin extends Plugin { settings: PromptfireSettings; historyManager: HistoryManager; + snapshotManager: SnapshotManager; async onload() { await this.loadSettings(); this.historyManager = new HistoryManager(this.app, this.settings.history); + this.snapshotManager = new SnapshotManager(this.app); // Ribbon icon this.addRibbonIcon('clipboard-copy', 'Copy Promptfire context', () => { @@ -64,6 +68,12 @@ export default class PromptfirePlugin extends Plugin { callback: () => this.openHistory() }); + this.addCommand({ + id: 'view-snapshots', + name: 'View context snapshots', + callback: () => this.openSnapshots() + }); + this.addCommand({ id: 'copy-context-selective', name: 'Copy context (select sections)', @@ -120,6 +130,145 @@ export default class PromptfirePlugin extends Plugin { 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 = { + 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 { return await this.historyManager.cleanup(); } diff --git a/src/settings.ts b/src/settings.ts index 192c0af..4670cb9 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -629,6 +629,19 @@ export class PromptfireSettingTab extends PluginSettingTab { 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 === diff --git a/src/snapshot-modal.ts b/src/snapshot-modal.ts new file mode 100644 index 0000000..b312f02 --- /dev/null +++ b/src/snapshot-modal.ts @@ -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(); + } +} diff --git a/src/snapshots.ts b/src/snapshots.ts new file mode 100644 index 0000000..fcd5759 --- /dev/null +++ b/src/snapshots.ts @@ -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 { + 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 { + 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 { + 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 { + 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>): Promise { + 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; + } +}