@happy-nut/monacori 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -113,9 +113,9 @@ function setupLazyDiff() {
113
113
  if (wrappers[0]) ensureFileReady(wrappers[0]); // first file ready so the initial caret has a row to land on
114
114
  }
115
115
  if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
116
- const links = Array.from(document.querySelectorAll('#changes-panel .file-link'));
116
+ let links = Array.from(document.querySelectorAll('#changes-panel .file-link')); // re-captured on in-place diff update
117
117
  let sourceLinks = Array.from(document.querySelectorAll('.source-link')); // re-captured when a deferred tree materializes
118
- const sourceFiles = JSON.parse(document.getElementById('source-files-data')?.textContent || '[]');
118
+ let sourceFiles = JSON.parse(document.getElementById('source-files-data')?.textContent || '[]');
119
119
  // i18n: the message catalog (en + ko) is emitted server-side; the locale lives in localStorage and the
120
120
  // whole UI switches live (no reload). t() feeds dynamically-built text; applyI18n() rewrites the static
121
121
  // chrome (data-i18n / -ph / -title / -aria). English is the first-paint default.
@@ -147,13 +147,13 @@ function applyI18n() {
147
147
  var sel = document.getElementById('settings-language');
148
148
  if (sel) sel.value = locale;
149
149
  }
150
- const fileStates = JSON.parse(document.getElementById('file-state-data')?.textContent || '[]');
151
- const httpEnvironments = JSON.parse(document.getElementById('http-env-data')?.textContent || '{}');
152
- const httpEnvNames = Object.keys(httpEnvironments);
150
+ let fileStates = JSON.parse(document.getElementById('file-state-data')?.textContent || '[]');
151
+ let httpEnvironments = JSON.parse(document.getElementById('http-env-data')?.textContent || '{}');
152
+ let httpEnvNames = Object.keys(httpEnvironments);
153
153
  const httpEnvKey = 'monacori-http-env:' + location.pathname;
154
154
  const httpRequestsByPath = new Map();
155
155
  const httpVarsByPath = new Map();
156
- const sourceByPath = new Map(sourceFiles.map((file) => [file.path, file]));
156
+ let sourceByPath = new Map(sourceFiles.map((file) => [file.path, file]));
157
157
  // Phase 2b lazy-LOAD: source content is fetched once after first paint (serve /source-data or the
158
158
  // Electron bridge) and merged into the metadata-only source records; until then sourceLoaded is false
159
159
  // and the source view shows a brief loading state. Non-lazy-load modes embed source -> already loaded.
@@ -184,16 +184,16 @@ function loadSourceData() {
184
184
  }
185
185
  sourceLoaded = true;
186
186
  sourceLoading = false;
187
- try { startSymbolIndex(); } catch (e) {}
187
+ scheduleSymbolIndex();
188
188
  if (pendingSourceOpen) { var po = pendingSourceOpen; pendingSourceOpen = null; openSourceFile(po.path, po.shouldSwitch); }
189
189
  else if (isSourceViewerVisible() && document.getElementById('source-viewer').dataset.openPath) { openSourceFile(document.getElementById('source-viewer').dataset.openPath, false); }
190
190
  if (pendingSymbol) { var s = pendingSymbol; pendingSymbol = null; goToDefOrUsages(s); }
191
191
  }, function () { sourceLoaded = true; sourceLoading = false; });
192
192
  }
193
- const fileSignatureByPath = new Map(fileStates.map((file) => [file.path, file.signature]));
193
+ let fileSignatureByPath = new Map(fileStates.map((file) => [file.path, file.signature]));
194
194
  const reviewMeta = document.getElementById('review-meta');
195
195
  const watchEnabled = reviewMeta?.dataset.watch === 'true';
196
- const currentSignature = reviewMeta?.dataset.signature || '';
196
+ let currentSignature = reviewMeta?.dataset.signature || '';
197
197
  const uiStateKey = 'monacori-diff-ui:' + location.pathname;
198
198
  const recentKey = 'monacori-diff-recent:' + location.pathname;
199
199
  const viewedKey = 'monacori-diff-viewed:' + location.pathname;
@@ -455,14 +455,26 @@ function scheduleDiffScroll(row) {
455
455
  });
456
456
  }
457
457
 
458
+ var setActiveRaf = 0, setActiveScrollPending = true;
458
459
  function setActive(index, shouldScroll = true) {
459
460
  if (hunkTotal() === 0) return;
460
461
  current = ((index % hunkTotal()) + hunkTotal()) % hunkTotal();
462
+ // Coalesce rapid presses (holding/spamming F7 or Shift+F7) into one DOM apply per animation frame. The
463
+ // key handler returns immediately and `current` updates synchronously (so next()/nav math stays correct),
464
+ // while the heavy DOM work (full link/wrapper sweeps, body materialize) runs at most once per frame
465
+ // instead of once per keystroke — the input queue never blocks and can't pile up on big repos.
466
+ setActiveScrollPending = shouldScroll;
467
+ if (setActiveRaf) return;
468
+ setActiveRaf = requestAnimationFrame(function () {
469
+ setActiveRaf = 0;
470
+ applySetActive(current, setActiveScrollPending);
471
+ });
472
+ }
473
+ function applySetActive(idx, shouldScroll) {
461
474
  document.getElementById('source-viewer')?.classList.add('hidden');
462
475
  document.getElementById('diff-view')?.classList.remove('hidden');
463
476
  setTab('changes');
464
- const file = hunkPathAt(current);
465
- const idx = current;
477
+ const file = hunkPathAt(idx);
466
478
  links.forEach((link) => link.classList.toggle('active', link.dataset.file === file));
467
479
  renderBreadcrumb(document.getElementById('diff-breadcrumb'), file);
468
480
  var dvt = document.getElementById('diff-viewed-toggle');
@@ -973,6 +985,13 @@ document.addEventListener('keydown', (event) => {
973
985
  openMergedView((event.code === 'Slash' || event.key === '?') ? 'q' : 'c');
974
986
  return;
975
987
  }
988
+ // Cmd/Ctrl+Shift+N opens/closes the prompt memo. Electron also routes this via the Review menu; in the
989
+ // browser/serve build (no menu) this keydown is the only path. Match the physical key so layout/IME never swallows it.
990
+ if ((event.metaKey || event.ctrlKey) && event.shiftKey && (event.code === 'KeyN' || event.key === 'n' || event.key === 'N')) {
991
+ event.preventDefault();
992
+ openMemoView();
993
+ return;
994
+ }
976
995
  // "?" = question, ">" = change-request composer on the current line/selection (no modifier).
977
996
  if (!event.altKey && !event.metaKey && !event.ctrlKey && (event.key === '?' || event.key === '>')) {
978
997
  const ce = document.activeElement;
@@ -1215,7 +1234,7 @@ document.addEventListener('copy', handleSourceCopy);
1215
1234
 
1216
1235
  applyI18n(); // first paint already shows English (inline); this swaps to the saved locale before the rest of init renders dynamic text
1217
1236
  populateHttpEnvSelect();
1218
- if (!REVIEW_LAZY_LOAD) setTimeout(startSymbolIndex, 0); // non-lazy indexes now; lazy-LOAD defers the (large) source blob + index to the first source-view open / go-to-def
1237
+ if (!REVIEW_LAZY_LOAD) scheduleSymbolIndex(); // non-lazy indexes when idle; lazy-LOAD defers the (large) source blob + index to the first source-view open / go-to-def
1219
1238
  const restored = restoreUiState();
1220
1239
  if (!restored) {
1221
1240
  const initial = location.hash.match(/^#hunk-(\d+)$/);
@@ -1227,6 +1246,19 @@ initSourceTreeFolds();
1227
1246
  if (watchEnabled) setInterval(checkForLiveUpdate, 1500);
1228
1247
  window.addEventListener('beforeunload', saveUiState);
1229
1248
 
1249
+ // First render has painted — drop the boot overlay (it bridged the blank gap right after loadFile). Two
1250
+ // rAFs so the spinner stays until the diff/tree are actually on screen, then a short fade-out.
1251
+ (function () {
1252
+ var ov = document.getElementById('boot-overlay');
1253
+ if (!ov) return;
1254
+ requestAnimationFrame(function () {
1255
+ requestAnimationFrame(function () {
1256
+ ov.classList.add('hide');
1257
+ setTimeout(function () { ov.remove(); }, 240);
1258
+ });
1259
+ });
1260
+ })();
1261
+
1230
1262
  (function setupSidebarResize() {
1231
1263
  const resizer = document.querySelector('.sidebar-resizer');
1232
1264
  if (!resizer) return;
@@ -1943,6 +1975,96 @@ function openMergedView(kind) {
1943
1975
  }
1944
1976
  }
1945
1977
 
1978
+ // Prompt memo (Cmd/Ctrl+Shift+N): one freeform Markdown scratchpad with a live split preview, persisted
1979
+ // across reopens via the same store as comments/locale. "Send to terminal" hands the current draft to the
1980
+ // same pane-pick mode the merged views use, so a half-formed prompt can target any live claude/codex session.
1981
+ var memoKey = 'monacori-memo';
1982
+ function loadMemo() {
1983
+ var v = persistRead(memoKey);
1984
+ if (typeof v === 'string') return v;
1985
+ try { var s = localStorage.getItem(memoKey); return typeof s === 'string' ? s : ''; } catch (e) { return ''; }
1986
+ }
1987
+ function saveMemo(text) { persistSave(memoKey, text || ''); }
1988
+ function renderMemoMd(text) {
1989
+ if (!text || !text.trim()) return '<div class="mc-memo-empty" data-i18n="memo.previewEmpty">' + escapeHtml(t('memo.previewEmpty')) + '</div>';
1990
+ return renderMarkdownBlocks(text).map(function (b) { return b.html; }).join('');
1991
+ }
1992
+ function openMemoView() {
1993
+ var existing = document.getElementById('mc-memo');
1994
+ if (existing) { existing.remove(); return; } // the shortcut toggles: a second press closes the memo
1995
+ var modal = document.createElement('div');
1996
+ modal.id = 'mc-memo';
1997
+ modal.className = 'mc-modal';
1998
+ var panel = document.createElement('div');
1999
+ panel.className = 'mc-modal-panel mc-memo-panel';
2000
+ var head = document.createElement('div');
2001
+ head.className = 'mc-modal-head';
2002
+ var title = document.createElement('span');
2003
+ title.setAttribute('data-i18n', 'memo.title');
2004
+ title.textContent = t('memo.title');
2005
+ var closeBtn = document.createElement('button');
2006
+ closeBtn.type = 'button';
2007
+ closeBtn.className = 'mc-btn mc-ghost';
2008
+ closeBtn.setAttribute('data-i18n', 'merged.close');
2009
+ closeBtn.textContent = t('merged.close');
2010
+ closeBtn.addEventListener('click', function () { modal.remove(); });
2011
+
2012
+ var body = document.createElement('div');
2013
+ body.className = 'mc-memo-body';
2014
+ var area = document.createElement('textarea');
2015
+ area.className = 'mc-modal-text mc-memo-edit';
2016
+ area.spellcheck = false;
2017
+ area.setAttribute('data-i18n-ph', 'memo.placeholder');
2018
+ area.placeholder = t('memo.placeholder');
2019
+ area.value = loadMemo();
2020
+ var preview = document.createElement('div');
2021
+ preview.className = 'md-cell mc-memo-preview';
2022
+ preview.innerHTML = renderMemoMd(area.value);
2023
+ area.addEventListener('input', function () {
2024
+ saveMemo(area.value);
2025
+ preview.innerHTML = renderMemoMd(area.value);
2026
+ });
2027
+
2028
+ // Terminal send: hand the current draft to pane-pick mode (arrows choose the session, Enter sends). Shown
2029
+ // only once a terminal pane exists; enterSendMode reopens the panel if it was closed.
2030
+ var sendBtn = null;
2031
+ if (window.__monacoriTerminal && typeof window.__monacoriTerminal.paneCount === 'function' && window.__monacoriTerminal.paneCount() > 0) {
2032
+ sendBtn = document.createElement('button');
2033
+ sendBtn.type = 'button';
2034
+ sendBtn.className = 'mc-btn mc-send-term';
2035
+ sendBtn.setAttribute('data-i18n', 'merged.sendToTerminal');
2036
+ sendBtn.textContent = t('merged.sendToTerminal');
2037
+ sendBtn.addEventListener('click', function () {
2038
+ var text = area.value;
2039
+ modal.remove();
2040
+ window.__monacoriTerminal.enterSendMode(text);
2041
+ });
2042
+ }
2043
+
2044
+ head.appendChild(title);
2045
+ if (sendBtn) head.appendChild(sendBtn);
2046
+ head.appendChild(closeBtn);
2047
+ body.appendChild(area);
2048
+ body.appendChild(preview);
2049
+ panel.appendChild(head);
2050
+ panel.appendChild(body);
2051
+ modal.appendChild(panel);
2052
+ modal.addEventListener('mousedown', function (e) { if (e.target === modal) modal.remove(); });
2053
+ modal.addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); modal.remove(); } });
2054
+ document.body.appendChild(modal);
2055
+ // Focus the editor; Electron async-restores focus to <body>, so retry briefly (same as the composer/merged view).
2056
+ var memoFocusTries = 0;
2057
+ var tryFocusMemo = function () {
2058
+ if (!document.getElementById('mc-memo')) return true;
2059
+ if (document.activeElement === area) return true;
2060
+ try { area.focus(); } catch (e) {}
2061
+ return document.activeElement === area;
2062
+ };
2063
+ if (!tryFocusMemo()) {
2064
+ var memoFocusIv = setInterval(function () { if (tryFocusMemo() || ++memoFocusTries > 12) clearInterval(memoFocusIv); }, 25);
2065
+ }
2066
+ }
2067
+
1946
2068
  document.addEventListener('click', function (event) {
1947
2069
  var t = event.target;
1948
2070
  if (!t || !t.closest) return;
@@ -2011,8 +2133,17 @@ refreshComments();
2011
2133
 
2012
2134
  function setActive(p) {
2013
2135
  active = p;
2014
- panes.forEach(function (q) { q.el.classList.toggle('is-active', q === p); });
2015
- if (p) requestAnimationFrame(function () { try { p.term.focus(); } catch (e) {} });
2136
+ panes.forEach(function (q) {
2137
+ q.el.classList.toggle('is-active', q === p);
2138
+ // 2+ panes: dim every pane but the active one (no border, just a clean focus cue). A lone pane stays full.
2139
+ q.el.classList.toggle('is-inactive', panes.length > 1 && q !== p);
2140
+ });
2141
+ if (p) requestAnimationFrame(function () {
2142
+ try {
2143
+ if (p.labelEl && p.labelEl.getAttribute('contenteditable') === 'true') return;
2144
+ p.term.focus();
2145
+ } catch (e) {}
2146
+ });
2016
2147
  }
2017
2148
 
2018
2149
  function makePane() {
@@ -2044,7 +2175,13 @@ refreshComments();
2044
2175
  term.attachCustomKeyEventHandler(function (e) {
2045
2176
  if (e.type === 'keydown' && e.metaKey) {
2046
2177
  var k = (e.key || '').toLowerCase();
2047
- if (k === 'c' || k === 'v' || k === 'x' || k === 'a') return true;
2178
+ // The bare modifier press (Cmd goes down BEFORE the letter on macOS) must not blur blurring
2179
+ // here drops the textarea focus the upcoming Cmd+V paste / Cmd+C copy needs, which broke them.
2180
+ if (k === 'meta' || k === 'control' || k === 'alt' || k === 'shift') return true;
2181
+ // Match the PHYSICAL key (e.code), not e.key: under a non-Latin layout/IME (e.g. Korean 한글)
2182
+ // Cmd+V reports e.key as 'ㅍ', so a key-based check misses it — blurring the terminal and
2183
+ // breaking paste/copy/cut/select-all whenever the Korean input source is active.
2184
+ if (e.code === 'KeyC' || e.code === 'KeyV' || e.code === 'KeyX' || e.code === 'KeyA') return true;
2048
2185
  try { term.blur(); } catch (x) {}
2049
2186
  return false;
2050
2187
  }
@@ -2067,8 +2204,18 @@ refreshComments();
2067
2204
  if (el.getAttribute('contenteditable') === 'true') return;
2068
2205
  setActive(pane);
2069
2206
  el.contentEditable = 'true';
2070
- el.focus();
2071
- try { var range = document.createRange(); range.selectNodeContents(el); var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } catch (e) {}
2207
+ // Electron asynchronously restores focus to <body> after the keydown, so a one-shot focus loses the
2208
+ // race and the label turns editable but never gets the caret retry until it sticks, then select all
2209
+ // (same pattern as the composer/memo). This is why rename "did nothing" before.
2210
+ var renameTries = 0;
2211
+ var focusLabel = function () {
2212
+ if (el.getAttribute('contenteditable') !== 'true') return true; // finished/cancelled meanwhile
2213
+ try { el.focus(); } catch (e) {}
2214
+ if (document.activeElement !== el) return false;
2215
+ try { var range = document.createRange(); range.selectNodeContents(el); var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } catch (e) {}
2216
+ return true;
2217
+ };
2218
+ if (!focusLabel()) { var renameIv = setInterval(function () { if (focusLabel() || ++renameTries > 12) clearInterval(renameIv); }, 25); }
2072
2219
  function finish(commit) {
2073
2220
  el.removeEventListener('keydown', onKey);
2074
2221
  el.removeEventListener('blur', onBlur);
@@ -2239,6 +2386,15 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onMergedView === 'function
2239
2386
  // split), so the user can pick which claude/codex session receives the prompt.
2240
2387
  window.monacoriMenu.onMergedView(function (kind) { openMergedView(kind); });
2241
2388
  }
2389
+ if (window.monacoriMenu && typeof window.monacoriMenu.onOpenMemo === 'function') {
2390
+ // Cmd/Ctrl+Shift+N from the Review menu -> open/close the prompt memo.
2391
+ window.monacoriMenu.onOpenMemo(function () { openMemoView(); });
2392
+ }
2393
+ if (window.monacoriMenu && typeof window.monacoriMenu.onDiffUpdate === 'function') {
2394
+ // Electron watch: main rebuilds on working-tree changes and pushes the new HTML so we refresh the diff
2395
+ // in place — NO window reload — keeping the integrated terminal's pty sessions (claude/codex) alive.
2396
+ window.monacoriMenu.onDiffUpdate(function (html) { try { applyDiffUpdate(html); } catch (e) {} });
2397
+ }
2242
2398
  if (window.monacoriMenu && typeof window.monacoriMenu.onCloseTab === 'function') {
2243
2399
  // Cmd/Ctrl+W: close the active Files-mode tab (no-op outside the source viewer).
2244
2400
  window.monacoriMenu.onCloseTab(function () {
@@ -2471,6 +2627,71 @@ function restoreUiState() {
2471
2627
  return false;
2472
2628
  }
2473
2629
 
2630
+ // In-place diff refresh (instead of a full window reload): apply a compact payload of just the changed
2631
+ // regions (diff container, sidebar trees, status, data) and re-run the bootstrap steps. The window never
2632
+ // reloads, so the integrated terminal's pty sessions (claude/codex) survive a watch refresh. Electron's
2633
+ // main pushes the payload over IPC (monacori:diff-update); serve mode's poller fetches /__ai_flow_update.
2634
+ function applyDiffUpdate(u) {
2635
+ if (!u || !u.signature || u.signature === currentSignature) return false; // unchanged — nothing to do
2636
+
2637
+ // Remember what to restore after the swap (comments/viewed persist on their own; these don't).
2638
+ var sv = document.getElementById('source-viewer');
2639
+ var openPath = (sv && sv.dataset.openPath) || '';
2640
+ var wasSource = isSourceViewerVisible();
2641
+ var container = document.getElementById('diff2html-container');
2642
+ var diffScrollTop = container ? container.scrollTop : 0;
2643
+
2644
+ // 1) Replace the visible regions straight from the payload (no full-HTML parse).
2645
+ if (container) container.innerHTML = u.diffContainer || '';
2646
+ var changesPanel = document.getElementById('changes-panel');
2647
+ if (changesPanel) changesPanel.innerHTML = u.changesPanel || '';
2648
+ // Files tree: keep the inert island (lazy, not yet opened) in sync, and refresh the live panel when it's
2649
+ // already materialized — or always, in eager mode where the panel holds the tree directly.
2650
+ var filesIsland = document.getElementById('files-tree-html');
2651
+ if (filesIsland) filesIsland.textContent = u.filesTree || '';
2652
+ var filesPanel = document.getElementById('files-panel');
2653
+ if (filesPanel && (!REVIEW_LAZY || filesPanel.innerHTML.trim())) filesPanel.innerHTML = u.filesTree || '';
2654
+ var statusEl = document.querySelector('.review-status');
2655
+ if (statusEl) statusEl.innerHTML = u.reviewStatus || '';
2656
+ if (reviewMeta) { reviewMeta.setAttribute('data-signature', u.signature); if (u.generatedAt) reviewMeta.setAttribute('data-generated-at', u.generatedAt); }
2657
+
2658
+ // 2) Re-derive module-level state directly from the payload objects.
2659
+ fileStates = u.fileStates || [];
2660
+ fileSignatureByPath = new Map(fileStates.map(function (f) { return [f.path, f.signature]; }));
2661
+ sourceFiles = u.sourceFilesMeta || [];
2662
+ sourceByPath = new Map(sourceFiles.map(function (f) { return [f.path, f]; }));
2663
+ httpEnvironments = u.httpEnvironments || {};
2664
+ httpEnvNames = Object.keys(httpEnvironments);
2665
+ currentSignature = u.signature;
2666
+ links = Array.from(document.querySelectorAll('#changes-panel .file-link'));
2667
+ sourceLinks = Array.from(document.querySelectorAll('.source-link'));
2668
+
2669
+ // 3) Reset lazy-materialize + index state so the new diff bodies / source / symbols rebuild on demand.
2670
+ bodyPromise = {};
2671
+ diffBootDone = false;
2672
+ sourceLoaded = !REVIEW_LAZY_LOAD; // lazyLoad: re-fetch source content on next use
2673
+ sourceLoading = false;
2674
+ symbolIndex = null;
2675
+ if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
2676
+ else { prepareDiff2HtmlHunks(); diffBootDone = true; }
2677
+ if (!REVIEW_LAZY_LOAD) setTimeout(startSymbolIndex, 0);
2678
+
2679
+ // 4) Re-run the DOM-dependent bootstrap steps.
2680
+ applyI18n();
2681
+ populateHttpEnvSelect();
2682
+ initSourceTreeFolds();
2683
+ refreshComments();
2684
+
2685
+ // 5) Best-effort restore of what the user was looking at.
2686
+ if (wasSource && openPath && sourceByPath.has(openPath)) {
2687
+ openSourceFile(openPath, false);
2688
+ } else if (container) {
2689
+ showDiffView(false);
2690
+ container.scrollTop = diffScrollTop;
2691
+ }
2692
+ return true;
2693
+ }
2694
+
2474
2695
  async function checkForLiveUpdate() {
2475
2696
  if (checkingForUpdates) return;
2476
2697
  checkingForUpdates = true;
@@ -2483,8 +2704,12 @@ async function checkForLiveUpdate() {
2483
2704
  liveStatus.textContent = t('status.live.updated') + ' ' + new Date(state.generatedAt).toLocaleTimeString();
2484
2705
  }
2485
2706
  if (state.signature && state.signature !== currentSignature) {
2486
- saveUiState();
2487
- location.reload();
2707
+ // serve mode: fetch just the compact update payload and refresh in place (same path Electron uses
2708
+ // over IPC) rather than reloading — so an open integrated terminal keeps its sessions.
2709
+ try {
2710
+ var fresh = await fetch('__ai_flow_update', { cache: 'no-store' });
2711
+ if (fresh.ok) applyDiffUpdate(await fresh.json());
2712
+ } catch (e) {}
2488
2713
  }
2489
2714
  } catch {
2490
2715
  if (liveStatus) liveStatus.textContent = t('status.live.waiting');
@@ -3104,6 +3329,14 @@ function symbolIndexWorker() {
3104
3329
  self.postMessage({ index: index, total: total });
3105
3330
  };
3106
3331
  }
3332
+ // Run symbol indexing off the critical path: requestIdleCallback so the heavy postMessage of the whole
3333
+ // source blob to the worker (structured-clone serialization is synchronous on the main thread) never
3334
+ // competes with key handling — especially on big repos right after the diff/tree first paints.
3335
+ function scheduleSymbolIndex() {
3336
+ var run = function () { try { startSymbolIndex(); } catch (e) {} };
3337
+ if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') window.requestIdleCallback(run, { timeout: 3000 });
3338
+ else setTimeout(run, 0);
3339
+ }
3107
3340
  function startSymbolIndex() {
3108
3341
  try {
3109
3342
  if (typeof Worker === 'undefined' || typeof Blob === 'undefined' || typeof URL === 'undefined' || !URL.createObjectURL) return;
@@ -3810,16 +4043,21 @@ function populateHttpEnvSelect() {
3810
4043
  opts += '<option value="' + escapeHtml(name) + '"' + (name === currentHttpEnvName ? ' selected' : '') + '>' + escapeHtml(name) + '</option>';
3811
4044
  });
3812
4045
  select.innerHTML = opts;
3813
- select.addEventListener('change', function () {
3814
- currentHttpEnvName = select.value;
3815
- try { localStorage.setItem(httpEnvKey, currentHttpEnvName); } catch (error) {}
3816
- const path = document.getElementById('source-viewer')?.dataset.openPath || '';
3817
- if (path && isHttpFile(path)) {
3818
- const file = sourceByPath.get(path);
3819
- const body = document.getElementById('source-body');
3820
- if (file && body) body.innerHTML = renderHttpTable(file);
3821
- }
3822
- });
4046
+ // The <select> lives in the toolbar (not swapped on in-place diff updates), so wire the change handler
4047
+ // exactly once — populateHttpEnvSelect is re-called by applyDiffUpdate to refresh the options.
4048
+ if (!select.dataset.wired) {
4049
+ select.dataset.wired = '1';
4050
+ select.addEventListener('change', function () {
4051
+ currentHttpEnvName = select.value;
4052
+ try { localStorage.setItem(httpEnvKey, currentHttpEnvName); } catch (error) {}
4053
+ const path = document.getElementById('source-viewer')?.dataset.openPath || '';
4054
+ if (path && isHttpFile(path)) {
4055
+ const file = sourceByPath.get(path);
4056
+ const body = document.getElementById('source-body');
4057
+ if (file && body) body.innerHTML = renderHttpTable(file);
4058
+ }
4059
+ });
4060
+ }
3823
4061
  }
3824
4062
 
3825
4063
  function renderSourceTable(file, query) {
package/dist/viewer.css CHANGED
@@ -465,14 +465,16 @@ td.d2h-del:not(.d2h-code-side-linenumber) { color: #d8e0e8; }
465
465
  }
466
466
  .tree-dir summary::-webkit-details-marker { display: none; }
467
467
  .tree-dir summary:hover { background: var(--bg); }
468
- .tree-dir:not([open]) .folder-icon { transform: rotate(-90deg); }
469
468
  .folder-icon {
470
469
  display: inline-grid;
471
470
  place-items: center;
472
- font-size: 9px;
473
471
  color: var(--muted);
474
- transition: transform 120ms ease;
475
472
  }
473
+ .folder-icon .folder-ic { width: 14px; height: 14px; display: block; }
474
+ /* Closed vs open folder glyph (replaces the old rotated "v" chevron). */
475
+ .tree-dir > summary .fi-open { display: none; }
476
+ .tree-dir[open] > summary .fi-closed { display: none; }
477
+ .tree-dir[open] > summary .fi-open { display: block; }
476
478
  .file-link.tree-file { padding-left: calc(8px + (var(--depth) * 14px)); }
477
479
  .tree-focus { box-shadow: inset 0 0 0 1px var(--active); border-radius: 6px; }
478
480
  summary.tree-focus { background: var(--bg); }
@@ -524,7 +526,20 @@ summary.tree-focus { background: var(--bg); }
524
526
  .status-deleted { background: var(--del); color: #cf222e; }
525
527
  .status-renamed { background: #fff8c5; color: #9a6700; }
526
528
  .status-source { background: var(--line); color: var(--muted); }
527
- .content { min-width: 0; padding: 0; }
529
+ .content { min-width: 0; padding: 0; display: flex; flex-direction: column; min-height: 100vh; }
530
+ /* Pin the diff's horizontal scrollbar to the viewport bottom instead of letting it float
531
+ mid-screen when a file's diff is short: fill the content column vertically so the last
532
+ file's diff body extends all the way down. */
533
+ #diff-view:not(.hidden) { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; }
534
+ #diff-view .diff2html-container { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; }
535
+ .diff2html-container .d2h-wrapper { flex: 1 1 auto; display: flex; flex-direction: column; }
536
+ .diff2html-container .d2h-file-wrapper:last-child { flex: 1 1 auto; }
537
+ .diff2html-container .d2h-file-wrapper:last-child .d2h-files-diff { height: 100%; }
538
+ /* Slimmer scrollbars — the default overlay bars read as chunky on the dark UI. */
539
+ ::-webkit-scrollbar { width: 9px; height: 9px; }
540
+ ::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--muted) 32%, transparent); border-radius: 5px; }
541
+ ::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--muted) 52%, transparent); }
542
+ ::-webkit-scrollbar-track { background: transparent; }
528
543
  .toolbar {
529
544
  position: sticky;
530
545
  top: 0;
@@ -629,6 +644,31 @@ h1 { margin: 0; font-size: 18px; }
629
644
  background: var(--panel);
630
645
  user-select: text;
631
646
  }
647
+ /* Extend the line-number gutter (and its divider) to the bottom of the panel so it never stops at the
648
+ last line with an empty strip + cut-off border below it — one continuous gutter. The .num cell is 58px
649
+ wide + 8px padding each side + 1px border ≈ 75px; the gradient mirrors that. (pre-wrap = no h-scroll, so
650
+ a fixed background stays aligned.) */
651
+ .source-body:not(.empty):not(.image-body) {
652
+ background-image: linear-gradient(to right, var(--line) 0 74px, var(--border) 74px 75px, var(--panel) 75px);
653
+ background-repeat: no-repeat;
654
+ }
655
+ /* Boot overlay: painted the instant the review HTML loads, removed once the renderer's bootstrap has drawn
656
+ the diff/tree — so there's no blank screen between the startup spinner and first render. Mirrors
657
+ app-main's LOADING_HTML spinner exactly so the hand-off from the loading screen is seamless. */
658
+ #boot-overlay {
659
+ position: fixed; inset: 0; z-index: 200;
660
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px;
661
+ background: #2b2b2b; color: #9aa4af;
662
+ font: 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
663
+ transition: opacity 0.22s ease;
664
+ }
665
+ #boot-overlay.hide { opacity: 0; pointer-events: none; }
666
+ .boot-spinner {
667
+ width: 34px; height: 34px;
668
+ border: 3px solid #3a3a3a; border-top-color: #4a9eff; border-radius: 50%;
669
+ animation: boot-spin 0.8s linear infinite;
670
+ }
671
+ @keyframes boot-spin { to { transform: rotate(360deg); } }
632
672
  /* Empty state ("Select a file…") centered in the available space, not top-left. */
633
673
  .source-body.empty {
634
674
  display: flex;
@@ -688,7 +728,8 @@ h1 { margin: 0; font-size: 18px; }
688
728
  .source-row.md-row.cursor-line .num,
689
729
  .source-row.csv-row.cursor-line .num { color: var(--active); }
690
730
  .num {
691
- width: 58px;
731
+ width: 75px;
732
+ box-sizing: border-box; /* fixed 75px total so the .source-body gutter gradient lines up exactly (no bleed) */
692
733
  user-select: none;
693
734
  text-align: right;
694
735
  color: var(--muted);
@@ -760,6 +801,12 @@ h1 { margin: 0; font-size: 18px; }
760
801
  .mc-modal-head span { margin-right: auto; }
761
802
  .mc-modal-text { width: 100%; height: 100%; box-sizing: border-box; resize: none; border: 0; padding: 12px; background: var(--bg); color: var(--text); font: 12px/1.55 Monaco, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
762
803
  .mc-modal-text:focus { outline: none; }
804
+ /* Prompt memo: split editor | live Markdown preview inside the standard modal shell. */
805
+ .mc-memo-body { display: grid; grid-template-columns: 1fr 1fr; min-height: 0; height: 100%; }
806
+ .mc-memo-edit { height: 100%; border-right: 1px solid var(--border); }
807
+ .mc-memo-preview { height: 100%; overflow: auto; padding: 12px 16px; background: var(--panel); color: var(--text); }
808
+ .mc-memo-preview > :first-child { margin-top: 0; }
809
+ .mc-memo-empty { color: var(--muted); font-size: 12px; font-style: italic; }
763
810
  .tok-comment { color: var(--token-comment); font-style: italic; }
764
811
  .tok-keyword { color: var(--token-keyword); font-weight: 650; }
765
812
  .tok-string { color: var(--token-string); }
@@ -1074,9 +1121,11 @@ h1 { margin: 0; font-size: 18px; }
1074
1121
  .terminal-pane-label[contenteditable="true"] { color: var(--text); background: var(--bg); outline: none; }
1075
1122
  .terminal-pane.is-active .terminal-pane-label { color: var(--text); }
1076
1123
  .terminal-pane-host { flex: 1 1 auto; min-width: 0; min-height: 0; padding: 4px 0 4px 8px; }
1077
- .terminal-pane.is-active { box-shadow: inset 2px 0 0 var(--active); }
1124
+ /* No border on the active pane the inactive panes dim back instead, so the focused one stands out
1125
+ cleanly without an outline. Only applies with 2+ panes (a lone pane is never dimmed; see setActive). */
1126
+ .terminal-pane { transition: opacity 120ms ease; }
1127
+ .terminal-panel:not(.send-mode) .terminal-pane.is-inactive { opacity: 0.4; }
1078
1128
  /* Pane-pick mode (merged "Send to terminal"): chosen pane ringed + full opacity, the rest dimmed. */
1079
- .terminal-panel.send-mode .terminal-pane { transition: opacity 120ms ease; }
1080
1129
  .terminal-pane.is-dimmed { opacity: 0.3; }
1081
1130
  .terminal-pane.is-send-target { box-shadow: inset 0 0 0 2px var(--active); opacity: 1; position: relative; }
1082
1131
  /* Faint ⏎ hint floating over the chosen pane; it vanishes the instant Enter exits send mode. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happy-nut/monacori",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
4
  "description": "Validation control plane for AI-generated code changes.",
5
5
  "type": "module",
6
6
  "repository": {
Binary file
Binary file