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

253
src/ui/OpenCodeView.ts Normal file
View File

@@ -0,0 +1,253 @@
import { ItemView, WorkspaceLeaf, setIcon } from "obsidian";
import { OPENCODE_VIEW_TYPE } from "../types";
import { OPENCODE_ICON_NAME } from "../icons";
import type OpenCodePlugin from "../main";
import type { ServerState } from "../server/types";
export class OpenCodeView extends ItemView {
plugin: OpenCodePlugin;
private iframeEl: HTMLIFrameElement | null = null;
private currentState: ServerState = "stopped";
private unsubscribeStateChange: (() => void) | null = null;
constructor(leaf: WorkspaceLeaf, plugin: OpenCodePlugin) {
super(leaf);
this.plugin = plugin;
}
getViewType(): string {
return OPENCODE_VIEW_TYPE;
}
getDisplayText(): string {
return "OpenCode";
}
getIcon(): string {
return OPENCODE_ICON_NAME;
}
async onOpen(): Promise<void> {
this.contentEl.empty();
this.contentEl.addClass("opencode-container");
// Subscribe to state changes
this.unsubscribeStateChange = this.plugin.onServerStateChange((state: ServerState) => {
this.currentState = state;
this.updateView();
});
// Initial render
this.currentState = this.plugin.getServerState();
this.updateView();
// Start server if not running (lazy start) - don't await to avoid blocking view open
if (this.currentState === "stopped") {
this.plugin.startServer();
}
}
async onClose(): Promise<void> {
// Unsubscribe from state changes to prevent memory leak
if (this.unsubscribeStateChange) {
this.unsubscribeStateChange();
this.unsubscribeStateChange = null;
}
// Clean up iframe
if (this.iframeEl) {
const iframeUrl = this.iframeEl.src;
if (iframeUrl.includes("/session/")) {
this.plugin.setCachedIframeUrl(iframeUrl);
}
this.iframeEl.src = "about:blank";
this.iframeEl = null;
}
}
private updateView(): void {
switch (this.currentState) {
case "stopped":
this.renderStoppedState();
break;
case "starting":
this.renderStartingState();
break;
case "running":
this.renderRunningState();
break;
case "error":
this.renderErrorState();
break;
}
}
private renderStoppedState(): void {
this.contentEl.empty();
const statusContainer = this.contentEl.createDiv({
cls: "opencode-status-container",
});
const iconEl = statusContainer.createDiv({ cls: "opencode-status-icon" });
setIcon(iconEl, "power-off");
statusContainer.createEl("h3", { text: "OpenCode is stopped" });
statusContainer.createEl("p", {
text: "Click the button below to start the OpenCode server.",
cls: "opencode-status-message",
});
const startButton = statusContainer.createEl("button", {
text: "Start OpenCode",
cls: "mod-cta",
});
startButton.addEventListener("click", () => {
this.plugin.startServer();
});
}
private renderStartingState(): void {
this.contentEl.empty();
const statusContainer = this.contentEl.createDiv({
cls: "opencode-status-container",
});
const loadingEl = statusContainer.createDiv({ cls: "opencode-loading" });
loadingEl.createDiv({ cls: "opencode-spinner" });
statusContainer.createEl("h3", { text: "Starting OpenCode..." });
statusContainer.createEl("p", {
text: "Please wait while the server starts up.",
cls: "opencode-status-message",
});
}
private renderRunningState(): void {
this.contentEl.empty();
const headerEl = this.contentEl.createDiv({ cls: "opencode-header" });
const titleSection = headerEl.createDiv({ cls: "opencode-header-title" });
const iconEl = titleSection.createSpan();
setIcon(iconEl, OPENCODE_ICON_NAME);
titleSection.createSpan({ text: "OpenCode" });
const actionsEl = headerEl.createDiv({ cls: "opencode-header-actions" });
const reloadButton = actionsEl.createEl("button", {
attr: { "aria-label": "Reload" },
});
setIcon(reloadButton, "refresh-cw");
reloadButton.addEventListener("click", () => {
this.reloadIframe();
});
const stopButton = actionsEl.createEl("button", {
attr: { "aria-label": "Stop server" },
});
setIcon(stopButton, "square");
stopButton.addEventListener("click", () => {
this.plugin.stopServer();
});
const iframeContainer = this.contentEl.createDiv({
cls: "opencode-iframe-container",
});
const iframeUrl = this.plugin.getStoredIframeUrl() ?? this.plugin.getServerUrl();
console.log("[OpenCode] Loading iframe with URL:", iframeUrl);
this.iframeEl = iframeContainer.createEl("iframe", {
cls: "opencode-iframe",
attr: {
src: iframeUrl,
frameborder: "0",
allow: "clipboard-read; clipboard-write",
},
});
this.iframeEl.addEventListener("error", () => {
console.error("Failed to load OpenCode iframe");
});
this.iframeEl.addEventListener("focus", () => {
this.plugin.refreshContextForView(this);
});
this.iframeEl.addEventListener("pointerdown", () => {
this.plugin.refreshContextForView(this);
});
void this.plugin.ensureSessionUrl(this);
}
getIframeUrl(): string | null {
return this.iframeEl?.src ?? null;
}
setIframeUrl(url: string): void {
if (this.iframeEl && this.iframeEl.src !== url) {
this.iframeEl.src = url;
}
}
private renderErrorState(): void {
this.contentEl.empty();
const statusContainer = this.contentEl.createDiv({
cls: "opencode-status-container opencode-error",
});
const iconEl = statusContainer.createDiv({ cls: "opencode-status-icon" });
setIcon(iconEl, "alert-circle");
statusContainer.createEl("h3", { text: "Failed to start OpenCode" });
const errorMessage = this.plugin.getLastError();
if (errorMessage) {
statusContainer.createEl("p", {
text: errorMessage,
cls: "opencode-status-message opencode-error-message",
});
} else {
statusContainer.createEl("p", {
text: "There was an error starting the OpenCode server.",
cls: "opencode-status-message",
});
}
const buttonContainer = statusContainer.createDiv({
cls: "opencode-button-group",
});
const retryButton = buttonContainer.createEl("button", {
text: "Retry",
cls: "mod-cta",
});
retryButton.addEventListener("click", () => {
this.plugin.startServer();
});
const settingsButton = buttonContainer.createEl("button", {
text: "Open Settings",
});
settingsButton.addEventListener("click", () => {
(this.app as any).setting.open();
(this.app as any).setting.openTabById("obsidian-opencode");
});
}
private reloadIframe(): void {
if (this.iframeEl) {
const src = this.iframeEl.src;
this.iframeEl.src = "about:blank";
setTimeout(() => {
if (this.iframeEl) {
this.iframeEl.src = src;
}
}, 100);
}
}
}

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