- 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>
499 lines
17 KiB
TypeScript
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';
|
|
}
|
|
}
|
|
}
|