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 { ContextGeneratorModal } from './generator';
|
||||||
import { PreviewModal } from './preview';
|
import { PreviewModal } from './preview';
|
||||||
import { SourceRegistry, formatSourceOutput } from './sources';
|
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 {
|
export default class ClaudeContextPlugin extends Plugin {
|
||||||
settings: ClaudeContextSettings;
|
settings: ClaudeContextSettings;
|
||||||
|
historyManager: HistoryManager;
|
||||||
|
|
||||||
async onload() {
|
async onload() {
|
||||||
await this.loadSettings();
|
await this.loadSettings();
|
||||||
|
this.historyManager = new HistoryManager(this.app, this.settings.history);
|
||||||
|
|
||||||
// Ribbon icon
|
// Ribbon icon
|
||||||
this.addRibbonIcon('clipboard-copy', 'Copy Claude context', () => {
|
this.addRibbonIcon('clipboard-copy', 'Copy Claude context', () => {
|
||||||
|
|
@ -34,15 +38,38 @@ export default class ClaudeContextPlugin extends Plugin {
|
||||||
callback: () => new ContextGeneratorModal(this.app, this).open()
|
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));
|
this.addSettingTab(new ClaudeContextSettingTab(this.app, this));
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadSettings() {
|
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() {
|
async saveSettings() {
|
||||||
await this.saveData(this.settings);
|
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(
|
async copyContextToClipboard(
|
||||||
|
|
@ -124,10 +151,14 @@ export default class ClaudeContextPlugin extends Plugin {
|
||||||
}
|
}
|
||||||
outputParts.push(vaultParts.join(`\n\n${this.settings.separator}\n\n`));
|
outputParts.push(vaultParts.join(`\n\n${this.settings.separator}\n\n`));
|
||||||
|
|
||||||
|
// Track active note for history
|
||||||
|
let activeNotePath: string | null = null;
|
||||||
|
|
||||||
// Include active note
|
// Include active note
|
||||||
if (forceIncludeNote || this.settings.includeActiveNote) {
|
if (forceIncludeNote || this.settings.includeActiveNote) {
|
||||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||||
if (activeView?.file) {
|
if (activeView?.file) {
|
||||||
|
activeNotePath = activeView.file.path;
|
||||||
const content = await this.app.vault.read(activeView.file);
|
const content = await this.app.vault.read(activeView.file);
|
||||||
if (this.settings.includeFilenames) {
|
if (this.settings.includeFilenames) {
|
||||||
outputParts.push(`# === ACTIVE: ${activeView.file.name} ===\n\n${content}`);
|
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) {
|
if (this.settings.showPreview) {
|
||||||
new PreviewModal(this.app, combined, totalCount, async () => {
|
new PreviewModal(this.app, combined, totalCount, copyAndSave).open();
|
||||||
await navigator.clipboard.writeText(combined);
|
|
||||||
this.showCopyNotice(fileCount, sourceCount, templateName);
|
|
||||||
}).open();
|
|
||||||
} else {
|
} else {
|
||||||
await navigator.clipboard.writeText(combined);
|
await copyAndSave();
|
||||||
this.showCopyNotice(fileCount, sourceCount, templateName);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { ContextSource, getSourceIcon, SourceRegistry } from './sources';
|
||||||
import { SourceModal } from './source-modal';
|
import { SourceModal } from './source-modal';
|
||||||
import { PromptTemplate, STARTER_TEMPLATES } from './templates';
|
import { PromptTemplate, STARTER_TEMPLATES } from './templates';
|
||||||
import { TemplateModal, TemplateImportExportModal } from './template-modal';
|
import { TemplateModal, TemplateImportExportModal } from './template-modal';
|
||||||
|
import { HistorySettings, DEFAULT_HISTORY_SETTINGS } from './history';
|
||||||
|
|
||||||
export interface ClaudeContextSettings {
|
export interface ClaudeContextSettings {
|
||||||
contextFolder: string;
|
contextFolder: string;
|
||||||
|
|
@ -16,6 +17,7 @@ export interface ClaudeContextSettings {
|
||||||
showSourceLabels: boolean;
|
showSourceLabels: boolean;
|
||||||
promptTemplates: PromptTemplate[];
|
promptTemplates: PromptTemplate[];
|
||||||
defaultTemplateId: string | null;
|
defaultTemplateId: string | null;
|
||||||
|
history: HistorySettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: ClaudeContextSettings = {
|
export const DEFAULT_SETTINGS: ClaudeContextSettings = {
|
||||||
|
|
@ -29,6 +31,7 @@ export const DEFAULT_SETTINGS: ClaudeContextSettings = {
|
||||||
showSourceLabels: true,
|
showSourceLabels: true,
|
||||||
promptTemplates: [],
|
promptTemplates: [],
|
||||||
defaultTemplateId: null,
|
defaultTemplateId: null,
|
||||||
|
history: DEFAULT_HISTORY_SETTINGS,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ClaudeContextSettingTab extends PluginSettingTab {
|
export class ClaudeContextSettingTab extends PluginSettingTab {
|
||||||
|
|
@ -223,6 +226,84 @@ export class ClaudeContextSettingTab extends PluginSettingTab {
|
||||||
await this.plugin.saveSettings();
|
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) {
|
private renderSourcesList(container: HTMLElement) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue