Initial commit

This commit is contained in:
Mateusz Tymek
2026-01-03 16:07:55 +00:00
commit d2c90fbd49
14 changed files with 1982 additions and 0 deletions

219
src/OpenCodeView.ts Normal file
View 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
View 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
View 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
View 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
View 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";