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:
harmness
2026-09-10 13:27:43 +08:00
commit cbe41e4f4f
23 changed files with 1125 additions and 0 deletions
+53
View File
@@ -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>
)
}
+105
View File
@@ -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')
+67
View File
@@ -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;
}
+10
View File
@@ -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>,
)
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />