diff --git a/src/generator.ts b/src/generator.ts index 7247b26..4480e6d 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -2,7 +2,7 @@ import { App, Modal, Notice, Setting, TFile, TFolder } from 'obsidian'; import ClaudeContextPlugin from './main'; import { createFreetextSource, SourcePosition } from './sources'; import { estimateTokens } from './history'; -import { OutputTarget, getTargetIcon, formatTokenCount } from './targets'; +import { OutputTarget, OutputFormat, getTargetIcon, formatTokenCount } from './targets'; interface FolderConfig { name: string; @@ -87,10 +87,26 @@ export class ContextGeneratorModal extends Modal { selectedTemplateId: string | null = null; selectedTargetIds: Set = new Set(); + // Preview pane state + private previewContent: HTMLElement | null = null; + private previewLineInfo: HTMLElement | null = null; + private debounceTimer: ReturnType | null = null; + private previewOpen: boolean = false; + private static readonly PREVIEW_LINES = 30; + private static readonly DEBOUNCE_MS = 300; + + // Status bar DOM references + private statusFileCount: HTMLElement | null = null; + private statusTokens: HTMLElement | null = null; + private statusTarget: HTMLElement | null = null; + private statusFormat: HTMLElement | null = null; + private targetsHeadingEl: HTMLElement | null = null; + constructor(app: App, plugin: ClaudeContextPlugin) { super(app); this.plugin = plugin; this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG)); + this.previewOpen = this.plugin.settings.generatorPreviewOpen ?? false; this.config.folders = this.scanVaultStructure().map(name => ({ name, purpose: '' })); // Initialize with default template this.selectedTemplateId = this.plugin.settings.defaultTemplateId; @@ -108,6 +124,39 @@ export class ContextGeneratorModal extends Modal { contentEl.createEl('h2', { text: 'Context Generator' }); + // === STATUS BAR === + const statusBar = contentEl.createDiv({ cls: 'cc-gen-statusbar' }); + + this.statusFileCount = statusBar.createEl('span', { cls: 'cc-statusbar-item' }); + statusBar.createEl('span', { cls: 'cc-statusbar-sep', text: '\u00B7' }); + this.statusTokens = statusBar.createEl('span', { cls: 'cc-statusbar-item cc-statusbar-tokens' }); + statusBar.createEl('span', { cls: 'cc-statusbar-sep', text: '\u00B7' }); + this.statusTarget = statusBar.createEl('span', { cls: 'cc-statusbar-item cc-statusbar-clickable' }); + statusBar.createEl('span', { cls: 'cc-statusbar-sep', text: '\u00B7' }); + this.statusFormat = statusBar.createEl('span', { cls: 'cc-statusbar-item cc-statusbar-clickable' }); + + // Click target label → scroll to Output Targets section + this.statusTarget.addEventListener('click', () => { + if (this.targetsHeadingEl) { + this.targetsHeadingEl.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + + // Click format label → cycle through formats on primary target + this.statusFormat.addEventListener('click', () => { + const primary = this.getPrimaryTarget(); + if (!primary) return; + const formats: OutputFormat[] = ['markdown', 'xml', 'plain']; + const idx = formats.indexOf(primary.format); + primary.format = formats[(idx + 1) % formats.length] ?? 'markdown'; + this.plugin.saveSettings(); + this.updateStatusBar(); + this.schedulePreviewUpdate(); + }); + + // Initial status bar render + this.updateStatusBar(); + // === TWO-ZONE GRID LAYOUT === const layout = contentEl.createDiv({ cls: 'cc-gen-layout' }); const selectionZone = layout.createDiv({ cls: 'cc-gen-zone cc-gen-selection' }); @@ -124,31 +173,31 @@ export class ContextGeneratorModal extends Modal { .setName('conventions.md') .addToggle(toggle => toggle .setValue(this.config.generateFiles.conventions) - .onChange(v => this.config.generateFiles.conventions = v)); + .onChange(v => { this.config.generateFiles.conventions = v; this.schedulePreviewUpdate(); })); new Setting(selectionZone) .setName('structure.md') .addToggle(toggle => toggle .setValue(this.config.generateFiles.structure) - .onChange(v => this.config.generateFiles.structure = v)); + .onChange(v => { this.config.generateFiles.structure = v; this.schedulePreviewUpdate(); })); new Setting(selectionZone) .setName('workflows.md') .addToggle(toggle => toggle .setValue(this.config.generateFiles.workflows) - .onChange(v => this.config.generateFiles.workflows = v)); + .onChange(v => { this.config.generateFiles.workflows = v; this.schedulePreviewUpdate(); })); new Setting(selectionZone) .setName('templates.md') .addToggle(toggle => toggle .setValue(this.config.generateFiles.templates) - .onChange(v => this.config.generateFiles.templates = v)); + .onChange(v => { this.config.generateFiles.templates = v; this.schedulePreviewUpdate(); })); new Setting(selectionZone) .setName('examples.md') .addToggle(toggle => toggle .setValue(this.config.generateFiles.examples) - .onChange(v => this.config.generateFiles.examples = v)); + .onChange(v => { this.config.generateFiles.examples = v; this.schedulePreviewUpdate(); })); // === FOLDER STRUCTURE === selectionZone.createEl('h3', { text: 'Folder structure' }); @@ -170,7 +219,7 @@ export class ContextGeneratorModal extends Modal { .addOption('flat', 'Flat (#tag)') .addOption('none', 'No tags') .setValue(this.config.tagsStyle) - .onChange(v => this.config.tagsStyle = v)); + .onChange(v => { this.config.tagsStyle = v; this.schedulePreviewUpdate(); })); new Setting(selectionZone) .setName('Predefined tags') @@ -180,6 +229,7 @@ export class ContextGeneratorModal extends Modal { .setValue(this.config.customTags.join(', ')) .onChange(v => { this.config.customTags = v.split(',').map(s => s.trim()).filter(s => s); + this.schedulePreviewUpdate(); })); // === FRONTMATTER SECTION === @@ -192,6 +242,7 @@ export class ContextGeneratorModal extends Modal { .setValue(this.config.frontmatterFields.join(', ')) .onChange(v => { this.config.frontmatterFields = v.split(',').map(s => s.trim()).filter(s => s); + this.schedulePreviewUpdate(); })); // === NOTE TEMPLATES === @@ -229,7 +280,7 @@ export class ContextGeneratorModal extends Modal { .addTextArea(text => { text.setPlaceholder('e.g. Personal Zettelkasten for development and knowledge management') .setValue(this.config.vaultDescription) - .onChange(v => this.config.vaultDescription = v); + .onChange(v => { this.config.vaultDescription = v; this.schedulePreviewUpdate(); }); text.inputEl.rows = 2; text.inputEl.style.width = '100%'; }); @@ -240,7 +291,7 @@ export class ContextGeneratorModal extends Modal { .addOption('english', 'English') .addOption('german', 'Deutsch') .setValue(this.config.language) - .onChange(v => this.config.language = v)); + .onChange(v => { this.config.language = v; this.schedulePreviewUpdate(); })); // === FORMATTING === configZone.createEl('h3', { text: 'Formatting' }); @@ -253,7 +304,7 @@ export class ContextGeneratorModal extends Modal { .addOption('camelCase', 'camelCase') .addOption('free', 'Free / no convention') .setValue(this.config.fileNaming) - .onChange(v => this.config.fileNaming = v)); + .onChange(v => { this.config.fileNaming = v; this.schedulePreviewUpdate(); })); new Setting(configZone) .setName('Link style') @@ -261,7 +312,7 @@ export class ContextGeneratorModal extends Modal { .addOption('wikilinks', '[[Wikilinks]]') .addOption('markdown', '[Markdown](links)') .setValue(this.config.linkStyle) - .onChange(v => this.config.linkStyle = v)); + .onChange(v => { this.config.linkStyle = v; this.schedulePreviewUpdate(); })); new Setting(configZone) .setName('Heading depth') @@ -271,7 +322,7 @@ export class ContextGeneratorModal extends Modal { .addOption('h4', 'H1 - H4') .addOption('h6', 'Unlimited') .setValue(this.config.headingDepth) - .onChange(v => this.config.headingDepth = v)); + .onChange(v => { this.config.headingDepth = v; this.schedulePreviewUpdate(); })); new Setting(configZone) .setName('Date format') @@ -280,7 +331,7 @@ export class ContextGeneratorModal extends Modal { .addOption('DD.MM.YYYY', 'DD.MM.YYYY') .addOption('MM/DD/YYYY', 'MM/DD/YYYY') .setValue(this.config.dateFormat) - .onChange(v => this.config.dateFormat = v)); + .onChange(v => { this.config.dateFormat = v; this.schedulePreviewUpdate(); })); // === RULES === configZone.createEl('h3', { text: 'Rules' }); @@ -293,6 +344,7 @@ export class ContextGeneratorModal extends Modal { .setValue(this.config.customRules.join('\n')) .onChange(v => { this.config.customRules = v.split('\n').map(s => s.trim()).filter(s => s); + this.schedulePreviewUpdate(); }); text.inputEl.rows = 3; text.inputEl.style.width = '100%'; @@ -305,6 +357,7 @@ export class ContextGeneratorModal extends Modal { .setValue(this.config.forbiddenActions.join(', ')) .onChange(v => { this.config.forbiddenActions = v.split(',').map(s => s.trim()).filter(s => s); + this.schedulePreviewUpdate(); })); // === ADDITIONAL CONTEXT === @@ -316,7 +369,7 @@ export class ContextGeneratorModal extends Modal { .addTextArea(text => { text.setPlaceholder('Enter additional context that will be included when copying...') .setValue(this.temporaryFreetext) - .onChange(v => this.temporaryFreetext = v); + .onChange(v => { this.temporaryFreetext = v; this.schedulePreviewUpdate(); }); text.inputEl.rows = 4; text.inputEl.style.width = '100%'; }); @@ -327,7 +380,7 @@ export class ContextGeneratorModal extends Modal { .addOption('prefix', 'Prefix (before vault content)') .addOption('suffix', 'Suffix (after vault content)') .setValue(this.temporaryPosition) - .onChange(v => this.temporaryPosition = v as SourcePosition)); + .onChange(v => { this.temporaryPosition = v as SourcePosition; this.schedulePreviewUpdate(); })); new Setting(configZone) .setName('Save as default') @@ -357,6 +410,7 @@ export class ContextGeneratorModal extends Modal { dropdown.setValue(this.selectedTemplateId || ''); dropdown.onChange(v => { this.selectedTemplateId = v || null; + this.schedulePreviewUpdate(); }); }); @@ -369,7 +423,7 @@ export class ContextGeneratorModal extends Modal { // === OUTPUT TARGETS === if (this.plugin.settings.targets.length > 0) { - configZone.createEl('h3', { text: 'Output Targets' }); + this.targetsHeadingEl = configZone.createEl('h3', { text: 'Output Targets' }); const targetsContainer = configZone.createDiv({ cls: 'targets-checkboxes' }); targetsContainer.style.display = 'flex'; @@ -397,6 +451,7 @@ export class ContextGeneratorModal extends Modal { } else { this.selectedTargetIds.delete(target.id); } + this.schedulePreviewUpdate(); }); const icon = row.createEl('span', { text: getTargetIcon(target.format) }); @@ -436,6 +491,42 @@ export class ContextGeneratorModal extends Modal { // === TOKEN ESTIMATE === this.renderTokenEstimate(configZone); + // ============================================= + // PREVIEW PANE (full-width, between grid and footer) + // ============================================= + const previewSection = contentEl.createDiv({ + cls: `cc-section cc-preview-section${this.previewOpen ? '' : ' is-collapsed'}`, + }); + + const previewHeader = previewSection.createDiv({ cls: 'cc-section-header' }); + const previewTitle = previewHeader.createEl('span', { + text: this.previewOpen ? 'Hide Preview' : 'Show Preview', + cls: 'cc-section-title', + }); + previewHeader.createEl('span', { text: '\u203A', cls: 'cc-section-chevron' }); + + const previewBody = previewSection.createDiv({ cls: 'cc-section-content cc-preview-body' }); + this.previewContent = previewBody.createEl('pre', { cls: 'cc-preview-code' }); + this.previewLineInfo = previewBody.createDiv({ cls: 'cc-preview-line-info' }); + + previewHeader.addEventListener('click', async () => { + this.previewOpen = !this.previewOpen; + if (this.previewOpen) { + previewSection.removeClass('is-collapsed'); + previewTitle.textContent = 'Hide Preview'; + this.updatePreview(); + } else { + previewSection.addClass('is-collapsed'); + previewTitle.textContent = 'Show Preview'; + } + this.plugin.settings.generatorPreviewOpen = this.previewOpen; + await this.plugin.saveSettings(); + }); + + if (this.previewOpen) { + this.updatePreview(); + } + // ============================================= // FOOTER (full-width, below both zones) // ============================================= @@ -507,6 +598,7 @@ export class ContextGeneratorModal extends Modal { input.style.flex = '1'; input.addEventListener('input', () => { folder.purpose = input.value; + this.schedulePreviewUpdate(); }); } } @@ -526,6 +618,7 @@ export class ContextGeneratorModal extends Modal { nameInput.style.flex = '1'; nameInput.addEventListener('input', () => { template.name = nameInput.value; + this.schedulePreviewUpdate(); }); const folderInput = row.createEl('input', { type: 'text' }); @@ -534,6 +627,7 @@ export class ContextGeneratorModal extends Modal { folderInput.style.flex = '1'; folderInput.addEventListener('input', () => { template.folder = folderInput.value; + this.schedulePreviewUpdate(); }); const tagInput = row.createEl('input', { type: 'text' }); @@ -542,12 +636,14 @@ export class ContextGeneratorModal extends Modal { tagInput.style.flex = '1'; tagInput.addEventListener('input', () => { template.tag = tagInput.value; + this.schedulePreviewUpdate(); }); const removeBtn = row.createEl('button', { text: '✕' }); removeBtn.addEventListener('click', () => { this.config.templates.splice(index, 1); this.renderTemplates(container); + this.schedulePreviewUpdate(); }); }); } @@ -850,7 +946,125 @@ Here is the content with a link to ${link}. `; } + private getPrimaryTarget(): OutputTarget | null { + const primaryId = this.plugin.settings.primaryTargetId; + const targets = this.plugin.settings.targets; + if (primaryId) { + return targets.find(t => t.id === primaryId && this.selectedTargetIds.has(t.id)) ?? null; + } + return targets.find(t => this.selectedTargetIds.has(t.id)) ?? null; + } + + private static readonly FORMAT_LABELS: Record = { + markdown: 'Markdown', + xml: 'XML', + plain: 'Plain Text', + }; + + private updateStatusBar() { + if (!this.statusFileCount || !this.statusTokens || !this.statusTarget || !this.statusFormat) return; + + // File count: VAULT.md + enabled optional files + const gf = this.config.generateFiles; + const fileCount = 1 + + (gf.conventions ? 1 : 0) + + (gf.structure ? 1 : 0) + + (gf.workflows ? 1 : 0) + + (gf.templates ? 1 : 0) + + (gf.examples ? 1 : 0); + this.statusFileCount.textContent = `${fileCount} file${fileCount !== 1 ? 's' : ''}`; + + // Token estimate from generated output + const output = this.generatePreviewOutput(); + const tokens = estimateTokens(output); + const primary = this.getPrimaryTarget(); + + const tokenText = `~${(tokens / 1000).toFixed(1)}k tokens`; + this.statusTokens.textContent = tokenText; + + // Token warning color + if (primary && tokens > primary.maxTokens) { + this.statusTokens.addClass('is-warning'); + } else { + this.statusTokens.removeClass('is-warning'); + } + + // Target name + this.statusTarget.textContent = primary + ? `Target: ${primary.name}` + : 'Target: None'; + + // Format + if (primary) { + this.statusFormat.textContent = `Format: ${ContextGeneratorModal.FORMAT_LABELS[primary.format]}`; + } else { + this.statusFormat.textContent = 'Format: —'; + } + } + + private schedulePreviewUpdate() { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + this.debounceTimer = setTimeout(() => { + this.debounceTimer = null; + this.updatePreview(); + this.updateStatusBar(); + }, ContextGeneratorModal.DEBOUNCE_MS); + } + + private updatePreview() { + if (!this.previewOpen || !this.previewContent || !this.previewLineInfo) return; + + const fullOutput = this.generatePreviewOutput(); + const allLines = fullOutput.split('\n'); + const maxLines = ContextGeneratorModal.PREVIEW_LINES; + const truncated = allLines.length > maxLines; + const displayLines = truncated ? allLines.slice(0, maxLines) : allLines; + + this.previewContent.textContent = displayLines.join('\n'); + this.previewLineInfo.textContent = truncated + ? `Showing ${maxLines} of ~${allLines.length} lines` + : `${allLines.length} lines`; + } + + private generatePreviewOutput(): string { + const parts: string[] = []; + + // VAULT.md is always included + parts.push(this.generateVaultMd()); + + if (this.config.generateFiles.conventions) { + parts.push(this.generateConventionsMd()); + } + if (this.config.generateFiles.structure) { + parts.push(this.generateStructureMd()); + } + if (this.config.generateFiles.workflows) { + parts.push(this.generateWorkflowsMd()); + } + if (this.config.generateFiles.templates) { + parts.push(this.generateTemplatesMd()); + } + if (this.config.generateFiles.examples) { + parts.push(this.generateExamplesMd()); + } + + return parts.join('\n\n---\n\n'); + } + onClose() { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + this.previewContent = null; + this.previewLineInfo = null; + this.statusFileCount = null; + this.statusTokens = null; + this.statusTarget = null; + this.statusFormat = null; + this.targetsHeadingEl = null; const { contentEl } = this; contentEl.empty(); } diff --git a/src/settings.ts b/src/settings.ts index 4728b48..001f001 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -25,6 +25,7 @@ export interface ClaudeContextSettings { targetOutputFolder: string; lastSettingsTab: string; collapsedSections: string[]; + generatorPreviewOpen: boolean; } export const DEFAULT_SETTINGS: ClaudeContextSettings = { @@ -44,6 +45,7 @@ export const DEFAULT_SETTINGS: ClaudeContextSettings = { targetOutputFolder: '_claude/outputs', lastSettingsTab: 'general', collapsedSections: [], + generatorPreviewOpen: false, }; export type SettingsTabId = 'general' | 'sources' | 'templates' | 'output' | 'history'; diff --git a/styles.css b/styles.css index 1528168..55b3cb7 100644 --- a/styles.css +++ b/styles.css @@ -88,7 +88,7 @@ .cc-gen-modal .modal-content { max-height: 80vh; - overflow: hidden; + overflow-y: auto; display: flex; flex-direction: column; } @@ -131,6 +131,77 @@ margin-top: 8px; } +/* Generator status bar */ +.cc-gen-statusbar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + margin-bottom: 12px; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + font-size: 12px; + color: var(--text-muted); +} + +.cc-statusbar-item { + white-space: nowrap; +} + +.cc-statusbar-sep { + color: var(--text-faint); + user-select: none; +} + +.cc-statusbar-clickable { + cursor: pointer; + border-radius: 3px; + padding: 1px 4px; + transition: background 0.15s ease, color 0.15s ease; +} + +.cc-statusbar-clickable:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.cc-statusbar-tokens.is-warning { + color: var(--text-warning, #e0a526); + font-weight: 600; +} + +/* Generator preview pane */ +.cc-preview-section { + margin-top: 12px; +} + +.cc-preview-body { + border: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + border-radius: 4px; + max-height: 300px; + overflow-y: auto; + padding: 10px; +} + +.cc-preview-code { + font-family: var(--font-monospace); + font-size: 11px; + white-space: pre-wrap; + word-break: break-word; + background: transparent; + margin: 0; +} + +.cc-preview-line-info { + font-size: 11px; + color: var(--text-muted); + font-style: italic; + text-align: right; + margin-top: 6px; +} + /* Stack on narrow viewports */ @media (max-width: 768px) { .cc-gen-modal {