CommunicationBridge.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  1. import * as vscode from "vscode"
  2. import * as path from "path"
  3. import { errorHandler } from "../utils/ErrorHandler"
  4. import { PluginCommunicator, UnifiedMessage } from "../types/UnifiedMessage"
  5. import { logger } from "../globals"
  6. /**
  7. * Communication bridge between VSCode and WebUI
  8. * Handles bi-directional messaging and state synchronization
  9. * Combines functionality from multiple JetBrains classes:
  10. * - PathInserter.kt
  11. * - FontSizeSynchronizer.kt
  12. * - SessionCommandSynchronizer.kt
  13. * - OpenInIdeHandler.kt
  14. * - WebViewLoadHandler.kt
  15. */
  16. export interface CommunicationBridgeOptions {
  17. webview?: vscode.Webview
  18. context?: vscode.ExtensionContext
  19. onStateChange?: (key: string, value: any) => Promise<void>
  20. }
  21. export class CommunicationBridge implements PluginCommunicator {
  22. private webview?: vscode.Webview
  23. private context?: vscode.ExtensionContext
  24. private onStateChange?: (key: string, value: any) => Promise<void>
  25. private messageHandlerDisposable?: vscode.Disposable
  26. constructor(options: CommunicationBridgeOptions = {}) {
  27. this.webview = options.webview
  28. this.context = options.context
  29. this.onStateChange = options.onStateChange
  30. if (this.webview) {
  31. this.setupMessageHandlers()
  32. }
  33. }
  34. /**
  35. * Set the webview instance for communication
  36. * @param webview VSCode webview instance
  37. */
  38. setWebview(webview: vscode.Webview): void {
  39. // Clean up existing message handlers
  40. if (this.messageHandlerDisposable) {
  41. this.messageHandlerDisposable.dispose()
  42. }
  43. this.webview = webview
  44. if (webview) {
  45. this.setupMessageHandlers()
  46. logger.appendLine("Webview set and message handlers configured")
  47. } else {
  48. logger.appendLine("Webview cleared")
  49. }
  50. }
  51. /**
  52. * Set the extension context
  53. * @param context VSCode extension context
  54. */
  55. setContext(context: vscode.ExtensionContext): void {
  56. this.context = context
  57. }
  58. /**
  59. * Set the state change callback
  60. * @param callback Function to handle state changes
  61. */
  62. setStateChangeCallback(callback: (key: string, value: any) => Promise<void>): void {
  63. this.onStateChange = callback
  64. }
  65. // VSCode → WebUI communication methods
  66. /**
  67. * Send a unified message to the webview using postMessage protocol
  68. * @param message Unified message object
  69. */
  70. sendMessage(message: UnifiedMessage): void {
  71. try {
  72. if (!this.webview) {
  73. logger.appendLine("No webview available to send message")
  74. return
  75. }
  76. // Add timestamp if not present
  77. const messageWithMetadata = {
  78. ...message,
  79. timestamp: message.timestamp || Date.now(),
  80. }
  81. // Send message using webview.postMessage
  82. this.webview.postMessage(messageWithMetadata)
  83. //logger.appendLine(`Sent unified message: ${JSON.stringify(messageWithMetadata)}`);
  84. } catch (error) {
  85. logger.appendLine(`Error sending unified message: ${error}`)
  86. errorHandler.handleCommunicationError(error instanceof Error ? error : new Error(String(error)), {
  87. operation: "sendMessage",
  88. messageType: message.type,
  89. })
  90. }
  91. }
  92. /**
  93. * Send file paths to the web UI
  94. * Mirrors PathInserter.kt insertPaths functionality
  95. * @param paths Array of file paths to insert
  96. */
  97. insertPaths(paths: string[]): void {
  98. try {
  99. if (!paths || paths.length === 0) {
  100. logger.appendLine("No paths provided to insert")
  101. return
  102. }
  103. // Validate and normalize paths
  104. const validPaths = this.validatePaths(paths)
  105. if (validPaths.length === 0) {
  106. logger.appendLine("No valid paths to insert after validation")
  107. vscode.window.showWarningMessage("OpenCode: No valid paths to insert")
  108. return
  109. }
  110. // Send unified message
  111. this.sendMessage({
  112. type: "insertPaths",
  113. paths: validPaths,
  114. })
  115. logger.appendLine(`Inserted ${validPaths.length} paths: ${validPaths.join(", ")}`)
  116. } catch (error) {
  117. logger.appendLine(`Error inserting paths: ${error}`)
  118. errorHandler.handleCommunicationError(error instanceof Error ? error : new Error(String(error)), {
  119. operation: "insertPaths",
  120. paths,
  121. pathCount: paths?.length,
  122. })
  123. }
  124. }
  125. /**
  126. * Send directory path to the web UI for pasting
  127. * Mirrors PathInserter.kt pastePath functionality
  128. * @param path Directory path to paste
  129. */
  130. pastePath(path: string): void {
  131. try {
  132. if (!path || path.trim().length === 0) {
  133. logger.appendLine("No path provided to paste")
  134. return
  135. }
  136. // Validate and normalize the path
  137. const normalizedPath = this.normalizePath(path.trim())
  138. if (!normalizedPath) {
  139. logger.appendLine(`Invalid path to paste: ${path}`)
  140. vscode.window.showWarningMessage(`OpenCode: Invalid path - ${path}`)
  141. return
  142. }
  143. // Send unified message
  144. this.sendMessage({
  145. type: "pastePath",
  146. path: normalizedPath,
  147. })
  148. logger.appendLine(`Pasted path: ${normalizedPath}`)
  149. } catch (error) {
  150. logger.appendLine(`Error pasting path: ${error}`)
  151. errorHandler.handleCommunicationError(error instanceof Error ? error : new Error(String(error)), {
  152. operation: "pastePath",
  153. path,
  154. })
  155. }
  156. }
  157. /**
  158. * Update opened files list in the web UI
  159. * Mirrors IdeOpenFilesUpdater.kt functionality
  160. * @param files Array of open file paths
  161. * @param current Currently active file path
  162. */
  163. updateOpenedFiles(files: string[], current?: string): void {
  164. try {
  165. if (!files) {
  166. files = []
  167. }
  168. // Validate and normalize file paths
  169. const validFiles = this.validatePaths(files)
  170. // Send unified message
  171. this.sendMessage({
  172. type: "updateOpenedFiles",
  173. openedFiles: validFiles,
  174. currentFile: current || null,
  175. })
  176. //logger.appendLine(`Updated opened files: ${validFiles.length} files, current: ${current || 'none'}`);
  177. } catch (error) {
  178. logger.appendLine(`Error updating opened files: ${error}`)
  179. }
  180. }
  181. /**
  182. * Set chips collapsed state in the web UI
  183. * @param collapsed Whether chips should be collapsed
  184. */
  185. // WebUI → VSCode communication handlers
  186. /**
  187. * Handle file open request from web UI
  188. * Mirrors OpenInIdeHandler.kt functionality
  189. * @param path File path to open (may include line numbers like "file.js:10-25")
  190. */
  191. async handleOpenFile(path: string): Promise<void> {
  192. try {
  193. if (!path || path.trim().length === 0) {
  194. logger.appendLine("No path provided to open")
  195. return
  196. }
  197. // Parse line range from path (mirrors JetBrains regex logic)
  198. const rangeRegex = /:(\d+)(?:-(\d+))?$/
  199. const match = rangeRegex.exec(path)
  200. let startLine: number | undefined
  201. let endLine: number | undefined
  202. let cleanPath = path
  203. if (match) {
  204. startLine = parseInt(match[1], 10)
  205. if (match[2]) {
  206. endLine = parseInt(match[2], 10)
  207. }
  208. cleanPath = path.replace(rangeRegex, "")
  209. }
  210. // Normalize and resolve the path
  211. const normalizedPath = this.normalizePath(cleanPath)
  212. if (!normalizedPath) {
  213. logger.appendLine(`Invalid path to open: ${cleanPath}`)
  214. vscode.window.showWarningMessage(`OpenCode: Invalid file path - ${cleanPath}`)
  215. return
  216. }
  217. // Convert to VSCode URI
  218. const fileUri = vscode.Uri.file(normalizedPath)
  219. // Check if file exists
  220. try {
  221. await vscode.workspace.fs.stat(fileUri)
  222. } catch (error) {
  223. // File doesn't exist, try to refresh and find it
  224. logger.appendLine(`File not found, attempting to refresh: ${normalizedPath}`)
  225. }
  226. const document = await vscode.workspace.openTextDocument(fileUri)
  227. if (startLine !== undefined) {
  228. const startZero = Math.max(0, startLine - 1)
  229. let endZero = startZero
  230. if (endLine !== undefined) {
  231. endZero = Math.max(startZero, endLine - 1)
  232. }
  233. const lastIndex = document.lineCount > 0 ? document.lineCount - 1 : 0
  234. const clampedStart = Math.min(startZero, lastIndex)
  235. const clampedEnd = Math.min(endZero, lastIndex)
  236. const startPos = new vscode.Position(clampedStart, 0)
  237. const endLineObj = document.lineAt(clampedEnd)
  238. const endPos = endLineObj.range.end
  239. const range = new vscode.Range(startPos, endPos)
  240. try {
  241. const editor = await vscode.window.showTextDocument(document, {
  242. selection: range,
  243. viewColumn: vscode.ViewColumn.Active,
  244. })
  245. editor.selection = new vscode.Selection(range.start, range.end)
  246. editor.revealRange(range, vscode.TextEditorRevealType.InCenter)
  247. if (endLine !== undefined) {
  248. logger.appendLine(`Opened file at lines ${startLine}-${endLine}: ${normalizedPath}`)
  249. } else {
  250. logger.appendLine(`Opened file at line ${startLine}: ${normalizedPath}`)
  251. }
  252. } catch (error) {
  253. logger.appendLine(`Failed to open file with line number, trying without: ${error}`)
  254. await vscode.window.showTextDocument(fileUri)
  255. logger.appendLine(`Opened file (fallback): ${normalizedPath}`)
  256. }
  257. } else {
  258. await vscode.window.showTextDocument(document)
  259. logger.appendLine(`Opened file: ${normalizedPath}`)
  260. }
  261. } catch (error) {
  262. logger.appendLine(`Error opening file: ${error}`)
  263. await errorHandler.handleFileOperationError(error instanceof Error ? error : new Error(String(error)), {
  264. operation: "openFile",
  265. filePath: path,
  266. hasLineNumbers: !!path.match(/:(\d+)(?:-(\d+))?$/),
  267. })
  268. }
  269. }
  270. /**
  271. * Handle url open request from web UI
  272. * @param url URL to open
  273. */
  274. async handleOpenUrl(url: string): Promise<void> {
  275. try {
  276. if (!url || url.trim().length === 0) {
  277. logger.appendLine("No url provided to open")
  278. return
  279. }
  280. await vscode.env.openExternal(vscode.Uri.parse(url))
  281. logger.appendLine(`Opened url: ${url}`)
  282. } catch (error) {
  283. logger.appendLine(`Error opening url: ${error}`)
  284. await errorHandler.handleCommunicationError(error instanceof Error ? error : new Error(String(error)), {
  285. operation: "openUrl",
  286. messageType: "openUrl",
  287. })
  288. }
  289. }
  290. /**
  291. * Handle reload path request from web UI - refreshes file from disk after AI agent modifies it
  292. * @param filePath File path to reload
  293. */
  294. async handleReloadPath(filePath: string): Promise<void> {
  295. try {
  296. if (!filePath || filePath.trim().length === 0) {
  297. logger.appendLine("No path provided to reload")
  298. return
  299. }
  300. const normalizedPath = this.normalizePath(filePath)
  301. if (!normalizedPath) {
  302. logger.appendLine(`Invalid path to reload: ${filePath}`)
  303. return
  304. }
  305. const fileUri = vscode.Uri.file(normalizedPath)
  306. // Check if file exists and refresh it
  307. try {
  308. await vscode.workspace.fs.stat(fileUri)
  309. // File exists - find open editors and refresh them
  310. for (const editor of vscode.window.visibleTextEditors) {
  311. if (editor.document.uri.fsPath === fileUri.fsPath) {
  312. // Revert the document to reload from disk
  313. await vscode.commands.executeCommand("workbench.action.files.revert", editor.document.uri)
  314. logger.appendLine(`Reloaded file: ${normalizedPath}`)
  315. return
  316. }
  317. }
  318. // File not open in editor, no action needed
  319. logger.appendLine(`File not open in editor, skipping reload: ${normalizedPath}`)
  320. } catch {
  321. // File doesn't exist yet (new file), refresh workspace
  322. logger.appendLine(`File not found, refreshing workspace: ${normalizedPath}`)
  323. }
  324. } catch (error) {
  325. logger.appendLine(`Error reloading path: ${error}`)
  326. }
  327. }
  328. /**
  329. * Handle state change from web UI
  330. * @param key Setting key
  331. * @param value Setting value
  332. */
  333. async handleStateChange(key: string, value: any): Promise<void> {
  334. try {
  335. logger.appendLine(`Handling state change: ${key} = ${value}`)
  336. // Use the callback if provided
  337. if (this.onStateChange) {
  338. await this.onStateChange(key, value)
  339. return
  340. }
  341. // Fallback to direct configuration update
  342. const config = vscode.workspace.getConfiguration("opencode")
  343. switch (key) {
  344. case "customCommand":
  345. if (typeof value === "string") {
  346. await config.update("customCommand", value, vscode.ConfigurationTarget.Global)
  347. logger.appendLine(`Custom command updated to: ${value}`)
  348. } else {
  349. logger.appendLine(`Invalid customCommand value: ${value}`)
  350. }
  351. break
  352. default:
  353. logger.appendLine(`Unknown settings key: ${key}`)
  354. }
  355. } catch (error) {
  356. logger.appendLine(`Error handling state change: ${error}`)
  357. }
  358. }
  359. // Extended message handling callbacks
  360. private onUILoadedCallback?: (success: boolean, error?: string) => Promise<void>
  361. private onReadUris?: (uris: string[]) => Promise<void>
  362. /**
  363. * Set callback for UI loaded events
  364. */
  365. setUILoadedCallback(callback: (success: boolean, error?: string) => Promise<void>): void {
  366. this.onUILoadedCallback = callback
  367. }
  368. /**
  369. * Set callback for URI read requests
  370. */
  371. setReadUrisCallback(callback: (uris: string[]) => Promise<void>): void {
  372. this.onReadUris = callback
  373. }
  374. /**
  375. * Set up message handlers for webview communication
  376. * Consolidated handler for all webview message types
  377. * Mirrors WebViewLoadHandler.kt message handling setup
  378. */
  379. setupMessageHandlers(): void {
  380. if (!this.webview) {
  381. logger.appendLine("No webview available to set up message handlers")
  382. return
  383. }
  384. // Clean up existing handler
  385. if (this.messageHandlerDisposable) {
  386. this.messageHandlerDisposable.dispose()
  387. }
  388. this.messageHandlerDisposable = this.webview.onDidReceiveMessage(
  389. async (message) => {
  390. try {
  391. // ideBridge JSON tunnel from iframe
  392. if (message && message.type === "__ideBridgeSend" && typeof message.json === "string") {
  393. try {
  394. const m = JSON.parse(message.json)
  395. if (m && m.type === "openFile") {
  396. await this.handleOpenFile(m.payload?.path ?? m.path)
  397. // reply if id present
  398. if (m.id) {
  399. this.webview?.postMessage({ replyTo: m.id, ok: true })
  400. }
  401. } else if (m && m.type === "openUrl") {
  402. await this.handleOpenUrl(m.payload?.url ?? m.url)
  403. if (m.id) {
  404. this.webview?.postMessage({ replyTo: m.id, ok: true })
  405. }
  406. } else if (m && m.type === "reloadPath") {
  407. await this.handleReloadPath(m.payload?.path)
  408. if (m.id) {
  409. this.webview?.postMessage({ replyTo: m.id, ok: true })
  410. }
  411. } else {
  412. // Generic ack for unknown types
  413. if (m && m.id) this.webview?.postMessage({ replyTo: m.id, ok: true })
  414. }
  415. } catch (e) {
  416. try {
  417. const id = (() => {
  418. try {
  419. return JSON.parse(message.json).id
  420. } catch {
  421. return undefined
  422. }
  423. })()
  424. if (id) this.webview?.postMessage({ replyTo: id, ok: false, error: String(e) })
  425. } catch {}
  426. logger.appendLine(`Failed to process __ideBridgeSend: ${e}`)
  427. }
  428. return
  429. }
  430. switch (message.type) {
  431. case "openFile":
  432. await this.handleOpenFile(message.path)
  433. break
  434. case "openUrl":
  435. await this.handleOpenUrl(message.url)
  436. break
  437. case "settingsChanged":
  438. await this.handleStateChange(message.key, message.value)
  439. break
  440. case "bridgeValidation":
  441. logger.appendLine(`Bridge validation: ${message.success ? "success" : "failed"}`)
  442. if (!message.success && message.missingFunctions) {
  443. logger.appendLine(`Missing functions: ${message.missingFunctions.join(", ")}`)
  444. }
  445. break
  446. case "uiLoaded":
  447. logger.appendLine(`UI loaded: ${message.success ? "success" : "failed"}`)
  448. if (!message.success && message.error) {
  449. logger.appendLine(`UI load error: ${message.error}`)
  450. }
  451. // Call external callback if provided
  452. if (this.onUILoadedCallback) {
  453. await this.onUILoadedCallback(message.success, message.error)
  454. }
  455. break
  456. case "error":
  457. logger.appendLine(`Webview error: ${message.error}`)
  458. if (message.filename) {
  459. logger.appendLine(` at ${message.filename}:${message.lineno}`)
  460. }
  461. break
  462. case "readUris":
  463. if (Array.isArray(message.uris)) {
  464. logger.appendLine(`URI read request: ${message.uris.length} URIs`)
  465. if (this.onReadUris) {
  466. await this.onReadUris(message.uris)
  467. }
  468. }
  469. break
  470. case "executeCommand":
  471. try {
  472. const command: unknown = message.command
  473. const args: unknown[] = Array.isArray(message.args) ? message.args : []
  474. if (typeof command !== "string" || command.trim() === "") {
  475. logger.appendLine("Invalid executeCommand message: missing command")
  476. break
  477. }
  478. // Whitelist allowed commands for safety
  479. const allowed = new Set<string>([
  480. "workbench.action.showCommands",
  481. "workbench.action.quickOpen",
  482. "workbench.action.files.save",
  483. "editor.action.selectAll",
  484. "workbench.action.files.newUntitledFile",
  485. "actions.find",
  486. "undo",
  487. "redo",
  488. // Clipboard actions for macOS handling
  489. "editor.action.clipboardCopyAction",
  490. "editor.action.clipboardCutAction",
  491. "editor.action.clipboardPasteAction",
  492. ])
  493. const cmd = command as string // safe after type guard above
  494. if (!allowed.has(cmd)) {
  495. logger.appendLine(`Blocked executeCommand for non-whitelisted command: ${cmd}`)
  496. break
  497. }
  498. await vscode.commands.executeCommand(cmd, ...args)
  499. logger.appendLine(`Executed command from webview: ${cmd}`)
  500. } catch (e) {
  501. logger.appendLine(`Failed to execute command from webview: ${e}`)
  502. }
  503. break
  504. default:
  505. logger.appendLine(`Unknown message type: ${message.type}`)
  506. }
  507. } catch (error) {
  508. logger.appendLine(`Error handling message: ${error}`)
  509. }
  510. },
  511. undefined,
  512. this.context?.subscriptions,
  513. )
  514. logger.appendLine("Message handlers set up successfully")
  515. }
  516. // Private utility methods
  517. /**
  518. * Validate file paths before sending to web UI
  519. * @param paths Array of paths to validate
  520. * @returns Array of valid paths
  521. */
  522. private validatePaths(paths: string[]): string[] {
  523. const validPaths: string[] = []
  524. for (const rawPath of paths) {
  525. try {
  526. const normalizedPath = this.normalizePath(rawPath)
  527. if (normalizedPath) {
  528. validPaths.push(normalizedPath)
  529. } else {
  530. logger.appendLine(`Skipping invalid path: ${rawPath}`)
  531. }
  532. } catch (error) {
  533. logger.appendLine(`Error validating path ${rawPath}: ${error}`)
  534. }
  535. }
  536. return validPaths
  537. }
  538. /**
  539. * Normalize a file path for consistent handling
  540. * @param rawPath Raw path string
  541. * @returns Normalized path or null if invalid
  542. */
  543. private normalizePath(rawPath: string): string | null {
  544. try {
  545. if (!rawPath || rawPath.trim().length === 0) {
  546. return null
  547. }
  548. let normalizedPath = rawPath.trim()
  549. // Handle VSCode URI format
  550. if (normalizedPath.startsWith("file://")) {
  551. normalizedPath = vscode.Uri.parse(normalizedPath).fsPath
  552. }
  553. // Resolve relative paths against workspace
  554. if (!path.isAbsolute(normalizedPath)) {
  555. const workspaceFolder = vscode.workspace.workspaceFolders?.[0]
  556. if (workspaceFolder) {
  557. normalizedPath = path.resolve(workspaceFolder.uri.fsPath, normalizedPath)
  558. } else {
  559. // No workspace, can't resolve relative path
  560. return null
  561. }
  562. }
  563. // Normalize path separators
  564. normalizedPath = path.normalize(normalizedPath)
  565. // Convert to POSIX style for webview and testing consistency
  566. return normalizedPath.split(path.sep).join("/")
  567. } catch (error) {
  568. logger.appendLine(`Error normalizing path ${rawPath}: ${error}`)
  569. return null
  570. }
  571. }
  572. /**
  573. * Dispose of resources
  574. */
  575. dispose(): void {
  576. if (this.messageHandlerDisposable) {
  577. this.messageHandlerDisposable.dispose()
  578. this.messageHandlerDisposable = undefined
  579. }
  580. this.webview = undefined
  581. this.context = undefined
  582. this.onStateChange = undefined
  583. logger.appendLine("CommunicationBridge disposed")
  584. }
  585. }