import { App, Modal, Notice, Setting } from 'obsidian'; import PromptfirePlugin from './main'; import { ContextSource, SourceType, SourcePosition, FreetextConfig, FileConfig, ShellConfig, generateSourceId, SourceRegistry, } from './sources'; export class SourceModal extends Modal { plugin: PromptfirePlugin; sourceType: SourceType; existingSource: ContextSource | null; onSave: () => void; // Form state name: string = ''; enabled: boolean = true; position: SourcePosition = 'prefix'; // Freetext config freetextContent: string = ''; // File config filePath: string = ''; recursive: boolean = false; filePattern: string = '*'; // Shell config shellCommand: string = ''; shellArgs: string = ''; shellCwd: string = ''; shellTimeout: number = 5000; constructor( app: App, plugin: PromptfirePlugin, sourceType: SourceType, existingSource: ContextSource | null, onSave: () => void ) { super(app); this.plugin = plugin; this.sourceType = sourceType; this.existingSource = existingSource; this.onSave = onSave; // Load existing source data if editing if (existingSource) { this.name = existingSource.name; this.enabled = existingSource.enabled; this.position = existingSource.position; switch (existingSource.type) { case 'freetext': this.freetextContent = (existingSource.config as FreetextConfig).content; break; case 'file': case 'folder': const fileConfig = existingSource.config as FileConfig; this.filePath = fileConfig.path; this.recursive = fileConfig.recursive || false; this.filePattern = fileConfig.pattern || '*'; break; case 'shell': const shellConfig = existingSource.config as ShellConfig; this.shellCommand = shellConfig.command; this.shellArgs = shellConfig.args.join(' '); this.shellCwd = shellConfig.cwd || ''; this.shellTimeout = shellConfig.timeout || 5000; break; } } } onOpen() { const { contentEl } = this; contentEl.empty(); contentEl.addClass('promptfire-source-modal'); const title = this.existingSource ? 'Edit Source' : 'Add Source'; const typeLabel = this.getTypeLabel(); contentEl.createEl('h2', { text: `${title}: ${typeLabel}` }); // Common fields new Setting(contentEl) .setName('Name') .setDesc('Display name for this source') .addText(text => text .setPlaceholder('My Context') .setValue(this.name) .onChange(v => this.name = v)); new Setting(contentEl) .setName('Position') .setDesc('Where to insert this content') .addDropdown(dropdown => dropdown .addOption('prefix', 'Prefix (before vault content)') .addOption('suffix', 'Suffix (after vault content)') .setValue(this.position) .onChange(v => this.position = v as SourcePosition)); new Setting(contentEl) .setName('Enabled') .addToggle(toggle => toggle .setValue(this.enabled) .onChange(v => this.enabled = v)); // Type-specific fields contentEl.createEl('hr'); switch (this.sourceType) { case 'freetext': this.renderFreetextFields(contentEl); break; case 'file': case 'folder': this.renderFileFields(contentEl); break; case 'shell': this.renderShellFields(contentEl); break; } // Buttons contentEl.createEl('hr'); const buttonContainer = contentEl.createDiv({ cls: 'button-container' }); buttonContainer.style.display = 'flex'; buttonContainer.style.justifyContent = 'flex-end'; buttonContainer.style.gap = '10px'; const testBtn = buttonContainer.createEl('button', { text: 'Test' }); testBtn.addEventListener('click', () => this.testSource()); const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel' }); cancelBtn.addEventListener('click', () => this.close()); const saveBtn = buttonContainer.createEl('button', { text: 'Save', cls: 'mod-cta' }); saveBtn.addEventListener('click', () => this.save()); } private getTypeLabel(): string { switch (this.sourceType) { case 'freetext': return 'Freetext'; case 'file': return 'File'; case 'folder': return 'Folder'; case 'shell': return 'Shell Command'; default: return 'Unknown'; } } private renderFreetextFields(container: HTMLElement) { new Setting(container) .setName('Content') .setDesc('Text content to include') .addTextArea(text => { text.setPlaceholder('Enter your context text here...') .setValue(this.freetextContent) .onChange(v => this.freetextContent = v); text.inputEl.rows = 8; text.inputEl.style.width = '100%'; }); } private renderFileFields(container: HTMLElement) { new Setting(container) .setName('Path') .setDesc('Absolute path to file or folder') .addText(text => text .setPlaceholder('/home/user/project/README.md') .setValue(this.filePath) .onChange(v => this.filePath = v)); new Setting(container) .setName('Recursive') .setDesc('Read folder contents recursively') .addToggle(toggle => toggle .setValue(this.recursive) .onChange(v => this.recursive = v)); new Setting(container) .setName('Pattern') .setDesc('Glob pattern for file matching (e.g. *.md, *.ts)') .addText(text => text .setPlaceholder('*') .setValue(this.filePattern) .onChange(v => this.filePattern = v)); } private renderShellFields(container: HTMLElement) { new Setting(container) .setName('Command') .setDesc('Command to execute (e.g. git, ls, cat)') .addText(text => text .setPlaceholder('git') .setValue(this.shellCommand) .onChange(v => this.shellCommand = v)); new Setting(container) .setName('Arguments') .setDesc('Space-separated arguments') .addText(text => text .setPlaceholder('log --oneline -10') .setValue(this.shellArgs) .onChange(v => this.shellArgs = v)); new Setting(container) .setName('Working directory') .setDesc('Optional working directory for the command') .addText(text => text .setPlaceholder('/home/user/project') .setValue(this.shellCwd) .onChange(v => this.shellCwd = v)); new Setting(container) .setName('Timeout (ms)') .setDesc('Maximum execution time') .addText(text => text .setPlaceholder('5000') .setValue(String(this.shellTimeout)) .onChange(v => { const num = parseInt(v, 10); if (!isNaN(num) && num > 0) { this.shellTimeout = num; } })); } private buildSource(): ContextSource { let config; let type: SourceType = this.sourceType; switch (this.sourceType) { case 'freetext': config = { content: this.freetextContent } as FreetextConfig; break; case 'file': case 'folder': config = { path: this.filePath, recursive: this.recursive, pattern: this.filePattern, } as FileConfig; type = this.recursive ? 'folder' : 'file'; break; case 'shell': config = { command: this.shellCommand, args: this.shellArgs.split(/\s+/).filter(s => s), cwd: this.shellCwd || undefined, timeout: this.shellTimeout, } as ShellConfig; break; default: throw new Error(`Unknown source type: ${this.sourceType}`); } return { id: this.existingSource?.id || generateSourceId(), type, name: this.name || this.getDefaultName(), enabled: this.enabled, position: this.position, config, }; } private getDefaultName(): string { switch (this.sourceType) { case 'freetext': return 'Freetext'; case 'file': case 'folder': return this.filePath.split('/').pop() || 'File'; case 'shell': return `${this.shellCommand} ${this.shellArgs}`.trim(); default: return 'Source'; } } private async testSource() { const source = this.buildSource(); const registry = new SourceRegistry(); try { const result = await registry.resolveSource(source); if (result.error) { new Notice(`Error: ${result.error}`); } else { const preview = result.content.substring(0, 500); const suffix = result.content.length > 500 ? '\n\n... (truncated)' : ''; new Notice(`Success (${result.content.length} chars):\n${preview}${suffix}`, 10000); } } catch (error) { new Notice(`Test failed: ${error instanceof Error ? error.message : String(error)}`); } } private async save() { if (!this.validate()) { return; } const source = this.buildSource(); if (this.existingSource) { // Update existing const index = this.plugin.settings.sources.findIndex(s => s.id === this.existingSource!.id); if (index >= 0) { this.plugin.settings.sources[index] = source; } } else { // Add new this.plugin.settings.sources.push(source); } await this.plugin.saveSettings(); this.onSave(); this.close(); } private validate(): boolean { switch (this.sourceType) { case 'freetext': if (!this.freetextContent.trim()) { new Notice('Content is required'); return false; } break; case 'file': case 'folder': if (!this.filePath.trim()) { new Notice('Path is required'); return false; } break; case 'shell': if (!this.shellCommand.trim()) { new Notice('Command is required'); return false; } break; } return true; } onClose() { const { contentEl } = this; contentEl.empty(); } }