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 <noreply@anthropic.com>
This commit is contained in:
Luca G. Oelfke 2026-02-06 10:34:41 +01:00
parent 1ad0adeb06
commit f5acee3356
No known key found for this signature in database
GPG key ID: E22BABF67200F864
4 changed files with 909 additions and 8 deletions

471
src/history-modal.ts Normal file
View file

@ -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<string> = 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: <s>${this.diff.oldTemplate || 'None'}</s> → <b>${this.diff.newTemplate || 'None'}</b>`;
}
// 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();
}
}

300
src/history.ts Normal file
View file

@ -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<TFolder | null> {
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<HistoryMetadata, 'charCount' | 'estimatedTokens'>
): Promise<HistoryEntry | null> {
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<HistoryEntry[]> {
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<HistoryEntry | null> {
const entries = await this.loadEntries();
return entries.find(e => e.id === id) || null;
}
async deleteEntry(id: string): Promise<boolean> {
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<boolean> {
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<number> {
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<number> {
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);
}

View file

@ -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<number> {
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<HistoryMetadata, 'charCount' | 'estimatedTokens'> = {
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();
}
}

View file

@ -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) {