From d2c90fbd4983817089c4854bd0a5c98b6138436d Mon Sep 17 00:00:00 2001 From: Mateusz Tymek Date: Sat, 3 Jan 2026 16:07:55 +0000 Subject: [PATCH] Initial commit --- .gitignore | 2 + AGENTS.md | 135 +++++++++ bun.lock | 98 +++++++ esbuild.config.mjs | 49 ++++ main.js | 632 ++++++++++++++++++++++++++++++++++++++++++ manifest.json | 9 + package.json | 26 ++ src/OpenCodeView.ts | 219 +++++++++++++++ src/ProcessManager.ts | 207 ++++++++++++++ src/SettingsTab.ts | 168 +++++++++++ src/main.ts | 205 ++++++++++++++ src/types.ts | 15 + styles.css | 193 +++++++++++++ tsconfig.json | 24 ++ 14 files changed, 1982 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 bun.lock create mode 100644 esbuild.config.mjs create mode 100644 main.js create mode 100644 manifest.json create mode 100644 package.json create mode 100644 src/OpenCodeView.ts create mode 100644 src/ProcessManager.ts create mode 100644 src/SettingsTab.ts create mode 100644 src/main.ts create mode 100644 src/types.ts create mode 100644 styles.css create mode 100644 tsconfig.json 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" + ] +}