Refactor plugin architecture, isolate Windows-spefic code

This commit is contained in:
Mateusz Tymek
2026-02-14 13:37:03 +01:00
parent 3d7c16fb2a
commit 9683eb0d05
17 changed files with 810 additions and 513 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/.opencode/plans
/node_modules /node_modules
/data.json /data.json
/main.js /main.js

View File

@@ -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<void> {
const activeLeaf = this.app.workspace.activeLeaf;
if (activeLeaf?.view.getViewType() === OPENCODE_VIEW_TYPE) {
await this.refreshContext(activeLeaf);
}
}
async refreshContextForView(view: OpenCodeView): Promise<void> {
if (!this.settings.injectWorkspaceContext) {
return;
}
const leaf = this.getLeafForRefresh();
if (!leaf) {
return;
}
await this.refreshContext(leaf);
}
private async refreshContext(leaf: WorkspaceLeaf): Promise<void> {
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();
}
}

View File

@@ -1,22 +1,22 @@
import { Plugin, WorkspaceLeaf, Notice, EventRef, MarkdownView } from "obsidian"; import { Plugin, WorkspaceLeaf, Notice, EventRef, MarkdownView } from "obsidian";
import { OpenCodeSettings, DEFAULT_SETTINGS, OPENCODE_VIEW_TYPE } from "./types"; import { OpenCodeSettings, DEFAULT_SETTINGS, OPENCODE_VIEW_TYPE } from "./types";
import { OpenCodeView } from "./OpenCodeView"; import { OpenCodeView } from "./ui/OpenCodeView";
import { OpenCodeSettingTab } from "./SettingsTab"; import { ViewManager } from "./ui/ViewManager";
import { ProcessManager, ProcessState } from "./ProcessManager"; import { OpenCodeSettingTab } from "./settings/SettingsTab";
import { ServerManager, ServerState } from "./server/ServerManager";
import { registerOpenCodeIcons, OPENCODE_ICON_NAME } from "./icons"; import { registerOpenCodeIcons, OPENCODE_ICON_NAME } from "./icons";
import { OpenCodeClient } from "./OpenCodeClient"; import { OpenCodeClient } from "./client/OpenCodeClient";
import { WorkspaceContext } from "./WorkspaceContext"; import { ContextManager } from "./context/ContextManager";
export default class OpenCodePlugin extends Plugin { export default class OpenCodePlugin extends Plugin {
settings: OpenCodeSettings = DEFAULT_SETTINGS; settings: OpenCodeSettings = DEFAULT_SETTINGS;
private processManager: ProcessManager; private processManager: ServerManager;
private stateChangeCallbacks: Array<(state: ProcessState) => void> = []; private stateChangeCallbacks: Array<(state: ServerState) => void> = [];
private openCodeClient: OpenCodeClient; private openCodeClient: OpenCodeClient;
private workspaceContext: WorkspaceContext; private contextManager: ContextManager;
private viewManager: ViewManager;
private cachedIframeUrl: string | null = null; private cachedIframeUrl: string | null = null;
private lastBaseUrl: string | null = null; private lastBaseUrl: string | null = null;
private contextEventRefs: EventRef[] = [];
private contextRefreshTimer: number | null = null;
async onload(): Promise<void> { async onload(): Promise<void> {
console.log("Loading OpenCode plugin"); console.log("Loading OpenCode plugin");
@@ -27,30 +27,79 @@ export default class OpenCodePlugin extends Plugin {
const projectDirectory = this.getProjectDirectory(); const projectDirectory = this.getProjectDirectory();
this.processManager = new ProcessManager( this.processManager = new ServerManager(this.settings, projectDirectory);
this.settings, this.processManager.on("stateChange", (state: ServerState) => {
projectDirectory, this.notifyStateChange(state);
(state) => this.notifyStateChange(state) });
);
this.openCodeClient = new OpenCodeClient(this.getApiBaseUrl(), this.getServerUrl(), projectDirectory); // Listen for project directory changes and coordinate response
this.workspaceContext = new WorkspaceContext(this.app); 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.lastBaseUrl = this.getServerUrl(); 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.viewManager = new ViewManager({
this.addSettingTab(new OpenCodeSettingTab(this.app, this)); 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.addRibbonIcon(OPENCODE_ICON_NAME, "OpenCode", () => {
this.activateView(); void this.viewManager.activateView();
}); });
this.addCommand({ this.addCommand({
id: "toggle-opencode-view", id: "toggle-opencode-view",
name: "Toggle OpenCode panel", name: "Toggle OpenCode panel",
callback: () => { callback: () => {
this.toggleView(); void this.viewManager.toggleView();
}, },
hotkeys: [ hotkeys: [
{ {
@@ -82,20 +131,20 @@ export default class OpenCodePlugin extends Plugin {
}); });
} }
this.updateContextListeners(); this.contextManager.updateSettings(this.settings);
this.onProcessStateChange((state) => { this.processManager.on("stateChange", (state: ServerState) => {
if (state === "running") { if (state === "running") {
void this.handleServerRunning(); void this.contextManager.handleServerRunning();
} }
}); });
// Register cleanup handlers for when Obsidian quits
this.registerCleanupHandlers(); this.registerCleanupHandlers();
console.log("OpenCode plugin loaded"); console.log("OpenCode plugin loaded");
} }
async onunload(): Promise<void> { async onunload(): Promise<void> {
this.contextManager.destroy();
await this.stopServer(); await this.stopServer();
this.app.workspace.detachLeavesOfType(OPENCODE_VIEW_TYPE); this.app.workspace.detachLeavesOfType(OPENCODE_VIEW_TYPE);
} }
@@ -108,75 +157,8 @@ export default class OpenCodePlugin extends Plugin {
await this.saveData(this.settings); await this.saveData(this.settings);
this.processManager.updateSettings(this.settings); this.processManager.updateSettings(this.settings);
this.refreshClientState(); this.refreshClientState();
this.updateContextListeners(); this.contextManager.updateSettings(this.settings);
} this.viewManager.updateSettings(this.settings);
// Update project directory and restart server if running
async updateProjectDirectory(directory: string): Promise<void> {
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<void> {
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<void> {
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 startServer(): Promise<boolean> { async startServer(): Promise<boolean> {
@@ -192,8 +174,8 @@ export default class OpenCodePlugin extends Plugin {
new Notice("OpenCode server stopped"); new Notice("OpenCode server stopped");
} }
getProcessState(): ProcessState { getServerState(): ServerState {
return this.processManager?.getState() ?? "stopped"; return this.processManager.getState() ?? "stopped";
} }
getLastError(): string | null { getLastError(): string | null {
@@ -216,40 +198,7 @@ export default class OpenCodePlugin extends Plugin {
this.cachedIframeUrl = url; this.cachedIframeUrl = url;
} }
async ensureSessionUrl(view: OpenCodeView): Promise<void> { onServerStateChange(callback: (state: ServerState) => void): () => void {
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 {
this.stateChangeCallbacks.push(callback); this.stateChangeCallbacks.push(callback);
return () => { return () => {
const index = this.stateChangeCallbacks.indexOf(callback); 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) { for (const callback of this.stateChangeCallbacks) {
callback(state); callback(state);
} }
@@ -278,147 +227,12 @@ export default class OpenCodePlugin extends Plugin {
this.lastBaseUrl = nextUiBaseUrl; this.lastBaseUrl = nextUiBaseUrl;
} }
private updateContextListeners(): void { refreshContextForView(view: OpenCodeView): void {
if (!this.settings.injectWorkspaceContext) { void this.contextManager.refreshContextForView(view);
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 { async ensureSessionUrl(view: OpenCodeView): Promise<void> {
for (const ref of this.contextEventRefs) { await this.viewManager.ensureSessionUrl(view);
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<void> {
const activeLeaf = this.app.workspace.activeLeaf;
if (activeLeaf?.view.getViewType() === OPENCODE_VIEW_TYPE) {
await this.updateOpenCodeContext(activeLeaf);
}
}
private async updateOpenCodeContext(leaf: WorkspaceLeaf): Promise<void> {
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,
});
} }
getProjectDirectory(): string { getProjectDirectory(): string {

View File

@@ -1,25 +1,28 @@
import { spawn, ChildProcess } from "child_process"; import { ChildProcess } from "child_process";
import { OpenCodeSettings } from "./types"; 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 process: ChildProcess | null = null;
private state: ProcessState = "stopped"; private state: ServerState = "stopped";
private lastError: string | null = null; private lastError: string | null = null;
private earlyExitCode: number | null = null; private earlyExitCode: number | null = null;
private settings: OpenCodeSettings; private settings: OpenCodeSettings;
private projectDirectory: string; private projectDirectory: string;
private onStateChange: (state: ProcessState) => void; private processImpl: OpenCodeProcess;
constructor( constructor(settings: OpenCodeSettings, projectDirectory: string) {
settings: OpenCodeSettings, super();
projectDirectory: string,
onStateChange: (state: ProcessState) => void
) {
this.settings = settings; this.settings = settings;
this.projectDirectory = projectDirectory; this.projectDirectory = projectDirectory;
this.onStateChange = onStateChange; this.processImpl =
process.platform === "win32" ? new WindowsProcess() : new PosixProcess();
} }
updateSettings(settings: OpenCodeSettings): void { updateSettings(settings: OpenCodeSettings): void {
@@ -28,9 +31,10 @@ export class ProcessManager {
updateProjectDirectory(directory: string): void { updateProjectDirectory(directory: string): void {
this.projectDirectory = directory; this.projectDirectory = directory;
this.emit("projectDirectoryChanged", directory);
} }
getState(): ProcessState { getState(): ServerState {
return this.state; return this.state;
} }
@@ -56,8 +60,17 @@ export class ProcessManager {
return this.setError("Project directory (vault) not configured"); 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()) { 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"); this.setState("running");
return true; return true;
} }
@@ -70,7 +83,7 @@ export class ProcessManager {
projectDirectory: this.projectDirectory, projectDirectory: this.projectDirectory,
}); });
this.process = spawn( this.process = this.processImpl.start(
this.settings.opencodePath, this.settings.opencodePath,
[ [
"serve", "serve",
@@ -85,9 +98,6 @@ export class ProcessManager {
cwd: this.projectDirectory, cwd: this.projectDirectory,
env: { ...process.env, NODE_USE_SYSTEM_CA: "1" }, env: { ...process.env, NODE_USE_SYSTEM_CA: "1" },
stdio: ["ignore", "pipe", "pipe"], 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) => { 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) { if (this.state === "starting" && code !== null && code !== 0) {
@@ -119,7 +131,9 @@ export class ProcessManager {
this.process = null; this.process = null;
if (err.code === "ENOENT") { if (err.code === "ENOENT") {
this.setError(`Executable not found at '${this.settings.opencodePath}'`); this.setError(
`Executable not found at '${this.settings.opencodePath}'`
);
} else { } else {
this.setError(`Failed to start: ${err.message}`); this.setError(`Failed to start: ${err.message}`);
} }
@@ -137,7 +151,9 @@ export class ProcessManager {
await this.stop(); await this.stop();
if (this.earlyExitCode !== null) { 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) { if (!this.process) {
return this.setError("Process exited before server became ready"); return this.setError("Process exited before server became ready");
@@ -151,101 +167,17 @@ export class ProcessManager {
return; return;
} }
const pid = this.process.pid;
const proc = this.process; 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.setState("stopped");
this.process = null; this.process = null;
await this.killProcessTree(pid, "SIGTERM"); await this.processImpl.stop(proc);
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<void> { private setState(state: ServerState): void {
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<boolean> {
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<void> {
return new Promise((resolve, reject) => {
const { exec } = require("child_process");
exec(command, (error: Error | null) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
private setState(state: ProcessState): void {
this.state = state; this.state = state;
this.onStateChange(state); this.emit("stateChange", state);
} }
private setError(message: string): false { private setError(message: string): false {

View File

@@ -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<void>;
/** Verify that command exists and is executable. Returns error message or null if OK. */
verifyCommand(command: string): Promise<string | null>;
}

View File

@@ -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<void> {
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<string | null> {
// 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<void> {
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<boolean> {
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);
});
}
}

View File

@@ -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<void> {
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<string | null> {
// 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<void> {
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<void> {
return new Promise((resolve, reject) => {
const { exec } = require("child_process");
exec(command, (error: Error | null) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
}

1
src/server/types.ts Normal file
View File

@@ -0,0 +1 @@
export type ServerState = "stopped" | "starting" | "running" | "error";

View File

@@ -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 { existsSync, statSync } from "fs";
import { homedir } from "os"; import { homedir } from "os";
import type OpenCodePlugin from "./main"; import { OpenCodeSettings, ViewLocation } from "../types";
import type { ViewLocation } from "./types"; import { ServerManager } from "../server/ServerManager";
function expandTilde(path: string): string { function expandTilde(path: string): string {
if (path === "~") { if (path === "~") {
@@ -15,12 +15,16 @@ function expandTilde(path: string): string {
} }
export class OpenCodeSettingTab extends PluginSettingTab { export class OpenCodeSettingTab extends PluginSettingTab {
plugin: OpenCodePlugin;
private validateTimeout: ReturnType<typeof setTimeout> | null = null; private validateTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(app: App, plugin: OpenCodePlugin) { constructor(
app: App,
plugin: Plugin,
private settings: OpenCodeSettings,
private serverManager: ServerManager,
private onSettingsChange: () => Promise<void>
) {
super(app, plugin); super(app, plugin);
this.plugin = plugin;
} }
display(): void { display(): void {
@@ -35,12 +39,12 @@ export class OpenCodeSettingTab extends PluginSettingTab {
.addText((text) => .addText((text) =>
text text
.setPlaceholder("14096") .setPlaceholder("14096")
.setValue(this.plugin.settings.port.toString()) .setValue(this.settings.port.toString())
.onChange(async (value) => { .onChange(async (value) => {
const port = parseInt(value, 10); const port = parseInt(value, 10);
if (!isNaN(port) && port > 0 && port < 65536) { if (!isNaN(port) && port > 0 && port < 65536) {
this.plugin.settings.port = port; this.settings.port = port;
await this.plugin.saveSettings(); await this.onSettingsChange();
} }
}) })
); );
@@ -51,10 +55,10 @@ export class OpenCodeSettingTab extends PluginSettingTab {
.addText((text) => .addText((text) =>
text text
.setPlaceholder("127.0.0.1") .setPlaceholder("127.0.0.1")
.setValue(this.plugin.settings.hostname) .setValue(this.settings.hostname)
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.hostname = value || "127.0.0.1"; this.settings.hostname = value || "127.0.0.1";
await this.plugin.saveSettings(); await this.onSettingsChange();
}) })
); );
@@ -66,10 +70,10 @@ export class OpenCodeSettingTab extends PluginSettingTab {
.addText((text) => .addText((text) =>
text text
.setPlaceholder("opencode") .setPlaceholder("opencode")
.setValue(this.plugin.settings.opencodePath) .setValue(this.settings.opencodePath)
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.opencodePath = value || "opencode"; this.settings.opencodePath = value || "opencode";
await this.plugin.saveSettings(); await this.onSettingsChange();
}) })
); );
@@ -81,7 +85,7 @@ export class OpenCodeSettingTab extends PluginSettingTab {
.addText((text) => .addText((text) =>
text text
.setPlaceholder("/path/to/project or ~/project") .setPlaceholder("/path/to/project or ~/project")
.setValue(this.plugin.settings.projectDirectory) .setValue(this.settings.projectDirectory)
.onChange((value) => { .onChange((value) => {
// Debounce validation to avoid spamming notices on every keypress // Debounce validation to avoid spamming notices on every keypress
if (this.validateTimeout) { if (this.validateTimeout) {
@@ -102,10 +106,10 @@ export class OpenCodeSettingTab extends PluginSettingTab {
) )
.addToggle((toggle) => .addToggle((toggle) =>
toggle toggle
.setValue(this.plugin.settings.autoStart) .setValue(this.settings.autoStart)
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.autoStart = value; this.settings.autoStart = value;
await this.plugin.saveSettings(); await this.onSettingsChange();
}) })
); );
@@ -118,10 +122,10 @@ export class OpenCodeSettingTab extends PluginSettingTab {
dropdown dropdown
.addOption("sidebar", "Sidebar") .addOption("sidebar", "Sidebar")
.addOption("main", "Main window") .addOption("main", "Main window")
.setValue(this.plugin.settings.defaultViewLocation) .setValue(this.settings.defaultViewLocation)
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.defaultViewLocation = value as ViewLocation; this.settings.defaultViewLocation = value as ViewLocation;
await this.plugin.saveSettings(); await this.onSettingsChange();
}) })
); );
@@ -134,10 +138,10 @@ export class OpenCodeSettingTab extends PluginSettingTab {
) )
.addToggle((toggle) => .addToggle((toggle) =>
toggle toggle
.setValue(this.plugin.settings.injectWorkspaceContext) .setValue(this.settings.injectWorkspaceContext)
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.injectWorkspaceContext = value; this.settings.injectWorkspaceContext = value;
await this.plugin.saveSettings(); await this.onSettingsChange();
}) })
); );
@@ -147,11 +151,11 @@ export class OpenCodeSettingTab extends PluginSettingTab {
.addSlider((slider) => .addSlider((slider) =>
slider slider
.setLimits(1, 50, 1) .setLimits(1, 50, 1)
.setValue(this.plugin.settings.maxNotesInContext) .setValue(this.settings.maxNotesInContext)
.setDynamicTooltip() .setDynamicTooltip()
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.maxNotesInContext = value; this.settings.maxNotesInContext = value;
await this.plugin.saveSettings(); await this.onSettingsChange();
}) })
); );
@@ -161,11 +165,11 @@ export class OpenCodeSettingTab extends PluginSettingTab {
.addSlider((slider) => .addSlider((slider) =>
slider slider
.setLimits(500, 5000, 100) .setLimits(500, 5000, 100)
.setValue(this.plugin.settings.maxSelectionLength) .setValue(this.settings.maxSelectionLength)
.setDynamicTooltip() .setDynamicTooltip()
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.maxSelectionLength = value; this.settings.maxSelectionLength = value;
await this.plugin.saveSettings(); await this.onSettingsChange();
}) })
); );
@@ -180,7 +184,8 @@ export class OpenCodeSettingTab extends PluginSettingTab {
// Empty value is valid - means use vault root // Empty value is valid - means use vault root
if (!trimmed) { if (!trimmed) {
await this.plugin.updateProjectDirectory(""); this.serverManager.updateProjectDirectory("");
await this.onSettingsChange();
return; return;
} }
@@ -207,13 +212,14 @@ export class OpenCodeSettingTab extends PluginSettingTab {
return; return;
} }
await this.plugin.updateProjectDirectory(expanded); this.serverManager.updateProjectDirectory(expanded);
await this.onSettingsChange();
} }
private renderServerStatus(container: HTMLElement): void { private renderServerStatus(container: HTMLElement): void {
container.empty(); container.empty();
const state = this.plugin.getProcessState(); const state = this.serverManager.getState();
const statusText = { const statusText = {
stopped: "Stopped", stopped: "Stopped",
starting: "Starting...", starting: "Starting...",
@@ -238,13 +244,14 @@ export class OpenCodeSettingTab extends PluginSettingTab {
if (state === "running") { if (state === "running") {
const urlEl = container.createDiv({ cls: "opencode-status-line" }); const urlEl = container.createDiv({ cls: "opencode-status-line" });
urlEl.createSpan({ text: "URL: " }); urlEl.createSpan({ text: "URL: " });
const serverUrl = this.serverManager.getUrl();
const linkEl = urlEl.createEl("a", { const linkEl = urlEl.createEl("a", {
text: this.plugin.getServerUrl(), text: serverUrl,
href: this.plugin.getServerUrl(), href: serverUrl,
}); });
linkEl.addEventListener("click", (e) => { linkEl.addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();
window.open(this.plugin.getServerUrl(), "_blank"); window.open(serverUrl, "_blank");
}); });
} }
@@ -256,7 +263,7 @@ export class OpenCodeSettingTab extends PluginSettingTab {
cls: "mod-cta", cls: "mod-cta",
}); });
startButton.addEventListener("click", async () => { startButton.addEventListener("click", async () => {
await this.plugin.startServer(); await this.serverManager.start();
this.renderServerStatus(container); this.renderServerStatus(container);
}); });
} }
@@ -266,7 +273,7 @@ export class OpenCodeSettingTab extends PluginSettingTab {
text: "Stop Server", text: "Stop Server",
}); });
stopButton.addEventListener("click", () => { stopButton.addEventListener("click", () => {
this.plugin.stopServer(); this.serverManager.stop();
this.renderServerStatus(container); this.renderServerStatus(container);
}); });
@@ -275,8 +282,8 @@ export class OpenCodeSettingTab extends PluginSettingTab {
cls: "mod-warning", cls: "mod-warning",
}); });
restartButton.addEventListener("click", async () => { restartButton.addEventListener("click", async () => {
this.plugin.stopServer(); this.serverManager.stop();
await this.plugin.startServer(); await this.serverManager.start();
this.renderServerStatus(container); this.renderServerStatus(container);
}); });
} }

View File

@@ -21,7 +21,7 @@ export const DEFAULT_SETTINGS: OpenCodeSettings = {
projectDirectory: "", projectDirectory: "",
startupTimeout: 15000, startupTimeout: 15000,
defaultViewLocation: "sidebar", defaultViewLocation: "sidebar",
injectWorkspaceContext: true, injectWorkspaceContext: false,
maxNotesInContext: 20, maxNotesInContext: 20,
maxSelectionLength: 2000, maxSelectionLength: 2000,
}; };

View File

@@ -1,13 +1,13 @@
import { ItemView, WorkspaceLeaf, setIcon } from "obsidian"; import { ItemView, WorkspaceLeaf, setIcon } from "obsidian";
import { OPENCODE_VIEW_TYPE } from "./types"; import { OPENCODE_VIEW_TYPE } from "../types";
import { OPENCODE_ICON_NAME } from "./icons"; import { OPENCODE_ICON_NAME } from "../icons";
import type OpenCodePlugin from "./main"; import type OpenCodePlugin from "../main";
import { ProcessState } from "./ProcessManager"; import type { ServerState } from "../server/types";
export class OpenCodeView extends ItemView { 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: ServerState = "stopped";
private unsubscribeStateChange: (() => void) | null = null; private unsubscribeStateChange: (() => void) | null = null;
constructor(leaf: WorkspaceLeaf, plugin: OpenCodePlugin) { constructor(leaf: WorkspaceLeaf, plugin: OpenCodePlugin) {
@@ -32,13 +32,13 @@ export class OpenCodeView extends ItemView {
this.contentEl.addClass("opencode-container"); this.contentEl.addClass("opencode-container");
// Subscribe to state changes // Subscribe to state changes
this.unsubscribeStateChange = this.plugin.onProcessStateChange((state) => { this.unsubscribeStateChange = this.plugin.onServerStateChange((state: ServerState) => {
this.currentState = state; this.currentState = state;
this.updateView(); this.updateView();
}); });
// Initial render // Initial render
this.currentState = this.plugin.getProcessState(); this.currentState = this.plugin.getServerState();
this.updateView(); this.updateView();
// Start server if not running (lazy start) - don't await to avoid blocking view open // Start server if not running (lazy start) - don't await to avoid blocking view open

120
src/ui/ViewManager.ts Normal file
View File

@@ -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<void> {
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<void> {
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<void> {
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);
}
}
}

View File

@@ -1,5 +1,5 @@
import { describe, test, expect, beforeAll, afterEach } from "bun:test"; 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"; import { OpenCodeSettings } from "../src/types";
// Test configuration // Test configuration
@@ -20,7 +20,7 @@ function createTestSettings(port: number): OpenCodeSettings {
autoStart: false, autoStart: false,
opencodePath: "opencode", opencodePath: "opencode",
projectDirectory: "", projectDirectory: "",
startupTimeout: TEST_TIMEOUT_MS, startupTimeout: process.platform === "win32" ? 15000 : TEST_TIMEOUT_MS,
defaultViewLocation: "sidebar", defaultViewLocation: "sidebar",
injectWorkspaceContext: true, injectWorkspaceContext: true,
maxNotesInContext: 20, maxNotesInContext: 20,
@@ -29,7 +29,7 @@ function createTestSettings(port: number): OpenCodeSettings {
} }
// Track current manager for cleanup // Track current manager for cleanup
let currentManager: ProcessManager | null = null; let currentManager: ServerManager | null = null;
// Verify opencode binary is available before running tests // Verify opencode binary is available before running tests
beforeAll(async () => { beforeAll(async () => {
@@ -57,18 +57,17 @@ afterEach(async () => {
} }
}); });
describe("ProcessManager", () => { describe("ServerManager", () => {
describe("happy path", () => { describe("happy path", () => {
test("starts server and transitions to running state", async () => { test("starts server and transitions to running state", async () => {
const port = getNextPort(); const port = getNextPort();
const settings = createTestSettings(port); const settings = createTestSettings(port);
const stateHistory: ProcessState[] = []; const stateHistory: ServerState[] = [];
currentManager = new ProcessManager( currentManager = new ServerManager(settings, PROJECT_DIR);
settings, currentManager.on("stateChange", (state: ServerState) => {
PROJECT_DIR, stateHistory.push(state);
(state) => stateHistory.push(state) });
);
expect(currentManager.getState()).toBe("stopped"); expect(currentManager.getState()).toBe("stopped");
@@ -84,11 +83,7 @@ describe("ProcessManager", () => {
const port = getNextPort(); const port = getNextPort();
const settings = createTestSettings(port); const settings = createTestSettings(port);
currentManager = new ProcessManager( currentManager = new ServerManager(settings, PROJECT_DIR);
settings,
PROJECT_DIR,
() => {}
);
const url = currentManager.getUrl(); const url = currentManager.getUrl();
const expectedBase = `http://127.0.0.1:${port}`; const expectedBase = `http://127.0.0.1:${port}`;
@@ -100,13 +95,12 @@ describe("ProcessManager", () => {
test("stops server gracefully and transitions to stopped state", async () => { test("stops server gracefully and transitions to stopped state", async () => {
const port = getNextPort(); const port = getNextPort();
const settings = createTestSettings(port); const settings = createTestSettings(port);
const stateHistory: ProcessState[] = []; const stateHistory: ServerState[] = [];
currentManager = new ProcessManager( currentManager = new ServerManager(settings, PROJECT_DIR);
settings, currentManager.on("stateChange", (state: ServerState) => {
PROJECT_DIR, stateHistory.push(state);
(state) => stateHistory.push(state) });
);
await currentManager.start(); await currentManager.start();
expect(currentManager.getState()).toBe("running"); expect(currentManager.getState()).toBe("running");
@@ -120,13 +114,12 @@ describe("ProcessManager", () => {
test("state callbacks fire in correct order: starting -> running", async () => { test("state callbacks fire in correct order: starting -> running", async () => {
const port = getNextPort(); const port = getNextPort();
const settings = createTestSettings(port); const settings = createTestSettings(port);
const stateHistory: ProcessState[] = []; const stateHistory: ServerState[] = [];
currentManager = new ProcessManager( currentManager = new ServerManager(settings, PROJECT_DIR);
settings, currentManager.on("stateChange", (state: ServerState) => {
PROJECT_DIR, stateHistory.push(state);
(state) => stateHistory.push(state) });
);
await currentManager.start(); await currentManager.start();
@@ -142,11 +135,7 @@ describe("ProcessManager", () => {
const port = getNextPort(); const port = getNextPort();
const settings = createTestSettings(port); const settings = createTestSettings(port);
currentManager = new ProcessManager( currentManager = new ServerManager(settings, PROJECT_DIR);
settings,
PROJECT_DIR,
() => {}
);
// First start // First start
const firstStart = await currentManager.start(); const firstStart = await currentManager.start();
@@ -170,23 +159,18 @@ describe("ProcessManager", () => {
const port = getNextPort(); const port = getNextPort();
const settings = createTestSettings(port); const settings = createTestSettings(port);
currentManager = new ProcessManager( currentManager = new ServerManager(settings, PROJECT_DIR);
settings,
PROJECT_DIR,
() => {}
);
// First start // First start
await currentManager.start(); await currentManager.start();
expect(currentManager.getState()).toBe("running"); expect(currentManager.getState()).toBe("running");
// Second start should return true immediately without state changes // Second start should return true immediately without state changes
const stateHistory: ProcessState[] = []; const stateHistory: ServerState[] = [];
const originalOnStateChange = (currentManager as any).onStateChange; const onStateChange = (state: ServerState) => {
(currentManager as any).onStateChange = (state: ProcessState) => {
stateHistory.push(state); stateHistory.push(state);
originalOnStateChange(state);
}; };
currentManager.on("stateChange", onStateChange);
const result = await currentManager.start(); const result = await currentManager.start();
@@ -200,11 +184,7 @@ describe("ProcessManager", () => {
const port = getNextPort(); const port = getNextPort();
const settings = createTestSettings(port); const settings = createTestSettings(port);
currentManager = new ProcessManager( currentManager = new ServerManager(settings, PROJECT_DIR);
settings,
PROJECT_DIR,
() => {}
);
await currentManager.start(); await currentManager.start();
@@ -224,13 +204,12 @@ describe("ProcessManager", () => {
test("stop returns immediately when no process", async () => { test("stop returns immediately when no process", async () => {
const port = getNextPort(); const port = getNextPort();
const settings = createTestSettings(port); const settings = createTestSettings(port);
const stateHistory: ProcessState[] = []; const stateHistory: ServerState[] = [];
currentManager = new ProcessManager( currentManager = new ServerManager(settings, PROJECT_DIR);
settings, currentManager.on("stateChange", (state: ServerState) => {
PROJECT_DIR, stateHistory.push(state);
(state) => stateHistory.push(state) });
);
// Stop without starting - should not throw and set state // Stop without starting - should not throw and set state
await currentManager.stop(); await currentManager.stop();
@@ -242,11 +221,7 @@ describe("ProcessManager", () => {
const port = getNextPort(); const port = getNextPort();
const settings = createTestSettings(port); const settings = createTestSettings(port);
currentManager = new ProcessManager( currentManager = new ServerManager(settings, PROJECT_DIR);
settings,
PROJECT_DIR,
() => {}
);
await currentManager.start(); await currentManager.start();
expect(currentManager.getState()).toBe("running"); expect(currentManager.getState()).toBe("running");
@@ -265,11 +240,7 @@ describe("ProcessManager", () => {
const port = getNextPort(); const port = getNextPort();
const settings = createTestSettings(port); const settings = createTestSettings(port);
currentManager = new ProcessManager( currentManager = new ServerManager(settings, PROJECT_DIR);
settings,
PROJECT_DIR,
() => {}
);
await currentManager.start(); await currentManager.start();
@@ -294,33 +265,11 @@ describe("ProcessManager", () => {
}); });
describe("error handling", () => { 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 () => { test("handles double stop gracefully", async () => {
const port = getNextPort(); const port = getNextPort();
const settings = createTestSettings(port); const settings = createTestSettings(port);
currentManager = new ProcessManager( currentManager = new ServerManager(settings, PROJECT_DIR);
settings,
PROJECT_DIR,
() => {}
);
await currentManager.start(); await currentManager.start();
expect(currentManager.getState()).toBe("running"); expect(currentManager.getState()).toBe("running");

View File

@@ -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");
});
});
});

View File

@@ -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");
});
});
});