Initialize Harmness Browser JetBrains plugin skeleton
- Gradle + Kotlin + IntelliJ Platform SDK build - JCEF embedded browser tool window with configurable start URL - Settings page (Tools > Harmness Browser) - JS<->Java bridge scaffold (window.harmnessBridge) - Vite + React + TypeScript web scaffold for embedded page - README: build, register, upload to Marketplace, install guide
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
web/dist/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -0,0 +1,195 @@
|
||||
# Harmness Browser — JetBrains 插件
|
||||
|
||||
在 JetBrains IDE(IntelliJ IDEA / PyCharm / PhpStorm / WebStorm 等)的侧边栏内嵌一个
|
||||
**可配置地址的浏览器窗口**。默认搭配本地 [Harness](https://harness.io/) 这类 AI 助手
|
||||
Web 服务使用,让你不用切出 IDE 就能在代码区与 AI 界面之间来回操作。
|
||||
|
||||
本仓库已初始化:
|
||||
|
||||
- **JetBrains 插件本体**(Kotlin + Gradle + IntelliJ Platform SDK,`src/`)
|
||||
- **TypeScript Web 前端脚手架**(Vite + React + TS,`web/`,用于构建/调试将被嵌入的页面)
|
||||
- **JS ↔ 插件双向桥** 示例(页面上直接调用 `window.harmnessBridge`)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── build.gradle.kts # Gradle 构建(IntelliJ Platform plugin build)
|
||||
├── settings.gradle.kts
|
||||
├── gradle.properties
|
||||
├── scripts/
|
||||
│ └── setup-toolchain.sh # 一键下载 JDK17 + Gradle(无 root 环境)
|
||||
├── src/main/
|
||||
│ ├── kotlin/com/harmness/toolwindow/
|
||||
│ │ ├── HarmnessBrowserPanel.kt # JCEF 内嵌浏览器 + JS↔Java 桥
|
||||
│ │ ├── HarmnessToolWindowFactory.kt # 侧边栏工具窗口
|
||||
│ │ ├── HarmnessSettingsConfigurable.kt # 设置页入口
|
||||
│ │ ├── HarmnessBrowserConfigurablePanel.kt # 设置 UI(起始地址)
|
||||
│ │ ├── SettingsState.kt # 持久化设置存储
|
||||
│ │ └── WindowFocusService.kt # 工具窗口焦点广播(预留)
|
||||
│ └── resources/
|
||||
│ ├── META-INF/plugin.xml # 插件声明(扩展点注册)
|
||||
│ └── icons/harmness.svg # 工具窗口图标
|
||||
└── web/ # (可选)被嵌入页面的前端脚手架
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
└── src/{main.tsx, App.tsx, bridge.ts, ...}
|
||||
```
|
||||
|
||||
## 本地构建
|
||||
|
||||
### 1. 准备工具链(本机没有 JDK/Gradle 时)
|
||||
|
||||
```bash
|
||||
./scripts/setup-toolchain.sh
|
||||
# 会下载 Temurin JDK 17 与 Gradle 8.11.1 到 .tools/
|
||||
```
|
||||
|
||||
若本机已装 JDK 17+ 与 Gradle,可跳过,直接用系统的。
|
||||
|
||||
### 2. 构建插件 zip
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=/data/project/Plugin/harmness/.tools/jdk-17.0.12+7 # 若用脚本版
|
||||
./.tools/gradle-8.11.1/bin/gradle buildPlugin -x test
|
||||
```
|
||||
|
||||
产物:`build/distributions/harmness-toolwindow-0.1.0.zip`
|
||||
|
||||
> 首次构建会下载 IntelliJ Platform SDK(约 1–2GB),请保持网络畅通。
|
||||
|
||||
### 3. 在 IDE 里运行/调试
|
||||
|
||||
在 IDEA(Ultimate 或 Community 均可)中:
|
||||
|
||||
1. `File → Open` 打开本仓库根目录(Gradle 项目会自动识别)。
|
||||
2. `Gradle → Tasks → intellij → runIde`,或运行主类
|
||||
`com.intellij.idea.Main`(由 Gradle 任务 `runIde` 提供)。
|
||||
3. 等 IDE 启动后,右侧边栏会出现 **Harmness Browser** 工具窗口。
|
||||
|
||||
### 4. 前端(可选)
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev # 起一个位于 http://127.0.0.1:8888 的页面
|
||||
```
|
||||
|
||||
然后在插件设置里把「起始地址」填成 `http://127.0.0.1:8888`,即可在 IDE 侧边栏里看到该页面。
|
||||
|
||||
## 功能与使用
|
||||
|
||||
| 功能 | 说明 |
|
||||
| --- | --- |
|
||||
| 侧边栏浏览器 | 打开 IDE 后右侧出现可停靠/悬浮的工具窗口 |
|
||||
| 配置起始地址 | `Settings → Tools → Harmness Browser`(或点工具栏 ⚙) |
|
||||
| 地址栏 | 手动输入任意地址回车跳转 |
|
||||
| 前进/后退/刷新 | 工具栏 ← → ⟳ |
|
||||
| 外部浏览器打开 | 工具栏 ⧉ 把当前地址丢给系统浏览器 |
|
||||
| JS↔Java 桥 | 页面加载后注入 `window.harmnessBridge(payload)`,网页可调用插件能力(原型:`ping` / `getUrl` / `openExternal:*`) |
|
||||
|
||||
### JS↔Java 桥(给 Harness 前端用)
|
||||
|
||||
`web/src/bridge.ts` 提供类型化封装:
|
||||
|
||||
```ts
|
||||
import { callPlugin, openInExternalBrowser, initBridge } from './bridge'
|
||||
|
||||
initBridge(( ) => console.log('bridge ready'))
|
||||
|
||||
const pong = await callPlugin('ping') // -> "pong"
|
||||
openInExternalBrowser('https://example.com') // 用系统浏览器打开
|
||||
```
|
||||
|
||||
如果想要 Harness(或其前端)在收到网页请求时把代码插入编辑器、打开文件等,
|
||||
需要在 `HarmnessBrowserPanel.setupBridge()` 的 handler 里扩展桥协议,
|
||||
并通过 IntelliJ Platform API(`FileDocumentManager`、`EditorFactory`、`CodeInsightUtil` 等)
|
||||
实现。IntelliJ 官方插件 SDK 不提供对 JCEF 页面 DOM 的直接访问,双向通信一律走
|
||||
`JBCefJSQuery`(本插件已封装)。
|
||||
|
||||
## 发布到 JetBrains Marketplace
|
||||
|
||||
### 1. 注册 JetBrains 账号
|
||||
|
||||
访问 <https://account.jetbrains.com/login> 注册/登录(可用 Google / GitHub 账号)。
|
||||
|
||||
### 2. 申请插件仓库(只有一次)
|
||||
|
||||
1. 打开 <https://plugins.jetbrains.com/> 并登录。
|
||||
2. 右上角头像 → **Developer Console**(或直接 <https://plugins.jetbrains.com/developer>)。
|
||||
3. 点击 **Create New Plugin**(第一次需要填组织/开发者信息 + 同意
|
||||
[Plugin Hosting Terms](https://plugins.jetbrains.com/docs/marketplace/plugin-hosting-terms.html))。
|
||||
4. 创建后会得到一个 **Plugin ID**,把 `plugin.xml` 里的 `<id>`(当前为
|
||||
`com.harmness.toolwindow`)改成该 ID(建议保持相同,便于后续关联)。
|
||||
|
||||
### 3. 构建并本地验证产物
|
||||
|
||||
```bash
|
||||
./.tools/gradle-8.11.1/bin/gradle buildPlugin -x test
|
||||
```
|
||||
|
||||
生成的 `build/distributions/*.zip` 即发布物。
|
||||
|
||||
可选(推荐):在 IDE 里用 `Settings → Plugins → ⚙ → Install Plugin from Disk…`
|
||||
选这个 zip 验证一次,再上传。
|
||||
|
||||
### 4. 上传发布
|
||||
|
||||
1. 进入 <https://plugins.jetbrains.com/developer>,选你的插件。
|
||||
2. **Upload Plugin** 上传 zip。
|
||||
3. 填写版本信息:
|
||||
- **Version**:与 `build.gradle.kts` 的 `version` 一致(如 `0.1.0`)
|
||||
- **Since / Until build**:对应 `plugin.xml` 的 `242 / 243.*`
|
||||
- **Changelog**:版本变更说明
|
||||
- **Compatibility**:勾选目标 IDE(IDEA Community/Ultimate 等)
|
||||
4. 提交后,若勾选 **Publish**(或之后手动点 Publish),插件会进入审核,一般几分钟到数小时;
|
||||
审核通过后即可在 Marketplace 搜索到。
|
||||
|
||||
> 之后每次发新版,重复第 3–4 步即可。也可用 CI(GitHub Actions + JetBrains
|
||||
> [publish plugin task](https://plugins.jetbrains.com/docs/intellij/publishing-plugin.html),
|
||||
> `gradle publishPlugin`)自动化。
|
||||
|
||||
### 5. 其他人安装使用
|
||||
|
||||
- **IDE 内**:`Settings → Plugins → Marketplace`,搜索 `Harmness Browser` 安装。
|
||||
- **手动**:`Settings → Plugins → ⚙ → Install Plugin from Disk…` 选 zip。
|
||||
- **下载**:<https://plugins.jetbrains.com/plugin/xxxx>(你的插件主页)。
|
||||
|
||||
安装后在右侧栏点开 **Harmness Browser**,到
|
||||
`Settings → Tools → Harmness Browser` 把地址配置成你的 Harness 实例即可。
|
||||
|
||||
## 与 Harness 的适配注意点(重要)
|
||||
|
||||
1. **嵌入限制**:目标 Web 应用(Harness)必须允许被嵌入渲染:
|
||||
- 不能返回 `X-Frame-Options: DENY/SAMEORIGIN`,
|
||||
- CSP 里不能有 `frame-ancestors 'none'`。
|
||||
若 Harness 有这些限制,需要在其反向代理/HTTP 头里放行,或使用 Harness 专门的无 frame 限制入口。
|
||||
2. **登录态**:cookie 与本地存储默认按 JCEF 的浏览器实例隔离,登录 Harness 后一般会保留
|
||||
(IDE 重启后缓存仍有效,除非换 IDE/机器)。
|
||||
3. **性能与授权**:JCEF 每次都会初始化一个 Chromium 实例,多个 IDE 窗口会占用较多内存;
|
||||
老机器上建议只保留最需要的 IDE 窗口。
|
||||
4. **桥接协议**:要把「网页请求 → 编辑代码/打开文件」打通,需按前面「JS↔Java 桥」一节扩展插件。
|
||||
|
||||
## 关于「把 Harness 塞进侧边栏」的可行性评估(给决策用)
|
||||
|
||||
你的想法是合理的,也是目前社区常见的做法(例如在 IDE 里嵌入 ChatGPT / Copilot 网页版)。
|
||||
优点:
|
||||
|
||||
- 不用在两个窗口之间来回切换,交互摩擦小;
|
||||
- 利用 IDE 自带的 JCEF,不需要额外浏览器进程;
|
||||
- 可以做双向桥,网页 → 编辑器有原生 IPC 通路。
|
||||
|
||||
局限(需要接受或规避):
|
||||
|
||||
- JCEF = 内置 Chromium,内存占用明显(每个 IDE 窗口一份);
|
||||
- 官方 SDK 不给页面 DOM 权限,需求超过「纯网页浏览 + 简单桥」就要自己维护桥协议
|
||||
(本插件已铺好骨架);
|
||||
- 第三方网页的嵌入限制(X-Frame-Options / CSP)可能直接挡掉,需要 Harness 侧配合。
|
||||
|
||||
结论:**方向可行**。建议先把 `web/` 里替换成 Harness 实际前端,跑通「配置地址 → 侧边栏显示 →
|
||||
桥接 ping」这条链路,再决定扩展哪些 IDE 能力。
|
||||
|
||||
## License / 后续
|
||||
|
||||
- 当前版本为原型骨架(0.1.0),默认 `sinceBuild=242, untilBuild=243.*`,只覆盖 2024.2–2024.3。
|
||||
- 后续可加:多地址书签、地址访问白名单(安全)、按 IDE 主题切换深/浅色、自动重连等。
|
||||
@@ -0,0 +1,36 @@
|
||||
plugins {
|
||||
id("java")
|
||||
id("org.jetbrains.kotlin.jvm") version "2.0.21"
|
||||
id("org.jetbrains.intellij.platform") version "2.5.1"
|
||||
}
|
||||
|
||||
group = "com.harmness"
|
||||
version = "0.1.0"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
intellijPlatform {
|
||||
defaultRepositories()
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
intellijPlatform {
|
||||
intellijIdeaCommunity("2024.2.3")
|
||||
pluginVerifier()
|
||||
instrumentationTools()
|
||||
}
|
||||
}
|
||||
|
||||
intellijPlatform {
|
||||
pluginConfiguration {
|
||||
ideaVersion {
|
||||
sinceBuild = "242"
|
||||
untilBuild = "243.*"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=false
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# 准备本地构建工具链(无 root 安装,全部放到工作区 .tools 下)
|
||||
set -euo pipefail
|
||||
|
||||
TOOLS_DIR="/data/project/Plugin/harmness/.tools"
|
||||
mkdir -p "$TOOLS_DIR"
|
||||
cd "$TOOLS_DIR"
|
||||
|
||||
JDK_VERSION="jdk-17.0.12+7"
|
||||
GRADLE_VERSION="gradle-8.11.1"
|
||||
|
||||
echo "==> Downloading Temurin JDK17 ..."
|
||||
curl -fL --retry 3 -o jdk17.tar.gz \
|
||||
"https://github.com/adoptium/temurin17-binaries/releases/download/${JDK_VERSION}/OpenJDK17U-jdk_x64_linux_hotspot_17.0.12_7.tar.gz"
|
||||
tar xzf jdk17.tar.gz
|
||||
chmod +x jdk-17.0.12+7/bin/java
|
||||
"$TOOLS_DIR/jdk-17.0.12+7/bin/java" -version
|
||||
|
||||
echo "==> Downloading Gradle $GRADLE_VERSION ..."
|
||||
curl -fL --retry 3 -o gradle.zip \
|
||||
"https://services.gradle.org/distributions/${GRADLE_VERSION}-bin.zip"
|
||||
unzip -q gradle.zip
|
||||
chmod +x "$GRADLE_VERSION/bin/gradle"
|
||||
JAVA_HOME="$TOOLS_DIR/jdk-17.0.12+7" "$GRADLE_VERSION/bin/gradle" --version
|
||||
|
||||
echo "==> DONE"
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = "harmness-toolwindow"
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.harmness.toolwindow
|
||||
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBTextField
|
||||
import com.intellij.util.ui.FormBuilder
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
|
||||
/**
|
||||
* 设置面板 UI:编辑「起始地址」并即时生效。
|
||||
*/
|
||||
class HarmnessBrowserConfigurablePanel {
|
||||
|
||||
private val urlField = JBTextField()
|
||||
|
||||
val panel: JPanel = FormBuilder.createFormBuilder()
|
||||
.addLabeledComponent("Start URL", urlField, 1, false)
|
||||
.addComponentFillVertically(JBLabel(
|
||||
"<html>Embedded browser will open this URL when the tool window is shown.<br>" +
|
||||
"The target site must allow embedding (no strict <code>X-Frame-Options</code> / <code>CSP frame-ancestors</code>).<br>" +
|
||||
"e.g. <b>http://127.0.0.1:8888</b></html>", UIUtil.ComponentStyle.SMALL, UIUtil.FontColor.NORMAL), 1)
|
||||
.panel
|
||||
|
||||
init {
|
||||
urlField.columns = 40
|
||||
urlField.emptyText.text = "http://127.0.0.1:8888"
|
||||
}
|
||||
|
||||
fun isModified(settings: SettingsState): Boolean =
|
||||
urlField.text != settings.startUrl
|
||||
|
||||
fun apply(settings: SettingsState) {
|
||||
settings.startUrl = urlField.text.trim()
|
||||
}
|
||||
|
||||
fun reset(settings: SettingsState) {
|
||||
urlField.text = settings.startUrl
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG = Logger.getInstance(HarmnessBrowserConfigurablePanel::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
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 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)
|
||||
return
|
||||
}
|
||||
|
||||
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(AbstractAction("←") {
|
||||
if (cefBrowser.canGoBack()) cefBrowser.goBack()
|
||||
})
|
||||
val forward = JButton(AbstractAction("→") {
|
||||
if (cefBrowser.canGoForward()) cefBrowser.goForward()
|
||||
})
|
||||
val reload = JButton(AbstractAction("⟳") { cefBrowser.reload() })
|
||||
val settingsBtn = JButton(AbstractAction("⚙") { openSettings() })
|
||||
val externalBtn = JButton(AbstractAction("⧉") { 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.harmness.toolwindow
|
||||
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import com.intellij.openapi.options.Configurable
|
||||
import com.intellij.openapi.options.Configurable.WithEpName
|
||||
import javax.swing.JComponent
|
||||
|
||||
/**
|
||||
* Settings configurable registered in plugin.xml.
|
||||
* Shows the HarmnessBrowserConfigurablePanel under Tools → Harmness Browser.
|
||||
*/
|
||||
class HarmnessSettingsConfigurable : Configurable, WithEpName {
|
||||
|
||||
private var panel: HarmnessBrowserConfigurablePanel? = null
|
||||
|
||||
override fun getDisplayName(): String = "Harmness Browser"
|
||||
|
||||
override fun getHelpTopic(): String? = null
|
||||
|
||||
override fun createComponent(): JComponent {
|
||||
panel = HarmnessBrowserConfigurablePanel()
|
||||
return panel!!.panel
|
||||
}
|
||||
|
||||
override fun isModified(): Boolean = panel?.isModified(SettingsState.getInstance()) ?: false
|
||||
|
||||
override fun apply() {
|
||||
panel?.apply(SettingsState.getInstance())
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
panel?.reset(SettingsState.getInstance())
|
||||
}
|
||||
|
||||
override fun disposeUIResources() {
|
||||
panel = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG = Logger.getInstance(HarmnessSettingsConfigurable::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.harmness.toolwindow
|
||||
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.wm.ToolWindow
|
||||
import com.intellij.openapi.wm.ToolWindowFactory
|
||||
import com.intellij.openapi.wm.ToolWindowManager
|
||||
import com.intellij.ui.content.ContentFactory
|
||||
import javax.swing.JComponent
|
||||
|
||||
/**
|
||||
* 本插件的工具窗口工厂。
|
||||
*
|
||||
* 工具窗口是 IntelliJ 侧边栏的区域(如 Project、Terminal、Version Control),
|
||||
* 由 IntelliJ 自动管理生命周期。我们在此创建一个自定义工具窗口 "Harmness Browser",
|
||||
* 其内容就是 [HarmnessBrowserPanel]。
|
||||
*
|
||||
* plugin.xml 中通过扩展点注册本类:
|
||||
* ```
|
||||
* <toolWindow id="Harmness Browser" ... factoryClass="com.harmness.toolwindow.HarmnessToolWindowFactory"/>
|
||||
* ```
|
||||
*/
|
||||
class HarmnessToolWindowFactory : ToolWindowFactory, DumbAware {
|
||||
|
||||
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
|
||||
LOG.info("Creating Harmness Browser tool window for project: ${project.name}")
|
||||
|
||||
// 检查 JCEF 支持
|
||||
if (!isJcefSupported()) {
|
||||
LOG.warn("JCEF is not supported in this IDE instance. The Harmness Browser tool window will show an error message.")
|
||||
}
|
||||
|
||||
val panel = HarmnessBrowserPanel(project, toolWindow.disposable)
|
||||
|
||||
val content = ContentFactory.getInstance().createContent(panel, "Browser", false)
|
||||
toolWindow.contentManager.addContent(content)
|
||||
|
||||
// 注册工具窗口可见性监听器,用于通知 WindowFocusService
|
||||
toolWindow.addContentManagerListener(object : com.intellij.ui.content.ContentManagerListener {
|
||||
override fun selectionChanged(selectionChange: com.intellij.ui.content.ContentManagerEvent) {
|
||||
val isActive = selectionChange.manager.selectedContent == selectionChange.content
|
||||
WindowFocusService.getInstance().setActive(isActive)
|
||||
if (isActive) {
|
||||
// 当工具窗口被选中时,触发面板刷新
|
||||
panel.loadCurrentUrl()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 注册工具窗口可见性监听器(窗口获得/失去焦点)
|
||||
toolWindow.addVisibleListener { visible ->
|
||||
WindowFocusService.getInstance().setActive(visible)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isJcefSupported(): Boolean {
|
||||
return try {
|
||||
val clz = Class.forName("com.intellij.ui.jcef.JBCefApp")
|
||||
val method = clz.getMethod("isSupported")
|
||||
method.invoke(null) as Boolean
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG = Logger.getInstance(HarmnessToolWindowFactory::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.harmness.toolwindow
|
||||
|
||||
import com.intellij.openapi.components.PersistentStateComponent
|
||||
import com.intellij.openapi.components.State
|
||||
import com.intellij.openapi.components.Storage
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
|
||||
/**
|
||||
* 插件设置:目前仅保存一个「浏览器起始地址」。
|
||||
*
|
||||
* 该地址必须是一个允许被嵌入的 Web 应用地址(例如本地 Harness 服务地址),
|
||||
* 并且需要在页面上开启允许被 iframe/嵌入浏览(X-Frame-Options / CSP frame-ancestors)
|
||||
* 或者由 Harness 提供专门的无 frame 限制入口。
|
||||
*/
|
||||
@State(name = "HarmnessToolwindowSettings", storages = [Storage("harmness-toolwindow.xml")])
|
||||
class SettingsState : PersistentStateComponent<SettingsState.State> {
|
||||
|
||||
data class State(
|
||||
var startUrl: String = "http://127.0.0.1:8888",
|
||||
)
|
||||
|
||||
private var myState = State()
|
||||
private val logger = Logger.getInstance(SettingsState::class.java)
|
||||
|
||||
override fun getState(): State = myState
|
||||
|
||||
override fun loadState(state: State) {
|
||||
myState = state
|
||||
logger.info("Loaded settings: ${state.startUrl}")
|
||||
}
|
||||
|
||||
var startUrl: String
|
||||
get() = myState.startUrl.ifBlank { DEFAULT_URL }
|
||||
set(value) {
|
||||
myState = myState.copy(startUrl = value.trim())
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_URL = "http://127.0.0.1:8888"
|
||||
|
||||
fun getInstance(): SettingsState =
|
||||
com.intellij.openapi.components.ServiceManager.getService(SettingsState::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<idea-plugin>
|
||||
<id>com.harmness.toolwindow</id>
|
||||
<name>Harmness Browser</name>
|
||||
<vendor>harmness</vendor>
|
||||
|
||||
<description>
|
||||
Embed a configurable browser panel (e.g. a local Harmness instance) directly inside
|
||||
JetBrains IDE sidebars. Enables seamless AI-assisted coding inside PyCharm / IntelliJ
|
||||
IDEA without switching to a separate browser window.
|
||||
</description>
|
||||
|
||||
<!--
|
||||
IDE version compatibility:
|
||||
sinceBuild / untilBuild 控制插件兼容的 IDE 版本范围。
|
||||
这里要求 2024.2 及以上、2024.3.* 及以下。
|
||||
如需支持更老版本,请参考 https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
|
||||
-->
|
||||
<idea-version since-build="242" until-build="243.*"/>
|
||||
|
||||
<!-- 依赖 platform -->
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
|
||||
<!-- 工具窗口扩展点 -->
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<toolWindow
|
||||
id="Harmness Browser"
|
||||
icon="/icons/harmness.svg"
|
||||
factoryClass="com.harmness.toolwindow.HarmnessToolWindowFactory"
|
||||
anchor="right"
|
||||
condition="class#==com.intellij.ui.jcef.JBCefApp"
|
||||
/>
|
||||
|
||||
<!-- 插件设置页:Settings | Tools | Harmness Browser -->
|
||||
<applicationConfigurable
|
||||
parent="tools"
|
||||
instance="com.harmness.toolwindow.HarmnessSettingsConfigurable"
|
||||
id="com.harmness.toolwindow.HarmnessSettingsConfigurable"
|
||||
displayName="Harmness Browser"/>
|
||||
|
||||
<!-- 持久化组件 -->
|
||||
<applicationService
|
||||
serviceImplementation="com.harmness.toolwindow.SettingsState"/>
|
||||
|
||||
<applicationService
|
||||
serviceImplementation="com.harmness.toolwindow.WindowFocusService"/>
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8B5CF6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="40" height="40" rx="8" fill="url(#g)"/>
|
||||
<path d="M10 14h20v2H10zm0 5h20v2H10zm0 5h14v2H10z" fill="white"/>
|
||||
<circle cx="30" cy="27" r="6" fill="white" opacity=".25"/>
|
||||
<path d="M28 27l4-4" stroke="white" stroke-width="1.6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 602 B |
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Harmness Embedded</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "harmness-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { callPlugin, initBridge, isBridgeReady, openInExternalBrowser } from './bridge'
|
||||
|
||||
export default function App() {
|
||||
const [ready, setReady] = useState(false)
|
||||
const [pong, setPong] = useState('')
|
||||
const [status, setStatus] = useState('detecting…')
|
||||
|
||||
useEffect(() => {
|
||||
const dispose = initBridge(() => {
|
||||
setReady(true)
|
||||
setStatus('bridge ready')
|
||||
})
|
||||
return dispose
|
||||
}, [])
|
||||
|
||||
const runPing = async () => {
|
||||
try {
|
||||
setPong(await callPlugin('ping'))
|
||||
} catch (e) {
|
||||
setPong(`error: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<h1>Harmness Embedded</h1>
|
||||
<p className="status">
|
||||
Bridge: <b>{isBridgeReady() ? 'ready' : 'not ready'}</b> ({status})
|
||||
</p>
|
||||
|
||||
<div className="controls">
|
||||
<button onClick={runPing} disabled={!ready}>
|
||||
ping plugin
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openInExternalBrowser('https://www.jetbrains.com')}
|
||||
disabled={!ready}
|
||||
>
|
||||
open external browser
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pong && <pre className="result">{pong}</pre>}
|
||||
|
||||
<p className="hint">
|
||||
这个页面由 <code>web/</code> 下的 Vite + React + TypeScript 脚手架生成。
|
||||
开发时在 <code>web/</code> 运行 <code>npm run dev</code>(端口 8888),
|
||||
然后在插件 Settings 中把起始地址配置为 <code>http://127.0.0.1:8888</code>。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 与 JetBrains 插件(JCEF 嵌入浏览器)之间的 JS 桥接类型定义。
|
||||
*
|
||||
* 插件在页面加载完成后会向 `window` 注入全局函数 `harmnessBridge`(见
|
||||
* HarmnessBrowserPanel.kt 的 JS_BRIDGE_NAME)。网页侧通过它调用插件代码,
|
||||
* 典型用途:
|
||||
* - 打开系统浏览器访问外部链接
|
||||
* - 获取当前页面地址
|
||||
* - 未来:调用「打开编辑器」「插入文本」等 IDE 能力(需要插件侧扩展桥接协议)
|
||||
*/
|
||||
|
||||
export interface BridgeCallbacks {
|
||||
onSuccess?: (response: string) => void
|
||||
onFailure?: (errorCode: number, errorMessage: string) => void
|
||||
}
|
||||
|
||||
export interface BridgePayload {
|
||||
query: string
|
||||
callbacks?: BridgeCallbacks
|
||||
}
|
||||
|
||||
/** 插件注入的全局桥接函数签名 */
|
||||
export type HarmnessBridgeFn = (payload: BridgePayload) => void
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** 由插件注入:页面加载完成后可用 */
|
||||
harmnessBridge?: HarmnessBridgeFn
|
||||
}
|
||||
}
|
||||
|
||||
/** 单一挂载点,避免页面多次初始化时反复覆盖监听器 */
|
||||
let initialized = false
|
||||
|
||||
/** 桥接是否就绪 */
|
||||
export function isBridgeReady(): boolean {
|
||||
return typeof window.harmnessBridge === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化桥接:监听 `__HARMNESS_READY__`(插件注入桥后回调),
|
||||
* 之后即可调用 [callPlugin]。
|
||||
*/
|
||||
export function initBridge(onReady?: () => void): () => void {
|
||||
if (initialized) return () => {}
|
||||
initialized = true
|
||||
|
||||
const ready = () => {
|
||||
onReady?.()
|
||||
}
|
||||
|
||||
if (isBridgeReady()) {
|
||||
onReady?.()
|
||||
}
|
||||
|
||||
// 插件在注入桥后调用 window.__HARMNESS_READY__()
|
||||
const prev = (window as any).__HARMNESS_READY__
|
||||
;(window as any).__HARMNESS_READY__ = () => {
|
||||
if (typeof prev === 'function') prev()
|
||||
ready()
|
||||
}
|
||||
|
||||
return () => {
|
||||
initialized = false
|
||||
delete (window as any).__HARMNESS_READY__
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用插件侧方法。若桥未就绪则返回 rejected Promise。
|
||||
*
|
||||
* @param query 桥协议字符串,例如 "openExternal:https://example.com"
|
||||
* @param timeoutMs 超时(毫秒),默认 5000
|
||||
*/
|
||||
export function callPlugin(query: string, timeoutMs = 5000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bridge = window.harmnessBridge
|
||||
if (typeof bridge !== 'function') {
|
||||
reject(new Error('harmnessBridge is not available yet'))
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
reject(new Error(`Bridge call timed out after ${timeoutMs}ms: ${query}`))
|
||||
}, timeoutMs)
|
||||
|
||||
bridge({
|
||||
query,
|
||||
callbacks: {
|
||||
onSuccess: (response) => {
|
||||
window.clearTimeout(timer)
|
||||
resolve(response)
|
||||
},
|
||||
onFailure: (code, message) => {
|
||||
window.clearTimeout(timer)
|
||||
reject(new Error(`Bridge error ${code}: ${message}`))
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 便捷方法
|
||||
export const openInExternalBrowser = (url: string) => callPlugin(`openExternal:${url}`)
|
||||
export const ping = () => callPlugin('ping')
|
||||
@@ -0,0 +1,67 @@
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
color: #1f2328;
|
||||
background: #f6f8fa;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: #57606a;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.result {
|
||||
background: #0d1117;
|
||||
color: #7ee787;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: #57606a;
|
||||
border-top: 1px solid #d8dee4;
|
||||
padding-top: 12px;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
// 默认端口,与插件 Settings 默认地址一致
|
||||
port: 8888,
|
||||
strictPort: false,
|
||||
},
|
||||
build: {
|
||||
// 输出到插件 resources 目录(可选:也可用 Vite dev server 做开发)
|
||||
outDir: 'dist',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user