245 lines
9.3 KiB
Kotlin
245 lines
9.3 KiB
Kotlin
package com.harmness.toolwindow
|
|
|
|
import com.intellij.ide.BrowserUtil
|
|
import com.intellij.openapi.Disposable
|
|
import com.intellij.openapi.diagnostic.Logger
|
|
import com.intellij.openapi.util.Disposer
|
|
import com.intellij.ui.components.JBLabel
|
|
import com.intellij.ui.components.JBPanel
|
|
import com.intellij.ui.jcef.JBCefApp
|
|
import com.intellij.ui.jcef.JBCefBrowser
|
|
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.CefLoadHandlerAdapter
|
|
import java.awt.BorderLayout
|
|
import java.awt.Desktop
|
|
import java.awt.FlowLayout
|
|
import java.awt.event.ActionEvent
|
|
import javax.swing.AbstractAction
|
|
import javax.swing.JButton
|
|
import javax.swing.JComponent
|
|
import javax.swing.JPanel
|
|
import javax.swing.JTextField
|
|
|
|
/**
|
|
* 嵌入浏览器面板。
|
|
*
|
|
* 职责:
|
|
* - 使用 JBCefBrowser 渲染用户配置的起始地址(例如本地 Harness 地址)
|
|
* - 工具栏:地址栏、前进/后退、刷新、设置入口(快捷跳转到 IDE Settings 的插件配置页)
|
|
* - 通过 [JBCefJSQuery] 提供 JS ↔ 插件代码双向桥
|
|
* - 拦截并处理 _blank 弹窗(默认吞掉,可选交给系统浏览器)
|
|
*
|
|
* 由于 IntelliJ Platform 官方插件模型目前不允许在 JCEF 浏览器内部直接调用
|
|
* "打开当前项目文件" 之类的 API(无 DOM 访问权限),插件通过注入到页面中的
|
|
* 桥接函数 [JS_BRIDGE_NAME] 让网页(如 Harness 前端)发起请求,
|
|
* 再由本类把结果返回给页面。
|
|
*/
|
|
class HarmnessBrowserPanel(
|
|
private val project: com.intellij.openapi.project.Project,
|
|
parentDisposable: Disposable
|
|
) : Disposable, JBPanel<HarmnessBrowserPanel>(BorderLayout()) {
|
|
|
|
private val settings = SettingsState.getInstance()
|
|
|
|
private lateinit var browser: JBCefBrowser
|
|
private lateinit var addressField: JTextField
|
|
private lateinit var query: JBCefJSQuery
|
|
private val loadEnded = java.util.concurrent.atomic.AtomicBoolean(false)
|
|
|
|
init {
|
|
Disposer.register(parentDisposable, this)
|
|
|
|
if (!JBCefApp.isSupported()) {
|
|
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()
|
|
|
|
// 布局:顶部工具栏 + 浏览器
|
|
add(createToolbar(), BorderLayout.NORTH)
|
|
add(browser.component, BorderLayout.CENTER)
|
|
|
|
// 设置弹窗处理
|
|
setupPopupHandling()
|
|
// 设置 JS 桥
|
|
setupBridge()
|
|
|
|
// 初始加载
|
|
loadCurrentUrl()
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 工具栏
|
|
// ------------------------------------------------------------------
|
|
|
|
private fun createToolbar(): JComponent {
|
|
val cefBrowser = browser.cefBrowser
|
|
|
|
val back = JButton(object : AbstractAction("←") {
|
|
override fun actionPerformed(e: ActionEvent) {
|
|
if (cefBrowser.canGoBack()) cefBrowser.goBack()
|
|
}
|
|
})
|
|
val forward = JButton(object : AbstractAction("→") {
|
|
override fun actionPerformed(e: ActionEvent) {
|
|
if (cefBrowser.canGoForward()) cefBrowser.goForward()
|
|
}
|
|
})
|
|
val reload = JButton(object : AbstractAction("⟳") {
|
|
override fun actionPerformed(e: ActionEvent) { cefBrowser.reload() }
|
|
})
|
|
val settingsBtn = JButton(object : AbstractAction("⚙") {
|
|
override fun actionPerformed(e: ActionEvent) { openSettings() }
|
|
})
|
|
val externalBtn = JButton(object : AbstractAction("⧉") {
|
|
override fun actionPerformed(e: ActionEvent) { openInExternalBrowser() }
|
|
})
|
|
|
|
addressField = JTextField(settings.startUrl)
|
|
addressField.toolTipText = "当前页面地址(可手动输入后回车跳转)"
|
|
addressField.addActionListener { navigate(addressField.text) }
|
|
|
|
val toolbar = JPanel(FlowLayout(FlowLayout.LEFT, 4, 2))
|
|
toolbar.add(back)
|
|
toolbar.add(forward)
|
|
toolbar.add(reload)
|
|
toolbar.add(addressField)
|
|
toolbar.add(settingsBtn)
|
|
toolbar.add(externalBtn)
|
|
return toolbar
|
|
}
|
|
|
|
private fun navigate(url: String) {
|
|
val fixedUrl = if (url.startsWith("http://") || url.startsWith("https://")) url else "http://$url"
|
|
browser.loadURL(fixedUrl)
|
|
}
|
|
|
|
private fun openSettings() {
|
|
val app = com.intellij.openapi.application.ApplicationManager.getApplication()
|
|
val show = Runnable {
|
|
com.intellij.openapi.options.ShowSettingsUtil.getInstance()
|
|
.showSettingsDialog(project, "Harmness Browser")
|
|
}
|
|
if (app.isDispatchThread) show.run() else app.invokeLater(show)
|
|
}
|
|
|
|
private fun openInExternalBrowser() {
|
|
val url = addressField.text.ifBlank { settings.startUrl }
|
|
if (Desktop.isDesktopSupported()) {
|
|
try {
|
|
Desktop.getDesktop().browse(java.net.URI(url))
|
|
} catch (e: Exception) {
|
|
BrowserUtil.browse(url)
|
|
}
|
|
} else {
|
|
BrowserUtil.browse(url)
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 弹窗与外部链接策略
|
|
// ------------------------------------------------------------------
|
|
|
|
private fun setupPopupHandling() {
|
|
browser.jbCefClient.addLifeSpanHandler(object : CefLifeSpanHandlerAdapter() {
|
|
override fun onBeforePopup(
|
|
browser: CefBrowser,
|
|
frame: org.cef.browser.CefFrame,
|
|
targetUrl: String,
|
|
targetFrameName: String
|
|
): Boolean {
|
|
// 拒绝在 IDE 内打开新窗口;改由外部系统浏览器打开
|
|
if (targetUrl.isNotBlank()) {
|
|
BrowserUtil.browse(targetUrl)
|
|
}
|
|
return true // 取消弹窗
|
|
}
|
|
}, browser.cefBrowser)
|
|
|
|
browser.jbCefClient.addDisplayHandler(object : CefDisplayHandlerAdapter() {
|
|
override fun onTitleChange(browser: CefBrowser, title: String?) {
|
|
// 可用来同步工具窗口标题,暂时留空
|
|
}
|
|
}, browser.cefBrowser)
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// JS ↔ Java 桥
|
|
// ------------------------------------------------------------------
|
|
|
|
private fun setupBridge() {
|
|
query = JBCefJSQuery.create(browser)
|
|
query.addHandler { request ->
|
|
val r = request ?: ""
|
|
LOG.debug("JS bridge request: $r")
|
|
when {
|
|
r == "ping" -> JBCefJSQuery.Response("pong")
|
|
r == "getUrl" -> JBCefJSQuery.Response(browser.cefBrowser.url)
|
|
r.startsWith("openExternal:") -> {
|
|
val url = r.removePrefix("openExternal:")
|
|
BrowserUtil.browse(url)
|
|
JBCefJSQuery.Response("ok")
|
|
}
|
|
else -> JBCefJSQuery.Response("unknown request: $r", 1, "unknown")
|
|
}
|
|
}
|
|
|
|
// 注入桥接函数:页面加载完成后注入到 window.harmnessBridge
|
|
browser.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() {
|
|
override fun onLoadEnd(browser: CefBrowser, frame: org.cef.browser.CefFrame, httpStatusCode: Int) {
|
|
if (frame.isMain && loadEnded.compareAndSet(false, true)) {
|
|
val jsCode = """
|
|
window.$JS_BRIDGE_NAME = function(payload) {
|
|
var callbacks = payload && payload.callbacks;
|
|
${query.inject(
|
|
"payload.query",
|
|
"function(response) { if (callbacks && callbacks.onSuccess) callbacks.onSuccess(response); }",
|
|
"function(error_code, error_message) { if (callbacks && callbacks.onFailure) callbacks.onFailure(error_code, error_message); }"
|
|
)};
|
|
};
|
|
// 通知页面桥接已就绪
|
|
if (window.__HARMNESS_READY__) window.__HARMNESS_READY__();
|
|
""".trimIndent()
|
|
browser.executeJavaScript(jsCode, browser.url, 0)
|
|
}
|
|
}
|
|
}, browser.cefBrowser)
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 对外方法(供 ToolWindow 调用)
|
|
// ------------------------------------------------------------------
|
|
|
|
fun loadCurrentUrl() {
|
|
if (!::browser.isInitialized) return
|
|
val url = settings.startUrl
|
|
addressField.text = url
|
|
browser.loadURL(url)
|
|
loadEnded.set(false)
|
|
}
|
|
|
|
fun reloadFromSettings() {
|
|
loadCurrentUrl()
|
|
}
|
|
|
|
override fun dispose() {
|
|
loadEnded.set(false)
|
|
if (::query.isInitialized) {
|
|
query.dispose()
|
|
}
|
|
if (::browser.isInitialized) {
|
|
Disposer.dispose(browser)
|
|
}
|
|
}
|
|
|
|
companion object {
|
|
private val LOG = Logger.getInstance(HarmnessBrowserPanel::class.java)
|
|
const val JS_BRIDGE_NAME = "harmnessBridge"
|
|
}
|
|
} |