chore(vault): track .obsidian plugin configs (incl. obsidian-git settings)
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import { ItemView, WorkspaceLeaf, setIcon } from "obsidian";
|
||||
import { OPENCODE_VIEW_TYPE } from "./types";
|
||||
import { OPENCODE_ICON_NAME } from "./icons";
|
||||
import type OpenCodePlugin from "./main";
|
||||
import { ProcessState } from "./ProcessManager";
|
||||
|
||||
export class OpenCodeView extends ItemView {
|
||||
plugin: OpenCodePlugin;
|
||||
private iframeEl: HTMLIFrameElement | null = null;
|
||||
private currentState: ProcessState = "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.onProcessStateChange((state) => {
|
||||
this.currentState = state;
|
||||
this.updateView();
|
||||
});
|
||||
|
||||
// Initial render
|
||||
this.currentState = this.plugin.getProcessState();
|
||||
this.updateView();
|
||||
|
||||
// Start server if not running (lazy start) - don't await to avoid blocking view open
|
||||
if (this.currentState === "stopped") {
|
||||
this.plugin.startServer();
|
||||
}
|
||||
}
|
||||
|
||||
async onClose(): Promise<void> {
|
||||
// Unsubscribe from state changes to prevent memory leak
|
||||
if (this.unsubscribeStateChange) {
|
||||
this.unsubscribeStateChange();
|
||||
this.unsubscribeStateChange = null;
|
||||
}
|
||||
|
||||
// Clean up iframe
|
||||
if (this.iframeEl) {
|
||||
this.iframeEl.src = "about:blank";
|
||||
this.iframeEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
private updateView(): void {
|
||||
switch (this.currentState) {
|
||||
case "stopped":
|
||||
this.renderStoppedState();
|
||||
break;
|
||||
case "starting":
|
||||
this.renderStartingState();
|
||||
break;
|
||||
case "running":
|
||||
this.renderRunningState();
|
||||
break;
|
||||
case "error":
|
||||
this.renderErrorState();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private renderStoppedState(): void {
|
||||
this.contentEl.empty();
|
||||
|
||||
const statusContainer = this.contentEl.createDiv({
|
||||
cls: "opencode-status-container",
|
||||
});
|
||||
|
||||
const iconEl = statusContainer.createDiv({ cls: "opencode-status-icon" });
|
||||
setIcon(iconEl, "power-off");
|
||||
|
||||
statusContainer.createEl("h3", { text: "OpenCode is stopped" });
|
||||
statusContainer.createEl("p", {
|
||||
text: "Click the button below to start the OpenCode server.",
|
||||
cls: "opencode-status-message",
|
||||
});
|
||||
|
||||
const startButton = statusContainer.createEl("button", {
|
||||
text: "Start OpenCode",
|
||||
cls: "mod-cta",
|
||||
});
|
||||
startButton.addEventListener("click", () => {
|
||||
this.plugin.startServer();
|
||||
});
|
||||
}
|
||||
|
||||
private renderStartingState(): void {
|
||||
this.contentEl.empty();
|
||||
|
||||
const statusContainer = this.contentEl.createDiv({
|
||||
cls: "opencode-status-container",
|
||||
});
|
||||
|
||||
const loadingEl = statusContainer.createDiv({ cls: "opencode-loading" });
|
||||
loadingEl.createDiv({ cls: "opencode-spinner" });
|
||||
|
||||
statusContainer.createEl("h3", { text: "Starting OpenCode..." });
|
||||
statusContainer.createEl("p", {
|
||||
text: "Please wait while the server starts up.",
|
||||
cls: "opencode-status-message",
|
||||
});
|
||||
}
|
||||
|
||||
private renderRunningState(): void {
|
||||
this.contentEl.empty();
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
console.log("[OpenCode] Loading iframe with URL:", this.plugin.getServerUrl());
|
||||
|
||||
this.iframeEl = iframeContainer.createEl("iframe", {
|
||||
cls: "opencode-iframe",
|
||||
attr: {
|
||||
src: this.plugin.getServerUrl(),
|
||||
frameborder: "0",
|
||||
allow: "clipboard-read; clipboard-write",
|
||||
},
|
||||
});
|
||||
|
||||
this.iframeEl.addEventListener("error", () => {
|
||||
console.error("Failed to load OpenCode iframe");
|
||||
});
|
||||
}
|
||||
|
||||
private renderErrorState(): void {
|
||||
this.contentEl.empty();
|
||||
|
||||
const statusContainer = this.contentEl.createDiv({
|
||||
cls: "opencode-status-container opencode-error",
|
||||
});
|
||||
|
||||
const iconEl = statusContainer.createDiv({ cls: "opencode-status-icon" });
|
||||
setIcon(iconEl, "alert-circle");
|
||||
|
||||
statusContainer.createEl("h3", { text: "Failed to start OpenCode" });
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import { OpenCodeSettings } from "./types";
|
||||
|
||||
export type ProcessState = "stopped" | "starting" | "running" | "error";
|
||||
|
||||
export class ProcessManager {
|
||||
private process: ChildProcess | null = null;
|
||||
private state: ProcessState = "stopped";
|
||||
private lastError: string | null = null;
|
||||
private earlyExitCode: number | null = null;
|
||||
private settings: OpenCodeSettings;
|
||||
private projectDirectory: string;
|
||||
private onStateChange: (state: ProcessState) => void;
|
||||
|
||||
constructor(
|
||||
settings: OpenCodeSettings,
|
||||
projectDirectory: string,
|
||||
onStateChange: (state: ProcessState) => void
|
||||
) {
|
||||
this.settings = settings;
|
||||
this.projectDirectory = projectDirectory;
|
||||
this.onStateChange = onStateChange;
|
||||
}
|
||||
|
||||
updateSettings(settings: OpenCodeSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
updateProjectDirectory(directory: string): void {
|
||||
this.projectDirectory = directory;
|
||||
}
|
||||
|
||||
getState(): ProcessState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
getLastError(): string | null {
|
||||
return this.lastError;
|
||||
}
|
||||
|
||||
getUrl(): string {
|
||||
const encodedPath = btoa(this.projectDirectory);
|
||||
return `http://${this.settings.hostname}:${this.settings.port}/${encodedPath}`;
|
||||
}
|
||||
|
||||
async start(): Promise<boolean> {
|
||||
if (this.state === "running" || this.state === "starting") {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.setState("starting");
|
||||
this.lastError = null;
|
||||
this.earlyExitCode = null;
|
||||
|
||||
if (!this.projectDirectory) {
|
||||
return this.setError("Project directory (vault) not configured");
|
||||
}
|
||||
|
||||
if (await this.checkServerHealth()) {
|
||||
console.log("[OpenCode] Server already running on port", this.settings.port);
|
||||
this.setState("running");
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log("[OpenCode] Starting server:", {
|
||||
opencodePath: this.settings.opencodePath,
|
||||
port: this.settings.port,
|
||||
hostname: this.settings.hostname,
|
||||
cwd: this.projectDirectory,
|
||||
projectDirectory: this.projectDirectory,
|
||||
});
|
||||
|
||||
this.process = spawn(
|
||||
this.settings.opencodePath,
|
||||
[
|
||||
"serve",
|
||||
"--port",
|
||||
this.settings.port.toString(),
|
||||
"--hostname",
|
||||
this.settings.hostname,
|
||||
"--cors",
|
||||
"app://obsidian.md",
|
||||
],
|
||||
{
|
||||
cwd: this.projectDirectory,
|
||||
env: { ...process.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
}
|
||||
);
|
||||
|
||||
console.log("[OpenCode] Process spawned with PID:", this.process.pid);
|
||||
|
||||
this.process.stdout?.on("data", (data) => {
|
||||
console.log("[OpenCode]", data.toString().trim());
|
||||
});
|
||||
|
||||
this.process.stderr?.on("data", (data) => {
|
||||
console.error("[OpenCode Error]", data.toString().trim());
|
||||
});
|
||||
|
||||
this.process.on("exit", (code, signal) => {
|
||||
console.log(`[OpenCode] Process exited with code ${code}, signal ${signal}`);
|
||||
this.process = null;
|
||||
|
||||
if (this.state === "starting" && code !== null && code !== 0) {
|
||||
this.earlyExitCode = code;
|
||||
}
|
||||
|
||||
if (this.state === "running") {
|
||||
this.setState("stopped");
|
||||
}
|
||||
});
|
||||
|
||||
this.process.on("error", (err: NodeJS.ErrnoException) => {
|
||||
console.error("[OpenCode] Failed to start process:", err);
|
||||
this.process = null;
|
||||
|
||||
if (err.code === "ENOENT") {
|
||||
this.setError(`Executable not found at '${this.settings.opencodePath}'`);
|
||||
} else {
|
||||
this.setError(`Failed to start: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const ready = await this.waitForServerOrExit(this.settings.startupTimeout);
|
||||
if (ready) {
|
||||
this.setState("running");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.state === "error") {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.stop();
|
||||
if (this.earlyExitCode !== null) {
|
||||
return this.setError(`Process exited unexpectedly (exit code ${this.earlyExitCode})`);
|
||||
}
|
||||
if (!this.process) {
|
||||
return this.setError("Process exited before server became ready");
|
||||
}
|
||||
return this.setError("Server failed to start within timeout");
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.process) {
|
||||
this.setState("stopped");
|
||||
return;
|
||||
}
|
||||
|
||||
const proc = this.process;
|
||||
console.log("[OpenCode] Stopping process with PID:", proc.pid);
|
||||
|
||||
this.setState("stopped");
|
||||
this.process = null;
|
||||
|
||||
proc.kill("SIGTERM");
|
||||
|
||||
// Force kill after 2 seconds if still running
|
||||
setTimeout(() => {
|
||||
if (proc.exitCode === null && proc.signalCode === null) {
|
||||
console.log("[OpenCode] Process still running, sending SIGKILL");
|
||||
proc.kill("SIGKILL");
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
private setState(state: ProcessState): void {
|
||||
this.state = state;
|
||||
this.onStateChange(state);
|
||||
}
|
||||
|
||||
private setError(message: string): false {
|
||||
this.lastError = message;
|
||||
console.error("[OpenCode Error]", message);
|
||||
this.setState("error");
|
||||
return false;
|
||||
}
|
||||
|
||||
private async checkServerHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.getUrl()}/global/health`, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForServerOrExit(timeoutMs: number): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
const pollInterval = 500;
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
if (!this.process) {
|
||||
console.log("[OpenCode] Process exited before server became ready");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await this.checkServerHealth()) {
|
||||
return true;
|
||||
}
|
||||
await this.sleep(pollInterval);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { App, PluginSettingTab, Setting, Notice } from "obsidian";
|
||||
import { existsSync, statSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import type OpenCodePlugin from "./main";
|
||||
import type { ViewLocation } from "./types";
|
||||
|
||||
function expandTilde(path: string): string {
|
||||
if (path === "~") {
|
||||
return homedir();
|
||||
}
|
||||
if (path.startsWith("~/")) {
|
||||
return path.replace("~", homedir());
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export class OpenCodeSettingTab extends PluginSettingTab {
|
||||
plugin: OpenCodePlugin;
|
||||
private validateTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(app: App, plugin: OpenCodePlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
containerEl.createEl("h2", { text: "OpenCode Settings" });
|
||||
containerEl.createEl("h3", { text: "Server Configuration" });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Port")
|
||||
.setDesc("Port number for the OpenCode web server")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("14096")
|
||||
.setValue(this.plugin.settings.port.toString())
|
||||
.onChange(async (value) => {
|
||||
const port = parseInt(value, 10);
|
||||
if (!isNaN(port) && port > 0 && port < 65536) {
|
||||
this.plugin.settings.port = port;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Hostname")
|
||||
.setDesc("Hostname to bind the server to (usually 127.0.0.1)")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("127.0.0.1")
|
||||
.setValue(this.plugin.settings.hostname)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.hostname = value || "127.0.0.1";
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("OpenCode path")
|
||||
.setDesc(
|
||||
"Path to the OpenCode executable. Leave as 'opencode' if it's in your PATH."
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("opencode")
|
||||
.setValue(this.plugin.settings.opencodePath)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.opencodePath = value || "opencode";
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Project directory")
|
||||
.setDesc(
|
||||
"Override the starting directory for OpenCode. Leave empty to use the vault root. Supports ~ for home directory."
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("/path/to/project or ~/project")
|
||||
.setValue(this.plugin.settings.projectDirectory)
|
||||
.onChange((value) => {
|
||||
// Debounce validation to avoid spamming notices on every keypress
|
||||
if (this.validateTimeout) {
|
||||
clearTimeout(this.validateTimeout);
|
||||
}
|
||||
this.validateTimeout = setTimeout(async () => {
|
||||
await this.validateAndSetProjectDirectory(value);
|
||||
}, 500);
|
||||
})
|
||||
);
|
||||
|
||||
containerEl.createEl("h3", { text: "Behavior" });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Auto-start server")
|
||||
.setDesc(
|
||||
"Automatically start the OpenCode server when Obsidian opens (not recommended for faster startup)"
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.autoStart)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.autoStart = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Default view location")
|
||||
.setDesc(
|
||||
"Where to open the OpenCode panel: sidebar opens in the right panel, main opens as a tab in the editor area"
|
||||
)
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption("sidebar", "Sidebar")
|
||||
.addOption("main", "Main window")
|
||||
.setValue(this.plugin.settings.defaultViewLocation)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.defaultViewLocation = value as ViewLocation;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
containerEl.createEl("h3", { text: "Server Status" });
|
||||
|
||||
const statusContainer = containerEl.createDiv({ cls: "opencode-settings-status" });
|
||||
this.renderServerStatus(statusContainer);
|
||||
}
|
||||
|
||||
private async validateAndSetProjectDirectory(value: string): Promise<void> {
|
||||
const trimmed = value.trim();
|
||||
|
||||
// Empty value is valid - means use vault root
|
||||
if (!trimmed) {
|
||||
await this.plugin.updateProjectDirectory("");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate absolute path (supports ~, /, and Windows drive letters)
|
||||
if (!trimmed.startsWith("/") && !trimmed.startsWith("~") && !trimmed.match(/^[A-Za-z]:\\/)) {
|
||||
new Notice("Project directory must be an absolute path (or start with ~)");
|
||||
return;
|
||||
}
|
||||
|
||||
const expanded = expandTilde(trimmed);
|
||||
|
||||
try {
|
||||
if (!existsSync(expanded)) {
|
||||
new Notice("Project directory does not exist");
|
||||
return;
|
||||
}
|
||||
const stat = statSync(expanded);
|
||||
if (!stat.isDirectory()) {
|
||||
new Notice("Project directory path is not a directory");
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
new Notice(`Failed to validate path: ${(error as Error).message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.plugin.updateProjectDirectory(expanded);
|
||||
}
|
||||
|
||||
private renderServerStatus(container: HTMLElement): void {
|
||||
container.empty();
|
||||
|
||||
const state = this.plugin.getProcessState();
|
||||
const statusText = {
|
||||
stopped: "Stopped",
|
||||
starting: "Starting...",
|
||||
running: "Running",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
const statusClass = {
|
||||
stopped: "status-stopped",
|
||||
starting: "status-starting",
|
||||
running: "status-running",
|
||||
error: "status-error",
|
||||
};
|
||||
|
||||
const statusEl = container.createDiv({ cls: "opencode-status-line" });
|
||||
statusEl.createSpan({ text: "Status: " });
|
||||
statusEl.createSpan({
|
||||
text: statusText[state],
|
||||
cls: `opencode-status-badge ${statusClass[state]}`,
|
||||
});
|
||||
|
||||
if (state === "running") {
|
||||
const urlEl = container.createDiv({ cls: "opencode-status-line" });
|
||||
urlEl.createSpan({ text: "URL: " });
|
||||
const linkEl = urlEl.createEl("a", {
|
||||
text: this.plugin.getServerUrl(),
|
||||
href: this.plugin.getServerUrl(),
|
||||
});
|
||||
linkEl.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
window.open(this.plugin.getServerUrl(), "_blank");
|
||||
});
|
||||
}
|
||||
|
||||
const buttonContainer = container.createDiv({ cls: "opencode-settings-buttons" });
|
||||
|
||||
if (state === "stopped" || state === "error") {
|
||||
const startButton = buttonContainer.createEl("button", {
|
||||
text: "Start Server",
|
||||
cls: "mod-cta",
|
||||
});
|
||||
startButton.addEventListener("click", async () => {
|
||||
await this.plugin.startServer();
|
||||
this.renderServerStatus(container);
|
||||
});
|
||||
}
|
||||
|
||||
if (state === "running") {
|
||||
const stopButton = buttonContainer.createEl("button", {
|
||||
text: "Stop Server",
|
||||
});
|
||||
stopButton.addEventListener("click", () => {
|
||||
this.plugin.stopServer();
|
||||
this.renderServerStatus(container);
|
||||
});
|
||||
|
||||
const restartButton = buttonContainer.createEl("button", {
|
||||
text: "Restart Server",
|
||||
cls: "mod-warning",
|
||||
});
|
||||
restartButton.addEventListener("click", async () => {
|
||||
this.plugin.stopServer();
|
||||
await this.plugin.startServer();
|
||||
this.renderServerStatus(container);
|
||||
});
|
||||
}
|
||||
|
||||
if (state === "starting") {
|
||||
buttonContainer.createSpan({
|
||||
text: "Please wait...",
|
||||
cls: "opencode-status-waiting",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { addIcon } from "obsidian";
|
||||
|
||||
export const OPENCODE_ICON_NAME = "opencode-logo";
|
||||
|
||||
// Monochrome OpenCode "O" logo mark derived from the official brand assets
|
||||
// Uses currentColor for theme compatibility
|
||||
const OPENCODE_LOGO_SVG = `<svg viewBox="0 0 24 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 24H6V12H18V24Z" fill="currentColor" opacity="0.4"/>
|
||||
<path d="M18 6H6V24H18V6ZM24 30H0V0H24V30Z" fill="currentColor"/>
|
||||
</svg>`;
|
||||
|
||||
export function registerOpenCodeIcons(): void {
|
||||
addIcon(OPENCODE_ICON_NAME, OPENCODE_LOGO_SVG);
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
import { Plugin, WorkspaceLeaf, Notice } from "obsidian";
|
||||
import { OpenCodeSettings, DEFAULT_SETTINGS, OPENCODE_VIEW_TYPE } from "./types";
|
||||
import { OpenCodeView } from "./OpenCodeView";
|
||||
import { OpenCodeSettingTab } from "./SettingsTab";
|
||||
import { ProcessManager, ProcessState } from "./ProcessManager";
|
||||
import { registerOpenCodeIcons, OPENCODE_ICON_NAME } from "./icons";
|
||||
|
||||
export default class OpenCodePlugin extends Plugin {
|
||||
settings: OpenCodeSettings = DEFAULT_SETTINGS;
|
||||
private processManager: ProcessManager;
|
||||
private stateChangeCallbacks: Array<(state: ProcessState) => void> = [];
|
||||
|
||||
async onload(): Promise<void> {
|
||||
console.log("Loading OpenCode plugin");
|
||||
|
||||
registerOpenCodeIcons();
|
||||
|
||||
await this.loadSettings();
|
||||
|
||||
const projectDirectory = this.getProjectDirectory();
|
||||
|
||||
this.processManager = new ProcessManager(
|
||||
this.settings,
|
||||
projectDirectory,
|
||||
(state) => this.notifyStateChange(state)
|
||||
);
|
||||
|
||||
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.addRibbonIcon(OPENCODE_ICON_NAME, "OpenCode", () => {
|
||||
this.activateView();
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "toggle-opencode-view",
|
||||
name: "Toggle OpenCode panel",
|
||||
callback: () => {
|
||||
this.toggleView();
|
||||
},
|
||||
hotkeys: [
|
||||
{
|
||||
modifiers: ["Mod", "Shift"],
|
||||
key: "o",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "start-opencode-server",
|
||||
name: "Start OpenCode server",
|
||||
callback: () => {
|
||||
this.startServer();
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "stop-opencode-server",
|
||||
name: "Stop OpenCode server",
|
||||
callback: () => {
|
||||
this.stopServer();
|
||||
},
|
||||
});
|
||||
|
||||
if (this.settings.autoStart) {
|
||||
this.app.workspace.onLayoutReady(async () => {
|
||||
await this.startServer();
|
||||
});
|
||||
}
|
||||
|
||||
console.log("OpenCode plugin loaded");
|
||||
}
|
||||
|
||||
async onunload(): Promise<void> {
|
||||
this.stopServer();
|
||||
this.app.workspace.detachLeavesOfType(OPENCODE_VIEW_TYPE);
|
||||
}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
await this.saveData(this.settings);
|
||||
this.processManager.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());
|
||||
|
||||
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> {
|
||||
const success = await this.processManager.start();
|
||||
if (success) {
|
||||
new Notice("OpenCode server started");
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
stopServer(): void {
|
||||
this.processManager.stop();
|
||||
new Notice("OpenCode server stopped");
|
||||
}
|
||||
|
||||
getProcessState(): ProcessState {
|
||||
return this.processManager?.getState() ?? "stopped";
|
||||
}
|
||||
|
||||
getLastError(): string | null {
|
||||
return this.processManager.getLastError() ?? null;
|
||||
}
|
||||
|
||||
getServerUrl(): string {
|
||||
return this.processManager.getUrl();
|
||||
}
|
||||
|
||||
onProcessStateChange(callback: (state: ProcessState) => void): () => void {
|
||||
this.stateChangeCallbacks.push(callback);
|
||||
return () => {
|
||||
const index = this.stateChangeCallbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
this.stateChangeCallbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private notifyStateChange(state: ProcessState): void {
|
||||
for (const callback of this.stateChangeCallbacks) {
|
||||
callback(state);
|
||||
}
|
||||
}
|
||||
|
||||
getProjectDirectory(): string {
|
||||
if (this.settings.projectDirectory) {
|
||||
console.log("[OpenCode] Using project directory from settings:", this.settings.projectDirectory);
|
||||
return this.settings.projectDirectory;
|
||||
}
|
||||
const adapter = this.app.vault.adapter as any;
|
||||
const vaultPath = adapter.basePath || "";
|
||||
if (!vaultPath) {
|
||||
console.warn("[OpenCode] Warning: Could not determine vault path");
|
||||
}
|
||||
console.log("[OpenCode] Using vault path as project directory:", vaultPath);
|
||||
return vaultPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type ViewLocation = "sidebar" | "main";
|
||||
|
||||
export interface OpenCodeSettings {
|
||||
port: number;
|
||||
hostname: string;
|
||||
autoStart: boolean;
|
||||
opencodePath: string;
|
||||
projectDirectory: string;
|
||||
startupTimeout: number;
|
||||
defaultViewLocation: ViewLocation;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: OpenCodeSettings = {
|
||||
port: 14096,
|
||||
hostname: "127.0.0.1",
|
||||
autoStart: false,
|
||||
opencodePath: "opencode",
|
||||
projectDirectory: "",
|
||||
startupTimeout: 15000,
|
||||
defaultViewLocation: "sidebar",
|
||||
};
|
||||
|
||||
export const OPENCODE_VIEW_TYPE = "opencode-view";
|
||||
Reference in New Issue
Block a user