commit d2c90fbd4983817089c4854bd0a5c98b6138436d Author: Mateusz Tymek Date: Sat Jan 3 16:07:55 2026 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a56a7ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..20c2c97 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,135 @@ +# AGENTS.md - Obsidian OpenCode Plugin + +Guidelines for AI coding agents working on the obsidian-opencode plugin. + +## Project Overview + +Obsidian plugin that embeds the OpenCode AI assistant via an iframe. Spawns a local server process and displays its web UI in the Obsidian sidebar. + +**Tech Stack:** TypeScript, Obsidian Plugin API, esbuild, Node.js child processes + +## Build Commands + +```bash +bun install # Install dependencies +bun run dev # Development (watch mode) +bun run build # Production (type-check + bundle) +``` + +Output: `main.js` (CommonJS bundle) + +## Testing & Linting + +**Not configured.** If adding: +- Tests: Vitest, files in `src/__tests__/` or `*.test.ts` +- Linting: ESLint + `@typescript-eslint/parser` +- Scripts: `"test": "vitest run"`, `"lint": "eslint src"` + +## Project Structure + +``` +src/ +├── main.ts # Plugin entry, extends Plugin +├── types.ts # Types and constants +├── OpenCodeView.ts # Sidebar view (ItemView) with iframe +├── ProcessManager.ts # Server process lifecycle +└── SettingsTab.ts # Settings UI (PluginSettingTab) +``` + +## Code Style + +### Imports +- ES modules with named imports +- Order: Obsidian API, Node.js builtins, local modules +- Use `type` for type-only imports +- Relative paths with `./` prefix + +```typescript +import { Plugin, WorkspaceLeaf, Notice } from "obsidian"; +import { spawn, ChildProcess } from "child_process"; +import type OpenCodePlugin from "./main"; +import { OpenCodeSettings, DEFAULT_SETTINGS } from "./types"; +``` + +### Exports +- `export default class` for main plugin +- Named exports for other classes/types/constants +- One class per file, filename matches class (PascalCase) + +### Naming Conventions + +| Type | Convention | Example | +|------|------------|---------| +| Classes | PascalCase | `OpenCodePlugin`, `ProcessManager` | +| Interfaces/Types | PascalCase | `OpenCodeSettings`, `ProcessState` | +| Constants | UPPER_CASE or camelCase | `DEFAULT_SETTINGS`, `OPENCODE_VIEW_TYPE` | +| Variables/functions | camelCase | `getVaultPath`, `startServer` | +| Private members | camelCase (no prefix) | `private processManager` | +| Files | PascalCase (classes), lowercase (entry) | `ProcessManager.ts`, `main.ts` | + +### TypeScript Patterns +- `strictNullChecks` enabled - handle null/undefined +- Union types for state: `"stopped" | "starting" | "running" | "error"` +- `async/await` over Promises +- Explicit return types on public methods + +```typescript +getProcessState(): ProcessState { + return this.processManager?.getState() ?? "stopped"; +} +``` + +### Error Handling +- try/catch for async operations +- `console.error()` for debugging +- `new Notice()` for user-facing errors +- Boolean returns for success/failure +- Silent catch for non-critical ops (health checks) + +```typescript +try { + await this.processManager.start(); +} catch (error) { + console.error("Failed to start:", error); + new Notice(`Failed to start OpenCode: ${error.message}`); + return false; +} +``` + +### Obsidian API Patterns +- Extend `Plugin` with `onload()`/`onunload()` lifecycle +- Extend `ItemView` for views: `getViewType()`, `onOpen()`, `onClose()` +- Extend `PluginSettingTab` for settings: `display()` +- DOM helpers: `createEl()`, `createDiv()`, `setIcon()` +- Register in `onload()`, clean up in `onunload()` + +```typescript +this.registerView(OPENCODE_VIEW_TYPE, (leaf) => new OpenCodeView(leaf, this)); +this.addCommand({ id: "toggle-view", name: "Toggle panel", callback: () => this.toggleView() }); +``` + +### DOM Creation +```typescript +const container = this.contentEl.createDiv({ cls: "opencode-container" }); +container.createEl("h3", { text: "Title" }); +container.createEl("button", { text: "Click", cls: "mod-cta" }); +``` + +### State Management +- Callback-based subscriptions +- Centralized state in manager classes +- Immediate notification on state change + +## Config Summary + +**tsconfig.json:** ES6 target, ESNext modules, strictNullChecks, noImplicitAny + +**esbuild:** CJS format, es2018 target, node platform. Externals: obsidian, electron, CodeMirror, Node builtins + +## Desktop-Only + +Uses Node.js APIs unavailable on mobile: +- `child_process.spawn()` for server process +- File system via vault adapter + +Check for desktop environment before adding mobile-incompatible features. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..7807e36 --- /dev/null +++ b/bun.lock @@ -0,0 +1,98 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "obsidian-opencode", + "devDependencies": { + "@types/node": "^20.11.0", + "builtin-modules": "^3.3.0", + "esbuild": "^0.21.5", + "obsidian": "latest", + "tslib": "^2.6.2", + "typescript": "^5.4.5", + }, + }, + }, + "packages": { + "@codemirror/state": ["@codemirror/state@6.5.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw=="], + + "@codemirror/view": ["@codemirror/view@6.38.6", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + + "@types/codemirror": ["@types/codemirror@5.60.8", "", { "dependencies": { "@types/tern": "*" } }, "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="], + + "@types/tern": ["@types/tern@0.23.9", "", { "dependencies": { "@types/estree": "*" } }, "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw=="], + + "builtin-modules": ["builtin-modules@3.3.0", "", {}, "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw=="], + + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "moment": ["moment@2.29.4", "", {}, "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w=="], + + "obsidian": ["obsidian@1.11.0", "", { "dependencies": { "@types/codemirror": "5.60.8", "moment": "2.29.4" }, "peerDependencies": { "@codemirror/state": "6.5.0", "@codemirror/view": "6.38.6" } }, "sha512-lVqN9AmDWHzhNATi2tDnjqVgI6WUYKeT+lIsAycAyLt4XCC6zRsWzb+tFCiB7Rn3PpttefjoovilhYwvS4Iqxw=="], + + "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + } +} diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..069fa8a --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,49 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const banner = `/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +If you want to view the source, please visit the GitHub repository. +*/ +`; + +const prod = process.argv[2] === "production"; + +const context = await esbuild.context({ + banner: { + js: banner, + }, + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins, + ], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + platform: "node", +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/main.js b/main.js new file mode 100644 index 0000000..28c877a --- /dev/null +++ b/main.js @@ -0,0 +1,632 @@ +/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +If you want to view the source, please visit the GitHub repository. +*/ + +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/main.ts +var main_exports = {}; +__export(main_exports, { + default: () => OpenCodePlugin +}); +module.exports = __toCommonJS(main_exports); +var import_obsidian4 = require("obsidian"); + +// src/types.ts +var DEFAULT_SETTINGS = { + port: 14096, + hostname: "127.0.0.1", + autoStart: false, + opencodePath: "opencode" +}; +var OPENCODE_VIEW_TYPE = "opencode-view"; + +// src/OpenCodeView.ts +var import_obsidian = require("obsidian"); +var OpenCodeView = class extends import_obsidian.ItemView { + constructor(leaf, plugin) { + super(leaf); + this.iframeEl = null; + this.currentState = "stopped"; + this.plugin = plugin; + } + getViewType() { + return OPENCODE_VIEW_TYPE; + } + getDisplayText() { + return "OpenCode"; + } + getIcon() { + return "terminal"; + } + async onOpen() { + this.contentEl.empty(); + this.contentEl.addClass("opencode-container"); + this.plugin.onProcessStateChange((state) => { + this.currentState = state; + this.updateView(); + }); + this.currentState = this.plugin.getProcessState(); + this.updateView(); + if (this.currentState === "stopped") { + this.plugin.startServer(); + } + } + async onClose() { + if (this.iframeEl) { + this.iframeEl.src = "about:blank"; + this.iframeEl = null; + } + } + updateView() { + switch (this.currentState) { + case "stopped": + this.renderStoppedState(); + break; + case "starting": + this.renderStartingState(); + break; + case "running": + this.renderRunningState(); + break; + case "error": + this.renderErrorState(); + break; + } + } + renderStoppedState() { + this.contentEl.empty(); + const statusContainer = this.contentEl.createDiv({ + cls: "opencode-status-container" + }); + const iconEl = statusContainer.createDiv({ cls: "opencode-status-icon" }); + (0, import_obsidian.setIcon)(iconEl, "power-off"); + statusContainer.createEl("h3", { text: "OpenCode is stopped" }); + statusContainer.createEl("p", { + text: "Click the button below to start the OpenCode server.", + cls: "opencode-status-message" + }); + const startButton = statusContainer.createEl("button", { + text: "Start OpenCode", + cls: "mod-cta" + }); + startButton.addEventListener("click", () => { + this.plugin.startServer(); + }); + } + renderStartingState() { + this.contentEl.empty(); + const statusContainer = this.contentEl.createDiv({ + cls: "opencode-status-container" + }); + const loadingEl = statusContainer.createDiv({ cls: "opencode-loading" }); + loadingEl.createDiv({ cls: "opencode-spinner" }); + statusContainer.createEl("h3", { text: "Starting OpenCode..." }); + statusContainer.createEl("p", { + text: "Please wait while the server starts up.", + cls: "opencode-status-message" + }); + } + renderRunningState() { + this.contentEl.empty(); + const headerEl = this.contentEl.createDiv({ cls: "opencode-header" }); + const titleSection = headerEl.createDiv({ cls: "opencode-header-title" }); + const iconEl = titleSection.createSpan(); + (0, import_obsidian.setIcon)(iconEl, "terminal"); + titleSection.createSpan({ text: "OpenCode" }); + const actionsEl = headerEl.createDiv({ cls: "opencode-header-actions" }); + const reloadButton = actionsEl.createEl("button", { + attr: { "aria-label": "Reload" } + }); + (0, import_obsidian.setIcon)(reloadButton, "refresh-cw"); + reloadButton.addEventListener("click", () => { + this.reloadIframe(); + }); + const externalButton = actionsEl.createEl("button", { + attr: { "aria-label": "Open in browser" } + }); + (0, import_obsidian.setIcon)(externalButton, "external-link"); + externalButton.addEventListener("click", () => { + window.open(this.plugin.getServerUrl(), "_blank"); + }); + const stopButton = actionsEl.createEl("button", { + attr: { "aria-label": "Stop server" } + }); + (0, import_obsidian.setIcon)(stopButton, "square"); + stopButton.addEventListener("click", () => { + this.plugin.stopServer(); + }); + const iframeContainer = this.contentEl.createDiv({ + cls: "opencode-iframe-container" + }); + this.iframeEl = iframeContainer.createEl("iframe", { + cls: "opencode-iframe", + attr: { + src: this.plugin.getServerUrl(), + frameborder: "0", + allow: "clipboard-read; clipboard-write" + } + }); + this.iframeEl.addEventListener("error", () => { + console.error("Failed to load OpenCode iframe"); + }); + } + renderErrorState() { + this.contentEl.empty(); + const statusContainer = this.contentEl.createDiv({ + cls: "opencode-status-container opencode-error" + }); + const iconEl = statusContainer.createDiv({ cls: "opencode-status-icon" }); + (0, import_obsidian.setIcon)(iconEl, "alert-circle"); + statusContainer.createEl("h3", { text: "Failed to start OpenCode" }); + statusContainer.createEl("p", { + text: "There was an error starting the OpenCode server. Please check that OpenCode is installed and try again.", + cls: "opencode-status-message" + }); + const retryButton = statusContainer.createEl("button", { + text: "Retry", + cls: "mod-cta" + }); + retryButton.addEventListener("click", () => { + this.plugin.startServer(); + }); + const settingsButton = statusContainer.createEl("button", { + text: "Open Settings" + }); + settingsButton.addEventListener("click", () => { + this.app.setting.open(); + this.app.setting.openTabById("obsidian-opencode"); + }); + } + reloadIframe() { + if (this.iframeEl) { + const src = this.iframeEl.src; + this.iframeEl.src = "about:blank"; + setTimeout(() => { + if (this.iframeEl) { + this.iframeEl.src = src; + } + }, 100); + } + } +}; + +// src/SettingsTab.ts +var import_obsidian2 = require("obsidian"); +var OpenCodeSettingTab = class extends import_obsidian2.PluginSettingTab { + constructor(app, plugin) { + super(app, plugin); + this.plugin = plugin; + } + display() { + const { containerEl } = this; + containerEl.empty(); + containerEl.createEl("h2", { text: "OpenCode Settings" }); + containerEl.createEl("h3", { text: "Server Configuration" }); + new import_obsidian2.Setting(containerEl).setName("Port").setDesc("Port number for the OpenCode web server").addText( + (text) => text.setPlaceholder("14096").setValue(this.plugin.settings.port.toString()).onChange(async (value) => { + const port = parseInt(value, 10); + if (!isNaN(port) && port > 0 && port < 65536) { + this.plugin.settings.port = port; + await this.plugin.saveSettings(); + } + }) + ); + new import_obsidian2.Setting(containerEl).setName("Hostname").setDesc("Hostname to bind the server to (usually 127.0.0.1)").addText( + (text) => text.setPlaceholder("127.0.0.1").setValue(this.plugin.settings.hostname).onChange(async (value) => { + this.plugin.settings.hostname = value || "127.0.0.1"; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian2.Setting(containerEl).setName("OpenCode path").setDesc( + "Path to the OpenCode executable. Leave as 'opencode' if it's in your PATH." + ).addText( + (text) => text.setPlaceholder("opencode").setValue(this.plugin.settings.opencodePath).onChange(async (value) => { + this.plugin.settings.opencodePath = value || "opencode"; + await this.plugin.saveSettings(); + }) + ); + containerEl.createEl("h3", { text: "Behavior" }); + new import_obsidian2.Setting(containerEl).setName("Auto-start server").setDesc( + "Automatically start the OpenCode server when Obsidian opens (not recommended for faster startup)" + ).addToggle( + (toggle) => toggle.setValue(this.plugin.settings.autoStart).onChange(async (value) => { + this.plugin.settings.autoStart = value; + await this.plugin.saveSettings(); + }) + ); + containerEl.createEl("h3", { text: "Server Status" }); + const statusContainer = containerEl.createDiv({ cls: "opencode-settings-status" }); + this.renderServerStatus(statusContainer); + } + renderServerStatus(container) { + container.empty(); + const state = this.plugin.getProcessState(); + const statusText = { + stopped: "Stopped", + starting: "Starting...", + running: "Running", + error: "Error" + }; + const statusClass = { + stopped: "status-stopped", + starting: "status-starting", + running: "status-running", + error: "status-error" + }; + const statusEl = container.createDiv({ cls: "opencode-status-line" }); + statusEl.createSpan({ text: "Status: " }); + statusEl.createSpan({ + text: statusText[state], + cls: `opencode-status-badge ${statusClass[state]}` + }); + if (state === "running") { + const urlEl = container.createDiv({ cls: "opencode-status-line" }); + urlEl.createSpan({ text: "URL: " }); + const linkEl = urlEl.createEl("a", { + text: this.plugin.getServerUrl(), + href: this.plugin.getServerUrl() + }); + linkEl.addEventListener("click", (e) => { + e.preventDefault(); + window.open(this.plugin.getServerUrl(), "_blank"); + }); + } + const buttonContainer = container.createDiv({ cls: "opencode-settings-buttons" }); + if (state === "stopped" || state === "error") { + const startButton = buttonContainer.createEl("button", { + text: "Start Server", + cls: "mod-cta" + }); + startButton.addEventListener("click", async () => { + await this.plugin.startServer(); + this.renderServerStatus(container); + }); + } + if (state === "running") { + const stopButton = buttonContainer.createEl("button", { + text: "Stop Server" + }); + stopButton.addEventListener("click", () => { + this.plugin.stopServer(); + this.renderServerStatus(container); + }); + const restartButton = buttonContainer.createEl("button", { + text: "Restart Server", + cls: "mod-warning" + }); + restartButton.addEventListener("click", async () => { + this.plugin.stopServer(); + await this.plugin.startServer(); + this.renderServerStatus(container); + }); + } + if (state === "starting") { + buttonContainer.createSpan({ + text: "Please wait...", + cls: "opencode-status-waiting" + }); + } + } +}; + +// src/ProcessManager.ts +var import_child_process = require("child_process"); +var import_obsidian3 = require("obsidian"); +var ProcessManager = class { + constructor(settings, workingDirectory, projectDirectory, onStateChange) { + this.process = null; + this.state = "stopped"; + this.startupTimeout = null; + this.settings = settings; + this.workingDirectory = workingDirectory; + this.projectDirectory = projectDirectory; + this.onStateChange = onStateChange; + } + updateSettings(settings) { + this.settings = settings; + } + getState() { + return this.state; + } + getUrl() { + return `http://${this.settings.hostname}:${this.settings.port}`; + } + async start() { + var _a, _b; + if (this.state === "running" || this.state === "starting") { + return true; + } + this.setState("starting"); + try { + const alreadyRunning = await this.checkServerHealth(); + if (alreadyRunning) { + console.log("OpenCode server already running on port", this.settings.port); + this.setState("running"); + return true; + } + console.log("[OpenCode] Starting server:", { + path: this.settings.opencodePath, + cwd: this.workingDirectory, + project: this.projectDirectory, + port: this.settings.port, + hostname: this.settings.hostname + }); + this.process = (0, import_child_process.spawn)( + this.settings.opencodePath, + [ + "serve", + this.projectDirectory, + "--port", + this.settings.port.toString(), + "--hostname", + this.settings.hostname, + "--cors", + "app://obsidian.md" + ], + { + cwd: this.workingDirectory, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + detached: false + } + ); + console.log("[OpenCode] Process spawned with PID:", this.process.pid); + (_a = this.process.stdout) == null ? void 0 : _a.on("data", (data) => { + console.log("[OpenCode]", data.toString().trim()); + }); + (_b = this.process.stderr) == null ? void 0 : _b.on("data", (data) => { + console.error("[OpenCode Error]", data.toString().trim()); + }); + this.process.on("exit", (code, signal) => { + console.log(`OpenCode process exited with code ${code}, signal ${signal}`); + this.process = null; + if (this.state === "running") { + this.setState("stopped"); + } + }); + this.process.on("error", (err) => { + console.error("Failed to start OpenCode process:", err); + new import_obsidian3.Notice(`Failed to start OpenCode: ${err.message}`); + this.process = null; + this.setState("error"); + }); + const ready = await this.waitForServerOrExit(15e3); + if (ready) { + this.setState("running"); + return true; + } else { + this.stop(); + this.setState("error"); + new import_obsidian3.Notice("OpenCode server failed to start within timeout"); + return false; + } + } catch (error) { + console.error("Error starting OpenCode:", error); + this.setState("error"); + return false; + } + } + stop() { + if (this.startupTimeout) { + clearTimeout(this.startupTimeout); + this.startupTimeout = null; + } + if (this.process) { + try { + this.process.kill("SIGTERM"); + setTimeout(() => { + if (this.process && !this.process.killed) { + this.process.kill("SIGKILL"); + } + }, 2e3); + } catch (error) { + console.error("Error stopping OpenCode process:", error); + } + this.process = null; + } + this.setState("stopped"); + } + setState(state) { + this.state = state; + this.onStateChange(state); + } + async checkServerHealth() { + try { + const response = await fetch(`${this.getUrl()}/global/health`, { + method: "GET", + signal: AbortSignal.timeout(2e3) + }); + return response.ok; + } catch (e) { + return false; + } + } + async waitForServerOrExit(timeoutMs) { + const startTime = Date.now(); + const pollInterval = 500; + while (Date.now() - startTime < timeoutMs) { + if (!this.process) { + console.log("OpenCode process exited before server became ready"); + return false; + } + if (await this.checkServerHealth()) { + return true; + } + await this.sleep(pollInterval); + } + return false; + } + sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +}; + +// src/main.ts +var OpenCodePlugin = class extends import_obsidian4.Plugin { + constructor() { + super(...arguments); + this.settings = DEFAULT_SETTINGS; + this.processManager = null; + this.stateChangeCallbacks = []; + } + async onload() { + console.log("Loading OpenCode plugin"); + await this.loadSettings(); + this.processManager = new ProcessManager( + this.settings, + this.getVaultPath(), + this.getVaultPath(), + (state) => this.notifyStateChange(state) + ); + this.registerView(OPENCODE_VIEW_TYPE, (leaf) => new OpenCodeView(leaf, this)); + this.addRibbonIcon("terminal", "OpenCode", () => { + this.activateView(); + }); + this.addCommand({ + id: "toggle-opencode-view", + name: "Toggle OpenCode panel", + callback: () => { + this.toggleView(); + }, + hotkeys: [ + { + modifiers: ["Mod", "Shift"], + key: "o" + } + ] + }); + this.addCommand({ + id: "start-opencode-server", + name: "Start OpenCode server", + callback: () => { + this.startServer(); + } + }); + this.addCommand({ + id: "stop-opencode-server", + name: "Stop OpenCode server", + callback: () => { + this.stopServer(); + } + }); + this.addSettingTab(new OpenCodeSettingTab(this.app, this)); + if (this.settings.autoStart) { + this.app.workspace.onLayoutReady(async () => { + await this.startServer(); + }); + } + console.log("OpenCode plugin loaded"); + } + async onunload() { + console.log("Unloading OpenCode plugin"); + this.stopServer(); + this.app.workspace.detachLeavesOfType(OPENCODE_VIEW_TYPE); + console.log("OpenCode plugin unloaded"); + } + async loadSettings() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + async saveSettings() { + await this.saveData(this.settings); + if (this.processManager) { + this.processManager.updateSettings(this.settings); + } + } + // Get existing view leaf if any + getExistingLeaf() { + const leaves = this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE); + return leaves.length > 0 ? leaves[0] : null; + } + // Activate or create the view + async activateView() { + const existingLeaf = this.getExistingLeaf(); + if (existingLeaf) { + this.app.workspace.revealLeaf(existingLeaf); + return; + } + const leaf = this.app.workspace.getRightLeaf(false); + if (leaf) { + await leaf.setViewState({ + type: OPENCODE_VIEW_TYPE, + active: true + }); + this.app.workspace.revealLeaf(leaf); + } + } + // Toggle view visibility + async toggleView() { + const existingLeaf = this.getExistingLeaf(); + if (existingLeaf) { + const rightSplit = this.app.workspace.rightSplit; + if (rightSplit && !rightSplit.collapsed) { + existingLeaf.detach(); + } else { + this.app.workspace.revealLeaf(existingLeaf); + } + } else { + await this.activateView(); + } + } + // Start the OpenCode server + async startServer() { + if (!this.processManager) { + new import_obsidian4.Notice("OpenCode: Process manager not initialized"); + return false; + } + const success = await this.processManager.start(); + if (success) { + new import_obsidian4.Notice("OpenCode server started"); + } + return success; + } + // Stop the OpenCode server + stopServer() { + if (this.processManager) { + this.processManager.stop(); + new import_obsidian4.Notice("OpenCode server stopped"); + } + } + // Get the current process state + getProcessState() { + var _a, _b; + return (_b = (_a = this.processManager) == null ? void 0 : _a.getState()) != null ? _b : "stopped"; + } + // Get the server URL + getServerUrl() { + var _a, _b; + return (_b = (_a = this.processManager) == null ? void 0 : _a.getUrl()) != null ? _b : `http://127.0.0.1:${this.settings.port}`; + } + // Subscribe to process state changes + onProcessStateChange(callback) { + this.stateChangeCallbacks.push(callback); + } + // Notify all subscribers of state change + notifyStateChange(state) { + for (const callback of this.stateChangeCallbacks) { + callback(state); + } + } + // Get the vault path + getVaultPath() { + const adapter = this.app.vault.adapter; + return adapter.basePath || ""; + } +}; diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..0137019 --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "obsidian-opencode", + "name": "OpenCode", + "version": "0.1.0", + "minAppVersion": "1.4.0", + "description": "Embed OpenCode AI assistant in Obsidian for AI-powered note management", + "author": "mat", + "isDesktopOnly": true +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f07c547 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "obsidian-opencode", + "version": "0.1.0", + "description": "Embed OpenCode AI assistant in Obsidian", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "npx tsc -noEmit -skipLibCheck && node esbuild.config.mjs production" + }, + "keywords": [ + "obsidian", + "opencode", + "ai", + "assistant" + ], + "author": "mat", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.11.0", + "builtin-modules": "^3.3.0", + "esbuild": "^0.21.5", + "obsidian": "latest", + "tslib": "^2.6.2", + "typescript": "^5.4.5" + } +} diff --git a/src/OpenCodeView.ts b/src/OpenCodeView.ts new file mode 100644 index 0000000..9ed5507 --- /dev/null +++ b/src/OpenCodeView.ts @@ -0,0 +1,219 @@ +import { ItemView, WorkspaceLeaf, setIcon } from "obsidian"; +import { OPENCODE_VIEW_TYPE } from "./types"; +import type OpenCodePlugin from "./main"; +import { ProcessState } from "./ProcessManager"; + +export class OpenCodeView extends ItemView { + plugin: OpenCodePlugin; + private iframeEl: HTMLIFrameElement | null = null; + private currentState: ProcessState = "stopped"; + + constructor(leaf: WorkspaceLeaf, plugin: OpenCodePlugin) { + super(leaf); + this.plugin = plugin; + } + + getViewType(): string { + return OPENCODE_VIEW_TYPE; + } + + getDisplayText(): string { + return "OpenCode"; + } + + getIcon(): string { + return "terminal"; + } + + async onOpen(): Promise { + this.contentEl.empty(); + this.contentEl.addClass("opencode-container"); + + // Subscribe to state changes + this.plugin.onProcessStateChange((state) => { + this.currentState = state; + this.updateView(); + }); + + // Initial render + this.currentState = this.plugin.getProcessState(); + this.updateView(); + + // Start server if not running (lazy start) - don't await to avoid blocking view open + if (this.currentState === "stopped") { + this.plugin.startServer(); + } + } + + async onClose(): Promise { + // Clean up iframe + if (this.iframeEl) { + this.iframeEl.src = "about:blank"; + this.iframeEl = null; + } + } + + private updateView(): void { + switch (this.currentState) { + case "stopped": + this.renderStoppedState(); + break; + case "starting": + this.renderStartingState(); + break; + case "running": + this.renderRunningState(); + break; + case "error": + this.renderErrorState(); + break; + } + } + + private renderStoppedState(): void { + this.contentEl.empty(); + + const statusContainer = this.contentEl.createDiv({ + cls: "opencode-status-container", + }); + + const iconEl = statusContainer.createDiv({ cls: "opencode-status-icon" }); + setIcon(iconEl, "power-off"); + + statusContainer.createEl("h3", { text: "OpenCode is stopped" }); + statusContainer.createEl("p", { + text: "Click the button below to start the OpenCode server.", + cls: "opencode-status-message", + }); + + const startButton = statusContainer.createEl("button", { + text: "Start OpenCode", + cls: "mod-cta", + }); + startButton.addEventListener("click", () => { + this.plugin.startServer(); + }); + } + + private renderStartingState(): void { + this.contentEl.empty(); + + const statusContainer = this.contentEl.createDiv({ + cls: "opencode-status-container", + }); + + const loadingEl = statusContainer.createDiv({ cls: "opencode-loading" }); + loadingEl.createDiv({ cls: "opencode-spinner" }); + + statusContainer.createEl("h3", { text: "Starting OpenCode..." }); + statusContainer.createEl("p", { + text: "Please wait while the server starts up.", + cls: "opencode-status-message", + }); + } + + private renderRunningState(): void { + this.contentEl.empty(); + + // Create header with controls + const headerEl = this.contentEl.createDiv({ cls: "opencode-header" }); + + const titleSection = headerEl.createDiv({ cls: "opencode-header-title" }); + const iconEl = titleSection.createSpan(); + setIcon(iconEl, "terminal"); + titleSection.createSpan({ text: "OpenCode" }); + + const actionsEl = headerEl.createDiv({ cls: "opencode-header-actions" }); + + // Reload button + const reloadButton = actionsEl.createEl("button", { + attr: { "aria-label": "Reload" }, + }); + setIcon(reloadButton, "refresh-cw"); + reloadButton.addEventListener("click", () => { + this.reloadIframe(); + }); + + // Open in browser button + const externalButton = actionsEl.createEl("button", { + attr: { "aria-label": "Open in browser" }, + }); + setIcon(externalButton, "external-link"); + externalButton.addEventListener("click", () => { + window.open(this.plugin.getServerUrl(), "_blank"); + }); + + // Stop button + const stopButton = actionsEl.createEl("button", { + attr: { "aria-label": "Stop server" }, + }); + setIcon(stopButton, "square"); + stopButton.addEventListener("click", () => { + this.plugin.stopServer(); + }); + + // Create iframe container + const iframeContainer = this.contentEl.createDiv({ + cls: "opencode-iframe-container", + }); + + this.iframeEl = iframeContainer.createEl("iframe", { + cls: "opencode-iframe", + attr: { + src: this.plugin.getServerUrl(), + frameborder: "0", + allow: "clipboard-read; clipboard-write", + }, + }); + + // Handle iframe load errors + this.iframeEl.addEventListener("error", () => { + console.error("Failed to load OpenCode iframe"); + }); + } + + private renderErrorState(): void { + this.contentEl.empty(); + + const statusContainer = this.contentEl.createDiv({ + cls: "opencode-status-container opencode-error", + }); + + const iconEl = statusContainer.createDiv({ cls: "opencode-status-icon" }); + setIcon(iconEl, "alert-circle"); + + statusContainer.createEl("h3", { text: "Failed to start OpenCode" }); + statusContainer.createEl("p", { + text: "There was an error starting the OpenCode server. Please check that OpenCode is installed and try again.", + cls: "opencode-status-message", + }); + + const retryButton = statusContainer.createEl("button", { + text: "Retry", + cls: "mod-cta", + }); + retryButton.addEventListener("click", () => { + this.plugin.startServer(); + }); + + const settingsButton = statusContainer.createEl("button", { + text: "Open Settings", + }); + settingsButton.addEventListener("click", () => { + (this.app as any).setting.open(); + (this.app as any).setting.openTabById("obsidian-opencode"); + }); + } + + private reloadIframe(): void { + if (this.iframeEl) { + const src = this.iframeEl.src; + this.iframeEl.src = "about:blank"; + setTimeout(() => { + if (this.iframeEl) { + this.iframeEl.src = src; + } + }, 100); + } + } +} diff --git a/src/ProcessManager.ts b/src/ProcessManager.ts new file mode 100644 index 0000000..d2ce487 --- /dev/null +++ b/src/ProcessManager.ts @@ -0,0 +1,207 @@ +import { spawn, ChildProcess } from "child_process"; +import { Notice } from "obsidian"; +import { OpenCodeSettings } from "./types"; + +export type ProcessState = "stopped" | "starting" | "running" | "error"; + +export class ProcessManager { + private process: ChildProcess | null = null; + private state: ProcessState = "stopped"; + private settings: OpenCodeSettings; + private workingDirectory: string; + private projectDirectory: string; + private onStateChange: (state: ProcessState) => void; + private startupTimeout: NodeJS.Timeout | null = null; + + constructor( + settings: OpenCodeSettings, + workingDirectory: string, + projectDirectory: string, + onStateChange: (state: ProcessState) => void + ) { + this.settings = settings; + this.workingDirectory = workingDirectory; + this.projectDirectory = projectDirectory; + this.onStateChange = onStateChange; + } + + updateSettings(settings: OpenCodeSettings) { + this.settings = settings; + } + + getState(): ProcessState { + return this.state; + } + + getUrl(): string { + return `http://${this.settings.hostname}:${this.settings.port}`; + } + + async start(): Promise { + if (this.state === "running" || this.state === "starting") { + return true; + } + + this.setState("starting"); + + try { + // Validate vault/project directory is set + if (!this.projectDirectory) { + const error = "Project directory (vault) not configured"; + console.error("[OpenCode Error]", error); + new Notice(`Failed to start OpenCode: ${error}`); + this.setState("error"); + return false; + } + + // Check if server is already running on this port + const alreadyRunning = await this.checkServerHealth(); + if (alreadyRunning) { + console.log("OpenCode server already running on port", this.settings.port); + this.setState("running"); + return true; + } + + // Start the opencode serve process (headless server, no browser) + // OpenCode is initialized with the vault directory as the project + console.log("[OpenCode] Starting server with vault:", { + vaultDirectory: this.projectDirectory, + workingDirectory: this.workingDirectory, + opencodePath: this.settings.opencodePath, + port: this.settings.port, + hostname: this.settings.hostname, + }); + + this.process = spawn( + this.settings.opencodePath, + [ + "serve", + this.projectDirectory, + "--port", + this.settings.port.toString(), + "--hostname", + this.settings.hostname, + "--cors", + "app://obsidian.md", + ], + { + cwd: this.workingDirectory, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + } + ); + + console.log("[OpenCode] Process spawned with PID:", this.process.pid); + + // Handle process output + this.process.stdout?.on("data", (data) => { + console.log("[OpenCode]", data.toString().trim()); + }); + + this.process.stderr?.on("data", (data) => { + console.error("[OpenCode Error]", data.toString().trim()); + }); + + // Handle process exit + this.process.on("exit", (code, signal) => { + console.log(`OpenCode process exited with code ${code}, signal ${signal}`); + this.process = null; + // Only set stopped if we're in running state (not during startup) + if (this.state === "running") { + this.setState("stopped"); + } + }); + + this.process.on("error", (err) => { + console.error("Failed to start OpenCode process:", err); + new Notice(`Failed to start OpenCode: ${err.message}`); + this.process = null; + this.setState("error"); + }); + + // Wait for server to be ready, detecting early process exit + const ready = await this.waitForServerOrExit(15000); + if (ready) { + this.setState("running"); + return true; + } else { + this.stop(); + this.setState("error"); + new Notice("OpenCode server failed to start within timeout"); + return false; + } + } catch (error) { + console.error("Error starting OpenCode:", error); + this.setState("error"); + return false; + } + } + + stop(): void { + if (this.startupTimeout) { + clearTimeout(this.startupTimeout); + this.startupTimeout = null; + } + + if (this.process) { + try { + // Try graceful shutdown first + this.process.kill("SIGTERM"); + + // Force kill after 2 seconds if still running + setTimeout(() => { + if (this.process && !this.process.killed) { + this.process.kill("SIGKILL"); + } + }, 2000); + } catch (error) { + console.error("Error stopping OpenCode process:", error); + } + this.process = null; + } + + this.setState("stopped"); + } + + private setState(state: ProcessState): void { + this.state = state; + this.onStateChange(state); + } + + private async checkServerHealth(): Promise { + try { + const response = await fetch(`${this.getUrl()}/global/health`, { + method: "GET", + signal: AbortSignal.timeout(2000), + }); + return response.ok; + } catch { + return false; + } + } + + private async waitForServerOrExit(timeoutMs: number): Promise { + const startTime = Date.now(); + const pollInterval = 500; + + while (Date.now() - startTime < timeoutMs) { + // If process exited early, fail fast + if (!this.process) { + console.log("OpenCode process exited before server became ready"); + return false; + } + + if (await this.checkServerHealth()) { + return true; + } + await this.sleep(pollInterval); + } + + return false; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/SettingsTab.ts b/src/SettingsTab.ts new file mode 100644 index 0000000..7367b75 --- /dev/null +++ b/src/SettingsTab.ts @@ -0,0 +1,168 @@ +import { App, PluginSettingTab, Setting } from "obsidian"; +import type OpenCodePlugin from "./main"; + +export class OpenCodeSettingTab extends PluginSettingTab { + plugin: OpenCodePlugin; + + constructor(app: App, plugin: OpenCodePlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + containerEl.createEl("h2", { text: "OpenCode Settings" }); + + // Server settings section + containerEl.createEl("h3", { text: "Server Configuration" }); + + new Setting(containerEl) + .setName("Port") + .setDesc("Port number for the OpenCode web server") + .addText((text) => + text + .setPlaceholder("14096") + .setValue(this.plugin.settings.port.toString()) + .onChange(async (value) => { + const port = parseInt(value, 10); + if (!isNaN(port) && port > 0 && port < 65536) { + this.plugin.settings.port = port; + await this.plugin.saveSettings(); + } + }) + ); + + new Setting(containerEl) + .setName("Hostname") + .setDesc("Hostname to bind the server to (usually 127.0.0.1)") + .addText((text) => + text + .setPlaceholder("127.0.0.1") + .setValue(this.plugin.settings.hostname) + .onChange(async (value) => { + this.plugin.settings.hostname = value || "127.0.0.1"; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("OpenCode path") + .setDesc( + "Path to the OpenCode executable. Leave as 'opencode' if it's in your PATH." + ) + .addText((text) => + text + .setPlaceholder("opencode") + .setValue(this.plugin.settings.opencodePath) + .onChange(async (value) => { + this.plugin.settings.opencodePath = value || "opencode"; + await this.plugin.saveSettings(); + }) + ); + + // Behavior settings section + containerEl.createEl("h3", { text: "Behavior" }); + + new Setting(containerEl) + .setName("Auto-start server") + .setDesc( + "Automatically start the OpenCode server when Obsidian opens (not recommended for faster startup)" + ) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.autoStart) + .onChange(async (value) => { + this.plugin.settings.autoStart = value; + await this.plugin.saveSettings(); + }) + ); + + // Server status section + containerEl.createEl("h3", { text: "Server Status" }); + + const statusContainer = containerEl.createDiv({ cls: "opencode-settings-status" }); + this.renderServerStatus(statusContainer); + } + + private renderServerStatus(container: HTMLElement): void { + container.empty(); + + const state = this.plugin.getProcessState(); + const statusText = { + stopped: "Stopped", + starting: "Starting...", + running: "Running", + error: "Error", + }; + + const statusClass = { + stopped: "status-stopped", + starting: "status-starting", + running: "status-running", + error: "status-error", + }; + + const statusEl = container.createDiv({ cls: "opencode-status-line" }); + statusEl.createSpan({ text: "Status: " }); + statusEl.createSpan({ + text: statusText[state], + cls: `opencode-status-badge ${statusClass[state]}`, + }); + + if (state === "running") { + const urlEl = container.createDiv({ cls: "opencode-status-line" }); + urlEl.createSpan({ text: "URL: " }); + const linkEl = urlEl.createEl("a", { + text: this.plugin.getServerUrl(), + href: this.plugin.getServerUrl(), + }); + linkEl.addEventListener("click", (e) => { + e.preventDefault(); + window.open(this.plugin.getServerUrl(), "_blank"); + }); + } + + // Control buttons + const buttonContainer = container.createDiv({ cls: "opencode-settings-buttons" }); + + if (state === "stopped" || state === "error") { + const startButton = buttonContainer.createEl("button", { + text: "Start Server", + cls: "mod-cta", + }); + startButton.addEventListener("click", async () => { + await this.plugin.startServer(); + this.renderServerStatus(container); + }); + } + + if (state === "running") { + const stopButton = buttonContainer.createEl("button", { + text: "Stop Server", + }); + stopButton.addEventListener("click", () => { + this.plugin.stopServer(); + this.renderServerStatus(container); + }); + + const restartButton = buttonContainer.createEl("button", { + text: "Restart Server", + cls: "mod-warning", + }); + restartButton.addEventListener("click", async () => { + this.plugin.stopServer(); + await this.plugin.startServer(); + this.renderServerStatus(container); + }); + } + + if (state === "starting") { + buttonContainer.createSpan({ + text: "Please wait...", + cls: "opencode-status-waiting", + }); + } + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..2b903cb --- /dev/null +++ b/src/main.ts @@ -0,0 +1,205 @@ +import { Plugin, WorkspaceLeaf, Notice } from "obsidian"; +import { OpenCodeSettings, DEFAULT_SETTINGS, OPENCODE_VIEW_TYPE } from "./types"; +import { OpenCodeView } from "./OpenCodeView"; +import { OpenCodeSettingTab } from "./SettingsTab"; +import { ProcessManager, ProcessState } from "./ProcessManager"; + +export default class OpenCodePlugin extends Plugin { + settings: OpenCodeSettings = DEFAULT_SETTINGS; + private processManager: ProcessManager | null = null; + private stateChangeCallbacks: Array<(state: ProcessState) => void> = []; + + async onload(): Promise { + console.log("Loading OpenCode plugin"); + + await this.loadSettings(); + + // Get the vault directory path to pass to OpenCode + const vaultPath = this.getVaultPath(); + + // Initialize process manager with vault as the project directory + this.processManager = new ProcessManager( + this.settings, + vaultPath, + vaultPath, + (state) => this.notifyStateChange(state) + ); + + console.log("[OpenCode] Configured with vault directory:", vaultPath); + + // Register the OpenCode view + this.registerView(OPENCODE_VIEW_TYPE, (leaf) => new OpenCodeView(leaf, this)); + + // Add ribbon icon + this.addRibbonIcon("terminal", "OpenCode", () => { + this.activateView(); + }); + + // Add command to toggle view + this.addCommand({ + id: "toggle-opencode-view", + name: "Toggle OpenCode panel", + callback: () => { + this.toggleView(); + }, + hotkeys: [ + { + modifiers: ["Mod", "Shift"], + key: "o", + }, + ], + }); + + // Add command to start server + this.addCommand({ + id: "start-opencode-server", + name: "Start OpenCode server", + callback: () => { + this.startServer(); + }, + }); + + // Add command to stop server + this.addCommand({ + id: "stop-opencode-server", + name: "Stop OpenCode server", + callback: () => { + this.stopServer(); + }, + }); + + // Register settings tab + this.addSettingTab(new OpenCodeSettingTab(this.app, this)); + + // Auto-start if enabled + if (this.settings.autoStart) { + this.app.workspace.onLayoutReady(async () => { + await this.startServer(); + }); + } + + console.log("OpenCode plugin loaded"); + } + + async onunload(): Promise { + console.log("Unloading OpenCode plugin"); + + // Stop the server + this.stopServer(); + + // Detach all views + this.app.workspace.detachLeavesOfType(OPENCODE_VIEW_TYPE); + + console.log("OpenCode plugin unloaded"); + } + + async loadSettings(): Promise { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + // Update process manager with new settings + if (this.processManager) { + this.processManager.updateSettings(this.settings); + } + } + + // Get existing view leaf if any + private getExistingLeaf(): WorkspaceLeaf | null { + const leaves = this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE); + return leaves.length > 0 ? leaves[0] : null; + } + + // Activate or create the view + async activateView(): Promise { + const existingLeaf = this.getExistingLeaf(); + + if (existingLeaf) { + this.app.workspace.revealLeaf(existingLeaf); + return; + } + + // Create new leaf in right sidebar + const leaf = this.app.workspace.getRightLeaf(false); + if (leaf) { + await leaf.setViewState({ + type: OPENCODE_VIEW_TYPE, + active: true, + }); + this.app.workspace.revealLeaf(leaf); + } + } + + // Toggle view visibility + async toggleView(): Promise { + const existingLeaf = this.getExistingLeaf(); + + if (existingLeaf) { + // Check if visible + const rightSplit = this.app.workspace.rightSplit; + if (rightSplit && !rightSplit.collapsed) { + existingLeaf.detach(); + } else { + this.app.workspace.revealLeaf(existingLeaf); + } + } else { + await this.activateView(); + } + } + + // Start the OpenCode server + async startServer(): Promise { + if (!this.processManager) { + new Notice("OpenCode: Process manager not initialized"); + return false; + } + + const success = await this.processManager.start(); + if (success) { + new Notice("OpenCode server started"); + } + return success; + } + + // Stop the OpenCode server + stopServer(): void { + if (this.processManager) { + this.processManager.stop(); + new Notice("OpenCode server stopped"); + } + } + + // Get the current process state + getProcessState(): ProcessState { + return this.processManager?.getState() ?? "stopped"; + } + + // Get the server URL + getServerUrl(): string { + return this.processManager?.getUrl() ?? `http://127.0.0.1:${this.settings.port}`; + } + + // Subscribe to process state changes + onProcessStateChange(callback: (state: ProcessState) => void): void { + this.stateChangeCallbacks.push(callback); + } + + // Notify all subscribers of state change + private notifyStateChange(state: ProcessState): void { + for (const callback of this.stateChangeCallbacks) { + callback(state); + } + } + + // Get the vault path - this is the root directory of the Obsidian vault + // which will be passed to OpenCode as the project directory + private getVaultPath(): string { + const adapter = this.app.vault.adapter as any; + const vaultPath = adapter.basePath || ""; + if (!vaultPath) { + console.warn("[OpenCode] Warning: Could not determine vault path"); + } + return vaultPath; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..71aaf01 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,15 @@ +export interface OpenCodeSettings { + port: number; + hostname: string; + autoStart: boolean; + opencodePath: string; +} + +export const DEFAULT_SETTINGS: OpenCodeSettings = { + port: 14096, + hostname: "127.0.0.1", + autoStart: false, + opencodePath: "opencode", +}; + +export const OPENCODE_VIEW_TYPE = "opencode-view"; diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..0358049 --- /dev/null +++ b/styles.css @@ -0,0 +1,193 @@ +/* OpenCode Plugin Styles */ + +/* Main container */ +.opencode-container { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +/* Header */ +.opencode-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + border-bottom: 1px solid var(--background-modifier-border); + background-color: var(--background-secondary); + flex-shrink: 0; +} + +.opencode-header-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; +} + +.opencode-header-title svg { + width: 16px; + height: 16px; +} + +.opencode-header-actions { + display: flex; + gap: 4px; +} + +.opencode-header-actions button { + background: transparent; + border: none; + padding: 4px 6px; + border-radius: 4px; + cursor: pointer; + color: var(--text-muted); +} + +.opencode-header-actions button:hover { + background-color: var(--background-modifier-hover); + color: var(--text-normal); +} + +.opencode-header-actions button svg { + width: 16px; + height: 16px; +} + +/* iframe container */ +.opencode-iframe-container { + flex: 1; + overflow: hidden; + position: relative; +} + +.opencode-iframe { + width: 100%; + height: 100%; + border: none; + background-color: var(--background-primary); +} + +/* Status container (for stopped/starting/error states) */ +.opencode-status-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 24px; + text-align: center; + gap: 16px; +} + +.opencode-status-icon { + width: 48px; + height: 48px; + color: var(--text-muted); +} + +.opencode-status-icon svg { + width: 100%; + height: 100%; +} + +.opencode-status-container h3 { + margin: 0; + font-size: 1.2em; +} + +.opencode-status-message { + color: var(--text-muted); + max-width: 300px; + margin: 0; +} + +.opencode-status-container button { + margin-top: 8px; +} + +.opencode-status-container button + button { + margin-left: 8px; +} + +/* Error state */ +.opencode-status-container.opencode-error .opencode-status-icon { + color: var(--text-error); +} + +/* Loading spinner */ +.opencode-loading { + display: flex; + justify-content: center; + align-items: center; +} + +.opencode-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--background-modifier-border); + border-top-color: var(--interactive-accent); + border-radius: 50%; + animation: opencode-spin 1s linear infinite; +} + +@keyframes opencode-spin { + to { + transform: rotate(360deg); + } +} + +/* Settings tab styles */ +.opencode-settings-status { + margin-top: 12px; + padding: 12px; + background-color: var(--background-secondary); + border-radius: 8px; +} + +.opencode-status-line { + margin-bottom: 8px; +} + +.opencode-status-line a { + color: var(--text-accent); +} + +.opencode-status-badge { + padding: 2px 8px; + border-radius: 4px; + font-size: 0.85em; + font-weight: 500; +} + +.opencode-status-badge.status-stopped { + background-color: var(--background-modifier-border); + color: var(--text-muted); +} + +.opencode-status-badge.status-starting { + background-color: var(--text-warning); + color: var(--background-primary); +} + +.opencode-status-badge.status-running { + background-color: var(--text-success); + color: var(--background-primary); +} + +.opencode-status-badge.status-error { + background-color: var(--text-error); + color: var(--background-primary); +} + +.opencode-settings-buttons { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.opencode-status-waiting { + color: var(--text-muted); + font-style: italic; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cbe01ec --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "lib": [ + "DOM", + "ES5", + "ES6", + "ES7" + ] + }, + "include": [ + "src/**/*.ts" + ] +}