Defensive panel: toolbar/settings independent of JCEF, lazy browser init, error card, drop WindowFocusService
This commit is contained in:
@@ -6,31 +6,41 @@ import com.intellij.openapi.diagnostic.Logger
|
|||||||
import com.intellij.openapi.util.Disposer
|
import com.intellij.openapi.util.Disposer
|
||||||
import com.intellij.ui.components.JBLabel
|
import com.intellij.ui.components.JBLabel
|
||||||
import com.intellij.ui.components.JBPanel
|
import com.intellij.ui.components.JBPanel
|
||||||
|
import com.intellij.ui.components.JBScrollPane
|
||||||
import com.intellij.ui.jcef.JBCefApp
|
import com.intellij.ui.jcef.JBCefApp
|
||||||
import com.intellij.ui.jcef.JBCefBrowser
|
import com.intellij.ui.jcef.JBCefBrowser
|
||||||
import com.intellij.ui.jcef.JBCefJSQuery
|
import com.intellij.ui.jcef.JBCefJSQuery
|
||||||
import org.cef.browser.CefBrowser
|
|
||||||
import org.cef.handler.CefDisplayHandlerAdapter
|
|
||||||
import org.cef.handler.CefLifeSpanHandlerAdapter
|
import org.cef.handler.CefLifeSpanHandlerAdapter
|
||||||
import org.cef.handler.CefLoadHandlerAdapter
|
import org.cef.handler.CefLoadHandlerAdapter
|
||||||
import java.awt.BorderLayout
|
import java.awt.BorderLayout
|
||||||
|
import java.awt.CardLayout
|
||||||
import java.awt.Desktop
|
import java.awt.Desktop
|
||||||
import java.awt.FlowLayout
|
import java.awt.FlowLayout
|
||||||
|
import java.awt.GridBagConstraints
|
||||||
|
import java.awt.GridBagLayout
|
||||||
|
import java.awt.Insets
|
||||||
import java.awt.event.ActionEvent
|
import java.awt.event.ActionEvent
|
||||||
import javax.swing.AbstractAction
|
import javax.swing.AbstractAction
|
||||||
import javax.swing.BorderFactory
|
import javax.swing.BorderFactory
|
||||||
import javax.swing.JButton
|
import javax.swing.JButton
|
||||||
import javax.swing.JComponent
|
import javax.swing.JComponent
|
||||||
import javax.swing.JPanel
|
import javax.swing.JPanel
|
||||||
|
import javax.swing.JTextArea
|
||||||
import javax.swing.JTextField
|
import javax.swing.JTextField
|
||||||
import javax.swing.SwingUtilities
|
import javax.swing.SwingUtilities
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 嵌入浏览器面板。
|
* Harmness 嵌入浏览器面板。
|
||||||
*
|
*
|
||||||
* 两种视图模式,通过右上角「⚙」图标切换:
|
* 布局:
|
||||||
* - [Mode.BROWSER]:显示配置好的网页(JCEF)
|
* - NORTH:工具栏(← → ⟳ 地址栏 ⚙)
|
||||||
* - [Mode.SETTINGS]:表单,可修改「起始地址」,点保存后自动切回浏览器并加载新地址
|
* - CENTER:CardLayout,包含三张卡:
|
||||||
|
* - "settings":设置表单(起始地址 + 保存/取消)
|
||||||
|
* - "browser":JCEF 浏览器(懒初始化,失败时显示错误)
|
||||||
|
* - "error":初始化失败时的错误信息
|
||||||
|
*
|
||||||
|
* 关键设计:工具栏和设置表单**不依赖** JCEF。即使 JCEF 初始化失败,
|
||||||
|
* 界面仍然可用,错误信息会显示在中心区域,方便排错。
|
||||||
*/
|
*/
|
||||||
class HarmnessBrowserPanel(
|
class HarmnessBrowserPanel(
|
||||||
private val project: com.intellij.openapi.project.Project,
|
private val project: com.intellij.openapi.project.Project,
|
||||||
@@ -38,57 +48,33 @@ class HarmnessBrowserPanel(
|
|||||||
) : Disposable, JBPanel<HarmnessBrowserPanel>(BorderLayout()) {
|
) : Disposable, JBPanel<HarmnessBrowserPanel>(BorderLayout()) {
|
||||||
|
|
||||||
private val settings = SettingsState.getInstance()
|
private val settings = SettingsState.getInstance()
|
||||||
|
private val logger = Logger.getInstance(HarmnessBrowserPanel::class.java)
|
||||||
|
|
||||||
private lateinit var browser: JBCefBrowser
|
// JCEF 浏览器(懒初始化)
|
||||||
private lateinit var addressField: JTextField
|
private var browser: JBCefBrowser? = null
|
||||||
private lateinit var query: JBCefJSQuery
|
private var query: JBCefJSQuery? = null
|
||||||
private val loadEnded = java.util.concurrent.atomic.AtomicBoolean(false)
|
|
||||||
|
|
||||||
private var mode = Mode.BROWSER
|
private var addressField: JTextField = JTextField()
|
||||||
|
private var settingsUrlField: JTextField = JTextField(30)
|
||||||
|
|
||||||
private enum class Mode { BROWSER, SETTINGS }
|
private val cardLayout = CardLayout()
|
||||||
|
private val center = JBPanel<JBPanel<*>>(cardLayout)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
Disposer.register(parentDisposable, this)
|
Disposer.register(parentDisposable, this)
|
||||||
|
|
||||||
if (!JBCefApp.isSupported()) {
|
// 工具栏(不依赖 JCEF,总是先建立)
|
||||||
add(JBLabel(
|
|
||||||
"JCEF (Java Chromium Embedded Framework) is not supported in this IDE instance.\n" +
|
|
||||||
"Please use an IDE build that bundles JCEF, or a compatible JDK.", 0),
|
|
||||||
BorderLayout.CENTER)
|
|
||||||
} else {
|
|
||||||
browser = JBCefBrowser()
|
|
||||||
setupPopupHandling()
|
|
||||||
setupBridge()
|
|
||||||
|
|
||||||
// 顶部工具栏(右上角 ⚙)
|
|
||||||
add(createToolbar(), BorderLayout.NORTH)
|
|
||||||
// 初始为浏览器视图
|
|
||||||
showBrowserMode()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// 视图切换
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
private fun showBrowserMode() {
|
|
||||||
mode = Mode.BROWSER
|
|
||||||
removeAll()
|
|
||||||
add(createToolbar(), BorderLayout.NORTH)
|
add(createToolbar(), BorderLayout.NORTH)
|
||||||
add(browser.component, BorderLayout.CENTER)
|
add(center, BorderLayout.CENTER)
|
||||||
revalidate()
|
|
||||||
repaint()
|
|
||||||
loadCurrentUrl()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showSettingsMode() {
|
// 中心卡片
|
||||||
mode = Mode.SETTINGS
|
center.add(createStandbyCard(), CARD_STANDBY)
|
||||||
removeAll()
|
center.add(createSettingsCard(), CARD_SETTINGS)
|
||||||
add(createToolbar(), BorderLayout.NORTH)
|
center.add(createErrorCard(), CARD_ERROR)
|
||||||
add(createSettingsForm(), BorderLayout.CENTER)
|
cardLayout.show(center, CARD_STANDBY)
|
||||||
revalidate()
|
|
||||||
repaint()
|
// 在窗口系统就绪后异步初始化浏览器,避免阻塞 EDT 或初始化失败拖垮整个面板
|
||||||
|
SwingUtilities.invokeLater { initBrowserSafely() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -96,28 +82,35 @@ class HarmnessBrowserPanel(
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
private fun createToolbar(): JComponent {
|
private fun createToolbar(): JComponent {
|
||||||
val cefBrowser = browser.cefBrowser
|
|
||||||
|
|
||||||
val back = JButton(object : AbstractAction("←") {
|
val back = JButton(object : AbstractAction("←") {
|
||||||
override fun actionPerformed(e: ActionEvent) {
|
override fun actionPerformed(e: ActionEvent) {
|
||||||
if (cefBrowser.canGoBack()) cefBrowser.goBack()
|
val cef = browser?.cefBrowser
|
||||||
|
if (cef != null && cef.canGoBack()) cef.goBack()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
val forward = JButton(object : AbstractAction("→") {
|
val forward = JButton(object : AbstractAction("→") {
|
||||||
override fun actionPerformed(e: ActionEvent) {
|
override fun actionPerformed(e: ActionEvent) {
|
||||||
if (cefBrowser.canGoForward()) cefBrowser.goForward()
|
val cef = browser?.cefBrowser
|
||||||
|
if (cef != null && cef.canGoForward()) cef.goForward()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
val reload = JButton(object : AbstractAction("⟳") {
|
val reload = JButton(object : AbstractAction("⟳") {
|
||||||
override fun actionPerformed(e: ActionEvent) { cefBrowser.reload() }
|
override fun actionPerformed(e: ActionEvent) { browser?.cefBrowser?.reload() }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 右上角:设置图标(⚙),点击在两种模式间切换
|
// 右上角设置图标:在浏览器视图与设置视图间切换
|
||||||
val settingsBtn = JButton(object : AbstractAction("⚙") {
|
val settingsBtn = JButton(object : AbstractAction("⚙") {
|
||||||
override fun actionPerformed(e: ActionEvent) { toggleSettings() }
|
override fun actionPerformed(e: ActionEvent) {
|
||||||
|
if (cardLayout == null) return
|
||||||
|
if (currentCard == CARD_SETTINGS) {
|
||||||
|
showBrowser()
|
||||||
|
} else {
|
||||||
|
showSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
settingsBtn.toolTipText = if (mode == Mode.BROWSER) "设置" else "返回主界面"
|
settingsBtn.toolTipText = "设置"
|
||||||
settingsBtn.border = BorderFactory.createEmptyBorder(2, 6, 2, 6)
|
settingsBtn.border = BorderFactory.createEmptyBorder(2, 8, 2, 8)
|
||||||
|
|
||||||
addressField = JTextField(settings.startUrl)
|
addressField = JTextField(settings.startUrl)
|
||||||
addressField.toolTipText = "当前页面地址(可手动输入后回车跳转)"
|
addressField.toolTipText = "当前页面地址(可手动输入后回车跳转)"
|
||||||
@@ -132,34 +125,110 @@ class HarmnessBrowserPanel(
|
|||||||
return toolbar
|
return toolbar
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toggleSettings() {
|
// ------------------------------------------------------------------
|
||||||
if (mode == Mode.BROWSER) {
|
// 卡片管理与视图切换
|
||||||
showSettingsMode()
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private var currentCard: String = CARD_STANDBY
|
||||||
|
|
||||||
|
private fun showCard(name: String) {
|
||||||
|
currentCard = name
|
||||||
|
cardLayout.show(center, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSettings() {
|
||||||
|
settingsUrlField.text = settings.startUrl
|
||||||
|
showCard(CARD_SETTINGS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBrowser() {
|
||||||
|
if (browser == null) {
|
||||||
|
// 浏览器还没建好:尝试初始化;失败显示错误
|
||||||
|
initBrowserSafely()
|
||||||
} else {
|
} else {
|
||||||
// 从设置页切回主界面时重新加载
|
showCard(CARD_BROWSER)
|
||||||
showBrowserMode()
|
loadCurrentUrl()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigate(url: String) {
|
// ------------------------------------------------------------------
|
||||||
val fixedUrl = if (url.startsWith("http://") || url.startsWith("https://")) url else "http://$url"
|
// JCEF 浏览器初始化(安全包装)
|
||||||
browser.loadURL(fixedUrl)
|
// ------------------------------------------------------------------
|
||||||
loadEnded.set(false)
|
|
||||||
|
private fun initBrowserSafely() {
|
||||||
|
try {
|
||||||
|
if (browser != null) return
|
||||||
|
if (!JBCefApp.isSupported()) {
|
||||||
|
showError("JCEF (Java Chromium Embedded Framework) 在当前 IDE 中不受支持。\n" +
|
||||||
|
"请使用官方发行版 IDE(内置 JBR),或检查 IDE 是否禁用了 JCEF。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val b = JBCefBrowser()
|
||||||
|
browser = b
|
||||||
|
setupPopupHandling(b)
|
||||||
|
setupBridge(b)
|
||||||
|
|
||||||
|
// 把浏览器组件放进 "browser" 卡片
|
||||||
|
center.add(b.component, CARD_BROWSER)
|
||||||
|
if (currentCard != CARD_SETTINGS) {
|
||||||
|
showCard(CARD_BROWSER)
|
||||||
|
}
|
||||||
|
loadCurrentUrl()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
logger.error("Failed to init JCEF browser", t)
|
||||||
|
showError(buildErrorMessage(t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildErrorMessage(t: Throwable): String {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
sb.append("JCEF 浏览器初始化失败:\n")
|
||||||
|
appendEx(sb, t)
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appendEx(sb: StringBuilder, t: Throwable, depth: Int = 0) {
|
||||||
|
if (depth > 4) return
|
||||||
|
sb.append(t.javaClass.simpleName).append(": ").append(t.message).append('\n')
|
||||||
|
for (el in t.stackTrace.take(6)) {
|
||||||
|
sb.append(" at ").append(el).append('\n')
|
||||||
|
}
|
||||||
|
t.cause?.let { appendEx(sb, it, depth + 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showError(message: String) {
|
||||||
|
val area = center.getClientProperty(ERROR_AREA_KEY) as? JTextArea
|
||||||
|
?: run {
|
||||||
|
val a = JTextArea()
|
||||||
|
center.putClientProperty(ERROR_AREA_KEY, a)
|
||||||
|
a
|
||||||
|
}
|
||||||
|
area.text = message
|
||||||
|
showCard(CARD_ERROR)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 设置表单
|
// 卡片内容
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
private fun createSettingsForm(): JComponent {
|
private fun createStandbyCard(): JComponent {
|
||||||
val panel = JBPanel<JBPanel<*>>()
|
val p = JBPanel<JBPanel<*>>(BorderLayout())
|
||||||
panel.layout = java.awt.GridBagLayout()
|
val label = JBLabel("正在初始化浏览器…", 0)
|
||||||
val gbc = java.awt.GridBagConstraints()
|
p.add(label, BorderLayout.CENTER)
|
||||||
gbc.insets = java.awt.Insets(6, 8, 6, 8)
|
return p
|
||||||
gbc.fill = java.awt.GridBagConstraints.HORIZONTAL
|
}
|
||||||
gbc.anchor = java.awt.GridBagConstraints.NORTH
|
|
||||||
|
|
||||||
val title = JBLabel("<html><h2>Settings</h2></html>")
|
/** 设置表单:起始地址 + 保存/取消 */
|
||||||
|
private fun createSettingsCard(): JComponent {
|
||||||
|
val panel = JBPanel<JBPanel<*>>()
|
||||||
|
panel.layout = GridBagLayout()
|
||||||
|
val gbc = GridBagConstraints()
|
||||||
|
gbc.insets = Insets(8, 10, 8, 10)
|
||||||
|
gbc.fill = GridBagConstraints.HORIZONTAL
|
||||||
|
gbc.anchor = GridBagConstraints.NORTHWEST
|
||||||
|
|
||||||
|
val title = JBLabel("<html><h2 style='margin:0'>设置</h2></html>")
|
||||||
gbc.gridx = 0; gbc.gridy = 0; gbc.gridwidth = 2
|
gbc.gridx = 0; gbc.gridy = 0; gbc.gridwidth = 2
|
||||||
panel.add(title, gbc)
|
panel.add(title, gbc)
|
||||||
gbc.gridwidth = 1
|
gbc.gridwidth = 1
|
||||||
@@ -168,30 +237,30 @@ class HarmnessBrowserPanel(
|
|||||||
gbc.gridx = 0; gbc.gridy = 1
|
gbc.gridx = 0; gbc.gridy = 1
|
||||||
panel.add(label, gbc)
|
panel.add(label, gbc)
|
||||||
|
|
||||||
val urlField = JTextField(settings.startUrl, 30)
|
settingsUrlField = JTextField(settings.startUrl, 28)
|
||||||
gbc.gridx = 1; gbc.gridy = 1
|
gbc.gridx = 1; gbc.gridy = 1
|
||||||
panel.add(urlField, gbc)
|
panel.add(settingsUrlField, gbc)
|
||||||
|
|
||||||
val hint = JBLabel("<html><small>例如:http://127.0.0.1:8899 (本地 Harness 服务地址)</small></html>")
|
val hint = JBLabel("<html><small>例如:http://127.0.0.1:8899 (本地 Harmness 服务地址)<br>" +
|
||||||
|
"目标站点需允许被嵌入(不要有 X-Frame-Options / CSP frame-ancestors 限制)</small></html>")
|
||||||
gbc.gridx = 0; gbc.gridy = 2; gbc.gridwidth = 2
|
gbc.gridx = 0; gbc.gridy = 2; gbc.gridwidth = 2
|
||||||
panel.add(hint, gbc)
|
panel.add(hint, gbc)
|
||||||
gbc.gridwidth = 1
|
gbc.gridwidth = 1
|
||||||
|
|
||||||
val saveBtn = JButton("保存")
|
val saveBtn = JButton("保存")
|
||||||
saveBtn.addActionListener {
|
saveBtn.addActionListener {
|
||||||
settings.startUrl = urlField.text.trim()
|
settings.startUrl = settingsUrlField.text.trim()
|
||||||
saveBtn.text = "已保存 ✓"
|
saveBtn.text = "已保存 ✓"
|
||||||
SwingUtilities.invokeLater {
|
SwingUtilities.invokeLater {
|
||||||
Thread.sleep(400)
|
try { Thread.sleep(500) } catch (_: InterruptedException) {}
|
||||||
saveBtn.text = "保存"
|
saveBtn.text = "保存"
|
||||||
}
|
}
|
||||||
showBrowserMode()
|
// 保存后切回浏览器视图并加载新地址
|
||||||
|
showBrowser()
|
||||||
}
|
}
|
||||||
|
|
||||||
val cancelBtn = JButton("取消")
|
val cancelBtn = JButton("取消")
|
||||||
cancelBtn.addActionListener {
|
cancelBtn.addActionListener { showBrowser() }
|
||||||
showBrowserMode()
|
|
||||||
}
|
|
||||||
|
|
||||||
val btnPanel = JPanel(FlowLayout(FlowLayout.LEFT, 8, 0))
|
val btnPanel = JPanel(FlowLayout(FlowLayout.LEFT, 8, 0))
|
||||||
btnPanel.add(saveBtn)
|
btnPanel.add(saveBtn)
|
||||||
@@ -206,45 +275,55 @@ class HarmnessBrowserPanel(
|
|||||||
return panel
|
return panel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createErrorCard(): JComponent {
|
||||||
|
val area = JTextArea()
|
||||||
|
area.isEditable = false
|
||||||
|
area.font = area.font.deriveFont(12f)
|
||||||
|
area.lineWrap = true
|
||||||
|
area.wrapStyleWord = true
|
||||||
|
center.putClientProperty(ERROR_AREA_KEY, area)
|
||||||
|
return JBScrollPane(area)
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 弹窗与外部链接策略
|
// 浏览器行为
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
private fun setupPopupHandling() {
|
private fun navigate(url: String) {
|
||||||
browser.jbCefClient.addLifeSpanHandler(object : CefLifeSpanHandlerAdapter() {
|
val b = browser ?: return
|
||||||
|
val fixedUrl = if (url.startsWith("http://") || url.startsWith("https://")) url else "http://$url"
|
||||||
|
b.loadURL(fixedUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupPopupHandling(b: JBCefBrowser) {
|
||||||
|
b.jbCefClient.addLifeSpanHandler(object : CefLifeSpanHandlerAdapter() {
|
||||||
override fun onBeforePopup(
|
override fun onBeforePopup(
|
||||||
browser: CefBrowser,
|
cefBrowser: org.cef.browser.CefBrowser,
|
||||||
frame: org.cef.browser.CefFrame,
|
frame: org.cef.browser.CefFrame,
|
||||||
targetUrl: String,
|
targetUrl: String,
|
||||||
targetFrameName: String
|
targetFrameName: String
|
||||||
): Boolean {
|
): Boolean {
|
||||||
// 拒绝在 IDE 内打开新窗口;改由外部系统浏览器打开
|
|
||||||
if (targetUrl.isNotBlank()) {
|
if (targetUrl.isNotBlank()) {
|
||||||
BrowserUtil.browse(targetUrl)
|
BrowserUtil.browse(targetUrl)
|
||||||
}
|
}
|
||||||
return true // 取消弹窗
|
return true // 取消弹窗
|
||||||
}
|
}
|
||||||
}, browser.cefBrowser)
|
}, b.cefBrowser)
|
||||||
|
|
||||||
browser.jbCefClient.addDisplayHandler(object : CefDisplayHandlerAdapter() {
|
|
||||||
override fun onTitleChange(browser: CefBrowser, title: String?) {
|
|
||||||
// 可用来同步工具窗口标题,暂时留空
|
|
||||||
}
|
|
||||||
}, browser.cefBrowser)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// JS ↔ Java 桥
|
// JS ↔ Java 桥
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
private fun setupBridge() {
|
private fun setupBridge(b: JBCefBrowser) {
|
||||||
query = JBCefJSQuery.create(browser)
|
val q = JBCefJSQuery.create(b)
|
||||||
query.addHandler { request ->
|
query = q
|
||||||
|
q.addHandler { request ->
|
||||||
val r = request ?: ""
|
val r = request ?: ""
|
||||||
LOG.debug("JS bridge request: $r")
|
logger.debug("JS bridge request: $r")
|
||||||
when {
|
when {
|
||||||
r == "ping" -> JBCefJSQuery.Response("pong")
|
r == "ping" -> JBCefJSQuery.Response("pong")
|
||||||
r == "getUrl" -> JBCefJSQuery.Response(browser.cefBrowser.url)
|
r == "getUrl" -> JBCefJSQuery.Response(b.cefBrowser.url)
|
||||||
r.startsWith("openExternal:") -> {
|
r.startsWith("openExternal:") -> {
|
||||||
val url = r.removePrefix("openExternal:")
|
val url = r.removePrefix("openExternal:")
|
||||||
BrowserUtil.browse(url)
|
BrowserUtil.browse(url)
|
||||||
@@ -254,52 +333,65 @@ class HarmnessBrowserPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注入桥接函数:页面加载完成后注入到 window.harmnessBridge
|
// 注入桥接函数:主 frame 加载完成后
|
||||||
browser.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() {
|
b.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() {
|
||||||
override fun onLoadEnd(browser: CefBrowser, frame: org.cef.browser.CefFrame, httpStatusCode: Int) {
|
override fun onLoadEnd(cefBrowser: org.cef.browser.CefBrowser, frame: org.cef.browser.CefFrame, httpStatusCode: Int) {
|
||||||
if (frame.isMain && loadEnded.compareAndSet(false, true)) {
|
if (frame.isMain) {
|
||||||
val jsCode = """
|
val jsCode = """
|
||||||
window.$JS_BRIDGE_NAME = function(payload) {
|
window.$JS_BRIDGE_NAME = function(payload) {
|
||||||
var callbacks = payload && payload.callbacks;
|
var callbacks = payload && payload.callbacks;
|
||||||
${query.inject(
|
${q.inject(
|
||||||
"payload.query",
|
"payload.query",
|
||||||
"function(response) { if (callbacks && callbacks.onSuccess) callbacks.onSuccess(response); }",
|
"function(response) { if (callbacks && callbacks.onSuccess) callbacks.onSuccess(response); }",
|
||||||
"function(error_code, error_message) { if (callbacks && callbacks.onFailure) callbacks.onFailure(error_code, error_message); }"
|
"function(error_code, error_message) { if (callbacks && callbacks.onFailure) callbacks.onFailure(error_code, error_message); }"
|
||||||
)};
|
)};
|
||||||
};
|
};
|
||||||
// 通知页面桥接已就绪
|
|
||||||
if (window.__HARMNESS_READY__) window.__HARMNESS_READY__();
|
if (window.__HARMNESS_READY__) window.__HARMNESS_READY__();
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
browser.executeJavaScript(jsCode, browser.url, 0)
|
b.cefBrowser.executeJavaScript(jsCode, cefBrowser.url, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, browser.cefBrowser)
|
}, b.cefBrowser)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 对外方法(供 ToolWindow 调用)
|
// 对外方法
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 加载当前设置中的起始地址(初次打开 / 设置变更后调用) */
|
||||||
fun loadCurrentUrl() {
|
fun loadCurrentUrl() {
|
||||||
if (!::browser.isInitialized) return
|
val b = browser ?: return
|
||||||
val url = settings.startUrl
|
val url = settings.startUrl
|
||||||
addressField.text = url
|
addressField.text = url
|
||||||
browser.loadURL(url)
|
b.loadURL(url)
|
||||||
loadEnded.set(false)
|
}
|
||||||
|
|
||||||
|
/** 工具窗口被选中时由工厂回调:若浏览器存在则刷新,否则尝试初始化 */
|
||||||
|
fun onToolWindowSelected() {
|
||||||
|
if (browser == null) {
|
||||||
|
initBrowserSafely()
|
||||||
|
} else {
|
||||||
|
loadCurrentUrl()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun dispose() {
|
override fun dispose() {
|
||||||
loadEnded.set(false)
|
query?.dispose()
|
||||||
if (::query.isInitialized) {
|
try {
|
||||||
query.dispose()
|
browser?.let { Disposer.dispose(it) }
|
||||||
}
|
} catch (t: Throwable) {
|
||||||
if (::browser.isInitialized) {
|
logger.warn("dispose browser failed", t)
|
||||||
Disposer.dispose(browser)
|
|
||||||
}
|
}
|
||||||
|
browser = null
|
||||||
|
query = null
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val LOG = Logger.getInstance(HarmnessBrowserPanel::class.java)
|
private const val CARD_BROWSER = "browser"
|
||||||
|
private const val CARD_SETTINGS = "settings"
|
||||||
|
private const val CARD_ERROR = "error"
|
||||||
|
private const val CARD_STANDBY = "standby"
|
||||||
|
private const val ERROR_AREA_KEY = "harmness.errorArea"
|
||||||
const val JS_BRIDGE_NAME = "harmnessBridge"
|
const val JS_BRIDGE_NAME = "harmnessBridge"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,34 +5,46 @@ import com.intellij.openapi.project.DumbAware
|
|||||||
import com.intellij.openapi.project.Project
|
import com.intellij.openapi.project.Project
|
||||||
import com.intellij.openapi.wm.ToolWindow
|
import com.intellij.openapi.wm.ToolWindow
|
||||||
import com.intellij.openapi.wm.ToolWindowFactory
|
import com.intellij.openapi.wm.ToolWindowFactory
|
||||||
|
import com.intellij.ui.components.JBLabel
|
||||||
|
import com.intellij.ui.components.JBPanel
|
||||||
import com.intellij.ui.content.ContentFactory
|
import com.intellij.ui.content.ContentFactory
|
||||||
|
import com.intellij.ui.content.ContentManagerEvent
|
||||||
|
import com.intellij.ui.content.ContentManagerListener
|
||||||
|
import java.awt.BorderLayout
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 本插件的工具窗口工厂。
|
* 工具窗口工厂:创建侧边栏 "Harmness Browser"。
|
||||||
*
|
*
|
||||||
* 工具窗口是 IntelliJ 侧边栏的区域(如 Project、Terminal、Version Control),
|
* 采用防御式创建:面板构造或 JCEF 初始化失败时不会让整个工具窗口空白,
|
||||||
* 由 IntelliJ 自动管理生命周期。我们在此创建一个自定义工具窗口 "Harmness Browser",
|
* 而是显示具体错误信息,便于排查。
|
||||||
* 其内容就是 [HarmnessBrowserPanel]。
|
|
||||||
*/
|
*/
|
||||||
class HarmnessToolWindowFactory : ToolWindowFactory, DumbAware {
|
class HarmnessToolWindowFactory : ToolWindowFactory, DumbAware {
|
||||||
|
|
||||||
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
|
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
|
||||||
LOG.info("Creating Harmness Browser tool window for project: ${project.name}")
|
LOG.info("Creating Harmness Browser tool window for project: ${project.name}")
|
||||||
|
|
||||||
val panel = HarmnessBrowserPanel(project, toolWindow.disposable)
|
// 面板构造抛异常时,用一个错误提示面板代替,避免 IDE 显示空白内容
|
||||||
|
val panel = try {
|
||||||
|
HarmnessBrowserPanel(project, toolWindow.disposable)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
LOG.error("Failed to create HarmnessBrowserPanel", t)
|
||||||
|
val errPanel = JBPanel<JBPanel<*>>(BorderLayout())
|
||||||
|
val msg = buildString {
|
||||||
|
append("创建 Harmness 面板失败:\n")
|
||||||
|
append(t.javaClass.simpleName).append(": ").append(t.message ?: "no message")
|
||||||
|
}
|
||||||
|
errPanel.add(JBLabel(msg), BorderLayout.CENTER)
|
||||||
|
errPanel
|
||||||
|
}
|
||||||
|
|
||||||
val content = ContentFactory.getInstance().createContent(panel, "Browser", false)
|
val content = ContentFactory.getInstance().createContent(panel, "Browser", false)
|
||||||
toolWindow.contentManager.addContent(content)
|
toolWindow.contentManager.addContent(content)
|
||||||
|
|
||||||
// 当工具窗口被选中时刷新
|
toolWindow.contentManager.addContentManagerListener(object : ContentManagerListener {
|
||||||
val contentMgr = toolWindow.contentManager
|
override fun selectionChanged(event: ContentManagerEvent) {
|
||||||
contentMgr.addContentManagerListener(object : com.intellij.ui.content.ContentManagerListener {
|
if (event.content != toolWindow.contentManager.selectedContent) return
|
||||||
override fun selectionChanged(event: com.intellij.ui.content.ContentManagerEvent) {
|
runCatching { (panel as? HarmnessBrowserPanel)?.onToolWindowSelected() }
|
||||||
val isActive = event.content == contentMgr.selectedContent
|
.onFailure { LOG.warn("selectionChanged handler failed", it) }
|
||||||
WindowFocusService.getInstance().setActive(isActive)
|
|
||||||
if (isActive) {
|
|
||||||
panel.loadCurrentUrl()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ package com.harmness.toolwindow
|
|||||||
|
|
||||||
import com.intellij.openapi.components.PersistentStateComponent
|
import com.intellij.openapi.components.PersistentStateComponent
|
||||||
import com.intellij.openapi.components.Service
|
import com.intellij.openapi.components.Service
|
||||||
import com.intellij.openapi.components.ServiceManager
|
|
||||||
import com.intellij.openapi.components.State
|
import com.intellij.openapi.components.State
|
||||||
import com.intellij.openapi.components.Storage
|
import com.intellij.openapi.components.Storage
|
||||||
|
import com.intellij.openapi.components.service
|
||||||
import com.intellij.openapi.diagnostic.Logger
|
import com.intellij.openapi.diagnostic.Logger
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 插件设置:仅保存「浏览器起始地址」。
|
* 插件设置:仅保存「浏览器起始地址」。
|
||||||
|
*
|
||||||
|
* 通过 @Service 注解注册(无需在 plugin.xml 中重复声明)。
|
||||||
*/
|
*/
|
||||||
@State(name = "HarmnessToolwindowSettings", storages = [Storage("harmness-toolwindow.xml")])
|
@State(name = "HarmnessToolwindowSettings", storages = [Storage("harmness-toolwindow.xml")])
|
||||||
@Service(Service.Level.APP)
|
@Service(Service.Level.APP)
|
||||||
@@ -38,7 +40,6 @@ class SettingsState : PersistentStateComponent<SettingsState.State> {
|
|||||||
// 与 DSH Web GUI 默认端口一致
|
// 与 DSH Web GUI 默认端口一致
|
||||||
const val DEFAULT_URL = "http://127.0.0.1:8899"
|
const val DEFAULT_URL = "http://127.0.0.1:8899"
|
||||||
|
|
||||||
fun getInstance(): SettingsState =
|
fun getInstance(): SettingsState = service()
|
||||||
ServiceManager.getService(SettingsState::class.java)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package com.harmness.toolwindow
|
|
||||||
|
|
||||||
import com.intellij.openapi.components.Service
|
|
||||||
import com.intellij.openapi.components.service
|
|
||||||
import com.intellij.openapi.diagnostic.Logger
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 全局单例服务,负责把「工具窗口是否可见/激活」的实时状态广播给浏览器面板。
|
|
||||||
*
|
|
||||||
* 之所以需要它:JCEF 浏览器的 JavaScript 侧想知道窗口何时被聚焦/失焦,
|
|
||||||
* 以便暂停或恢复渲染(例如 Harness 界面在后台时降低 CPU 占用)。
|
|
||||||
* 由于 ToolWindow 在内容区与焦点事件里提供的上下文有限,我们用这个轻量级
|
|
||||||
* 事件总线在 ToolWindowManager 和 JBCefBrowser 之间传递状态。
|
|
||||||
*/
|
|
||||||
@Service(Service.Level.APP)
|
|
||||||
class WindowFocusService {
|
|
||||||
private val logger = Logger.getInstance(WindowFocusService::class.java)
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
var active: Boolean = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
private val listeners = mutableListOf<(Boolean) -> Unit>()
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun addListener(listener: (Boolean) -> Unit) {
|
|
||||||
listeners.add(listener)
|
|
||||||
// 注册时立即补发一次当前状态,避免面板错过初始状态
|
|
||||||
listener(active)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun removeListener(listener: (Boolean) -> Unit) {
|
|
||||||
listeners.remove(listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun setActive(value: Boolean) {
|
|
||||||
if (active == value) return
|
|
||||||
active = value
|
|
||||||
logger.debug("Window focus changed: $value")
|
|
||||||
listeners.toList().forEach { it(value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun getInstance(): WindowFocusService = service()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,11 +20,5 @@
|
|||||||
factoryClass="com.harmness.toolwindow.HarmnessToolWindowFactory"
|
factoryClass="com.harmness.toolwindow.HarmnessToolWindowFactory"
|
||||||
anchor="right"
|
anchor="right"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<applicationService
|
|
||||||
serviceImplementation="com.harmness.toolwindow.SettingsState"/>
|
|
||||||
|
|
||||||
<applicationService
|
|
||||||
serviceImplementation="com.harmness.toolwindow.WindowFocusService"/>
|
|
||||||
</extensions>
|
</extensions>
|
||||||
</idea-plugin>
|
</idea-plugin>
|
||||||
Reference in New Issue
Block a user