/ src / features / dreamnode-updater / ui / interbrain-update-modal.ts
interbrain-update-modal.ts
  1  /**
  2   * InterBrain Update Modal
  3   *
  4   * Simple all-or-nothing update for the InterBrain plugin itself.
  5   * Unlike DreamNode cherry-picking, InterBrain updates must be accepted in full
  6   * (pull from main, build, reload). Users can request an AI summary before deciding.
  7   */
  8  
  9  import { App, Modal, Setting } from 'obsidian';
 10  import { FetchResult, CommitInfo } from '../../social-resonance-filter/services/git-sync-service';
 11  import {
 12    getUpdateSummaryService,
 13    initializeUpdateSummaryService,
 14    UpdateSummary
 15  } from '../services/update-summary-service';
 16  
 17  export class InterBrainUpdateModal extends Modal {
 18    private fetchResult: FetchResult;
 19    private onAccept: () => Promise<void>;
 20    private onReject: () => void;
 21    private summaryEl: HTMLElement | null = null;
 22    private isSummaryLoading = false;
 23  
 24    constructor(
 25      app: App,
 26      fetchResult: FetchResult,
 27      onAccept: () => Promise<void>,
 28      onReject: () => void
 29    ) {
 30      super(app);
 31      this.fetchResult = fetchResult;
 32      this.onAccept = onAccept;
 33      this.onReject = onReject;
 34    }
 35  
 36    onOpen() {
 37      const { contentEl } = this;
 38      contentEl.empty();
 39      contentEl.addClass('interbrain-update-modal');
 40  
 41      // Header
 42      contentEl.createEl('h2', { text: '๐Ÿง  InterBrain Update' });
 43  
 44      // Commit count info
 45      const infoEl = contentEl.createDiv({ cls: 'interbrain-update-info' });
 46      infoEl.setText(
 47        `${this.fetchResult.commits.length} new commit${this.fetchResult.commits.length > 1 ? 's' : ''} available`
 48      );
 49  
 50      // Commit list (expandable)
 51      this.renderCommitList(contentEl);
 52  
 53      // Summary container (populated on demand)
 54      this.summaryEl = contentEl.createDiv({ cls: 'interbrain-summary-container' });
 55  
 56      // Action buttons
 57      this.renderActions(contentEl);
 58      this.addStyles();
 59    }
 60  
 61    private renderCommitList(containerEl: HTMLElement): void {
 62      const detailsEl = containerEl.createEl('details', { cls: 'interbrain-commits' });
 63      detailsEl.createEl('summary', { text: 'View commits' });
 64  
 65      const listEl = detailsEl.createEl('ul');
 66      this.fetchResult.commits.forEach((commit: CommitInfo) => {
 67        const li = listEl.createEl('li');
 68        li.createEl('strong', { text: commit.subject });
 69        const date = new Date(commit.timestamp * 1000).toLocaleDateString();
 70        li.createEl('span', {
 71          text: ` ยท ${commit.author} ยท ${date}`,
 72          cls: 'interbrain-commit-meta'
 73        });
 74      });
 75    }
 76  
 77    private renderActions(containerEl: HTMLElement): void {
 78      const buttonContainer = containerEl.createDiv({ cls: 'modal-button-container' });
 79  
 80      // Only show summarize if there are 2+ commits
 81      const canSummarize = this.fetchResult.commits.length >= 2;
 82  
 83      new Setting(buttonContainer)
 84        .addButton(btn => {
 85          btn
 86            .setButtonText('๐Ÿ“ Summarize')
 87            .onClick(async () => {
 88              await this.showSummary();
 89            });
 90          if (!canSummarize) {
 91            btn.setDisabled(true);
 92            btn.buttonEl.setAttribute('title', 'Need at least 2 commits to summarize');
 93          }
 94        })
 95        .addButton(btn => btn
 96          .setButtonText('Update & Reload')
 97          .setCta()
 98          .onClick(async () => {
 99            this.close();
100            await this.onAccept();
101          }))
102        .addButton(btn => btn
103          .setButtonText('Not Now')
104          .onClick(() => {
105            this.close();
106            this.onReject();
107          }));
108    }
109  
110    private async showSummary(): Promise<void> {
111      if (!this.summaryEl || this.isSummaryLoading) return;
112  
113      this.isSummaryLoading = true;
114      this.summaryEl.empty();
115      this.summaryEl.createDiv({
116        cls: 'interbrain-summary-loading',
117        text: 'Generating summary...'
118      });
119  
120      try {
121        initializeUpdateSummaryService();
122        const summaryService = getUpdateSummaryService();
123        const summary = await summaryService.generateUpdateSummary(this.fetchResult);
124  
125        this.summaryEl.empty();
126        this.displaySummary(summary);
127      } catch (error) {
128        this.summaryEl.empty();
129        this.summaryEl.createDiv({
130          cls: 'interbrain-summary-error',
131          text: 'Failed to generate summary'
132        });
133        console.error('[InterBrainUpdate] Summary failed:', error);
134      } finally {
135        this.isSummaryLoading = false;
136      }
137    }
138  
139    private displaySummary(summary: UpdateSummary): void {
140      if (!this.summaryEl) return;
141  
142      // Header
143      this.summaryEl.createEl('strong', { text: "What's been happening:" });
144  
145      // The briefing - just natural prose
146      const contentEl = this.summaryEl.createDiv({ cls: 'interbrain-summary-content' });
147      const briefingText = summary.briefing || summary.userFacingChanges;
148      contentEl.createEl('p', { text: briefingText });
149  
150      // Stats
151      const statsEl = this.summaryEl.createDiv({ cls: 'interbrain-summary-stats' });
152      statsEl.setText(
153        `${this.fetchResult.filesChanged} files ยท ` +
154        `+${this.fetchResult.insertions} -${this.fetchResult.deletions}`
155      );
156    }
157  
158    private addStyles(): void {
159      const styleId = 'interbrain-update-modal-styles';
160      if (document.getElementById(styleId)) return;
161  
162      const style = document.createElement('style');
163      style.id = styleId;
164      style.textContent = `
165        .interbrain-update-modal {
166          max-width: 500px;
167        }
168  
169        .interbrain-update-info {
170          color: var(--text-muted);
171          margin-bottom: 1em;
172        }
173  
174        .interbrain-commits {
175          background: var(--background-secondary);
176          padding: 1em;
177          border-radius: 8px;
178          margin: 1em 0;
179        }
180  
181        .interbrain-commits summary {
182          cursor: pointer;
183          font-weight: 500;
184        }
185  
186        .interbrain-commits ul {
187          margin-top: 0.75em;
188          padding-left: 1.25em;
189        }
190  
191        .interbrain-commits li {
192          margin: 0.5em 0;
193          line-height: 1.4;
194        }
195  
196        .interbrain-commit-meta {
197          color: var(--text-muted);
198          font-size: 0.85em;
199        }
200  
201        .interbrain-summary-container {
202          margin: 1em 0;
203        }
204  
205        .interbrain-summary-loading {
206          padding: 1em;
207          text-align: center;
208          color: var(--text-muted);
209        }
210  
211        .interbrain-summary-error {
212          padding: 1em;
213          text-align: center;
214          color: var(--text-error);
215        }
216  
217        .interbrain-summary-impact {
218          background: var(--background-secondary);
219          padding: 0.75em 1em;
220          border-radius: 6px;
221          margin-bottom: 0.75em;
222          font-weight: 500;
223        }
224  
225        .interbrain-summary-content {
226          line-height: 1.6;
227        }
228  
229        .interbrain-summary-content p {
230          margin: 0 0 0.5em 0;
231        }
232  
233        .interbrain-summary-technical {
234          color: var(--text-muted);
235          font-size: 0.95em;
236        }
237  
238        .interbrain-summary-stats {
239          color: var(--text-muted);
240          font-size: 0.85em;
241          margin-top: 0.75em;
242          padding-top: 0.75em;
243          border-top: 1px solid var(--background-modifier-border);
244        }
245      `;
246      document.head.appendChild(style);
247    }
248  
249    onClose() {
250      this.contentEl.empty();
251    }
252  }