From f5acee3356401428bd970601be8de115f839d079 Mon Sep 17 00:00:00 2001 From: "Luca G. Oelfke" Date: Fri, 6 Feb 2026 10:34:41 +0100 Subject: [PATCH] feat: add context history with versioning and diff - Add HistoryManager for saving generated contexts as JSON files - Track metadata: timestamp, template, files, sources, tokens, user notes - Add history modal with list view, detail view, and comparison - Diff view shows added/removed files, sources, and size changes - Configurable: storage folder, max entries, auto-cleanup days - Feature is opt-in (disabled by default) - Add "View context history" command Co-Authored-By: Claude Opus 4.5 --- src/history-modal.ts | 471 +++++++++++++++++++++++++++++++++++++++++++ src/history.ts | 300 +++++++++++++++++++++++++++ src/main.ts | 65 +++++- src/settings.ts | 81 ++++++++ 4 files changed, 909 insertions(+), 8 deletions(-) create mode 100644 src/history-modal.ts create mode 100644 src/history.ts diff --git a/src/history-modal.ts b/src/history-modal.ts new file mode 100644 index 0000000..b16698e --- /dev/null +++ b/src/history-modal.ts @@ -0,0 +1,471 @@ +import { App, Modal, Notice, Setting } from 'obsidian'; +import ClaudeContextPlugin from './main'; +import { + HistoryEntry, + HistoryManager, + diffEntries, + formatTimestamp, + formatRelativeTime, + DiffResult, +} from './history'; + +export class HistoryModal extends Modal { + plugin: ClaudeContextPlugin; + historyManager: HistoryManager; + entries: HistoryEntry[] = []; + selectedEntries: Set = new Set(); + + constructor(app: App, plugin: ClaudeContextPlugin, historyManager: HistoryManager) { + super(app); + this.plugin = plugin; + this.historyManager = historyManager; + } + + async onOpen() { + await this.loadAndRender(); + } + + async loadAndRender() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('claude-context-history-modal'); + + contentEl.createEl('h2', { text: 'Context History' }); + + if (!this.plugin.settings.history.enabled) { + const notice = contentEl.createEl('p', { + text: 'History is disabled. Enable it in settings to start tracking generated contexts.' + }); + notice.style.fontStyle = 'italic'; + notice.style.color = 'var(--text-muted)'; + return; + } + + this.entries = await this.historyManager.loadEntries(); + + if (this.entries.length === 0) { + const notice = contentEl.createEl('p', { + text: 'No history entries yet. Generate and copy context to create entries.' + }); + notice.style.fontStyle = 'italic'; + notice.style.color = 'var(--text-muted)'; + return; + } + + // Toolbar + const toolbar = contentEl.createDiv({ cls: 'history-toolbar' }); + toolbar.style.display = 'flex'; + toolbar.style.gap = '8px'; + toolbar.style.marginBottom = '15px'; + + const compareBtn = toolbar.createEl('button', { text: 'Compare Selected' }); + compareBtn.disabled = true; + compareBtn.addEventListener('click', () => this.compareSelected()); + + const clearBtn = toolbar.createEl('button', { text: 'Clear All' }); + clearBtn.addEventListener('click', async () => { + if (confirm('Delete all history entries? This cannot be undone.')) { + const count = await this.historyManager.clearAll(); + new Notice(`Deleted ${count} history entries`); + await this.loadAndRender(); + } + }); + + const countInfo = toolbar.createEl('span', { + text: `${this.entries.length} entries` + }); + countInfo.style.marginLeft = 'auto'; + countInfo.style.color = 'var(--text-muted)'; + + // Entries list + const list = contentEl.createDiv({ cls: 'history-list' }); + list.style.maxHeight = '400px'; + list.style.overflow = 'auto'; + list.style.border = '1px solid var(--background-modifier-border)'; + list.style.borderRadius = '4px'; + + for (const entry of this.entries) { + const row = list.createDiv({ cls: 'history-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'; + + // Checkbox for comparison + const checkbox = row.createEl('input', { type: 'checkbox' }); + checkbox.addEventListener('change', () => { + if (checkbox.checked) { + this.selectedEntries.add(entry.id); + } else { + this.selectedEntries.delete(entry.id); + } + compareBtn.disabled = this.selectedEntries.size !== 2; + }); + + // Info container + const infoContainer = row.createDiv(); + infoContainer.style.flex = '1'; + + // Timestamp and template + const header = infoContainer.createDiv(); + header.style.display = 'flex'; + header.style.alignItems = 'center'; + header.style.gap = '8px'; + + const time = header.createEl('span', { text: formatRelativeTime(entry.timestamp) }); + time.style.fontWeight = '500'; + time.title = formatTimestamp(entry.timestamp); + + if (entry.metadata.templateName) { + const templateBadge = header.createEl('span', { text: entry.metadata.templateName }); + templateBadge.style.padding = '2px 6px'; + templateBadge.style.borderRadius = '3px'; + templateBadge.style.fontSize = '11px'; + templateBadge.style.backgroundColor = 'var(--background-modifier-hover)'; + } + + // Stats line + const stats = infoContainer.createDiv(); + stats.style.fontSize = '12px'; + stats.style.color = 'var(--text-muted)'; + stats.style.marginTop = '4px'; + + const fileCount = entry.metadata.includedFiles.length; + const sourceCount = entry.metadata.includedSources.length; + const tokens = entry.metadata.estimatedTokens; + + let statsText = `${fileCount} files`; + if (sourceCount > 0) statsText += `, ${sourceCount} sources`; + statsText += ` ยท ~${tokens.toLocaleString()} tokens`; + + stats.setText(statsText); + + // User note + if (entry.metadata.userNote) { + const note = infoContainer.createDiv({ text: entry.metadata.userNote }); + note.style.fontSize = '12px'; + note.style.fontStyle = 'italic'; + note.style.marginTop = '4px'; + } + + // Action buttons + const actions = row.createDiv(); + actions.style.display = 'flex'; + actions.style.gap = '4px'; + + // Copy button + const copyBtn = actions.createEl('button', { text: '๐Ÿ“‹' }); + copyBtn.title = 'Copy to clipboard'; + copyBtn.style.padding = '4px 8px'; + copyBtn.addEventListener('click', async () => { + await navigator.clipboard.writeText(entry.content); + new Notice('Copied to clipboard'); + }); + + // View button + const viewBtn = actions.createEl('button', { text: '๐Ÿ‘' }); + viewBtn.title = 'View details'; + viewBtn.style.padding = '4px 8px'; + viewBtn.addEventListener('click', () => { + new HistoryDetailModal(this.app, this.plugin, this.historyManager, entry, () => { + this.loadAndRender(); + }).open(); + }); + + // Delete button + const deleteBtn = actions.createEl('button', { text: 'โœ•' }); + deleteBtn.title = 'Delete'; + deleteBtn.style.padding = '4px 8px'; + deleteBtn.addEventListener('click', async () => { + await this.historyManager.deleteEntry(entry.id); + new Notice('Entry deleted'); + await this.loadAndRender(); + }); + } + + // Remove bottom border from last row + const lastRow = list.lastElementChild as HTMLElement; + if (lastRow) { + lastRow.style.borderBottom = 'none'; + } + } + + async compareSelected() { + if (this.selectedEntries.size !== 2) { + new Notice('Select exactly 2 entries to compare'); + return; + } + + const ids = Array.from(this.selectedEntries); + const entry1 = this.entries.find(e => e.id === ids[0]); + const entry2 = this.entries.find(e => e.id === ids[1]); + + if (!entry1 || !entry2) return; + + // Ensure older entry is first + const [older, newer] = entry1.timestamp < entry2.timestamp + ? [entry1, entry2] + : [entry2, entry1]; + + new HistoryDiffModal(this.app, older, newer).open(); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} + +class HistoryDetailModal extends Modal { + plugin: ClaudeContextPlugin; + historyManager: HistoryManager; + entry: HistoryEntry; + onUpdate: () => void; + + constructor( + app: App, + plugin: ClaudeContextPlugin, + historyManager: HistoryManager, + entry: HistoryEntry, + onUpdate: () => void + ) { + super(app); + this.plugin = plugin; + this.historyManager = historyManager; + this.entry = entry; + this.onUpdate = onUpdate; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('claude-context-history-detail'); + + contentEl.createEl('h2', { text: 'History Entry Details' }); + + // Metadata section + const metaSection = contentEl.createDiv(); + metaSection.style.marginBottom = '20px'; + + this.addMetaRow(metaSection, 'Timestamp', formatTimestamp(this.entry.timestamp)); + this.addMetaRow(metaSection, 'Template', this.entry.metadata.templateName || 'None'); + this.addMetaRow(metaSection, 'Characters', this.entry.metadata.charCount.toLocaleString()); + this.addMetaRow(metaSection, 'Est. Tokens', this.entry.metadata.estimatedTokens.toLocaleString()); + + if (this.entry.metadata.activeNote) { + this.addMetaRow(metaSection, 'Active Note', this.entry.metadata.activeNote); + } + + // Included files + if (this.entry.metadata.includedFiles.length > 0) { + contentEl.createEl('h4', { text: 'Included Files' }); + const fileList = contentEl.createEl('ul'); + fileList.style.fontSize = '12px'; + fileList.style.maxHeight = '100px'; + fileList.style.overflow = 'auto'; + for (const file of this.entry.metadata.includedFiles) { + fileList.createEl('li', { text: file }); + } + } + + // Included sources + if (this.entry.metadata.includedSources.length > 0) { + contentEl.createEl('h4', { text: 'Included Sources' }); + const sourceList = contentEl.createEl('ul'); + sourceList.style.fontSize = '12px'; + for (const source of this.entry.metadata.includedSources) { + sourceList.createEl('li', { text: source }); + } + } + + // User note + contentEl.createEl('h4', { text: 'Note' }); + new Setting(contentEl) + .setDesc('Add a personal note to remember what this context was for') + .addTextArea(text => { + text.setPlaceholder('e.g., "Refactoring AuthService"') + .setValue(this.entry.metadata.userNote || '') + .onChange(async (value) => { + await this.historyManager.updateEntryNote(this.entry.id, value); + this.entry.metadata.userNote = value; + }); + text.inputEl.rows = 2; + text.inputEl.style.width = '100%'; + }); + + // Content preview + contentEl.createEl('h4', { text: 'Content Preview' }); + const preview = contentEl.createDiv(); + preview.style.maxHeight = '200px'; + preview.style.overflow = 'auto'; + preview.style.padding = '10px'; + preview.style.backgroundColor = 'var(--background-secondary)'; + preview.style.borderRadius = '4px'; + preview.style.fontFamily = 'monospace'; + preview.style.fontSize = '11px'; + preview.style.whiteSpace = 'pre-wrap'; + + const previewText = this.entry.content.length > 5000 + ? this.entry.content.substring(0, 5000) + '\n\n... (truncated)' + : this.entry.content; + preview.setText(previewText); + + // Buttons + const buttonContainer = contentEl.createDiv(); + buttonContainer.style.display = 'flex'; + buttonContainer.style.justifyContent = 'flex-end'; + buttonContainer.style.gap = '10px'; + buttonContainer.style.marginTop = '20px'; + + const copyBtn = buttonContainer.createEl('button', { text: 'Copy to Clipboard', cls: 'mod-cta' }); + copyBtn.addEventListener('click', async () => { + await navigator.clipboard.writeText(this.entry.content); + new Notice('Copied to clipboard'); + }); + + const closeBtn = buttonContainer.createEl('button', { text: 'Close' }); + closeBtn.addEventListener('click', () => { + this.onUpdate(); + this.close(); + }); + } + + private addMetaRow(container: HTMLElement, label: string, value: string) { + const row = container.createDiv(); + row.style.display = 'flex'; + row.style.marginBottom = '6px'; + + const labelEl = row.createEl('span', { text: `${label}:` }); + labelEl.style.width = '100px'; + labelEl.style.fontWeight = '500'; + + row.createEl('span', { text: value }); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} + +class HistoryDiffModal extends Modal { + older: HistoryEntry; + newer: HistoryEntry; + diff: DiffResult; + + constructor(app: App, older: HistoryEntry, newer: HistoryEntry) { + super(app); + this.older = older; + this.newer = newer; + this.diff = diffEntries(older, newer); + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('claude-context-history-diff'); + + contentEl.createEl('h2', { text: 'Compare Contexts' }); + + // Header with timestamps + const header = contentEl.createDiv(); + header.style.display = 'flex'; + header.style.justifyContent = 'space-between'; + header.style.marginBottom = '20px'; + header.style.padding = '10px'; + header.style.backgroundColor = 'var(--background-secondary)'; + header.style.borderRadius = '4px'; + + const olderInfo = header.createDiv(); + olderInfo.createEl('div', { text: 'Older' }).style.fontWeight = '500'; + olderInfo.createEl('div', { text: formatTimestamp(this.older.timestamp) }).style.fontSize = '12px'; + + const arrow = header.createEl('span', { text: 'โ†’' }); + arrow.style.alignSelf = 'center'; + arrow.style.fontSize = '20px'; + + const newerInfo = header.createDiv(); + newerInfo.style.textAlign = 'right'; + newerInfo.createEl('div', { text: 'Newer' }).style.fontWeight = '500'; + newerInfo.createEl('div', { text: formatTimestamp(this.newer.timestamp) }).style.fontSize = '12px'; + + // Summary stats + const summary = contentEl.createDiv(); + summary.style.marginBottom = '20px'; + + const charDiffText = this.diff.charDiff >= 0 + ? `+${this.diff.charDiff.toLocaleString()}` + : this.diff.charDiff.toLocaleString(); + const tokenDiffText = this.diff.tokenDiff >= 0 + ? `+${this.diff.tokenDiff.toLocaleString()}` + : this.diff.tokenDiff.toLocaleString(); + + summary.createEl('p', { + text: `Size change: ${charDiffText} characters (${tokenDiffText} tokens)` + }); + + // Template change + if (this.diff.templateChanged) { + const templateChange = summary.createEl('p'); + templateChange.innerHTML = `Template: ${this.diff.oldTemplate || 'None'} โ†’ ${this.diff.newTemplate || 'None'}`; + } + + // Files diff + if (this.diff.addedFiles.length > 0 || this.diff.removedFiles.length > 0) { + contentEl.createEl('h4', { text: 'Files Changed' }); + const filesDiv = contentEl.createDiv(); + filesDiv.style.fontSize = '12px'; + + for (const file of this.diff.addedFiles) { + const line = filesDiv.createDiv({ text: `+ ${file}` }); + line.style.color = 'var(--text-success)'; + } + + for (const file of this.diff.removedFiles) { + const line = filesDiv.createDiv({ text: `- ${file}` }); + line.style.color = 'var(--text-error)'; + } + } + + // Sources diff + if (this.diff.addedSources.length > 0 || this.diff.removedSources.length > 0) { + contentEl.createEl('h4', { text: 'Sources Changed' }); + const sourcesDiv = contentEl.createDiv(); + sourcesDiv.style.fontSize = '12px'; + + for (const source of this.diff.addedSources) { + const line = sourcesDiv.createDiv({ text: `+ ${source}` }); + line.style.color = 'var(--text-success)'; + } + + for (const source of this.diff.removedSources) { + const line = sourcesDiv.createDiv({ text: `- ${source}` }); + line.style.color = 'var(--text-error)'; + } + } + + // No changes + if ( + this.diff.addedFiles.length === 0 && + this.diff.removedFiles.length === 0 && + this.diff.addedSources.length === 0 && + this.diff.removedSources.length === 0 && + !this.diff.templateChanged + ) { + contentEl.createEl('p', { + text: 'No structural changes detected. Content may have changed within files.' + }).style.fontStyle = 'italic'; + } + + // Close button + const closeBtn = contentEl.createEl('button', { text: 'Close' }); + closeBtn.style.marginTop = '20px'; + closeBtn.addEventListener('click', () => this.close()); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/history.ts b/src/history.ts new file mode 100644 index 0000000..cc29dd4 --- /dev/null +++ b/src/history.ts @@ -0,0 +1,300 @@ +import { App, TFile, TFolder } from 'obsidian'; + +// === Types === + +export interface HistoryEntry { + id: string; + timestamp: number; + content: string; + metadata: HistoryMetadata; +} + +export interface HistoryMetadata { + templateId: string | null; + templateName: string | null; + includedFiles: string[]; + includedSources: string[]; + activeNote: string | null; + charCount: number; + estimatedTokens: number; + userNote?: string; +} + +export interface HistorySettings { + enabled: boolean; + storageFolder: string; + maxEntries: number; + autoCleanupDays: number; +} + +export const DEFAULT_HISTORY_SETTINGS: HistorySettings = { + enabled: false, + storageFolder: '.context-history', + maxEntries: 50, + autoCleanupDays: 30, +}; + +// === ID Generation === + +export function generateHistoryId(): string { + return 'hist_' + Date.now().toString(36) + Math.random().toString(36).substring(2, 9); +} + +// === Token Estimation === + +export function estimateTokens(text: string): number { + // Rough estimation: ~4 characters per token for English text + // This is a simplified heuristic, actual tokenization varies by model + return Math.ceil(text.length / 4); +} + +// === History Manager === + +export class HistoryManager { + private app: App; + private settings: HistorySettings; + + constructor(app: App, settings: HistorySettings) { + this.app = app; + this.settings = settings; + } + + updateSettings(settings: HistorySettings) { + this.settings = settings; + } + + private get folderPath(): string { + return this.settings.storageFolder; + } + + private async ensureFolder(): Promise { + const existing = this.app.vault.getAbstractFileByPath(this.folderPath); + if (existing instanceof TFolder) { + return existing; + } + + try { + await this.app.vault.createFolder(this.folderPath); + return this.app.vault.getAbstractFileByPath(this.folderPath) as TFolder; + } catch { + return null; + } + } + + private getEntryFilename(entry: HistoryEntry): string { + const date = new Date(entry.timestamp); + const dateStr = date.toISOString().replace(/[:.]/g, '-').substring(0, 19); + return `${dateStr}_${entry.id}.json`; + } + + async saveEntry( + content: string, + metadata: Omit + ): Promise { + if (!this.settings.enabled) { + return null; + } + + const folder = await this.ensureFolder(); + if (!folder) { + return null; + } + + const entry: HistoryEntry = { + id: generateHistoryId(), + timestamp: Date.now(), + content, + metadata: { + ...metadata, + charCount: content.length, + estimatedTokens: estimateTokens(content), + }, + }; + + const filename = this.getEntryFilename(entry); + const filePath = `${this.folderPath}/${filename}`; + + try { + await this.app.vault.create(filePath, JSON.stringify(entry, null, 2)); + await this.cleanup(); + return entry; + } catch { + return null; + } + } + + async loadEntries(): Promise { + const folder = this.app.vault.getAbstractFileByPath(this.folderPath); + if (!folder || !(folder instanceof TFolder)) { + return []; + } + + const entries: HistoryEntry[] = []; + + for (const file of folder.children) { + if (file instanceof TFile && file.extension === 'json') { + try { + const content = await this.app.vault.read(file); + const entry = JSON.parse(content) as HistoryEntry; + entries.push(entry); + } catch { + // Skip invalid entries + } + } + } + + // Sort by timestamp descending (newest first) + entries.sort((a, b) => b.timestamp - a.timestamp); + + return entries; + } + + async loadEntry(id: string): Promise { + const entries = await this.loadEntries(); + return entries.find(e => e.id === id) || null; + } + + async deleteEntry(id: string): Promise { + const folder = this.app.vault.getAbstractFileByPath(this.folderPath); + 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 updateEntryNote(id: string, userNote: string): Promise { + const folder = this.app.vault.getAbstractFileByPath(this.folderPath); + 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 entry = JSON.parse(content) as HistoryEntry; + entry.metadata.userNote = userNote; + await this.app.vault.modify(file, JSON.stringify(entry, null, 2)); + return true; + } catch { + return false; + } + } + } + + return false; + } + + async cleanup(): Promise { + const entries = await this.loadEntries(); + let deletedCount = 0; + + // Delete entries exceeding max count + if (entries.length > this.settings.maxEntries) { + const toDelete = entries.slice(this.settings.maxEntries); + for (const entry of toDelete) { + if (await this.deleteEntry(entry.id)) { + deletedCount++; + } + } + } + + // Delete entries older than autoCleanupDays + if (this.settings.autoCleanupDays > 0) { + const cutoff = Date.now() - (this.settings.autoCleanupDays * 24 * 60 * 60 * 1000); + const remainingEntries = await this.loadEntries(); + + for (const entry of remainingEntries) { + if (entry.timestamp < cutoff) { + if (await this.deleteEntry(entry.id)) { + deletedCount++; + } + } + } + } + + return deletedCount; + } + + async clearAll(): Promise { + const entries = await this.loadEntries(); + let deletedCount = 0; + + for (const entry of entries) { + if (await this.deleteEntry(entry.id)) { + deletedCount++; + } + } + + return deletedCount; + } +} + +// === Diff Utilities === + +export interface DiffResult { + addedFiles: string[]; + removedFiles: string[]; + addedSources: string[]; + removedSources: string[]; + templateChanged: boolean; + oldTemplate: string | null; + newTemplate: string | null; + charDiff: number; + tokenDiff: number; +} + +export function diffEntries(older: HistoryEntry, newer: HistoryEntry): DiffResult { + const oldFiles = new Set(older.metadata.includedFiles); + const newFiles = new Set(newer.metadata.includedFiles); + + const oldSources = new Set(older.metadata.includedSources); + const newSources = new Set(newer.metadata.includedSources); + + return { + addedFiles: newer.metadata.includedFiles.filter(f => !oldFiles.has(f)), + removedFiles: older.metadata.includedFiles.filter(f => !newFiles.has(f)), + addedSources: newer.metadata.includedSources.filter(s => !oldSources.has(s)), + removedSources: older.metadata.includedSources.filter(s => !newSources.has(s)), + templateChanged: older.metadata.templateId !== newer.metadata.templateId, + oldTemplate: older.metadata.templateName, + newTemplate: newer.metadata.templateName, + charDiff: newer.metadata.charCount - older.metadata.charCount, + tokenDiff: newer.metadata.estimatedTokens - older.metadata.estimatedTokens, + }; +} + +// === Formatting === + +export function formatTimestamp(timestamp: number): string { + const date = new Date(timestamp); + return date.toLocaleString(); +} + +export function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + + return formatTimestamp(timestamp); +} diff --git a/src/main.ts b/src/main.ts index 96b7ddf..e6ae27b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,13 +3,17 @@ import { ClaudeContextSettings, ClaudeContextSettingTab, DEFAULT_SETTINGS } from import { ContextGeneratorModal } from './generator'; import { PreviewModal } from './preview'; import { SourceRegistry, formatSourceOutput } from './sources'; -import { TemplateEngine, PromptTemplate } from './templates'; +import { TemplateEngine } from './templates'; +import { HistoryManager, HistoryMetadata } from './history'; +import { HistoryModal } from './history-modal'; export default class ClaudeContextPlugin extends Plugin { settings: ClaudeContextSettings; + historyManager: HistoryManager; async onload() { await this.loadSettings(); + this.historyManager = new HistoryManager(this.app, this.settings.history); // Ribbon icon this.addRibbonIcon('clipboard-copy', 'Copy Claude context', () => { @@ -34,15 +38,38 @@ export default class ClaudeContextPlugin extends Plugin { callback: () => new ContextGeneratorModal(this.app, this).open() }); + this.addCommand({ + id: 'view-history', + name: 'View context history', + callback: () => this.openHistory() + }); + this.addSettingTab(new ClaudeContextSettingTab(this.app, this)); } async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + const loaded = await this.loadData(); + this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded); + // Ensure nested objects are properly merged + if (loaded?.history) { + this.settings.history = Object.assign({}, DEFAULT_SETTINGS.history, loaded.history); + } } async saveSettings() { await this.saveData(this.settings); + // Update history manager with new settings + if (this.historyManager) { + this.historyManager.updateSettings(this.settings.history); + } + } + + openHistory() { + new HistoryModal(this.app, this, this.historyManager).open(); + } + + async runHistoryCleanup(): Promise { + return await this.historyManager.cleanup(); } async copyContextToClipboard( @@ -124,10 +151,14 @@ export default class ClaudeContextPlugin extends Plugin { } outputParts.push(vaultParts.join(`\n\n${this.settings.separator}\n\n`)); + // Track active note for history + let activeNotePath: string | null = null; + // Include active note if (forceIncludeNote || this.settings.includeActiveNote) { const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); if (activeView?.file) { + activeNotePath = activeView.file.path; const content = await this.app.vault.read(activeView.file); if (this.settings.includeFilenames) { outputParts.push(`# === ACTIVE: ${activeView.file.name} ===\n\n${content}`); @@ -164,14 +195,32 @@ export default class ClaudeContextPlugin extends Plugin { } } - if (this.settings.showPreview) { - new PreviewModal(this.app, combined, totalCount, async () => { - await navigator.clipboard.writeText(combined); - this.showCopyNotice(fileCount, sourceCount, templateName); - }).open(); - } else { + // Prepare history metadata + const historyMetadata: Omit = { + templateId: effectiveTemplateId || null, + templateName, + includedFiles: files.map(f => f.path), + includedSources: [ + ...prefixSources.map(r => r.source.name), + ...suffixSources.map(r => r.source.name), + ...(temporaryFreetext?.trim() ? ['Session Context'] : []), + ], + activeNote: activeNotePath, + }; + + // Copy and save to history + const copyAndSave = async () => { await navigator.clipboard.writeText(combined); this.showCopyNotice(fileCount, sourceCount, templateName); + + // Save to history + await this.historyManager.saveEntry(combined, historyMetadata); + }; + + if (this.settings.showPreview) { + new PreviewModal(this.app, combined, totalCount, copyAndSave).open(); + } else { + await copyAndSave(); } } diff --git a/src/settings.ts b/src/settings.ts index 0130370..d6055a5 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -4,6 +4,7 @@ import { ContextSource, getSourceIcon, SourceRegistry } from './sources'; import { SourceModal } from './source-modal'; import { PromptTemplate, STARTER_TEMPLATES } from './templates'; import { TemplateModal, TemplateImportExportModal } from './template-modal'; +import { HistorySettings, DEFAULT_HISTORY_SETTINGS } from './history'; export interface ClaudeContextSettings { contextFolder: string; @@ -16,6 +17,7 @@ export interface ClaudeContextSettings { showSourceLabels: boolean; promptTemplates: PromptTemplate[]; defaultTemplateId: string | null; + history: HistorySettings; } export const DEFAULT_SETTINGS: ClaudeContextSettings = { @@ -29,6 +31,7 @@ export const DEFAULT_SETTINGS: ClaudeContextSettings = { showSourceLabels: true, promptTemplates: [], defaultTemplateId: null, + history: DEFAULT_HISTORY_SETTINGS, }; export class ClaudeContextSettingTab extends PluginSettingTab { @@ -223,6 +226,84 @@ export class ClaudeContextSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); }); }); + + // === HISTORY SECTION === + containerEl.createEl('h3', { text: 'Context History' }); + + const historyDesc = containerEl.createEl('p', { + text: 'Track and compare previously generated contexts. Useful for iterative LLM workflows.', + cls: 'setting-item-description' + }); + historyDesc.style.marginBottom = '10px'; + + new Setting(containerEl) + .setName('Enable history') + .setDesc('Save generated contexts for later review and comparison') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.history.enabled) + .onChange(async (value) => { + this.plugin.settings.history.enabled = value; + await this.plugin.saveSettings(); + this.display(); + })); + + if (this.plugin.settings.history.enabled) { + new Setting(containerEl) + .setName('Storage folder') + .setDesc('Folder in your vault where history entries are stored') + .addText(text => text + .setPlaceholder('.context-history') + .setValue(this.plugin.settings.history.storageFolder) + .onChange(async (value) => { + this.plugin.settings.history.storageFolder = value || '.context-history'; + await this.plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName('Maximum entries') + .setDesc('Oldest entries will be deleted when limit is exceeded') + .addText(text => text + .setPlaceholder('50') + .setValue(String(this.plugin.settings.history.maxEntries)) + .onChange(async (value) => { + const num = parseInt(value, 10); + if (!isNaN(num) && num > 0) { + this.plugin.settings.history.maxEntries = num; + await this.plugin.saveSettings(); + } + })); + + new Setting(containerEl) + .setName('Auto-cleanup (days)') + .setDesc('Delete entries older than this many days (0 = disabled)') + .addText(text => text + .setPlaceholder('30') + .setValue(String(this.plugin.settings.history.autoCleanupDays)) + .onChange(async (value) => { + const num = parseInt(value, 10); + if (!isNaN(num) && num >= 0) { + this.plugin.settings.history.autoCleanupDays = num; + await this.plugin.saveSettings(); + } + })); + + // History actions + const historyActions = containerEl.createDiv(); + historyActions.style.display = 'flex'; + historyActions.style.gap = '8px'; + historyActions.style.marginTop = '10px'; + + const viewHistoryBtn = historyActions.createEl('button', { text: 'View History' }); + viewHistoryBtn.addEventListener('click', () => { + this.plugin.openHistory(); + }); + + const cleanupBtn = historyActions.createEl('button', { text: 'Run Cleanup Now' }); + cleanupBtn.addEventListener('click', async () => { + const deleted = await this.plugin.runHistoryCleanup(); + new Notice(`Cleaned up ${deleted} old entries`); + }); + } } private renderSourcesList(container: HTMLElement) {