obsidian-promptfire/src/settings.ts
Luca G. Oelfke f5acee3356
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>
2026-02-06 10:34:41 +01:00

499 lines
17 KiB
TypeScript

import { App, Notice, PluginSettingTab, Setting } from 'obsidian';
import ClaudeContextPlugin from './main';
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;
separator: string;
includeFilenames: boolean;
showPreview: boolean;
includeActiveNote: boolean;
excludedFiles: string[];
sources: ContextSource[];
showSourceLabels: boolean;
promptTemplates: PromptTemplate[];
defaultTemplateId: string | null;
history: HistorySettings;
}
export const DEFAULT_SETTINGS: ClaudeContextSettings = {
contextFolder: '_claude',
separator: '---',
includeFilenames: true,
showPreview: false,
includeActiveNote: false,
excludedFiles: [],
sources: [],
showSourceLabels: true,
promptTemplates: [],
defaultTemplateId: null,
history: DEFAULT_HISTORY_SETTINGS,
};
export class ClaudeContextSettingTab extends PluginSettingTab {
plugin: ClaudeContextPlugin;
constructor(app: App, plugin: ClaudeContextPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('Context folder')
.setDesc('Folder containing your context files')
.addText(text => text
.setPlaceholder('_claude')
.setValue(this.plugin.settings.contextFolder)
.onChange(async (value) => {
this.plugin.settings.contextFolder = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Separator')
.setDesc('Text between files (e.g. "---" or "***")')
.addText(text => text
.setPlaceholder('---')
.setValue(this.plugin.settings.separator)
.onChange(async (value) => {
this.plugin.settings.separator = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Include filenames')
.setDesc('Add "# === filename.md ===" headers before each file')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.includeFilenames)
.onChange(async (value) => {
this.plugin.settings.includeFilenames = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Show preview')
.setDesc('Show preview modal before copying')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showPreview)
.onChange(async (value) => {
this.plugin.settings.showPreview = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Include active note')
.setDesc('Append currently open note to context')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.includeActiveNote)
.onChange(async (value) => {
this.plugin.settings.includeActiveNote = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Excluded files')
.setDesc('Comma-separated filenames to exclude (e.g. "examples.md, drafts.md")')
.addText(text => text
.setPlaceholder('file1.md, file2.md')
.setValue(this.plugin.settings.excludedFiles.join(', '))
.onChange(async (value) => {
this.plugin.settings.excludedFiles = value
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0);
await this.plugin.saveSettings();
}));
// === CONTEXT SOURCES SECTION ===
containerEl.createEl('h3', { text: 'Context Sources' });
const sourcesDesc = containerEl.createEl('p', {
text: 'Add additional context sources like freetext, external files, or shell command output.',
cls: 'setting-item-description'
});
sourcesDesc.style.marginBottom = '10px';
const buttonContainer = containerEl.createDiv({ cls: 'sources-button-container' });
buttonContainer.style.display = 'flex';
buttonContainer.style.gap = '8px';
buttonContainer.style.marginBottom = '15px';
const addFreetextBtn = buttonContainer.createEl('button', { text: '+ Freetext' });
addFreetextBtn.addEventListener('click', () => {
new SourceModal(this.app, this.plugin, 'freetext', null, () => this.display()).open();
});
const addFileBtn = buttonContainer.createEl('button', { text: '+ File' });
addFileBtn.addEventListener('click', () => {
new SourceModal(this.app, this.plugin, 'file', null, () => this.display()).open();
});
const addShellBtn = buttonContainer.createEl('button', { text: '+ Shell' });
addShellBtn.addEventListener('click', () => {
new SourceModal(this.app, this.plugin, 'shell', null, () => this.display()).open();
});
// Sources list
const sourcesContainer = containerEl.createDiv({ cls: 'sources-list-container' });
this.renderSourcesList(sourcesContainer);
new Setting(containerEl)
.setName('Show source labels')
.setDesc('Add position and name labels to source output')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showSourceLabels)
.onChange(async (value) => {
this.plugin.settings.showSourceLabels = value;
await this.plugin.saveSettings();
}));
// === PROMPT TEMPLATES SECTION ===
containerEl.createEl('h3', { text: 'Prompt Templates' });
const templatesDesc = containerEl.createEl('p', {
text: 'Create reusable prompt templates that wrap around your context.',
cls: 'setting-item-description'
});
templatesDesc.style.marginBottom = '10px';
const templateButtonContainer = containerEl.createDiv({ cls: 'templates-button-container' });
templateButtonContainer.style.display = 'flex';
templateButtonContainer.style.gap = '8px';
templateButtonContainer.style.marginBottom = '15px';
const addTemplateBtn = templateButtonContainer.createEl('button', { text: '+ New Template' });
addTemplateBtn.addEventListener('click', () => {
new TemplateModal(this.app, this.plugin, null, () => this.display()).open();
});
const importBtn = templateButtonContainer.createEl('button', { text: 'Import' });
importBtn.addEventListener('click', () => {
new TemplateImportExportModal(this.app, this.plugin, 'import', () => this.display()).open();
});
const exportBtn = templateButtonContainer.createEl('button', { text: 'Export' });
exportBtn.addEventListener('click', () => {
new TemplateImportExportModal(this.app, this.plugin, 'export', () => {}).open();
});
// Starter templates
const hasStarterTemplates = this.plugin.settings.promptTemplates.some(t => t.isBuiltin);
if (!hasStarterTemplates) {
const starterContainer = containerEl.createDiv();
starterContainer.style.padding = '10px';
starterContainer.style.backgroundColor = 'var(--background-secondary)';
starterContainer.style.borderRadius = '4px';
starterContainer.style.marginBottom = '15px';
const starterText = starterContainer.createEl('p', {
text: `Add ${STARTER_TEMPLATES.length} starter templates to get started quickly.`
});
starterText.style.margin = '0 0 10px 0';
const addStarterBtn = starterContainer.createEl('button', { text: 'Add Starter Templates' });
addStarterBtn.addEventListener('click', async () => {
this.plugin.settings.promptTemplates.push(...STARTER_TEMPLATES);
await this.plugin.saveSettings();
new Notice(`Added ${STARTER_TEMPLATES.length} starter templates`);
this.display();
});
}
// Templates list
const templatesContainer = containerEl.createDiv({ cls: 'templates-list-container' });
this.renderTemplatesList(templatesContainer);
// Default template setting
new Setting(containerEl)
.setName('Default template')
.setDesc('Template to use by default when copying context')
.addDropdown(dropdown => {
dropdown.addOption('', 'None (plain context)');
for (const template of this.plugin.settings.promptTemplates) {
dropdown.addOption(template.id, template.name);
}
dropdown.setValue(this.plugin.settings.defaultTemplateId || '');
dropdown.onChange(async (value) => {
this.plugin.settings.defaultTemplateId = value || null;
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) {
container.empty();
if (this.plugin.settings.sources.length === 0) {
const emptyMsg = container.createEl('p', {
text: 'No sources configured yet.',
cls: 'setting-item-description'
});
emptyMsg.style.fontStyle = 'italic';
return;
}
const list = container.createDiv({ cls: 'sources-list' });
list.style.border = '1px solid var(--background-modifier-border)';
list.style.borderRadius = '4px';
list.style.marginBottom = '15px';
for (const source of this.plugin.settings.sources) {
const row = list.createDiv({ cls: 'source-row' });
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.padding = '8px 12px';
row.style.borderBottom = '1px solid var(--background-modifier-border)';
row.style.gap = '10px';
// Icon
const icon = row.createEl('span', { text: getSourceIcon(source.type) });
icon.style.fontSize = '16px';
// Name
const name = row.createEl('span', { text: source.name });
name.style.flex = '1';
name.style.fontWeight = '500';
// Position badge
const position = row.createEl('span', { text: source.position });
position.style.padding = '2px 6px';
position.style.borderRadius = '3px';
position.style.fontSize = '11px';
position.style.backgroundColor = 'var(--background-modifier-hover)';
// Enabled toggle
const toggleContainer = row.createDiv();
const toggle = toggleContainer.createEl('input', { type: 'checkbox' });
toggle.checked = source.enabled;
toggle.addEventListener('change', async () => {
source.enabled = toggle.checked;
await this.plugin.saveSettings();
});
// Edit button
const editBtn = row.createEl('button', { text: '✎' });
editBtn.style.padding = '2px 8px';
editBtn.addEventListener('click', () => {
new SourceModal(this.app, this.plugin, source.type, source, () => this.display()).open();
});
// Delete button
const deleteBtn = row.createEl('button', { text: '✕' });
deleteBtn.style.padding = '2px 8px';
deleteBtn.addEventListener('click', async () => {
this.plugin.settings.sources = this.plugin.settings.sources.filter(s => s.id !== source.id);
await this.plugin.saveSettings();
this.display();
});
// Test button
const testBtn = row.createEl('button', { text: '▶' });
testBtn.title = 'Test source';
testBtn.style.padding = '2px 8px';
testBtn.addEventListener('click', async () => {
const registry = new SourceRegistry();
const result = await registry.resolveSource(source);
if (result.error) {
new (await import('obsidian')).Notice(`Error: ${result.error}`);
} else {
const preview = result.content.substring(0, 200) + (result.content.length > 200 ? '...' : '');
new (await import('obsidian')).Notice(`Success (${result.content.length} chars):\n${preview}`);
}
});
}
// Remove bottom border from last item
const lastRow = list.lastElementChild as HTMLElement;
if (lastRow) {
lastRow.style.borderBottom = 'none';
}
}
private renderTemplatesList(container: HTMLElement) {
container.empty();
if (this.plugin.settings.promptTemplates.length === 0) {
const emptyMsg = container.createEl('p', {
text: 'No templates configured yet.',
cls: 'setting-item-description'
});
emptyMsg.style.fontStyle = 'italic';
return;
}
const list = container.createDiv({ cls: 'templates-list' });
list.style.border = '1px solid var(--background-modifier-border)';
list.style.borderRadius = '4px';
list.style.marginBottom = '15px';
for (const template of this.plugin.settings.promptTemplates) {
const row = list.createDiv({ cls: 'template-row' });
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.padding = '8px 12px';
row.style.borderBottom = '1px solid var(--background-modifier-border)';
row.style.gap = '10px';
// Icon
const icon = row.createEl('span', { text: template.isBuiltin ? '📦' : '📝' });
icon.style.fontSize = '16px';
// Name and description
const textContainer = row.createDiv();
textContainer.style.flex = '1';
const name = textContainer.createEl('span', { text: template.name });
name.style.fontWeight = '500';
if (template.description) {
const desc = textContainer.createEl('div', { text: template.description });
desc.style.fontSize = '11px';
desc.style.color = 'var(--text-muted)';
}
// Builtin badge
if (template.isBuiltin) {
const badge = row.createEl('span', { text: 'builtin' });
badge.style.padding = '2px 6px';
badge.style.borderRadius = '3px';
badge.style.fontSize = '11px';
badge.style.backgroundColor = 'var(--background-modifier-hover)';
}
// Edit button (only for non-builtin)
if (!template.isBuiltin) {
const editBtn = row.createEl('button', { text: '✎' });
editBtn.style.padding = '2px 8px';
editBtn.addEventListener('click', () => {
new TemplateModal(this.app, this.plugin, template, () => this.display()).open();
});
}
// Duplicate button
const duplicateBtn = row.createEl('button', { text: '⧉' });
duplicateBtn.title = 'Duplicate';
duplicateBtn.style.padding = '2px 8px';
duplicateBtn.addEventListener('click', async () => {
const { generateTemplateId } = await import('./templates');
const duplicate: PromptTemplate = {
id: generateTemplateId(),
name: `${template.name} (copy)`,
description: template.description,
content: template.content,
isBuiltin: false,
};
this.plugin.settings.promptTemplates.push(duplicate);
await this.plugin.saveSettings();
new Notice(`Duplicated template: ${template.name}`);
this.display();
});
// Delete button
const deleteBtn = row.createEl('button', { text: '✕' });
deleteBtn.style.padding = '2px 8px';
deleteBtn.addEventListener('click', async () => {
this.plugin.settings.promptTemplates = this.plugin.settings.promptTemplates.filter(
t => t.id !== template.id
);
// Clear default if this was it
if (this.plugin.settings.defaultTemplateId === template.id) {
this.plugin.settings.defaultTemplateId = null;
}
await this.plugin.saveSettings();
this.display();
});
}
// Remove bottom border from last item
const lastRow = list.lastElementChild as HTMLElement;
if (lastRow) {
lastRow.style.borderBottom = 'none';
}
}
}