Refactor plugin architecture, isolate Windows-spefic code
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
/.opencode/plans
|
||||||
/node_modules
|
/node_modules
|
||||||
/data.json
|
/data.json
|
||||||
/main.js
|
/main.js
|
||||||
|
|||||||
209
src/context/ContextManager.ts
Normal file
209
src/context/ContextManager.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
356
src/main.ts
356
src/main.ts
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
18
src/server/process/OpenCodeProcess.ts
Normal file
18
src/server/process/OpenCodeProcess.ts
Normal 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>;
|
||||||
|
}
|
||||||
103
src/server/process/PosixProcess.ts
Normal file
103
src/server/process/PosixProcess.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/server/process/WindowsProcess.ts
Normal file
84
src/server/process/WindowsProcess.ts
Normal 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
1
src/server/types.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type ServerState = "stopped" | "starting" | "running" | "error";
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
120
src/ui/ViewManager.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
33
tests/process/PosixProcess.test.ts
Normal file
33
tests/process/PosixProcess.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
26
tests/process/WindowsProcess.test.ts
Normal file
26
tests/process/WindowsProcess.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user