Skip to content

Commit 858c429

Browse files
fix(pine): 4 targeted fixes — uiOpenPanel selector quoting, openPineEditor CDP keyboard fallback, ensurePineEditorOpen waits for model.getValue() not just DOM presence, pineSetSource retry on write fail
1 parent c1dfe6e commit 858c429

1 file changed

Lines changed: 154 additions & 54 deletions

File tree

src/tv/tools.js

Lines changed: 154 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -473,71 +473,149 @@ function invalidateMonacoCache() {
473473
_monacoLastCheck = 0;
474474
}
475475

476-
// Open the Pine Editor using keyboard shortcut first (fastest), then DOM fallback
476+
// Open the Pine Editor — tries TV API first, then DOM buttons, then keyboard shortcut
477477
async function openPineEditor() {
478-
// Primary: Alt+P is the TradingView keyboard shortcut for Pine Editor on most platforms
479-
// We also try the bottom bar API and known DOM selectors
480-
await evaluate(`
478+
// Step 1: TV internal API (most reliable when available)
479+
const apiOk = await evaluate(`
481480
(function() {
482-
// TV API
483481
try {
484482
var bwb = window.TradingView && window.TradingView.bottomWidgetBar;
485483
if (bwb) {
486-
if (typeof bwb.activateScriptEditorTab === 'function') { bwb.activateScriptEditorTab(); return; }
487-
if (typeof bwb.showWidget === 'function') { bwb.showWidget('pine-editor'); return; }
484+
if (typeof bwb.activateScriptEditorTab === 'function') { bwb.activateScriptEditorTab(); return true; }
485+
if (typeof bwb.showWidget === 'function') { bwb.showWidget('pine-editor'); return true; }
488486
}
489487
} catch(e) {}
490-
491-
// DOM buttons — sorted by most likely to exist
492-
var sels = [
493-
'[data-name="pine-editor-toolbar"]',
494-
'[data-name="pine-dialog-button"]',
495-
'[aria-label="Pine Editor"]',
496-
'[aria-label="Pine"]',
497-
'[class*="scriptEditorButton"]',
498-
];
488+
return false;
489+
})()
490+
`).catch(() => false);
491+
if (apiOk) return;
492+
493+
// Step 2: Known DOM selectors — each tried individually (no comma-separated querySelector)
494+
const domSels = [
495+
'[data-name="pine-dialog-button"]',
496+
'[data-name="pine-editor-toolbar"]',
497+
'[aria-label="Pine Editor"]',
498+
'[aria-label="Pine"]',
499+
'[class*="scriptEditorButton"]',
500+
'[class*="pine-editor-button"]',
501+
];
502+
const domOk = await evaluate(`
503+
(function() {
504+
var sels = ${JSON.stringify(domSels)};
499505
for (var i = 0; i < sels.length; i++) {
500506
var b = document.querySelector(sels[i]);
501-
if (b && b.offsetParent !== null) { b.click(); return; }
507+
if (b && b.offsetParent !== null) { b.click(); return sels[i]; }
502508
}
503-
504-
// Text scan fallback
505-
var btns = document.querySelectorAll('[role="button"], button');
506-
for (var j = 0; j < btns.length; j++) {
507-
var t = (btns[j].getAttribute('aria-label') || btns[j].textContent || '').trim();
508-
if (t === 'Pine' || /pine editor/i.test(t)) { btns[j].click(); return; }
509+
// Text content scan
510+
var all = document.querySelectorAll('[role="button"], button');
511+
for (var j = 0; j < all.length; j++) {
512+
var t = (all[j].getAttribute('aria-label') || all[j].textContent || '').trim();
513+
if (t === 'Pine' || t === 'Pine Editor' || /^pine\s*editor$/i.test(t)) {
514+
all[j].click(); return 'text:' + t;
515+
}
509516
}
517+
return null;
510518
})()
511-
`).catch(() => {});
519+
`).catch(() => null);
520+
if (domOk) return;
521+
522+
// Step 3: Keyboard shortcut Alt+P (TradingView default for Pine Editor)
523+
const c = await getClient();
524+
await c.Input.dispatchKeyEvent({ type: 'keyDown', modifiers: 1, key: 'p', code: 'KeyP', windowsVirtualKeyCode: 80 });
525+
await c.Input.dispatchKeyEvent({ type: 'keyUp', key: 'p', code: 'KeyP', windowsVirtualKeyCode: 80 });
512526
}
513527

514528
// Ensure Pine Editor is open and Monaco is accessible.
515-
// Returns true/false. Maximum wait: ~3 seconds total.
529+
// Smart wait: confirms Monaco model is loaded and editable, not just present.
516530
async function ensurePineEditorOpen() {
517-
// Check cache first — if we already have Monaco, done immediately
531+
// Fast path: Monaco already cached and alive
518532
const cached = await getMonaco();
519533
if (cached) return true;
520534

521535
// Open the editor
522536
await openPineEditor();
523537

524-
// Poll with a short initial delay, then check every 300ms — max 10 attempts = 3s
525-
await new Promise(r => setTimeout(r, 600));
526-
for (let i = 0; i < 10; i++) {
527-
const found = await getMonaco();
528-
if (found) return true;
529-
await new Promise(r => setTimeout(r, 300));
538+
// Wait for Monaco to become fully ready — not just present but writable.
539+
// TradingView loads Monaco asynchronously; the container appears before the
540+
// editor model is ready. We poll for both presence AND ability to getValue().
541+
for (let i = 0; i < 20; i++) {
542+
// Give the animation/load a moment on first few iterations
543+
const delay = i < 3 ? 500 : 300;
544+
await new Promise(r => setTimeout(r, delay));
545+
546+
// Check if Monaco is accessible AND its model is ready
547+
const ready = await evaluate(`
548+
(function() {
549+
// First: run discovery to populate window.__ot_monaco
550+
if (!window.__ot_monaco || typeof window.__ot_monaco.getValue !== 'function') {
551+
// Quick re-discovery attempt
552+
try {
553+
if (window.monaco && window.monaco.editor) {
554+
var eds = window.monaco.editor.getEditors();
555+
if (eds && eds.length > 0) window.__ot_monaco = eds[eds.length - 1];
556+
}
557+
} catch(e) {}
558+
if (!window.__ot_monaco) {
559+
try {
560+
if (typeof require === 'function') {
561+
var m = require('vs/editor/editor.main');
562+
if (m && m.editor) {
563+
var eds = m.editor.getEditors();
564+
if (eds && eds.length > 0) window.__ot_monaco = eds[eds.length - 1];
565+
}
566+
}
567+
} catch(e) {}
568+
}
569+
if (!window.__ot_monaco) {
570+
var root = document.querySelector('.monaco-editor.pine-editor-monaco')
571+
|| document.querySelector('[class*="pine-editor"] .monaco-editor');
572+
if (root) {
573+
var el = root;
574+
for (var up = 0; up < 25 && el; up++, el = el.parentElement) {
575+
var fk = Object.keys(el).find(function(k) { return k.startsWith('__reactFiber$'); });
576+
if (!fk) continue;
577+
var node = el[fk];
578+
for (var d = 0; d < 40 && node; d++, node = node.return) {
579+
var v = ((node.memoizedProps || {}).value || {});
580+
if (v.monacoEnv && v.monacoEnv.editor) {
581+
var eds = v.monacoEnv.editor.getEditors();
582+
if (eds && eds.length > 0) { window.__ot_monaco = eds[eds.length - 1]; break; }
583+
}
584+
}
585+
if (window.__ot_monaco) break;
586+
}
587+
}
588+
}
589+
}
590+
591+
// Now check if Monaco model is actually ready (not just initialized)
592+
if (!window.__ot_monaco) return false;
593+
try {
594+
var model = window.__ot_monaco.getModel();
595+
if (!model) return false;
596+
// Confirm we can read the value — this fails if model not fully loaded
597+
window.__ot_monaco.getValue();
598+
return true;
599+
} catch(e) {
600+
return false;
601+
}
602+
})()
603+
`).catch(() => false);
604+
605+
if (ready) {
606+
_monacoCache = true;
607+
_monacoLastCheck = Date.now();
608+
return true;
609+
}
530610
}
531611

532-
// Editor container exists but Monaco not yet ready — still return true
533-
// (setEditorValue has a textarea fallback)
534-
const hasContainer = await evaluate(`
612+
// Last resort: check if editor container is at least visible
613+
const visible = await evaluate(`
535614
!!(document.querySelector('.monaco-editor.pine-editor-monaco') ||
536-
document.querySelector('[class*="pine-editor"] .monaco-editor') ||
537-
document.querySelector('[data-name="pine-editor"]'))
615+
document.querySelector('[class*="pine-editor"] .monaco-editor'))
538616
`).catch(() => false);
539617

540-
return hasContainer;
618+
return visible;
541619
}
542620

543621
// Inject source into Monaco using CDP Runtime.callFunctionOn — bypasses
@@ -661,14 +739,19 @@ export async function pineGetSource() {
661739
}
662740

663741
export async function pineSetSource({ source }) {
664-
invalidateMonacoCache(); // force fresh discovery after source change
742+
invalidateMonacoCache();
665743
const opened = await ensurePineEditorOpen();
666-
if (!opened) throw new Error('Could not open Pine Editor. Click the Pine Editor tab in TradingView first.');
667-
668-
await new Promise(r => setTimeout(r, 300));
744+
if (!opened) throw new Error('Could not open Pine Editor. Make sure TradingView is open in Chrome and the Pine Editor tab is visible.');
669745

746+
// ensurePineEditorOpen already confirmed Monaco model is ready — write immediately
670747
const result = await setEditorValue(source);
671-
if (!result.ok) throw new Error('Pine Editor is open but could not write to it. Try clicking inside the editor and retrying.');
748+
if (!result.ok) {
749+
// One retry after a short wait — editor may have just finished animating
750+
await new Promise(r => setTimeout(r, 800));
751+
const retry = await setEditorValue(source);
752+
if (!retry.ok) throw new Error('Could not write to Pine Editor. The editor opened but the Monaco model is not ready. Try again in a moment.');
753+
return { success: true, lines: source.split('\n').length, method: retry.method + '_retry' };
754+
}
672755

673756
return { success: true, lines: source.split('\n').length, method: result.method };
674757
}
@@ -1154,22 +1237,39 @@ export async function uiEvaluate({ code }) {
11541237
}
11551238

11561239
export async function uiOpenPanel({ panel }) {
1157-
const selectors = {
1158-
'pine-editor': '[data-name="pine-dialog-button"], [aria-label="Pine"]',
1159-
'strategy-tester': '[data-name="backtesting-button"], [aria-label="Strategy Tester"]',
1160-
'watchlist': '[data-name="base-watchlist-widget-button"]',
1161-
'alerts': '[data-name="alerts-button"], [aria-label*="Alert"]',
1240+
// Map panel names to arrays of selectors — avoids interpolation of quotes
1241+
const selectorMap = {
1242+
'pine-editor': ['[data-name="pine-dialog-button"]', '[aria-label="Pine"]', '[data-name="pine-editor-toolbar"]'],
1243+
'strategy-tester': ['[data-name="backtesting-button"]', '[aria-label="Strategy Tester"]', '[data-name="strategy-tester-button"]'],
1244+
'watchlist': ['[data-name="base-watchlist-widget-button"]', '[aria-label="Watchlist"]'],
1245+
'alerts': ['[data-name="alerts-button"]', '[aria-label="Alerts"]', '[data-name="alerts-create-button"]'],
11621246
};
1163-
const sel = selectors[panel];
1164-
if (!sel) throw new Error(`Unknown panel: ${panel}. Use: pine-editor, strategy-tester, watchlist, alerts`);
1247+
const sels = selectorMap[panel];
1248+
if (!sels) throw new Error(`Unknown panel: ${panel}. Use: pine-editor, strategy-tester, watchlist, alerts`);
1249+
11651250
const clicked = await evaluate(`
11661251
(function() {
1167-
var el = document.querySelector('${sel}');
1168-
if (el) { el.click(); return true; }
1169-
return false;
1252+
var sels = ${JSON.stringify(sels)};
1253+
for (var i = 0; i < sels.length; i++) {
1254+
try {
1255+
var el = document.querySelector(sels[i]);
1256+
if (el && el.offsetParent !== null) { el.click(); return sels[i]; }
1257+
} catch(e) {}
1258+
}
1259+
// Text content fallback
1260+
var labels = { 'pine-editor': /pine/i, 'strategy-tester': /strategy.?tester/i, 'watchlist': /watchlist/i, 'alerts': /alerts/i };
1261+
var label = labels[${JSON.stringify(panel)}];
1262+
if (label) {
1263+
var btns = document.querySelectorAll('button, [role="button"]');
1264+
for (var j = 0; j < btns.length; j++) {
1265+
var t = btns[j].getAttribute('aria-label') || btns[j].textContent || '';
1266+
if (label.test(t) && btns[j].offsetParent !== null) { btns[j].click(); return 'text:'+t.trim().slice(0,30); }
1267+
}
1268+
}
1269+
return null;
11701270
})()
11711271
`);
1172-
return { success: clicked, panel };
1272+
return { success: !!clicked, panel, clicked };
11731273
}
11741274

11751275
// ── Batch ──

0 commit comments

Comments
 (0)