This commit is contained in:
Mateusz Tymek
2026-01-04 22:10:56 +00:00
parent a6926aeab3
commit fb3692948e
5 changed files with 193 additions and 43 deletions

108
main.js
View File

@@ -27,7 +27,7 @@ __export(main_exports, {
default: () => OpenCodePlugin default: () => OpenCodePlugin
}); });
module.exports = __toCommonJS(main_exports); module.exports = __toCommonJS(main_exports);
var import_obsidian5 = require("obsidian"); var import_obsidian4 = require("obsidian");
// src/types.ts // src/types.ts
var DEFAULT_SETTINGS = { var DEFAULT_SETTINGS = {
@@ -59,6 +59,7 @@ var OpenCodeView = class extends import_obsidian2.ItemView {
super(leaf); super(leaf);
this.iframeEl = null; this.iframeEl = null;
this.currentState = "stopped"; this.currentState = "stopped";
this.unsubscribeStateChange = null;
this.plugin = plugin; this.plugin = plugin;
} }
getViewType() { getViewType() {
@@ -73,7 +74,7 @@ var OpenCodeView = class extends import_obsidian2.ItemView {
async onOpen() { async onOpen() {
this.contentEl.empty(); this.contentEl.empty();
this.contentEl.addClass("opencode-container"); this.contentEl.addClass("opencode-container");
this.plugin.onProcessStateChange((state) => { this.unsubscribeStateChange = this.plugin.onProcessStateChange((state) => {
this.currentState = state; this.currentState = state;
this.updateView(); this.updateView();
}); });
@@ -84,6 +85,10 @@ var OpenCodeView = class extends import_obsidian2.ItemView {
} }
} }
async onClose() { async onClose() {
if (this.unsubscribeStateChange) {
this.unsubscribeStateChange();
this.unsubscribeStateChange = null;
}
if (this.iframeEl) { if (this.iframeEl) {
this.iframeEl.src = "about:blank"; this.iframeEl.src = "about:blank";
this.iframeEl = null; this.iframeEl = null;
@@ -190,18 +195,29 @@ var OpenCodeView = class extends import_obsidian2.ItemView {
const iconEl = statusContainer.createDiv({ cls: "opencode-status-icon" }); const iconEl = statusContainer.createDiv({ cls: "opencode-status-icon" });
(0, import_obsidian2.setIcon)(iconEl, "alert-circle"); (0, import_obsidian2.setIcon)(iconEl, "alert-circle");
statusContainer.createEl("h3", { text: "Failed to start OpenCode" }); statusContainer.createEl("h3", { text: "Failed to start OpenCode" });
statusContainer.createEl("p", { const errorMessage = this.plugin.getLastError();
text: "There was an error starting the OpenCode server. Please check that OpenCode is installed and try again.", if (errorMessage) {
cls: "opencode-status-message" statusContainer.createEl("p", {
text: errorMessage,
cls: "opencode-status-message opencode-error-message"
});
} else {
statusContainer.createEl("p", {
text: "There was an error starting the OpenCode server.",
cls: "opencode-status-message"
});
}
const buttonContainer = statusContainer.createDiv({
cls: "opencode-button-group"
}); });
const retryButton = statusContainer.createEl("button", { const retryButton = buttonContainer.createEl("button", {
text: "Retry", text: "Retry",
cls: "mod-cta" cls: "mod-cta"
}); });
retryButton.addEventListener("click", () => { retryButton.addEventListener("click", () => {
this.plugin.startServer(); this.plugin.startServer();
}); });
const settingsButton = statusContainer.createEl("button", { const settingsButton = buttonContainer.createEl("button", {
text: "Open Settings" text: "Open Settings"
}); });
settingsButton.addEventListener("click", () => { settingsButton.addEventListener("click", () => {
@@ -394,11 +410,12 @@ var OpenCodeSettingTab = class extends import_obsidian3.PluginSettingTab {
// src/ProcessManager.ts // src/ProcessManager.ts
var import_child_process = require("child_process"); var import_child_process = require("child_process");
var import_obsidian4 = require("obsidian");
var ProcessManager = class { var ProcessManager = class {
constructor(settings, workingDirectory, projectDirectory, onStateChange) { constructor(settings, workingDirectory, projectDirectory, onStateChange) {
this.process = null; this.process = null;
this.state = "stopped"; this.state = "stopped";
this.lastError = null;
this.earlyExitCode = null;
this.startupTimeout = null; this.startupTimeout = null;
this.settings = settings; this.settings = settings;
this.workingDirectory = workingDirectory; this.workingDirectory = workingDirectory;
@@ -414,6 +431,9 @@ var ProcessManager = class {
getState() { getState() {
return this.state; return this.state;
} }
getLastError() {
return this.lastError;
}
getUrl() { getUrl() {
const baseUrl = `http://${this.settings.hostname}:${this.settings.port}`; const baseUrl = `http://${this.settings.hostname}:${this.settings.port}`;
const encodedPath = btoa(this.projectDirectory); const encodedPath = btoa(this.projectDirectory);
@@ -425,11 +445,12 @@ var ProcessManager = class {
return true; return true;
} }
this.setState("starting"); this.setState("starting");
this.lastError = null;
this.earlyExitCode = null;
try { try {
if (!this.projectDirectory) { if (!this.projectDirectory) {
const error = "Project directory (vault) not configured"; this.lastError = "Project directory (vault) not configured";
console.error("[OpenCode Error]", error); console.error("[OpenCode Error]", this.lastError);
new import_obsidian4.Notice(`Failed to start OpenCode: ${error}`);
this.setState("error"); this.setState("error");
return false; return false;
} }
@@ -474,13 +495,20 @@ var ProcessManager = class {
this.process.on("exit", (code, signal) => { this.process.on("exit", (code, signal) => {
console.log(`OpenCode process exited with code ${code}, signal ${signal}`); console.log(`OpenCode process exited with code ${code}, signal ${signal}`);
this.process = null; this.process = null;
if (this.state === "starting" && code !== null && code !== 0) {
this.earlyExitCode = code;
}
if (this.state === "running") { if (this.state === "running") {
this.setState("stopped"); this.setState("stopped");
} }
}); });
this.process.on("error", (err) => { this.process.on("error", (err) => {
console.error("Failed to start OpenCode process:", err); console.error("Failed to start OpenCode process:", err);
new import_obsidian4.Notice(`Failed to start OpenCode: ${err.message}`); if (err.code === "ENOENT") {
this.lastError = `OpenCode executable not found at '${this.settings.opencodePath}'`;
} else {
this.lastError = `Failed to start OpenCode: ${err.message}`;
}
this.process = null; this.process = null;
this.setState("error"); this.setState("error");
}); });
@@ -489,13 +517,23 @@ var ProcessManager = class {
this.setState("running"); this.setState("running");
return true; return true;
} else { } else {
if (this.state === "error") {
return false;
}
if (this.earlyExitCode !== null) {
this.lastError = `OpenCode process exited unexpectedly (exit code ${this.earlyExitCode})`;
} else if (!this.process) {
this.lastError = "OpenCode process exited before server became ready";
} else {
this.lastError = "OpenCode server failed to start within timeout";
}
this.stop(); this.stop();
this.setState("error"); this.setState("error");
new import_obsidian4.Notice("OpenCode server failed to start within timeout");
return false; return false;
} }
} catch (error) { } catch (error) {
console.error("Error starting OpenCode:", error); console.error("Error starting OpenCode:", error);
this.lastError = error instanceof Error ? error.message : String(error);
this.setState("error"); this.setState("error");
return false; return false;
} }
@@ -506,17 +544,30 @@ var ProcessManager = class {
this.startupTimeout = null; this.startupTimeout = null;
} }
if (this.process) { if (this.process) {
const proc = this.process;
const pid = proc.pid;
console.log("[OpenCode] Stopping process with PID:", pid);
this.setState("stopped");
this.process = null;
try { try {
this.process.kill("SIGTERM"); proc.kill("SIGTERM");
console.log("[OpenCode] Sent SIGTERM to process");
setTimeout(() => { setTimeout(() => {
if (this.process && !this.process.killed) { if (proc.exitCode === null && proc.signalCode === null) {
this.process.kill("SIGKILL"); console.log("[OpenCode] Process still running after SIGTERM, sending SIGKILL");
try {
proc.kill("SIGKILL");
} catch (error) {
console.error("[OpenCode] Error sending SIGKILL:", error);
}
} else {
console.log("[OpenCode] Process exited with:", proc.exitCode, proc.signalCode);
} }
}, 2e3); }, 2e3);
} catch (error) { } catch (error) {
console.error("Error stopping OpenCode process:", error); console.error("[OpenCode] Error stopping process:", error);
} }
this.process = null; return;
} }
this.setState("stopped"); this.setState("stopped");
} }
@@ -556,7 +607,7 @@ var ProcessManager = class {
}; };
// src/main.ts // src/main.ts
var OpenCodePlugin = class extends import_obsidian5.Plugin { var OpenCodePlugin = class extends import_obsidian4.Plugin {
constructor() { constructor() {
super(...arguments); super(...arguments);
this.settings = DEFAULT_SETTINGS; this.settings = DEFAULT_SETTINGS;
@@ -680,12 +731,12 @@ var OpenCodePlugin = class extends import_obsidian5.Plugin {
// Start the OpenCode server // Start the OpenCode server
async startServer() { async startServer() {
if (!this.processManager) { if (!this.processManager) {
new import_obsidian5.Notice("OpenCode: Process manager not initialized"); new import_obsidian4.Notice("OpenCode: Process manager not initialized");
return false; return false;
} }
const success = await this.processManager.start(); const success = await this.processManager.start();
if (success) { if (success) {
new import_obsidian5.Notice("OpenCode server started"); new import_obsidian4.Notice("OpenCode server started");
} }
return success; return success;
} }
@@ -693,7 +744,7 @@ var OpenCodePlugin = class extends import_obsidian5.Plugin {
stopServer() { stopServer() {
if (this.processManager) { if (this.processManager) {
this.processManager.stop(); this.processManager.stop();
new import_obsidian5.Notice("OpenCode server stopped"); new import_obsidian4.Notice("OpenCode server stopped");
} }
} }
// Get the current process state // Get the current process state
@@ -701,14 +752,25 @@ var OpenCodePlugin = class extends import_obsidian5.Plugin {
var _a, _b; var _a, _b;
return (_b = (_a = this.processManager) == null ? void 0 : _a.getState()) != null ? _b : "stopped"; return (_b = (_a = this.processManager) == null ? void 0 : _a.getState()) != null ? _b : "stopped";
} }
// Get the last error message from the process manager
getLastError() {
var _a, _b;
return (_b = (_a = this.processManager) == null ? void 0 : _a.getLastError()) != null ? _b : null;
}
// Get the server URL // Get the server URL
getServerUrl() { getServerUrl() {
var _a, _b; var _a, _b;
return (_b = (_a = this.processManager) == null ? void 0 : _a.getUrl()) != null ? _b : `http://127.0.0.1:${this.settings.port}`; 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 // Subscribe to process state changes, returns unsubscribe function
onProcessStateChange(callback) { onProcessStateChange(callback) {
this.stateChangeCallbacks.push(callback); this.stateChangeCallbacks.push(callback);
return () => {
const index = this.stateChangeCallbacks.indexOf(callback);
if (index > -1) {
this.stateChangeCallbacks.splice(index, 1);
}
};
} }
// Notify all subscribers of state change // Notify all subscribers of state change
notifyStateChange(state) { notifyStateChange(state) {

View File

@@ -27,6 +27,7 @@ This plugin embeds the OpenCode AI assistant into Obsidian by spawning a local s
**Decision:** Embed OpenCode's web UI via iframe rather than building native Obsidian UI. **Decision:** Embed OpenCode's web UI via iframe rather than building native Obsidian UI.
**Rationale:** **Rationale:**
- Can be up and running quickly
- OpenCode already provides a full-featured web interface - OpenCode already provides a full-featured web interface
- Reduces maintenance burden - UI updates come from OpenCode automatically - Reduces maintenance burden - UI updates come from OpenCode automatically
- Allows feature parity with standalone OpenCode web experience - Allows feature parity with standalone OpenCode web experience

View File

@@ -8,6 +8,7 @@ export class OpenCodeView extends ItemView {
plugin: OpenCodePlugin; plugin: OpenCodePlugin;
private iframeEl: HTMLIFrameElement | null = null; private iframeEl: HTMLIFrameElement | null = null;
private currentState: ProcessState = "stopped"; private currentState: ProcessState = "stopped";
private unsubscribeStateChange: (() => void) | null = null;
constructor(leaf: WorkspaceLeaf, plugin: OpenCodePlugin) { constructor(leaf: WorkspaceLeaf, plugin: OpenCodePlugin) {
super(leaf); super(leaf);
@@ -31,7 +32,7 @@ export class OpenCodeView extends ItemView {
this.contentEl.addClass("opencode-container"); this.contentEl.addClass("opencode-container");
// Subscribe to state changes // Subscribe to state changes
this.plugin.onProcessStateChange((state) => { this.unsubscribeStateChange = this.plugin.onProcessStateChange((state) => {
this.currentState = state; this.currentState = state;
this.updateView(); this.updateView();
}); });
@@ -47,6 +48,12 @@ export class OpenCodeView extends ItemView {
} }
async onClose(): Promise<void> { async onClose(): Promise<void> {
// Unsubscribe from state changes to prevent memory leak
if (this.unsubscribeStateChange) {
this.unsubscribeStateChange();
this.unsubscribeStateChange = null;
}
// Clean up iframe // Clean up iframe
if (this.iframeEl) { if (this.iframeEl) {
this.iframeEl.src = "about:blank"; this.iframeEl.src = "about:blank";
@@ -184,12 +191,26 @@ export class OpenCodeView extends ItemView {
setIcon(iconEl, "alert-circle"); setIcon(iconEl, "alert-circle");
statusContainer.createEl("h3", { text: "Failed to start OpenCode" }); 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.", // Display specific error message if available
cls: "opencode-status-message", const errorMessage = this.plugin.getLastError();
if (errorMessage) {
statusContainer.createEl("p", {
text: errorMessage,
cls: "opencode-status-message opencode-error-message",
});
} else {
statusContainer.createEl("p", {
text: "There was an error starting the OpenCode server.",
cls: "opencode-status-message",
});
}
const buttonContainer = statusContainer.createDiv({
cls: "opencode-button-group",
}); });
const retryButton = statusContainer.createEl("button", { const retryButton = buttonContainer.createEl("button", {
text: "Retry", text: "Retry",
cls: "mod-cta", cls: "mod-cta",
}); });
@@ -197,7 +218,7 @@ export class OpenCodeView extends ItemView {
this.plugin.startServer(); this.plugin.startServer();
}); });
const settingsButton = statusContainer.createEl("button", { const settingsButton = buttonContainer.createEl("button", {
text: "Open Settings", text: "Open Settings",
}); });
settingsButton.addEventListener("click", () => { settingsButton.addEventListener("click", () => {

View File

@@ -1,5 +1,4 @@
import { spawn, ChildProcess } from "child_process"; import { spawn, ChildProcess } from "child_process";
import { Notice } from "obsidian";
import { OpenCodeSettings } from "./types"; import { OpenCodeSettings } from "./types";
export type ProcessState = "stopped" | "starting" | "running" | "error"; export type ProcessState = "stopped" | "starting" | "running" | "error";
@@ -7,6 +6,8 @@ export type ProcessState = "stopped" | "starting" | "running" | "error";
export class ProcessManager { export class ProcessManager {
private process: ChildProcess | null = null; private process: ChildProcess | null = null;
private state: ProcessState = "stopped"; private state: ProcessState = "stopped";
private lastError: string | null = null;
private earlyExitCode: number | null = null;
private settings: OpenCodeSettings; private settings: OpenCodeSettings;
private workingDirectory: string; private workingDirectory: string;
private projectDirectory: string; private projectDirectory: string;
@@ -37,6 +38,10 @@ export class ProcessManager {
return this.state; return this.state;
} }
getLastError(): string | null {
return this.lastError;
}
getUrl(): string { getUrl(): string {
const baseUrl = `http://${this.settings.hostname}:${this.settings.port}`; const baseUrl = `http://${this.settings.hostname}:${this.settings.port}`;
// Encode the project directory path as base64 for the URL // Encode the project directory path as base64 for the URL
@@ -50,13 +55,14 @@ export class ProcessManager {
} }
this.setState("starting"); this.setState("starting");
this.lastError = null;
this.earlyExitCode = null;
try { try {
// Validate vault/project directory is set // Validate vault/project directory is set
if (!this.projectDirectory) { if (!this.projectDirectory) {
const error = "Project directory (vault) not configured"; this.lastError = "Project directory (vault) not configured";
console.error("[OpenCode Error]", error); console.error("[OpenCode Error]", this.lastError);
new Notice(`Failed to start OpenCode: ${error}`);
this.setState("error"); this.setState("error");
return false; return false;
} }
@@ -113,15 +119,28 @@ export class ProcessManager {
this.process.on("exit", (code, signal) => { this.process.on("exit", (code, signal) => {
console.log(`OpenCode process exited with code ${code}, signal ${signal}`); console.log(`OpenCode process exited with code ${code}, signal ${signal}`);
this.process = null; this.process = null;
// Track early exit during startup for better error messages
if (this.state === "starting" && code !== null && code !== 0) {
this.earlyExitCode = code;
}
// Only set stopped if we're in running state (not during startup) // Only set stopped if we're in running state (not during startup)
if (this.state === "running") { if (this.state === "running") {
this.setState("stopped"); this.setState("stopped");
} }
}); });
this.process.on("error", (err) => { this.process.on("error", (err: NodeJS.ErrnoException) => {
console.error("Failed to start OpenCode process:", err); console.error("Failed to start OpenCode process:", err);
new Notice(`Failed to start OpenCode: ${err.message}`);
// Provide user-friendly error messages for common errors
if (err.code === "ENOENT") {
this.lastError = `OpenCode executable not found at '${this.settings.opencodePath}'`;
} else {
this.lastError = `Failed to start OpenCode: ${err.message}`;
}
this.process = null; this.process = null;
this.setState("error"); this.setState("error");
}); });
@@ -132,13 +151,27 @@ export class ProcessManager {
this.setState("running"); this.setState("running");
return true; return true;
} else { } else {
// If already in error state (e.g., from spawn error event), don't overwrite
if (this.state === "error") {
return false;
}
// Determine appropriate error message
if (this.earlyExitCode !== null) {
this.lastError = `OpenCode process exited unexpectedly (exit code ${this.earlyExitCode})`;
} else if (!this.process) {
this.lastError = "OpenCode process exited before server became ready";
} else {
this.lastError = "OpenCode server failed to start within timeout";
}
this.stop(); this.stop();
this.setState("error"); this.setState("error");
new Notice("OpenCode server failed to start within timeout");
return false; return false;
} }
} catch (error) { } catch (error) {
console.error("Error starting OpenCode:", error); console.error("Error starting OpenCode:", error);
this.lastError = error instanceof Error ? error.message : String(error);
this.setState("error"); this.setState("error");
return false; return false;
} }
@@ -151,20 +184,42 @@ export class ProcessManager {
} }
if (this.process) { if (this.process) {
const proc = this.process;
const pid = proc.pid;
console.log("[OpenCode] Stopping process with PID:", pid);
// Set state to stopped first to prevent exit handler from interfering
this.setState("stopped");
// Now clear the process reference before killing
// This ensures the exit handler knows we initiated the stop
this.process = null;
try { try {
// Try graceful shutdown first // Try graceful shutdown first
this.process.kill("SIGTERM"); proc.kill("SIGTERM");
console.log("[OpenCode] Sent SIGTERM to process");
// Force kill after 2 seconds if still running // Force kill after 2 seconds if still running
setTimeout(() => { setTimeout(() => {
if (this.process && !this.process.killed) { // Check if process has exited (exitCode or signalCode will be set)
this.process.kill("SIGKILL"); if (proc.exitCode === null && proc.signalCode === null) {
console.log("[OpenCode] Process still running after SIGTERM, sending SIGKILL");
try {
proc.kill("SIGKILL");
} catch (error) {
console.error("[OpenCode] Error sending SIGKILL:", error);
}
} else {
console.log("[OpenCode] Process exited with:", proc.exitCode, proc.signalCode);
} }
}, 2000); }, 2000);
} catch (error) { } catch (error) {
console.error("Error stopping OpenCode process:", error); console.error("[OpenCode] Error stopping process:", error);
} }
this.process = null;
return;
} }
this.setState("stopped"); this.setState("stopped");

View File

@@ -197,14 +197,25 @@ export default class OpenCodePlugin extends Plugin {
return this.processManager?.getState() ?? "stopped"; return this.processManager?.getState() ?? "stopped";
} }
// Get the last error message from the process manager
getLastError(): string | null {
return this.processManager?.getLastError() ?? null;
}
// Get the server URL // Get the server URL
getServerUrl(): string { getServerUrl(): string {
return this.processManager?.getUrl() ?? `http://127.0.0.1:${this.settings.port}`; return this.processManager?.getUrl() ?? `http://127.0.0.1:${this.settings.port}`;
} }
// Subscribe to process state changes // Subscribe to process state changes, returns unsubscribe function
onProcessStateChange(callback: (state: ProcessState) => void): void { onProcessStateChange(callback: (state: ProcessState) => void): () => void {
this.stateChangeCallbacks.push(callback); this.stateChangeCallbacks.push(callback);
return () => {
const index = this.stateChangeCallbacks.indexOf(callback);
if (index > -1) {
this.stateChangeCallbacks.splice(index, 1);
}
};
} }
// Notify all subscribers of state change // Notify all subscribers of state change