Initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
|
||||
135
AGENTS.md
Normal file
135
AGENTS.md
Normal file
@@ -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.
|
||||
98
bun.lock
Normal file
98
bun.lock
Normal file
@@ -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=="],
|
||||
}
|
||||
}
|
||||
49
esbuild.config.mjs
Normal file
49
esbuild.config.mjs
Normal file
@@ -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();
|
||||
}
|
||||
632
main.js
Normal file
632
main.js
Normal file
@@ -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 || "";
|
||||
}
|
||||
};
|
||||
9
manifest.json
Normal file
9
manifest.json
Normal file
@@ -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
|
||||
}
|
||||
26
package.json
Normal file
26
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
219
src/OpenCodeView.ts
Normal file
219
src/OpenCodeView.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
207
src/ProcessManager.ts
Normal file
207
src/ProcessManager.ts
Normal file
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
168
src/SettingsTab.ts
Normal file
168
src/SettingsTab.ts
Normal file
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
205
src/main.ts
Normal file
205
src/main.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
15
src/types.ts
Normal file
15
src/types.ts
Normal file
@@ -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";
|
||||
193
styles.css
Normal file
193
styles.css
Normal file
@@ -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;
|
||||
}
|
||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user