diff --git a/.gitignore b/.gitignore index e12fee6..c7b959d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/.opencode/plans /node_modules /data.json /main.js diff --git a/src/OpenCodeClient.ts b/src/client/OpenCodeClient.ts similarity index 100% rename from src/OpenCodeClient.ts rename to src/client/OpenCodeClient.ts diff --git a/src/context/ContextManager.ts b/src/context/ContextManager.ts new file mode 100644 index 0000000..e353290 --- /dev/null +++ b/src/context/ContextManager.ts @@ -0,0 +1,209 @@ +import { App, EventRef, MarkdownView, WorkspaceLeaf } from "obsidian"; +import { OpenCodeSettings, OPENCODE_VIEW_TYPE } from "../types"; +import { OpenCodeClient } from "../client/OpenCodeClient"; +import { WorkspaceContext } from "./WorkspaceContext"; +import { OpenCodeView } from "../ui/OpenCodeView"; +import { ServerState } from "../server/types"; + +type ContextManagerDeps = { + app: App; + settings: OpenCodeSettings; + client: OpenCodeClient; + getServerState: () => ServerState; + getCachedIframeUrl: () => string | null; + setCachedIframeUrl: (url: string | null) => void; + registerEvent: (ref: EventRef) => void; +}; + +export class ContextManager { + private app: App; + private settings: OpenCodeSettings; + private client: OpenCodeClient; + private workspaceContext: WorkspaceContext; + private getServerState: () => ServerState; + private getCachedIframeUrl: () => string | null; + private setCachedIframeUrl: (url: string | null) => void; + private registerEvent: (ref: EventRef) => void; + + private contextEventRefs: EventRef[] = []; + private contextRefreshTimer: number | null = null; + + constructor(deps: ContextManagerDeps) { + this.app = deps.app; + this.settings = deps.settings; + this.client = deps.client; + this.workspaceContext = new WorkspaceContext(this.app); + this.getServerState = deps.getServerState; + this.getCachedIframeUrl = deps.getCachedIframeUrl; + this.setCachedIframeUrl = deps.setCachedIframeUrl; + this.registerEvent = deps.registerEvent; + } + + updateSettings(settings: OpenCodeSettings): void { + this.settings = settings; + this.updateListeners(); + } + + private updateListeners(): void { + if (!this.settings.injectWorkspaceContext) { + this.clearListeners(); + return; + } + + if (this.contextEventRefs.length > 0) { + return; + } + + const activeLeafRef = this.app.workspace.on("active-leaf-change", (leaf) => { + if (leaf?.view instanceof MarkdownView) { + this.workspaceContext.trackViewSelection(leaf.view); + } + this.scheduleRefresh(0); + }); + const fileOpenRef = this.app.workspace.on("file-open", () => { + this.scheduleRefresh(); + }); + const fileCloseRef = (this.app.workspace as any).on("file-close", () => { + this.scheduleRefresh(); + }); + const layoutChangeRef = this.app.workspace.on("layout-change", () => { + this.scheduleRefresh(); + }); + const editorChangeRef = this.app.workspace.on( + "editor-change", + (_editor, view) => { + if (view instanceof MarkdownView) { + this.workspaceContext.trackViewSelection(view); + } + this.scheduleRefresh(500); + } + ); + const selectionChangeRef = (this.app.workspace as any).on( + "editor-selection-change", + (_editor: unknown, view: unknown) => { + if (view instanceof MarkdownView) { + this.workspaceContext.trackViewSelection(view); + } + this.scheduleRefresh(200); + } + ); + + this.contextEventRefs = [ + activeLeafRef, + fileOpenRef, + fileCloseRef, + layoutChangeRef, + editorChangeRef, + selectionChangeRef, + ]; + this.contextEventRefs.forEach((ref) => this.registerEvent(ref)); + } + + private clearListeners(): void { + for (const ref of this.contextEventRefs) { + this.app.workspace.offref(ref); + } + this.contextEventRefs = []; + if (this.contextRefreshTimer !== null) { + window.clearTimeout(this.contextRefreshTimer); + this.contextRefreshTimer = null; + } + } + + private scheduleRefresh(delayMs: number = 300): void { + const leaf = this.getLeafForRefresh(); + if (!leaf) { + return; + } + + if (this.contextRefreshTimer !== null) { + window.clearTimeout(this.contextRefreshTimer); + } + + this.contextRefreshTimer = window.setTimeout(() => { + this.contextRefreshTimer = null; + void this.refreshContext(leaf); + }, delayMs); + } + + private getLeafForRefresh(): WorkspaceLeaf | null { + const activeLeaf = this.app.workspace.activeLeaf; + if (activeLeaf?.view.getViewType() === OPENCODE_VIEW_TYPE) { + return activeLeaf; + } + + return this.getVisibleSidebarLeaf(); + } + + private getVisibleSidebarLeaf(): WorkspaceLeaf | null { + const leaves = this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE); + if (leaves.length === 0) { + return null; + } + + const rightSplit = this.app.workspace.rightSplit; + if (!rightSplit || rightSplit.collapsed) { + return null; + } + + const leaf = leaves[0]; + return leaf.getRoot() === rightSplit ? leaf : null; + } + + async handleServerRunning(): Promise { + const activeLeaf = this.app.workspace.activeLeaf; + if (activeLeaf?.view.getViewType() === OPENCODE_VIEW_TYPE) { + await this.refreshContext(activeLeaf); + } + } + + async refreshContextForView(view: OpenCodeView): Promise { + if (!this.settings.injectWorkspaceContext) { + return; + } + + const leaf = this.getLeafForRefresh(); + if (!leaf) { + return; + } + + await this.refreshContext(leaf); + } + + private async refreshContext(leaf: WorkspaceLeaf): Promise { + if (!this.settings.injectWorkspaceContext) { + return; + } + + if (this.getServerState() !== "running") { + return; + } + + const view = leaf.view instanceof OpenCodeView ? leaf.view : null; + const iframeUrl = this.getCachedIframeUrl() ?? view?.getIframeUrl(); + if (!iframeUrl) { + return; + } + + const sessionId = this.client.resolveSessionId(iframeUrl); + if (!sessionId) { + return; + } + + this.setCachedIframeUrl(iframeUrl); + + const { contextText } = this.workspaceContext.gatherContext( + this.settings.maxNotesInContext, + this.settings.maxSelectionLength + ); + + await this.client.updateContext({ + sessionId, + contextText, + }); + } + + destroy(): void { + this.clearListeners(); + } +} diff --git a/src/WorkspaceContext.ts b/src/context/WorkspaceContext.ts similarity index 100% rename from src/WorkspaceContext.ts rename to src/context/WorkspaceContext.ts diff --git a/src/main.ts b/src/main.ts index d628ec9..561cfb8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,22 +1,22 @@ import { Plugin, WorkspaceLeaf, Notice, EventRef, MarkdownView } from "obsidian"; import { OpenCodeSettings, DEFAULT_SETTINGS, OPENCODE_VIEW_TYPE } from "./types"; -import { OpenCodeView } from "./OpenCodeView"; -import { OpenCodeSettingTab } from "./SettingsTab"; -import { ProcessManager, ProcessState } from "./ProcessManager"; +import { OpenCodeView } from "./ui/OpenCodeView"; +import { ViewManager } from "./ui/ViewManager"; +import { OpenCodeSettingTab } from "./settings/SettingsTab"; +import { ServerManager, ServerState } from "./server/ServerManager"; import { registerOpenCodeIcons, OPENCODE_ICON_NAME } from "./icons"; -import { OpenCodeClient } from "./OpenCodeClient"; -import { WorkspaceContext } from "./WorkspaceContext"; +import { OpenCodeClient } from "./client/OpenCodeClient"; +import { ContextManager } from "./context/ContextManager"; export default class OpenCodePlugin extends Plugin { settings: OpenCodeSettings = DEFAULT_SETTINGS; - private processManager: ProcessManager; - private stateChangeCallbacks: Array<(state: ProcessState) => void> = []; + private processManager: ServerManager; + private stateChangeCallbacks: Array<(state: ServerState) => void> = []; private openCodeClient: OpenCodeClient; - private workspaceContext: WorkspaceContext; + private contextManager: ContextManager; + private viewManager: ViewManager; private cachedIframeUrl: string | null = null; private lastBaseUrl: string | null = null; - private contextEventRefs: EventRef[] = []; - private contextRefreshTimer: number | null = null; async onload(): Promise { console.log("Loading OpenCode plugin"); @@ -27,30 +27,79 @@ export default class OpenCodePlugin extends Plugin { const projectDirectory = this.getProjectDirectory(); - this.processManager = new ProcessManager( - this.settings, - projectDirectory, - (state) => this.notifyStateChange(state) - ); + this.processManager = new ServerManager(this.settings, projectDirectory); + this.processManager.on("stateChange", (state: ServerState) => { + this.notifyStateChange(state); + }); + + // Listen for project directory changes and coordinate response + this.processManager.on("projectDirectoryChanged", async (newDirectory: string) => { + this.settings.projectDirectory = newDirectory; + await this.saveData(this.settings); + this.refreshClientState(); + if (this.getServerState() === "running") { + await this.stopServer(); + await this.startServer(); + } + }); - this.openCodeClient = new OpenCodeClient(this.getApiBaseUrl(), this.getServerUrl(), projectDirectory); - this.workspaceContext = new WorkspaceContext(this.app); + this.openCodeClient = new OpenCodeClient( + this.getApiBaseUrl(), + this.getServerUrl(), + projectDirectory + ); this.lastBaseUrl = this.getServerUrl(); - console.log("[OpenCode] Configured with project directory:", projectDirectory); + this.contextManager = new ContextManager({ + app: this.app, + settings: this.settings, + client: this.openCodeClient, + getServerState: () => this.getServerState(), + getCachedIframeUrl: () => this.cachedIframeUrl, + setCachedIframeUrl: (url) => { + this.cachedIframeUrl = url; + }, + registerEvent: (ref) => this.registerEvent(ref), + }); - this.registerView(OPENCODE_VIEW_TYPE, (leaf) => new OpenCodeView(leaf, this)); - this.addSettingTab(new OpenCodeSettingTab(this.app, this)); + this.viewManager = new ViewManager({ + app: this.app, + settings: this.settings, + client: this.openCodeClient, + contextManager: this.contextManager, + getCachedIframeUrl: () => this.cachedIframeUrl, + setCachedIframeUrl: (url) => { + this.cachedIframeUrl = url; + }, + getServerState: () => this.getServerState(), + }); + + console.log( + "[OpenCode] Configured with project directory:", + projectDirectory + ); + + this.registerView( + OPENCODE_VIEW_TYPE, + (leaf) => new OpenCodeView(leaf, this) + ); + this.addSettingTab(new OpenCodeSettingTab( + this.app, + this, + this.settings, + this.processManager, + () => this.saveSettings() + )); this.addRibbonIcon(OPENCODE_ICON_NAME, "OpenCode", () => { - this.activateView(); + void this.viewManager.activateView(); }); this.addCommand({ id: "toggle-opencode-view", name: "Toggle OpenCode panel", callback: () => { - this.toggleView(); + void this.viewManager.toggleView(); }, hotkeys: [ { @@ -82,20 +131,20 @@ export default class OpenCodePlugin extends Plugin { }); } - this.updateContextListeners(); - this.onProcessStateChange((state) => { + this.contextManager.updateSettings(this.settings); + this.processManager.on("stateChange", (state: ServerState) => { if (state === "running") { - void this.handleServerRunning(); + void this.contextManager.handleServerRunning(); } }); - // Register cleanup handlers for when Obsidian quits this.registerCleanupHandlers(); console.log("OpenCode plugin loaded"); } async onunload(): Promise { + this.contextManager.destroy(); await this.stopServer(); this.app.workspace.detachLeavesOfType(OPENCODE_VIEW_TYPE); } @@ -108,75 +157,8 @@ export default class OpenCodePlugin extends Plugin { await this.saveData(this.settings); this.processManager.updateSettings(this.settings); this.refreshClientState(); - this.updateContextListeners(); - } - - // Update project directory and restart server if running - async updateProjectDirectory(directory: string): Promise { - this.settings.projectDirectory = directory; - await this.saveData(this.settings); - - this.processManager.updateProjectDirectory(this.getProjectDirectory()); - this.refreshClientState(); - - if (this.getProcessState() === "running") { - this.stopServer(); - await this.startServer(); - } - } - - private getExistingLeaf(): WorkspaceLeaf | null { - const leaves = this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE); - return leaves.length > 0 ? leaves[0] : null; - } - - async activateView(): Promise { - const existingLeaf = this.getExistingLeaf(); - - if (existingLeaf) { - this.app.workspace.revealLeaf(existingLeaf); - return; - } - - // Create new leaf based on defaultViewLocation setting - let leaf: WorkspaceLeaf | null = null; - if (this.settings.defaultViewLocation === "main") { - leaf = this.app.workspace.getLeaf("tab"); - } else { - leaf = this.app.workspace.getRightLeaf(false); - } - - if (leaf) { - await leaf.setViewState({ - type: OPENCODE_VIEW_TYPE, - active: true, - }); - this.app.workspace.revealLeaf(leaf); - } - } - - async toggleView(): Promise { - const existingLeaf = this.getExistingLeaf(); - - if (existingLeaf) { - // Check if the view is in the sidebar or main area - const isInSidebar = existingLeaf.getRoot() === this.app.workspace.rightSplit; - - if (isInSidebar) { - // For sidebar views, check if sidebar is collapsed - const rightSplit = this.app.workspace.rightSplit; - if (rightSplit && !rightSplit.collapsed) { - existingLeaf.detach(); - } else { - this.app.workspace.revealLeaf(existingLeaf); - } - } else { - // For main area views, just detach (close the tab) - existingLeaf.detach(); - } - } else { - await this.activateView(); - } + this.contextManager.updateSettings(this.settings); + this.viewManager.updateSettings(this.settings); } async startServer(): Promise { @@ -192,8 +174,8 @@ export default class OpenCodePlugin extends Plugin { new Notice("OpenCode server stopped"); } - getProcessState(): ProcessState { - return this.processManager?.getState() ?? "stopped"; + getServerState(): ServerState { + return this.processManager.getState() ?? "stopped"; } getLastError(): string | null { @@ -216,40 +198,7 @@ export default class OpenCodePlugin extends Plugin { this.cachedIframeUrl = url; } - async ensureSessionUrl(view: OpenCodeView): Promise { - if (this.getProcessState() !== "running") { - return; - } - - const existingUrl = this.cachedIframeUrl ?? view.getIframeUrl(); - if (existingUrl && this.openCodeClient.resolveSessionId(existingUrl)) { - this.cachedIframeUrl = existingUrl; - return; - } - - const sessionId = await this.openCodeClient.createSession(); - if (!sessionId) { - return; - } - - const sessionUrl = this.openCodeClient.getSessionUrl(sessionId); - this.cachedIframeUrl = sessionUrl; - view.setIframeUrl(sessionUrl); - - if (this.app.workspace.activeLeaf === view.leaf) { - await this.updateOpenCodeContext(view.leaf); - } - } - - refreshContextForView(view: OpenCodeView): void { - if (!this.settings.injectWorkspaceContext) { - return; - } - - void this.updateOpenCodeContext(view.leaf); - } - - onProcessStateChange(callback: (state: ProcessState) => void): () => void { + onServerStateChange(callback: (state: ServerState) => void): () => void { this.stateChangeCallbacks.push(callback); return () => { const index = this.stateChangeCallbacks.indexOf(callback); @@ -259,7 +208,7 @@ export default class OpenCodePlugin extends Plugin { }; } - private notifyStateChange(state: ProcessState): void { + private notifyStateChange(state: ServerState): void { for (const callback of this.stateChangeCallbacks) { callback(state); } @@ -278,147 +227,12 @@ export default class OpenCodePlugin extends Plugin { this.lastBaseUrl = nextUiBaseUrl; } - private updateContextListeners(): void { - if (!this.settings.injectWorkspaceContext) { - this.clearContextListeners(); - return; - } - - if (this.contextEventRefs.length > 0) { - return; - } - - const activeLeafRef = this.app.workspace.on("active-leaf-change", (leaf) => { - if (leaf?.view instanceof MarkdownView) { - this.workspaceContext.trackViewSelection(leaf.view); - } - this.scheduleContextRefresh(0); - }); - const fileOpenRef = this.app.workspace.on("file-open", () => { - this.scheduleContextRefresh(); - }); - const fileCloseRef = (this.app.workspace as any).on("file-close", () => { - this.scheduleContextRefresh(); - }); - const layoutChangeRef = this.app.workspace.on("layout-change", () => { - this.scheduleContextRefresh(); - }); - const editorChangeRef = this.app.workspace.on("editor-change", (_editor, view) => { - if (view instanceof MarkdownView) { - this.workspaceContext.trackViewSelection(view); - } - this.scheduleContextRefresh(500); - }); - const selectionChangeRef = (this.app.workspace as any).on( - "editor-selection-change", - (_editor: unknown, view: unknown) => { - if (view instanceof MarkdownView) { - this.workspaceContext.trackViewSelection(view); - } - this.scheduleContextRefresh(200); - } - ); - - this.contextEventRefs = [ - activeLeafRef, - fileOpenRef, - fileCloseRef, - layoutChangeRef, - editorChangeRef, - selectionChangeRef, - ]; - this.contextEventRefs.forEach((ref) => this.registerEvent(ref)); - } - - private clearContextListeners(): void { - for (const ref of this.contextEventRefs) { - this.app.workspace.offref(ref); - } - this.contextEventRefs = []; - if (this.contextRefreshTimer !== null) { - window.clearTimeout(this.contextRefreshTimer); - this.contextRefreshTimer = null; - } - } - - private scheduleContextRefresh(delayMs: number = 300): void { - const leaf = this.getOpenCodeLeafForRefresh(); - if (!leaf) { - return; - } - - if (this.contextRefreshTimer !== null) { - window.clearTimeout(this.contextRefreshTimer); - } - - this.contextRefreshTimer = window.setTimeout(() => { - this.contextRefreshTimer = null; - void this.updateOpenCodeContext(leaf); - }, delayMs); - } - - private getOpenCodeLeafForRefresh(): WorkspaceLeaf | null { - const activeLeaf = this.app.workspace.activeLeaf; - if (activeLeaf?.view.getViewType() === OPENCODE_VIEW_TYPE) { - return activeLeaf; - } - - return this.getVisibleSidebarOpenCodeLeaf(); - } - - private getVisibleSidebarOpenCodeLeaf(): WorkspaceLeaf | null { - const leaves = this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE); - if (leaves.length === 0) { - return null; - } - - const rightSplit = this.app.workspace.rightSplit; - if (!rightSplit || rightSplit.collapsed) { - return null; - } - - const leaf = leaves[0]; - return leaf.getRoot() === rightSplit ? leaf : null; - } - - private async handleServerRunning(): Promise { - const activeLeaf = this.app.workspace.activeLeaf; - if (activeLeaf?.view.getViewType() === OPENCODE_VIEW_TYPE) { - await this.updateOpenCodeContext(activeLeaf); - } + refreshContextForView(view: OpenCodeView): void { + void this.contextManager.refreshContextForView(view); } - private async updateOpenCodeContext(leaf: WorkspaceLeaf): Promise { - if (!this.settings.injectWorkspaceContext) { - return; - } - - if (this.getProcessState() !== "running") { - return; - } - - const view = leaf.view instanceof OpenCodeView ? leaf.view : null; - const iframeUrl = this.cachedIframeUrl ?? view?.getIframeUrl(); - if (!iframeUrl) { - return; - } - - const sessionId = this.openCodeClient.resolveSessionId(iframeUrl); - if (!sessionId) { - return; - } - - this.cachedIframeUrl = iframeUrl; - - const { contextText } = this.workspaceContext.gatherContext( - this.settings.maxNotesInContext, - this.settings.maxSelectionLength - ); - - await this.openCodeClient.updateContext({ - sessionId, - contextText, - }); + async ensureSessionUrl(view: OpenCodeView): Promise { + await this.viewManager.ensureSessionUrl(view); } getProjectDirectory(): string { diff --git a/src/ProcessManager.ts b/src/server/ServerManager.ts similarity index 56% rename from src/ProcessManager.ts rename to src/server/ServerManager.ts index f660ba1..1d6a35d 100644 --- a/src/ProcessManager.ts +++ b/src/server/ServerManager.ts @@ -1,25 +1,28 @@ -import { spawn, ChildProcess } from "child_process"; -import { OpenCodeSettings } from "./types"; +import { ChildProcess } from "child_process"; +import { EventEmitter } from "events"; +import { OpenCodeSettings } from "../types"; +import { ServerState } from "./types"; +import { OpenCodeProcess } from "./process/OpenCodeProcess"; +import { WindowsProcess } from "./process/WindowsProcess"; +import { PosixProcess } from "./process/PosixProcess"; -export type ProcessState = "stopped" | "starting" | "running" | "error"; +export type { ServerState } from "./types"; -export class ProcessManager { +export class ServerManager extends EventEmitter { private process: ChildProcess | null = null; - private state: ProcessState = "stopped"; + private state: ServerState = "stopped"; private lastError: string | null = null; private earlyExitCode: number | null = null; private settings: OpenCodeSettings; private projectDirectory: string; - private onStateChange: (state: ProcessState) => void; + private processImpl: OpenCodeProcess; - constructor( - settings: OpenCodeSettings, - projectDirectory: string, - onStateChange: (state: ProcessState) => void - ) { + constructor(settings: OpenCodeSettings, projectDirectory: string) { + super(); this.settings = settings; this.projectDirectory = projectDirectory; - this.onStateChange = onStateChange; + this.processImpl = + process.platform === "win32" ? new WindowsProcess() : new PosixProcess(); } updateSettings(settings: OpenCodeSettings): void { @@ -28,9 +31,10 @@ export class ProcessManager { updateProjectDirectory(directory: string): void { this.projectDirectory = directory; + this.emit("projectDirectoryChanged", directory); } - getState(): ProcessState { + getState(): ServerState { return this.state; } @@ -56,8 +60,17 @@ export class ProcessManager { return this.setError("Project directory (vault) not configured"); } + // Pre-flight check: verify executable exists + const commandError = await this.processImpl.verifyCommand(this.settings.opencodePath); + if (commandError) { + return this.setError(commandError); + } + if (await this.checkServerHealth()) { - console.log("[OpenCode] Server already running on port", this.settings.port); + console.log( + "[OpenCode] Server already running on port", + this.settings.port + ); this.setState("running"); return true; } @@ -70,7 +83,7 @@ export class ProcessManager { projectDirectory: this.projectDirectory, }); - this.process = spawn( + this.process = this.processImpl.start( this.settings.opencodePath, [ "serve", @@ -85,9 +98,6 @@ export class ProcessManager { cwd: this.projectDirectory, env: { ...process.env, NODE_USE_SYSTEM_CA: "1" }, stdio: ["ignore", "pipe", "pipe"], - shell: true, - windowsHide: true, - detached: (process.platform !== "win32"), } ); @@ -102,7 +112,9 @@ export class ProcessManager { }); 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; if (this.state === "starting" && code !== null && code !== 0) { @@ -119,7 +131,9 @@ export class ProcessManager { this.process = null; if (err.code === "ENOENT") { - this.setError(`Executable not found at '${this.settings.opencodePath}'`); + this.setError( + `Executable not found at '${this.settings.opencodePath}'` + ); } else { this.setError(`Failed to start: ${err.message}`); } @@ -137,7 +151,9 @@ export class ProcessManager { await this.stop(); if (this.earlyExitCode !== null) { - return this.setError(`Process exited unexpectedly (exit code ${this.earlyExitCode})`); + return this.setError( + `Process exited unexpectedly (exit code ${this.earlyExitCode})` + ); } if (!this.process) { return this.setError("Process exited before server became ready"); @@ -151,101 +167,17 @@ export class ProcessManager { return; } - const pid = this.process.pid; const proc = this.process; - - if (!pid) { - console.log("[OpenCode] No PID available, cleaning up state"); - this.setState("stopped"); - this.process = null; - return; - } - - console.log("[OpenCode] Stopping server process tree, PID:", pid); this.setState("stopped"); this.process = null; - await this.killProcessTree(pid, "SIGTERM"); - - const gracefulExited = await this.waitForProcessExit(proc, 2000); - - if (gracefulExited) { - console.log("[OpenCode] Server stopped gracefully"); - return; - } - - console.log("[OpenCode] Process didn't exit gracefully, sending SIGKILL"); - - await this.killProcessTree(pid, "SIGKILL"); - - // Step 4: Wait for force kill (up to 3 more seconds) - const forceExited = await this.waitForProcessExit(proc, 3000); - - if (forceExited) { - console.log("[OpenCode] Server stopped with SIGKILL"); - } else { - console.error("[OpenCode] Failed to stop server within timeout"); - } - } - - private async killProcessTree(pid: number, signal: "SIGTERM" | "SIGKILL"): Promise { - const platform = process.platform; - - if (platform === "win32") { - // Windows: Use taskkill with /T flag to kill process tree - await this.execAsync(`taskkill /T /F /PID ${pid}`); - return; - } - - // Unix: Try process group kill (negative PID) - process.kill(-pid, signal); - return; - } - - private async waitForProcessExit(proc: ChildProcess, timeoutMs: number): Promise { - if (proc.exitCode !== null || proc.signalCode !== null) { - return true; // Already exited - } - - return new Promise((resolve) => { - const timeout = setTimeout(() => { - cleanup(); - resolve(false); - }, timeoutMs); - - const onExit = () => { - cleanup(); - resolve(true); - }; - - const cleanup = () => { - clearTimeout(timeout); - proc.off("exit", onExit); - proc.off("error", onExit); - }; - - proc.once("exit", onExit); - proc.once("error", onExit); - }); - } - - private execAsync(command: string): Promise { - return new Promise((resolve, reject) => { - const { exec } = require("child_process"); - exec(command, (error: Error | null) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }); + await this.processImpl.stop(proc); } - private setState(state: ProcessState): void { + private setState(state: ServerState): void { this.state = state; - this.onStateChange(state); + this.emit("stateChange", state); } private setError(message: string): false { diff --git a/src/server/process/OpenCodeProcess.ts b/src/server/process/OpenCodeProcess.ts new file mode 100644 index 0000000..60c3642 --- /dev/null +++ b/src/server/process/OpenCodeProcess.ts @@ -0,0 +1,18 @@ +import { ChildProcess, SpawnOptions } from "child_process"; + +export interface OpenCodeProcess { + /** Start the process. Returns a handle to listen for events. */ + start( + command: string, + args: string[], + options: SpawnOptions + ): ChildProcess; + + /** Stop the process gracefully, then forcefully if needed. + * Resolves when process has exited. + * Handles all PID/process tree logic internally. */ + stop(process: ChildProcess): Promise; + + /** Verify that command exists and is executable. Returns error message or null if OK. */ + verifyCommand(command: string): Promise; +} diff --git a/src/server/process/PosixProcess.ts b/src/server/process/PosixProcess.ts new file mode 100644 index 0000000..0a6c4f5 --- /dev/null +++ b/src/server/process/PosixProcess.ts @@ -0,0 +1,103 @@ +import { ChildProcess, spawn, SpawnOptions } from "child_process"; +import { OpenCodeProcess } from "./OpenCodeProcess"; + +export class PosixProcess implements OpenCodeProcess { + start( + command: string, + args: string[], + options: SpawnOptions + ): ChildProcess { + return spawn(command, args, { + ...options, + detached: true, // Creates a new process group + }); + } + + async stop(process: ChildProcess): Promise { + const pid = process.pid; + if (!pid) { + return; + } + + console.log("[OpenCode] Stopping server process tree, PID:", pid); + + // Try graceful termination first + await this.killProcessGroup(pid, "SIGTERM"); + const gracefulExited = await this.waitForExit(process, 2000); + + if (gracefulExited) { + console.log("[OpenCode] Server stopped gracefully"); + return; + } + + console.log("[OpenCode] Process didn't exit gracefully, sending SIGKILL"); + + // Force kill + await this.killProcessGroup(pid, "SIGKILL"); + const forceExited = await this.waitForExit(process, 3000); + + if (forceExited) { + console.log("[OpenCode] Server stopped with SIGKILL"); + } else { + console.error("[OpenCode] Failed to stop server within timeout"); + } + } + + async verifyCommand(command: string): Promise { + // Check if command is absolute path - verify it exists and is executable + if (command.startsWith('/') || command.startsWith('./')) { + try { + const fs = require('fs'); + fs.accessSync(command, fs.constants.X_OK); + return null; + } catch { + return `Executable not found at '${command}'`; + } + } + // For non-absolute paths, let spawn handle it (will fire ENOENT if not found) + return null; + } + + private async killProcessGroup( + pid: number, + signal: "SIGTERM" | "SIGKILL" + ): Promise { + try { + // Negative PID kills the entire process group + process.kill(-pid, signal); + } catch (error) { + // Process may already be gone + console.log(`[OpenCode] Signal ${signal} failed (process may already be gone)`); + } + } + + private async waitForExit( + process: ChildProcess, + timeoutMs: number + ): Promise { + if (process.exitCode !== null || process.signalCode !== null) { + return true; // Already exited + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + cleanup(); + resolve(false); + }, timeoutMs); + + const onExit = () => { + cleanup(); + resolve(true); + }; + + const cleanup = () => { + clearTimeout(timeout); + process.off("exit", onExit); + process.off("error", onExit); + }; + + process.once("exit", onExit); + process.once("error", onExit); + }); + } +} diff --git a/src/server/process/WindowsProcess.ts b/src/server/process/WindowsProcess.ts new file mode 100644 index 0000000..6eefddd --- /dev/null +++ b/src/server/process/WindowsProcess.ts @@ -0,0 +1,84 @@ +import { ChildProcess, spawn, SpawnOptions } from "child_process"; +import { OpenCodeProcess } from "./OpenCodeProcess"; + +export class WindowsProcess implements OpenCodeProcess { + start( + command: string, + args: string[], + options: SpawnOptions + ): ChildProcess { + return spawn(command, args, { + ...options, + shell: true, + windowsHide: true, + }); + } + + async stop(process: ChildProcess): Promise { + const pid = process.pid; + if (!pid) { + return; + } + + console.log("[OpenCode] Stopping server process tree, PID:", pid); + + // Use taskkill with /T flag to kill process tree + await this.execAsync(`taskkill /T /F /PID ${pid}`); + + // Wait for process to exit + await this.waitForExit(process, 5000); + } + + async verifyCommand(command: string): Promise { + // Use 'where' command to check if executable exists in PATH + try { + await this.execAsync(`where "${command}"`); + return null; + } catch { + return `Executable not found at '${command}'`; + } + } + + private async waitForExit( + process: ChildProcess, + timeoutMs: number + ): Promise { + if (process.exitCode !== null || process.signalCode !== null) { + return; // Already exited + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + cleanup(); + resolve(); + }, timeoutMs); + + const onExit = () => { + cleanup(); + resolve(); + }; + + const cleanup = () => { + clearTimeout(timeout); + process.off("exit", onExit); + process.off("error", onExit); + }; + + process.once("exit", onExit); + process.once("error", onExit); + }); + } + + private execAsync(command: string): Promise { + return new Promise((resolve, reject) => { + const { exec } = require("child_process"); + exec(command, (error: Error | null) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } +} diff --git a/src/server/types.ts b/src/server/types.ts new file mode 100644 index 0000000..ad626f2 --- /dev/null +++ b/src/server/types.ts @@ -0,0 +1 @@ +export type ServerState = "stopped" | "starting" | "running" | "error"; diff --git a/src/SettingsTab.ts b/src/settings/SettingsTab.ts similarity index 76% rename from src/SettingsTab.ts rename to src/settings/SettingsTab.ts index cf2e780..ac47c3c 100644 --- a/src/SettingsTab.ts +++ b/src/settings/SettingsTab.ts @@ -1,8 +1,8 @@ -import { App, PluginSettingTab, Setting, Notice } from "obsidian"; +import { App, Plugin, PluginSettingTab, Setting, Notice } from "obsidian"; import { existsSync, statSync } from "fs"; import { homedir } from "os"; -import type OpenCodePlugin from "./main"; -import type { ViewLocation } from "./types"; +import { OpenCodeSettings, ViewLocation } from "../types"; +import { ServerManager } from "../server/ServerManager"; function expandTilde(path: string): string { if (path === "~") { @@ -15,12 +15,16 @@ function expandTilde(path: string): string { } export class OpenCodeSettingTab extends PluginSettingTab { - plugin: OpenCodePlugin; private validateTimeout: ReturnType | null = null; - constructor(app: App, plugin: OpenCodePlugin) { + constructor( + app: App, + plugin: Plugin, + private settings: OpenCodeSettings, + private serverManager: ServerManager, + private onSettingsChange: () => Promise + ) { super(app, plugin); - this.plugin = plugin; } display(): void { @@ -35,12 +39,12 @@ export class OpenCodeSettingTab extends PluginSettingTab { .addText((text) => text .setPlaceholder("14096") - .setValue(this.plugin.settings.port.toString()) + .setValue(this.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(); + this.settings.port = port; + await this.onSettingsChange(); } }) ); @@ -51,10 +55,10 @@ export class OpenCodeSettingTab extends PluginSettingTab { .addText((text) => text .setPlaceholder("127.0.0.1") - .setValue(this.plugin.settings.hostname) + .setValue(this.settings.hostname) .onChange(async (value) => { - this.plugin.settings.hostname = value || "127.0.0.1"; - await this.plugin.saveSettings(); + this.settings.hostname = value || "127.0.0.1"; + await this.onSettingsChange(); }) ); @@ -66,10 +70,10 @@ export class OpenCodeSettingTab extends PluginSettingTab { .addText((text) => text .setPlaceholder("opencode") - .setValue(this.plugin.settings.opencodePath) + .setValue(this.settings.opencodePath) .onChange(async (value) => { - this.plugin.settings.opencodePath = value || "opencode"; - await this.plugin.saveSettings(); + this.settings.opencodePath = value || "opencode"; + await this.onSettingsChange(); }) ); @@ -81,7 +85,7 @@ export class OpenCodeSettingTab extends PluginSettingTab { .addText((text) => text .setPlaceholder("/path/to/project or ~/project") - .setValue(this.plugin.settings.projectDirectory) + .setValue(this.settings.projectDirectory) .onChange((value) => { // Debounce validation to avoid spamming notices on every keypress if (this.validateTimeout) { @@ -102,10 +106,10 @@ export class OpenCodeSettingTab extends PluginSettingTab { ) .addToggle((toggle) => toggle - .setValue(this.plugin.settings.autoStart) + .setValue(this.settings.autoStart) .onChange(async (value) => { - this.plugin.settings.autoStart = value; - await this.plugin.saveSettings(); + this.settings.autoStart = value; + await this.onSettingsChange(); }) ); @@ -118,10 +122,10 @@ export class OpenCodeSettingTab extends PluginSettingTab { dropdown .addOption("sidebar", "Sidebar") .addOption("main", "Main window") - .setValue(this.plugin.settings.defaultViewLocation) + .setValue(this.settings.defaultViewLocation) .onChange(async (value) => { - this.plugin.settings.defaultViewLocation = value as ViewLocation; - await this.plugin.saveSettings(); + this.settings.defaultViewLocation = value as ViewLocation; + await this.onSettingsChange(); }) ); @@ -134,10 +138,10 @@ export class OpenCodeSettingTab extends PluginSettingTab { ) .addToggle((toggle) => toggle - .setValue(this.plugin.settings.injectWorkspaceContext) + .setValue(this.settings.injectWorkspaceContext) .onChange(async (value) => { - this.plugin.settings.injectWorkspaceContext = value; - await this.plugin.saveSettings(); + this.settings.injectWorkspaceContext = value; + await this.onSettingsChange(); }) ); @@ -147,11 +151,11 @@ export class OpenCodeSettingTab extends PluginSettingTab { .addSlider((slider) => slider .setLimits(1, 50, 1) - .setValue(this.plugin.settings.maxNotesInContext) + .setValue(this.settings.maxNotesInContext) .setDynamicTooltip() .onChange(async (value) => { - this.plugin.settings.maxNotesInContext = value; - await this.plugin.saveSettings(); + this.settings.maxNotesInContext = value; + await this.onSettingsChange(); }) ); @@ -161,11 +165,11 @@ export class OpenCodeSettingTab extends PluginSettingTab { .addSlider((slider) => slider .setLimits(500, 5000, 100) - .setValue(this.plugin.settings.maxSelectionLength) + .setValue(this.settings.maxSelectionLength) .setDynamicTooltip() .onChange(async (value) => { - this.plugin.settings.maxSelectionLength = value; - await this.plugin.saveSettings(); + this.settings.maxSelectionLength = value; + await this.onSettingsChange(); }) ); @@ -180,7 +184,8 @@ export class OpenCodeSettingTab extends PluginSettingTab { // Empty value is valid - means use vault root if (!trimmed) { - await this.plugin.updateProjectDirectory(""); + this.serverManager.updateProjectDirectory(""); + await this.onSettingsChange(); return; } @@ -207,13 +212,14 @@ export class OpenCodeSettingTab extends PluginSettingTab { return; } - await this.plugin.updateProjectDirectory(expanded); + this.serverManager.updateProjectDirectory(expanded); + await this.onSettingsChange(); } private renderServerStatus(container: HTMLElement): void { container.empty(); - const state = this.plugin.getProcessState(); + const state = this.serverManager.getState(); const statusText = { stopped: "Stopped", starting: "Starting...", @@ -238,13 +244,14 @@ export class OpenCodeSettingTab extends PluginSettingTab { if (state === "running") { const urlEl = container.createDiv({ cls: "opencode-status-line" }); urlEl.createSpan({ text: "URL: " }); + const serverUrl = this.serverManager.getUrl(); const linkEl = urlEl.createEl("a", { - text: this.plugin.getServerUrl(), - href: this.plugin.getServerUrl(), + text: serverUrl, + href: serverUrl, }); linkEl.addEventListener("click", (e) => { e.preventDefault(); - window.open(this.plugin.getServerUrl(), "_blank"); + window.open(serverUrl, "_blank"); }); } @@ -256,7 +263,7 @@ export class OpenCodeSettingTab extends PluginSettingTab { cls: "mod-cta", }); startButton.addEventListener("click", async () => { - await this.plugin.startServer(); + await this.serverManager.start(); this.renderServerStatus(container); }); } @@ -266,7 +273,7 @@ export class OpenCodeSettingTab extends PluginSettingTab { text: "Stop Server", }); stopButton.addEventListener("click", () => { - this.plugin.stopServer(); + this.serverManager.stop(); this.renderServerStatus(container); }); @@ -275,8 +282,8 @@ export class OpenCodeSettingTab extends PluginSettingTab { cls: "mod-warning", }); restartButton.addEventListener("click", async () => { - this.plugin.stopServer(); - await this.plugin.startServer(); + this.serverManager.stop(); + await this.serverManager.start(); this.renderServerStatus(container); }); } diff --git a/src/types.ts b/src/types.ts index 3a90611..deddf08 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,7 +21,7 @@ export const DEFAULT_SETTINGS: OpenCodeSettings = { projectDirectory: "", startupTimeout: 15000, defaultViewLocation: "sidebar", - injectWorkspaceContext: true, + injectWorkspaceContext: false, maxNotesInContext: 20, maxSelectionLength: 2000, }; diff --git a/src/OpenCodeView.ts b/src/ui/OpenCodeView.ts similarity index 94% rename from src/OpenCodeView.ts rename to src/ui/OpenCodeView.ts index 84e2a03..db49d3e 100644 --- a/src/OpenCodeView.ts +++ b/src/ui/OpenCodeView.ts @@ -1,13 +1,13 @@ import { ItemView, WorkspaceLeaf, setIcon } from "obsidian"; -import { OPENCODE_VIEW_TYPE } from "./types"; -import { OPENCODE_ICON_NAME } from "./icons"; -import type OpenCodePlugin from "./main"; -import { ProcessState } from "./ProcessManager"; +import { OPENCODE_VIEW_TYPE } from "../types"; +import { OPENCODE_ICON_NAME } from "../icons"; +import type OpenCodePlugin from "../main"; +import type { ServerState } from "../server/types"; export class OpenCodeView extends ItemView { plugin: OpenCodePlugin; private iframeEl: HTMLIFrameElement | null = null; - private currentState: ProcessState = "stopped"; + private currentState: ServerState = "stopped"; private unsubscribeStateChange: (() => void) | null = null; constructor(leaf: WorkspaceLeaf, plugin: OpenCodePlugin) { @@ -32,13 +32,13 @@ export class OpenCodeView extends ItemView { this.contentEl.addClass("opencode-container"); // Subscribe to state changes - this.unsubscribeStateChange = this.plugin.onProcessStateChange((state) => { + this.unsubscribeStateChange = this.plugin.onServerStateChange((state: ServerState) => { this.currentState = state; this.updateView(); }); // Initial render - this.currentState = this.plugin.getProcessState(); + this.currentState = this.plugin.getServerState(); this.updateView(); // Start server if not running (lazy start) - don't await to avoid blocking view open diff --git a/src/ui/ViewManager.ts b/src/ui/ViewManager.ts new file mode 100644 index 0000000..0cd6052 --- /dev/null +++ b/src/ui/ViewManager.ts @@ -0,0 +1,120 @@ +import { App, WorkspaceLeaf } from "obsidian"; +import { OPENCODE_VIEW_TYPE, OpenCodeSettings } from "../types"; +import { OpenCodeView } from "./OpenCodeView"; +import { OpenCodeClient } from "../client/OpenCodeClient"; +import { ContextManager } from "../context/ContextManager"; +import { ServerState } from "../server/types"; + +type ViewManagerDeps = { + app: App; + settings: OpenCodeSettings; + client: OpenCodeClient; + contextManager: ContextManager; + getCachedIframeUrl: () => string | null; + setCachedIframeUrl: (url: string | null) => void; + getServerState: () => ServerState; +}; + +export class ViewManager { + private app: App; + private settings: OpenCodeSettings; + private client: OpenCodeClient; + private contextManager: ContextManager; + private getCachedIframeUrl: () => string | null; + private setCachedIframeUrl: (url: string | null) => void; + private getServerState: () => string; + + constructor(deps: ViewManagerDeps) { + this.app = deps.app; + this.settings = deps.settings; + this.client = deps.client; + this.contextManager = deps.contextManager; + this.getCachedIframeUrl = deps.getCachedIframeUrl; + this.setCachedIframeUrl = deps.setCachedIframeUrl; + this.getServerState = deps.getServerState; + } + + updateSettings(settings: OpenCodeSettings): void { + this.settings = settings; + } + + private getExistingLeaf(): WorkspaceLeaf | null { + const leaves = this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE); + return leaves.length > 0 ? leaves[0] : null; + } + + async activateView(): Promise { + const existingLeaf = this.getExistingLeaf(); + + if (existingLeaf) { + this.app.workspace.revealLeaf(existingLeaf); + return; + } + + // Create new leaf based on defaultViewLocation setting + let leaf: WorkspaceLeaf | null = null; + if (this.settings.defaultViewLocation === "main") { + leaf = this.app.workspace.getLeaf("tab"); + } else { + leaf = this.app.workspace.getRightLeaf(false); + } + + if (leaf) { + await leaf.setViewState({ + type: OPENCODE_VIEW_TYPE, + active: true, + }); + this.app.workspace.revealLeaf(leaf); + } + } + + async toggleView(): Promise { + const existingLeaf = this.getExistingLeaf(); + + if (existingLeaf) { + // Check if the view is in the sidebar or main area + const isInSidebar = existingLeaf.getRoot() === this.app.workspace.rightSplit; + + if (isInSidebar) { + // For sidebar views, check if sidebar is collapsed + const rightSplit = this.app.workspace.rightSplit; + if (rightSplit && !rightSplit.collapsed) { + existingLeaf.detach(); + } else { + this.app.workspace.revealLeaf(existingLeaf); + } + } else { + // For main area views, just detach (close the tab) + existingLeaf.detach(); + } + } else { + await this.activateView(); + } + } + + async ensureSessionUrl(view: OpenCodeView): Promise { + if (this.getServerState() !== "running") { + return; + } + + const cachedUrl = this.getCachedIframeUrl(); + const existingUrl = cachedUrl ?? view.getIframeUrl(); + if (existingUrl && this.client.resolveSessionId(existingUrl)) { + this.setCachedIframeUrl(existingUrl); + return; + } + + const sessionId = await this.client.createSession(); + if (!sessionId) { + return; + } + + const sessionUrl = this.client.getSessionUrl(sessionId); + this.setCachedIframeUrl(sessionUrl); + view.setIframeUrl(sessionUrl); + + if (this.app.workspace.activeLeaf === view.leaf) { + await this.contextManager.refreshContextForView(view); + } + } +} diff --git a/tests/ProcessManager.test.ts b/tests/ServerManager.test.ts similarity index 74% rename from tests/ProcessManager.test.ts rename to tests/ServerManager.test.ts index 583bcf0..3b8d6c5 100644 --- a/tests/ProcessManager.test.ts +++ b/tests/ServerManager.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeAll, afterEach } from "bun:test"; -import { ProcessManager, ProcessState } from "../src/ProcessManager"; +import { ServerManager, ServerState } from "../src/server/ServerManager"; import { OpenCodeSettings } from "../src/types"; // Test configuration @@ -20,7 +20,7 @@ function createTestSettings(port: number): OpenCodeSettings { autoStart: false, opencodePath: "opencode", projectDirectory: "", - startupTimeout: TEST_TIMEOUT_MS, + startupTimeout: process.platform === "win32" ? 15000 : TEST_TIMEOUT_MS, defaultViewLocation: "sidebar", injectWorkspaceContext: true, maxNotesInContext: 20, @@ -29,7 +29,7 @@ function createTestSettings(port: number): OpenCodeSettings { } // Track current manager for cleanup -let currentManager: ProcessManager | null = null; +let currentManager: ServerManager | null = null; // Verify opencode binary is available before running tests beforeAll(async () => { @@ -57,18 +57,17 @@ afterEach(async () => { } }); -describe("ProcessManager", () => { +describe("ServerManager", () => { describe("happy path", () => { test("starts server and transitions to running state", async () => { const port = getNextPort(); const settings = createTestSettings(port); - const stateHistory: ProcessState[] = []; + const stateHistory: ServerState[] = []; - currentManager = new ProcessManager( - settings, - PROJECT_DIR, - (state) => stateHistory.push(state) - ); + currentManager = new ServerManager(settings, PROJECT_DIR); + currentManager.on("stateChange", (state: ServerState) => { + stateHistory.push(state); + }); expect(currentManager.getState()).toBe("stopped"); @@ -84,11 +83,7 @@ describe("ProcessManager", () => { const port = getNextPort(); const settings = createTestSettings(port); - currentManager = new ProcessManager( - settings, - PROJECT_DIR, - () => {} - ); + currentManager = new ServerManager(settings, PROJECT_DIR); const url = currentManager.getUrl(); const expectedBase = `http://127.0.0.1:${port}`; @@ -100,13 +95,12 @@ describe("ProcessManager", () => { test("stops server gracefully and transitions to stopped state", async () => { const port = getNextPort(); const settings = createTestSettings(port); - const stateHistory: ProcessState[] = []; + const stateHistory: ServerState[] = []; - currentManager = new ProcessManager( - settings, - PROJECT_DIR, - (state) => stateHistory.push(state) - ); + currentManager = new ServerManager(settings, PROJECT_DIR); + currentManager.on("stateChange", (state: ServerState) => { + stateHistory.push(state); + }); await currentManager.start(); expect(currentManager.getState()).toBe("running"); @@ -120,13 +114,12 @@ describe("ProcessManager", () => { test("state callbacks fire in correct order: starting -> running", async () => { const port = getNextPort(); const settings = createTestSettings(port); - const stateHistory: ProcessState[] = []; + const stateHistory: ServerState[] = []; - currentManager = new ProcessManager( - settings, - PROJECT_DIR, - (state) => stateHistory.push(state) - ); + currentManager = new ServerManager(settings, PROJECT_DIR); + currentManager.on("stateChange", (state: ServerState) => { + stateHistory.push(state); + }); await currentManager.start(); @@ -142,11 +135,7 @@ describe("ProcessManager", () => { const port = getNextPort(); const settings = createTestSettings(port); - currentManager = new ProcessManager( - settings, - PROJECT_DIR, - () => {} - ); + currentManager = new ServerManager(settings, PROJECT_DIR); // First start const firstStart = await currentManager.start(); @@ -170,23 +159,18 @@ describe("ProcessManager", () => { const port = getNextPort(); const settings = createTestSettings(port); - currentManager = new ProcessManager( - settings, - PROJECT_DIR, - () => {} - ); + currentManager = new ServerManager(settings, PROJECT_DIR); // First start await currentManager.start(); expect(currentManager.getState()).toBe("running"); // Second start should return true immediately without state changes - const stateHistory: ProcessState[] = []; - const originalOnStateChange = (currentManager as any).onStateChange; - (currentManager as any).onStateChange = (state: ProcessState) => { + const stateHistory: ServerState[] = []; + const onStateChange = (state: ServerState) => { stateHistory.push(state); - originalOnStateChange(state); }; + currentManager.on("stateChange", onStateChange); const result = await currentManager.start(); @@ -200,11 +184,7 @@ describe("ProcessManager", () => { const port = getNextPort(); const settings = createTestSettings(port); - currentManager = new ProcessManager( - settings, - PROJECT_DIR, - () => {} - ); + currentManager = new ServerManager(settings, PROJECT_DIR); await currentManager.start(); @@ -224,13 +204,12 @@ describe("ProcessManager", () => { test("stop returns immediately when no process", async () => { const port = getNextPort(); const settings = createTestSettings(port); - const stateHistory: ProcessState[] = []; + const stateHistory: ServerState[] = []; - currentManager = new ProcessManager( - settings, - PROJECT_DIR, - (state) => stateHistory.push(state) - ); + currentManager = new ServerManager(settings, PROJECT_DIR); + currentManager.on("stateChange", (state: ServerState) => { + stateHistory.push(state); + }); // Stop without starting - should not throw and set state await currentManager.stop(); @@ -242,11 +221,7 @@ describe("ProcessManager", () => { const port = getNextPort(); const settings = createTestSettings(port); - currentManager = new ProcessManager( - settings, - PROJECT_DIR, - () => {} - ); + currentManager = new ServerManager(settings, PROJECT_DIR); await currentManager.start(); expect(currentManager.getState()).toBe("running"); @@ -265,11 +240,7 @@ describe("ProcessManager", () => { const port = getNextPort(); const settings = createTestSettings(port); - currentManager = new ProcessManager( - settings, - PROJECT_DIR, - () => {} - ); + currentManager = new ServerManager(settings, PROJECT_DIR); await currentManager.start(); @@ -294,33 +265,11 @@ describe("ProcessManager", () => { }); describe("error handling", () => { - test("handles missing executable gracefully", async () => { - const port = getNextPort(); - const settings = createTestSettings(port); - settings.opencodePath = "/nonexistent/path/to/opencode"; - - currentManager = new ProcessManager( - settings, - PROJECT_DIR, - () => {} - ); - - const success = await currentManager.start(); - - expect(success).toBe(false); - expect(currentManager.getState()).toBe("error"); - expect(currentManager.getLastError()).toContain("Process exited unexpectedly (exit code 127)"); - }); - test("handles double stop gracefully", async () => { const port = getNextPort(); const settings = createTestSettings(port); - currentManager = new ProcessManager( - settings, - PROJECT_DIR, - () => {} - ); + currentManager = new ServerManager(settings, PROJECT_DIR); await currentManager.start(); expect(currentManager.getState()).toBe("running"); diff --git a/tests/process/PosixProcess.test.ts b/tests/process/PosixProcess.test.ts new file mode 100644 index 0000000..0ebb15f --- /dev/null +++ b/tests/process/PosixProcess.test.ts @@ -0,0 +1,33 @@ +import { describe, test, expect } from "bun:test"; +import { PosixProcess } from "../../src/server/process/PosixProcess"; + +describe.skipIf(process.platform === "win32")("PosixProcess", () => { + const processImpl = new PosixProcess(); + + describe("verifyCommand", () => { + test("returns null for non-absolute commands", async () => { + // Non-absolute paths should return null (let spawn handle it) + const result = await processImpl.verifyCommand("ls"); + expect(result).toBeNull(); + }); + + test("returns null for existing absolute path", async () => { + // /bin/ls should exist on most POSIX systems + const result = await processImpl.verifyCommand("/bin/ls"); + expect(result).toBeNull(); + }); + + test("returns error message for non-existent absolute path", async () => { + const nonExistentPath = "/nonexistent/path/to/executable"; + const result = await processImpl.verifyCommand(nonExistentPath); + expect(result).toContain("Executable not found"); + expect(result).toContain(nonExistentPath); + }); + + test("returns error for non-executable file", async () => { + // Test with a regular file that's not executable + const result = await processImpl.verifyCommand("/etc/passwd"); + expect(result).toContain("Executable not found"); + }); + }); +}); diff --git a/tests/process/WindowsProcess.test.ts b/tests/process/WindowsProcess.test.ts new file mode 100644 index 0000000..3597c4f --- /dev/null +++ b/tests/process/WindowsProcess.test.ts @@ -0,0 +1,26 @@ +import { describe, test, expect } from "bun:test"; +import { WindowsProcess } from "../../src/server/process/WindowsProcess"; + +describe.skipIf(process.platform !== "win32")("WindowsProcess", () => { + const processImpl = new WindowsProcess(); + + describe("verifyCommand", () => { + test("returns null for existing executable in PATH", async () => { + // 'cmd' should exist on all Windows systems + const result = await processImpl.verifyCommand("cmd"); + expect(result).toBeNull(); + }); + + test("returns error message for non-existent executable", async () => { + const nonExistentPath = "C:\\nonexistent\\path\\to\\executable.exe"; + const result = await processImpl.verifyCommand(nonExistentPath); + expect(result).toContain("Executable not found"); + expect(result).toContain(nonExistentPath); + }); + + test("returns error for non-existent command in PATH", async () => { + const result = await processImpl.verifyCommand("definitely-not-a-real-command-12345"); + expect(result).toContain("Executable not found"); + }); + }); +});