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:
parent
1ad0adeb06
commit
f5acee3356
4 changed files with 909 additions and 8 deletions
471
src/history-modal.ts
Normal file
471
src/history-modal.ts
Normal 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
300
src/history.ts
Normal 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);
|
||||
}
|
||||
65
src/main.ts
65
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<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 {
|
|||
}
|
||||
}
|
||||
|
||||
// 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, async () => {
|
||||
await navigator.clipboard.writeText(combined);
|
||||
this.showCopyNotice(fileCount, sourceCount, templateName);
|
||||
}).open();
|
||||
new PreviewModal(this.app, combined, totalCount, copyAndSave).open();
|
||||
} else {
|
||||
await navigator.clipboard.writeText(combined);
|
||||
this.showCopyNotice(fileCount, sourceCount, templateName);
|
||||
await copyAndSave();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue