diff --git a/README.md b/README.md index 7b0c8a8..d6efe0f 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,165 @@ -# OpenCode plugin for Obsidian +# OpenCode Obsidian 插件 +[English](#english) | **中文** -Give your notes AI capability by embedding Opencode [OpenCode](https://opencode.ai) AI assistant directly in Obsidian: +--- -OpenCode embeded in Obsidian +在 Obsidian 中嵌入 [OpenCode](https://opencode.ai) AI 助手,让你的笔记拥有 AI 能力: + +OpenCode 嵌入 Obsidian + +**使用场景:** +- 总结和提炼长文内容 +- 起草、编辑和优化你的写作 +- 查询和探索你的知识库 +- 生成大纲和结构化笔记 + +本插件直接嵌入 OpenCode 的 Web 视图。通常类似插件会使用 ACP 协议,但本项目的目标是——无需自行实现(和维护)自定义聊天 UI,直接在 Obsidian 中获得 OpenCode 的完整功能。 + +_注意:插件作者与 OpenCode 或 Obsidian 无隶属关系,这是第三方软件。_ + +--- + +## ✨ 增强功能(本 Fork 新增) + +在原项目基础上新增了以下功能: + +### 🔄 多会话标签页 + +- 支持同时打开多个 AI 会话,通过标签栏快速切换 +- 每个会话独立 iframe,切换时完整保留模型选择、滚动位置、输入内容等所有状态 +- 右键标签可关闭会话,支持配置最大会话数 + +### 💾 会话持久化 + +- 会话信息(ID、URL、活动标签)自动保存 +- Obsidian 重启后自动恢复所有会话 +- 如果保存的会话已失效,自动创建新会话 + +### 🌐 中英文设置界面 + +- 设置页面支持中文/英文切换 +- 语言选择器位于设置页顶部,切换即时生效 +- 可通过 `src/settings/i18n.ts` 轻松扩展更多语言 + +### 📡 实时会话状态指示 + +- AI 处理请求时,左侧 Ribbon 图标自动变色并闪烁 +- 标签栏实时显示每个会话的状态:空闲 / 处理中(脉冲动画)/ 重试中(红色警告) +- 每 5 秒轮询 OpenCode API 获取最新状态 + +### 🕐 历史会话浏览器 + +- 点击标签栏右侧时钟图标,弹出历史会话面板 +- 按时间倒序列出所有历史会话,显示标题和相对时间 +- 点击任一历史会话即可在新标签页中加载 +- 底部「清理旧会话」按钮可一键批量删除非活跃的历史会话 +- 当前已打开的会话会标记「活跃」状态,不会被误删 + +### 🛡️ 启动故障自愈 + +- 重启 Obsidian 时自动检测是否有残留的 opencode 进程占用端口 +- 自动清理僵尸进程和孤儿 TCP 连接,避免服务启动失败 +- 无需手动 taskkill,开箱即用 + +--- + +## 环境要求 + +- 仅支持桌面端(使用 Node.js 子进程) +- 已安装 [OpenCode CLI](https://opencode.ai) +- 已安装 [Bun](https://bun.sh) + +## 安装 + +### 普通用户(BRAT 安装 — 推荐) + +通过 [BRAT](https://github.com/TfTHacker/obsidian42-brat)(Beta Reviewer's Auto-update Tool)安装最便捷: + +1. 在 Obsidian 社区插件中安装 BRAT 插件 +2. 打开 BRAT 设置,点击 "Add Beta plugin" +3. 输入:`nthforsth/opencode-obsidian` +4. 点击 "Add Plugin" — BRAT 会自动安装最新版本 +5. 在 Obsidian 设置 > 社区插件中启用 OpenCode 插件 + +BRAT 会自动检查更新,有新版本时通知你。 + +### 开发者 + +如果你想参与开发: + +1. 克隆到你的 vault 根目录下的 `.obsidian/plugins/obsidian-opencode`: + ```bash + git clone https://github.com/nthforsth/opencode-obsidian.git .obsidian/plugins/obsidian-opencode + ``` +2. 安装依赖并构建: + ```bash + bun install && bun run build + ``` +3. 在 Obsidian 设置 > 社区插件中启用 +4. 在工作区根目录添加 `AGENTS.md` 来引导 AI 助手 + +## 使用 + +- 点击左侧 Ribbon 栏的终端图标 +- 或使用快捷键 `Cmd/Ctrl+Shift+O` 切换面板 +- 打开面板后服务自动启动 + +## 设置 + +插件设置 + +### 自定义命令模式 + +当你需要更多控制(如添加额外 CLI 参数、使用自定义脚本、或通过容器运行 OpenCode)时,启用 "Use custom command"。 + +使用自定义命令时: + +- **主机名和端口必须匹配**上方的 Port 和 Hostname 设置 +- **必须包含 `--cors app://obsidian.md`** 以允许 Obsidian 嵌入 OpenCode 界面 + +示例: +```bash +opencode serve --port 14096 --hostname 127.0.0.1 --cors app://obsidian.md +``` + +其他设置(端口、主机名、自动启动、视图位置、上下文注入)均可在设置界面中直接配置。 + +### 上下文注入(实验性) + +本插件可自动向运行中的 OpenCode 实例注入上下文:已打开笔记的列表和当前选中的文本。 + +目前该功能仍在开发中,存在一些限制——从 OpenCode 界面创建新会话时不会生效。 + +## Windows 故障排除 + +如果已安装 opencode 但提示 "Executable not found at 'opencode'": + +1. 找到 opencode.cmd 路径: + ``` + where opencode.cmd + ``` + +2. 在插件设置中配置完整路径: + ``` + C:\Users\{username}\AppData\Roaming\npm\opencode.cmd + ``` + +这是因为 Electron/Obsidian 在 Windows 上不会完全继承 PATH。 + +--- + + + +# OpenCode Plugin for Obsidian + +**English** | [中文](#opencode-obsidian-插件) + +--- + +Give your notes AI capability by embedding [OpenCode](https://opencode.ai) AI assistant directly in Obsidian: + +OpenCode embedded in Obsidian **Use cases:** - Summarize and distill long-form content @@ -11,75 +167,122 @@ Give your notes AI capability by embedding Opencode [OpenCode](https://opencode. - Query and explore your knowledge base - Generate outlines and structured notes -This plugin uses OpenCode's web view that can be embedded directly into Obsidian window. Usually similar plugins would use the ACP protocol, but I want to see how how much is possible without having to implement (and manage) a custom chat UI - I want the full power of OpenCode in my Obsidian. +This plugin uses OpenCode's web view that can be embedded directly into the Obsidian window. Usually similar plugins would use the ACP protocol, but the goal of this project is to get the full power of OpenCode in Obsidian without having to implement (and maintain) a custom chat UI. + +_Note: plugin author is not affiliated with OpenCode or Obsidian — this is 3rd party software._ + +--- + +## ✨ Enhanced Features (This Fork) + +Built on top of the original project with the following additions: + +### 🔄 Multi-Session Tabs + +- Open multiple AI sessions simultaneously with a tab bar for quick switching +- Each session has its own iframe — model selection, scroll position, input content, and all JS state are preserved across tab switches +- Right-click a tab to close it; configurable max session limit + +### 💾 Session Persistence + +- Session info (ID, URL, active tab) is automatically saved +- All sessions are restored when Obsidian restarts +- Graceful fallback: creates a new session if saved data is stale -_Note: plugin author is not afiliated with OpenCode or Obsidian - this is a 3rd party software._ +### 🌐 Chinese/English Settings UI + +- Settings page supports Chinese/English toggle +- Language selector at the top of settings; changes apply instantly +- Easy to extend with more languages via `src/settings/i18n.ts` + +### 📡 Real-Time Session Status Indicator + +- Ribbon icon automatically changes color and pulses when AI is processing +- Tab bar shows per-session status in real time: idle / busy (pulse animation) / retry (red alert) +- Polls OpenCode API every 5 seconds for latest status + +### 🕐 Session History Browser + +- Click the clock icon on the right side of the tab bar to open the history panel +- Lists all past sessions in reverse chronological order with titles and relative timestamps +- Click any session to load it in a new tab instantly +- "Clean old sessions" button at the bottom for bulk-deleting inactive sessions +- Currently open sessions are marked as "Active" and protected from accidental deletion + +### 🛡️ Startup Self-Healing + +- Automatically detects leftover opencode processes holding the port on Obsidian restart +- Cleans up zombie processes and orphaned TCP connections to prevent server startup failures +- No manual taskkill needed — works out of the box + +--- ## Requirements - Desktop only (uses Node.js child processes) -- [OpenCode CLI](https://opencode.ai) installed +- [OpenCode CLI](https://opencode.ai) installed - [Bun](https://bun.sh) installed ## Installation -### For Users (BRAT - Recommended for Beta Testing) +### For Users (BRAT — Recommended) -The easiest way to install this plugin during beta is via [BRAT](https://github.com/TfTHacker/obsidian42-brat) (Beta Reviewer's Auto-update Tool): +The easiest way to install is via [BRAT](https://github.com/TfTHacker/obsidian42-brat) (Beta Reviewer's Auto-update Tool): 1. Install the BRAT plugin from Obsidian Community Plugins 2. Open BRAT settings and click "Add Beta plugin" -3. Enter: `mtymek/opencode-obsidian` -4. Click "Add Plugin" - BRAT will install the latest release automatically +3. Enter: `nthforsth/opencode-obsidian` +4. Click "Add Plugin" — BRAT will install the latest release automatically 5. Enable the OpenCode plugin in Obsidian Settings > Community Plugins BRAT will automatically check for updates and notify you when new versions are available. ### For Developers -If you want to contribute or develop the plugin: +If you want to contribute or develop: -1. Clone to `.obsidian/plugins/obsidian-opencode` subdirectory under your vault's root: +1. Clone to `.obsidian/plugins/obsidian-opencode` under your vault root: ```bash - git clone https://github.com/mtymek/opencode-obsidian.git .obsidian/plugins/obsidian-opencode + git clone https://github.com/nthforsth/opencode-obsidian.git .obsidian/plugins/obsidian-opencode ``` 2. Install dependencies and build: ```bash bun install && bun run build ``` 3. Enable in Obsidian Settings > Community Plugins -4. Add AGENTS.md to your workspace root to guide the AI assistant +4. Add `AGENTS.md` to your workspace root to guide the AI assistant ## Usage -- Click the terminal icon in the ribbon, or -- `Cmd/Ctrl+Shift+O` to toggle the panel +- Click the terminal icon in the left ribbon, or +- Press `Cmd/Ctrl+Shift+O` to toggle the panel - Server starts automatically when you open the panel - ## Settings +Plugin Settings + ### Custom Command Mode -Enable "Use custom command" when you need more control over how OpenCode starts—for example, to add extra CLI flags, use a custom wrapper script, or run OpenCode through a container or virtual environment. +Enable "Use custom command" when you need more control over how OpenCode starts — for example, to add extra CLI flags, use a custom wrapper script, or run OpenCode through a container. When using custom command: - **Hostname and port must match** the values set in the Port and Hostname fields above -- You **must include `--cors app://obsidian.md`** to allow Obsidian to embed the OpenCode interface +- **You must include `--cors app://obsidian.md`** to allow Obsidian to embed the OpenCode interface Example: ```bash opencode serve --port 14096 --hostname 127.0.0.1 --cors app://obsidian.md ``` -Other settings (port, hostname, auto-start, view location, context injection) are available through the settings UI and are self-explanatory. +Other settings (port, hostname, auto-start, view location, context injection) are available through the settings UI. -### Context injection (experimental) +### Context Injection (Experimental) -This plugin can automatically inject context to the running OC instance: list of open notes and currently selected text. +This plugin can automatically inject context into the running OpenCode instance: list of open notes and currently selected text. -Currently, this is work-in-progress feature with some limitations - it won't work when creating new session from OC interface. +This is a work-in-progress feature with some limitations — it won't work when creating new sessions from the OpenCode interface. ## Windows Troubleshooting @@ -96,4 +299,3 @@ If you see "Executable not found at 'opencode'" despite opencode being installed ``` This is due to Electron/Obsidian not fully inheriting PATH on Windows. - diff --git a/manifest.json b/manifest.json index a8cad25..3286b5c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { - "id": "opencode-obsidian", - "name": "OpenCode-Obsidian", + "id": "opencodian", + "name": "OpenCodian", "version": "0.2.1", "minAppVersion": "1.4.0", - "description": "Embed OpenCode AI assistant in Obsidian for AI-powered note management", + "description": "OpenCodian AI assistant embedded in Obsidian", "author": "mtymek", "isDesktopOnly": true } diff --git a/package.json b/package.json index 13bedcd..5246553 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "obsidian-opencode", + "name": "obsidian-opencodian", "version": "0.2.1", "description": "Embed OpenCode AI assistant in Obsidian", "main": "main.js", diff --git a/src/client/OpenCodeClient.ts b/src/client/OpenCodeClient.ts index 716b2a7..ce10816 100644 --- a/src/client/OpenCodeClient.ts +++ b/src/client/OpenCodeClient.ts @@ -7,10 +7,7 @@ type OpenCodePart = { ignored?: boolean; synthetic?: boolean; metadata?: Record; - time?: { - start: number; - end?: number; - }; + time?: { start: number; end?: number }; }; type OpenCodeMessageInfo = { @@ -29,6 +26,51 @@ type OpenCodeSession = { type OpenCodeResponse = T | { data?: T } | { message?: T } | null; +/** Session metadata returned by the session list API. */ +export interface SessionInfo { + id: string; + slug?: string; + title?: string; + parentID?: string; + time?: { + created: number; + updated: number; + archived?: number; + }; + summary?: { + additions: number; + deletions: number; + files: number; + }; +} + +/** Full assistant response returned by the server after sending a message. */ +export interface AssistantResponse { + info: { + id: string; + sessionID: string; + role: string; + mode?: string; + agent?: string; + modelID?: string; + providerID?: string; + cost?: number; + tokens?: Record; + finish?: string; + time: { created: number; completed?: number }; + }; + parts: OpenCodePart[]; +} + +/** Parsed chat message used by the native UI. */ +export interface ChatMessage { + id: string; + role: "user" | "assistant"; + content: string; + reasoning?: string; + timestamp: number; +} + export class OpenCodeClient { private apiBaseUrl: string; private uiBaseUrl: string; @@ -67,18 +109,14 @@ export class OpenCodeClient { const url = `${this.apiBaseUrl}/session?directory=${encodeURIComponent(this.projectDirectory)}`; const response = await fetch(url, { method: "GET", - headers: { - "x-opencode-directory": this.projectDirectory, - }, + headers: { "x-opencode-directory": this.projectDirectory }, }); - if (response.ok) { console.log("[OpenCode] Project initialized:", this.projectDirectory); return true; - } else { - console.warn("[OpenCode] Project initialization failed:", response.status); - return false; } + console.warn("[OpenCode] Project initialization failed:", response.status); + return false; } catch (error) { console.error("[OpenCode] Project initialization error:", error); return false; @@ -95,13 +133,49 @@ export class OpenCodeClient { } async createSession(): Promise { - const result = await this.request("POST", "/session", { - title: "Obsidian", - }); + const result = await this.request("POST", "/session", { title: "Obsidian" }); const session = this.unwrap(result); return session?.id ?? null; } + /** Get the status (idle/busy/retry) of all sessions. */ + async getSessionStatus(): Promise | null> { + const result = await this.request>("GET", "/session/status"); + return this.unwrap(result); + } + + /** List all sessions for the current project (excludes sub-agent sessions). */ + async listSessions(): Promise { + const result = await this.request("GET", "/session?scope=project"); + const sessions = this.unwrap(result); + if (!Array.isArray(sessions)) return null; + // Filter out sub-agent sessions (they have parentID) + return sessions.filter((s) => !s.parentID); + } + + /** Delete a session by ID. */ + async deleteSession(sessionId: string): Promise { + try { + const url = `${this.apiBaseUrl}/session/${sessionId}`; + const response = await fetch(url, { + method: "DELETE", + headers: { "x-opencode-directory": this.projectDirectory }, + }); + return response.ok; + } catch { + return false; + } + } + + /** Send a user message and return the full assistant response. */ + async sendMessage(sessionId: string, text: string): Promise { + const result = await this.request('POST', `/session/${sessionId}/message`, { + parts: [{ type: 'text', text }], + }); + return this.unwrap(result); + } + + /** Inject context into a session (no AI reply). */ async updateContext(params: { sessionId: string; contextText: string | null; @@ -120,9 +194,7 @@ export class OpenCodeClient { if (this.lastPart) { const updated = await this.updatePart(this.lastPart, { text: contextText }); - if (updated) { - return; - } + if (updated) return; await this.ignorePreviousPart(); } @@ -136,19 +208,10 @@ export class OpenCodeClient { const result = await this.request( "POST", `/session/${sessionId}/message`, - { - noReply: true, - parts: [{ type: "text", text: contextText }], - } + { noReply: true, parts: [{ type: "text", text: contextText }] } ); - - console.log("[OpenCode] Injected context message"); - console.log(contextText) - const message = this.unwrap(result); - if (!message) { - console.error("[OpenCode] Failed to inject context message"); - } + if (!message) console.error("[OpenCode] Failed to inject context message"); return message; } @@ -156,29 +219,17 @@ export class OpenCodeClient { const result = await this.request( "PATCH", `/session/${part.sessionID}/message/${part.messageID}/part/${part.id}`, - { - ...part, - ...updates, - } + { ...part, ...updates } ); const updated = this.unwrap(result); - if (updated) { - this.lastPart = updated; - return true; - } + if (updated) { this.lastPart = updated; return true; } return false; } private async ignorePreviousPart(): Promise { - if (!this.lastPart) { - return false; - } - + if (!this.lastPart) return false; const ignored = await this.updatePart(this.lastPart, { ignored: true }); - if (!ignored) { - return false; - } - + if (!ignored) return false; this.lastPart = null; return true; } @@ -194,18 +245,11 @@ export class OpenCodeClient { }, body: body ? JSON.stringify(body) : undefined, }); - if (!response.ok) { - console.error("[OpenCode] API request failed", { - path, - status: response.status, - }); + console.error("[OpenCode] API request failed", { path, status: response.status }); return null; } - - const json = await response - .json() - .catch(() => null); + const json = await response.json().catch(() => null); return json as OpenCodeResponse; } catch (error) { console.error("[OpenCode] API request error", error); @@ -214,17 +258,11 @@ export class OpenCodeClient { } private unwrap(result: OpenCodeResponse): T | null { - if (!result) { - return null; - } + if (!result) return null; if (typeof result === "object") { const payload = result as { data?: T; message?: T }; - if (payload.data) { - return payload.data; - } - if (payload.message) { - return payload.message; - } + if (payload.data) return payload.data; + if (payload.message) return payload.message; } return result as T; } diff --git a/src/context/ContextManager.ts b/src/context/ContextManager.ts index e353290..d2143f4 100644 --- a/src/context/ContextManager.ts +++ b/src/context/ContextManager.ts @@ -1,6 +1,5 @@ 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"; @@ -8,21 +7,15 @@ 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[] = []; @@ -31,11 +24,8 @@ export class ContextManager { 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; } @@ -110,97 +100,67 @@ export class ContextManager { } } - 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 { + /** Find the leaf that should receive context injection */ + private getTargetLeaf(): WorkspaceLeaf | null { const activeLeaf = this.app.workspace.activeLeaf; if (activeLeaf?.view.getViewType() === OPENCODE_VIEW_TYPE) { return activeLeaf; } - return this.getVisibleSidebarLeaf(); - } - - private getVisibleSidebarLeaf(): WorkspaceLeaf | null { + // Fallback: find a visible sidebar leaf const leaves = this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE); - if (leaves.length === 0) { - return null; - } + if (leaves.length === 0) return null; const rightSplit = this.app.workspace.rightSplit; - if (!rightSplit || rightSplit.collapsed) { - return null; + if (rightSplit && !rightSplit.collapsed) { + const sidebarLeaf = leaves.find(l => l.getRoot() === rightSplit); + if (sidebarLeaf) return sidebarLeaf; } - const leaf = leaves[0]; - return leaf.getRoot() === rightSplit ? leaf : null; + return leaves[0]; } - async handleServerRunning(): Promise { - const activeLeaf = this.app.workspace.activeLeaf; - if (activeLeaf?.view.getViewType() === OPENCODE_VIEW_TYPE) { - await this.refreshContext(activeLeaf); - } - } + /** Inject workspace context into the given view's active session */ + private async injectContextForView(view: OpenCodeView): Promise { + if (!this.settings.injectWorkspaceContext) return; + if (this.getServerState() !== "running") return; - async refreshContextForView(view: OpenCodeView): Promise { - if (!this.settings.injectWorkspaceContext) { - return; - } + const sessionId = view.getActiveSessionId(); + if (!sessionId) return; - const leaf = this.getLeafForRefresh(); - if (!leaf) { - return; - } + const { contextText } = this.workspaceContext.gatherContext( + this.settings.maxNotesInContext, + this.settings.maxSelectionLength + ); - await this.refreshContext(leaf); + await view.client.updateContext({ sessionId, contextText }); } - private async refreshContext(leaf: WorkspaceLeaf): Promise { - if (!this.settings.injectWorkspaceContext) { - return; - } + private scheduleRefresh(delayMs: number = 300): void { + const leaf = this.getTargetLeaf(); + if (!leaf) return; + if (!(leaf.view instanceof OpenCodeView)) return; - if (this.getServerState() !== "running") { - return; + if (this.contextRefreshTimer !== null) { + window.clearTimeout(this.contextRefreshTimer); } - const view = leaf.view instanceof OpenCodeView ? leaf.view : null; - const iframeUrl = this.getCachedIframeUrl() ?? view?.getIframeUrl(); - if (!iframeUrl) { - return; - } + this.contextRefreshTimer = window.setTimeout(() => { + this.contextRefreshTimer = null; + void this.injectContextForView(leaf.view as OpenCodeView); + }, delayMs); + } - const sessionId = this.client.resolveSessionId(iframeUrl); - if (!sessionId) { - return; + async handleServerRunning(): Promise { + const activeLeaf = this.app.workspace.activeLeaf; + if (activeLeaf?.view instanceof OpenCodeView) { + await this.injectContextForView(activeLeaf.view); } + } - this.setCachedIframeUrl(iframeUrl); - - const { contextText } = this.workspaceContext.gatherContext( - this.settings.maxNotesInContext, - this.settings.maxSelectionLength - ); - - await this.client.updateContext({ - sessionId, - contextText, - }); + async refreshContextForView(view: OpenCodeView): Promise { + if (!this.settings.injectWorkspaceContext) return; + await this.injectContextForView(view); } destroy(): void { diff --git a/src/main.ts b/src/main.ts index c925699..feec9e6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,6 @@ import { ViewManager } from "./ui/ViewManager"; import { OpenCodeSettingTab } from "./settings/SettingsTab"; import { ServerManager, ServerState } from "./server/ServerManager"; import { registerOpenCodeIcons, OPENCODE_ICON_NAME } from "./icons"; -import { OpenCodeClient } from "./client/OpenCodeClient"; import { ContextManager } from "./context/ContextManager"; import { ExecutableResolver } from "./server/ExecutableResolver"; @@ -13,11 +12,10 @@ export default class OpenCodePlugin extends Plugin { settings: OpenCodeSettings = DEFAULT_SETTINGS; private processManager: ServerManager; private stateChangeCallbacks: Array<(state: ServerState) => void> = []; - private openCodeClient: OpenCodeClient; private contextManager: ContextManager; private viewManager: ViewManager; - private cachedIframeUrl: string | null = null; - private lastBaseUrl: string | null = null; + private ribbonIconEl: HTMLElement | null = null; + private statusPollTimer: ReturnType | null = null; async onload(): Promise { console.log("Loading OpenCode plugin"); @@ -25,8 +23,6 @@ export default class OpenCodePlugin extends Plugin { registerOpenCodeIcons(); await this.loadSettings(); - - // Attempt autodetect if opencodePath is empty and not using custom command await this.attemptAutodetect(); const projectDirectory = this.getProjectDirectory(); @@ -36,57 +32,44 @@ export default class OpenCodePlugin extends Plugin { this.notifyStateChange(state); }); - // Listen for project directory changes and coordinate response + this.processManager.on("portChanged", async (newPort: number) => { + console.log("[OpenCode] Port auto-changed to", newPort, ", saving settings"); + await this.saveSettings(); + this.refreshViewUrls(); + new Notice(`OpenCode: 端口自动切换到 ${newPort}`); + }); + this.processManager.on("projectDirectoryChanged", async (newDirectory: string) => { this.settings.projectDirectory = newDirectory; await this.saveData(this.settings); - this.refreshClientState(); + this.refreshViewUrls(); if (this.getServerState() === "running") { await this.stopServer(); await this.startServer(); } }); - this.openCodeClient = new OpenCodeClient( - this.getApiBaseUrl(), - this.getServerUrl(), - projectDirectory - ); - this.lastBaseUrl = this.getServerUrl(); - 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.viewManager = new ViewManager({ app: this.app, settings: this.settings, - client: this.openCodeClient, contextManager: this.contextManager, - getCachedIframeUrl: () => this.cachedIframeUrl, - setCachedIframeUrl: (url) => { - this.cachedIframeUrl = url; - }, getServerState: () => this.getServerState(), }); - console.log( - "[OpenCode] Configured with project directory:", - projectDirectory - ); + 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, @@ -95,38 +78,33 @@ export default class OpenCodePlugin extends Plugin { () => this.saveSettings() )); - this.addRibbonIcon(OPENCODE_ICON_NAME, "OpenCode", () => { + this.ribbonIconEl = this.addRibbonIcon(OPENCODE_ICON_NAME, "OpenCode", () => { void this.viewManager.activateView(); }); this.addCommand({ id: "toggle-opencode-view", name: "Toggle OpenCode panel", - callback: () => { - void this.viewManager.toggleView(); - }, - hotkeys: [ - { - modifiers: ["Mod", "Shift"], - key: "o", - }, - ], + callback: () => void this.viewManager.toggleView(), + hotkeys: [{ modifiers: ["Mod", "Shift"], key: "o" }], }); this.addCommand({ id: "start-opencode-server", name: "Start OpenCode server", - callback: () => { - this.startServer(); - }, + callback: () => this.startServer(), }); this.addCommand({ id: "stop-opencode-server", name: "Stop OpenCode server", - callback: () => { - this.stopServer(); - }, + callback: () => this.stopServer(), + }); + + this.addCommand({ + id: "new-opencode-session", + name: "New OpenCode session", + callback: () => void this.openNewSession(), }); if (this.settings.autoStart) { @@ -139,15 +117,19 @@ export default class OpenCodePlugin extends Plugin { this.processManager.on("stateChange", (state: ServerState) => { if (state === "running") { void this.contextManager.handleServerRunning(); + this.startStatusPolling(); + } else if (state === "stopped" || state === "error") { + this.stopStatusPolling(); + this.updateRibbonBusyState(false); } }); this.registerCleanupHandlers(); - console.log("OpenCode plugin loaded"); } async onunload(): Promise { + this.stopStatusPolling(); this.contextManager.destroy(); await this.stopServer(); this.app.workspace.detachLeavesOfType(OPENCODE_VIEW_TYPE); @@ -157,21 +139,10 @@ export default class OpenCodePlugin extends Plugin { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } - /** - * Attempt to autodetect opencode executable on startup - * Triggers when opencodePath is empty and useCustomCommand is false - */ private async attemptAutodetect(): Promise { - // Only autodetect if path is empty and not using custom command mode - if (this.settings.opencodePath || this.settings.useCustomCommand) { - return; - } - + if (this.settings.opencodePath || this.settings.useCustomCommand) return; console.log("[OpenCode] Attempting to autodetect opencode executable..."); - const detectedPath = ExecutableResolver.resolve("opencode"); - - // Check if a different path was found (not the fallback) if (detectedPath && detectedPath !== "opencode") { console.log("[OpenCode] Autodetected opencode at:", detectedPath); this.settings.opencodePath = detectedPath; @@ -184,9 +155,10 @@ export default class OpenCodePlugin extends Plugin { } async saveSettings(): Promise { + const oldSettings = { ...this.settings }; await this.saveData(this.settings); this.processManager.updateSettings(this.settings); - this.refreshClientState(); + this.refreshViewUrls(); this.contextManager.updateSettings(this.settings); this.viewManager.updateSettings(this.settings); } @@ -195,14 +167,11 @@ export default class OpenCodePlugin extends Plugin { const success = await this.processManager.start(); if (success) { new Notice("OpenCode server started"); - const initialized = await this.openCodeClient.initializeProject(); - if (!initialized) { - console.warn("[OpenCode] Failed to initialize project on server"); - } + await this.initializeProjectOnServer(); } else { const error = this.processManager.getLastError(); if (error) { - new Notice(`OpenCode failed to start: ${error}`, 10000); // Show for 10 seconds + new Notice(`OpenCode failed to start: ${error}`, 10000); } else { new Notice("OpenCode failed to start. Check Settings for details.", 5000); } @@ -231,63 +200,126 @@ export default class OpenCodePlugin extends Plugin { return `http://${this.settings.hostname}:${this.settings.port}`; } - getStoredIframeUrl(): string | null { - return this.cachedIframeUrl; + /** Persist current session tabs so they survive Obsidian restarts */ + async saveSessionTabs(tabs: { sessionId: string | null; iframeUrl: string | null }[], activeIndex: number): Promise { + const data: Record = (await this.loadData()) ?? {}; + data._tabs = tabs.map(t => ({ sessionId: t.sessionId, iframeUrl: t.iframeUrl })); + data._activeTabIndex = activeIndex; + await this.saveData(data); } - setCachedIframeUrl(url: string | null): void { - this.cachedIframeUrl = url; + /** Load previously saved session tabs */ + async loadSessionTabs(): Promise<{ tabs: { sessionId: string; iframeUrl: string }[]; activeIndex: number } | null> { + const data = await this.loadData() as Record | null; + if (!data?._tabs || !Array.isArray(data._tabs)) return null; + const tabs = (data._tabs as Array).filter( + (t: any): t is { sessionId: string; iframeUrl: string } => + t && typeof t === "object" && typeof t.sessionId === "string" && typeof t.iframeUrl === "string" + ); + if (tabs.length === 0) return null; + const activeIndex = typeof data._activeTabIndex === "number" + ? Math.min(data._activeTabIndex as number, tabs.length - 1) + : 0; + return { tabs, activeIndex }; + } + + async openNewSession(): Promise { + const view = this.viewManager.getView(); + if (view) await view.addSession(); } onServerStateChange(callback: (state: ServerState) => void): () => void { this.stateChangeCallbacks.push(callback); return () => { const index = this.stateChangeCallbacks.indexOf(callback); - if (index > -1) { - this.stateChangeCallbacks.splice(index, 1); - } + if (index > -1) this.stateChangeCallbacks.splice(index, 1); }; } private notifyStateChange(state: ServerState): void { - for (const callback of this.stateChangeCallbacks) { - callback(state); - } + for (const cb of this.stateChangeCallbacks) cb(state); } - private refreshClientState(): void { - const nextUiBaseUrl = this.getServerUrl(); - const nextApiBaseUrl = this.getApiBaseUrl(); - const projectDirectory = this.getProjectDirectory(); - this.openCodeClient.updateBaseUrl(nextApiBaseUrl, nextUiBaseUrl, projectDirectory); + /** Update the single view's client URLs when server config changes */ + private refreshViewUrls(): void { + const view = this.viewManager.getView(); + if (!view) return; + view.client.updateBaseUrl( + this.getApiBaseUrl(), + this.getServerUrl(), + this.getProjectDirectory() + ); + } - if (this.lastBaseUrl && this.lastBaseUrl !== nextUiBaseUrl) { - this.cachedIframeUrl = null; + private async initializeProjectOnServer(): Promise { + try { + const url = `${this.getApiBaseUrl()}/session?directory=${encodeURIComponent(this.getProjectDirectory())}`; + const response = await fetch(url, { + method: "GET", + headers: { "x-opencode-directory": this.getProjectDirectory() }, + }); + if (!response.ok) { + console.warn("[OpenCode] Project initialization failed:", response.status); + } + } catch (error) { + console.error("[OpenCode] Project initialization error:", error); } - - this.lastBaseUrl = nextUiBaseUrl; } refreshContextForView(view: OpenCodeView): void { void this.contextManager.refreshContextForView(view); } - async ensureSessionUrl(view: OpenCodeView): Promise { - await this.viewManager.ensureSessionUrl(view); - } - 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"); + return adapter.basePath || ""; + } + + // ── Session status polling ── + + private startStatusPolling(): void { + this.stopStatusPolling(); + this.statusPollTimer = setInterval(() => void this.pollSessionStatus(), 5000); + void this.pollSessionStatus(); + } + + private stopStatusPolling(): void { + if (this.statusPollTimer) { + clearInterval(this.statusPollTimer); + this.statusPollTimer = null; + } + } + + private async pollSessionStatus(): Promise { + if (this.getServerState() !== "running") return; + try { + const view = this.viewManager.getView(); + if (!view) return; + const statuses = await view.client.getSessionStatus(); + if (!statuses) return; + + const isBusy = Object.values(statuses).some( + (s) => s.type === "busy" || s.type === "retry" + ); + this.updateRibbonBusyState(isBusy); + + // Push status to view for tab bar updates + view.updateSessionStatuses(statuses); + } catch { + // Silently ignore polling errors + } + } + + private updateRibbonBusyState(isBusy: boolean): void { + if (!this.ribbonIconEl) return; + if (isBusy) { + this.ribbonIconEl.classList.add("opencode-ribbon-busy"); + } else { + this.ribbonIconEl.classList.remove("opencode-ribbon-busy"); } - console.log("[OpenCode] Using vault path as project directory:", vaultPath); - return vaultPath; } private registerCleanupHandlers(): void { diff --git a/src/server/ServerManager.ts b/src/server/ServerManager.ts index aac8c92..b6a5195 100644 --- a/src/server/ServerManager.ts +++ b/src/server/ServerManager.ts @@ -100,6 +100,10 @@ export class ServerManager extends EventEmitter { return true; } + // Port is occupied but server is unresponsive → zombie process + // Attempt to kill it before spawning a new one + await this.killZombieOnPort(); + console.log("[OpenCode] Starting server:", { mode: this.settings.useCustomCommand ? "custom" : "path", command: executablePath, @@ -256,4 +260,81 @@ export class ServerManager extends EventEmitter { private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + + /** + * Detect and kill a zombie process occupying the configured port. + * Handles two scenarios: + * 1. Zombie process: port LISTENING, process alive but server unresponsive → kill it + * 2. Orphan socket: port LISTENING, process already dead → wait for OS to release + * If the port cannot be freed, falls back to finding an available port. + */ + private async killZombieOnPort(): Promise { + const isWin32 = process.platform === "win32"; + const mod = isWin32 + ? (await import("./process/WindowsProcess")).WindowsProcess + : (await import("./process/PosixProcess")).PosixProcess; + + const findPid = mod.findPidOnPort; + const killPid = mod.killPid; + const processExists = mod.processExists; + const findAvailablePort = mod.findAvailablePort; + + const pid = await findPid(this.settings.port); + if (!pid) { + console.log("[OpenCode] No process found on port", this.settings.port); + return; + } + + // Don't kill ourselves + if (pid === process.pid) { + console.warn("[OpenCode] Port occupied by current process (self), skipping kill"); + return; + } + + const exists = await processExists(pid); + + if (exists) { + // Scenario 1: Zombie process still alive → kill it + console.warn(`[OpenCode] Zombie process detected on port ${this.settings.port} (PID ${pid}), killing...`); + const killed = await killPid(pid); + + if (killed) { + // Wait for the port to be released (up to 5 seconds) + for (let i = 0; i < 10; i++) { + await this.sleep(500); + const checkPid = await findPid(this.settings.port); + if (!checkPid) { + console.log("[OpenCode] Port", this.settings.port, "released after zombie cleanup"); + return; + } + } + } + } else { + // Scenario 2: Orphan socket — process dead but kernel still holds the port + console.warn(`[OpenCode] Orphan socket detected on port ${this.settings.port} (PID ${pid} is dead), waiting for OS to release...`); + } + + // If we get here, port is still occupied (either kill failed or orphan socket) + // Wait up to 15 seconds for the OS to release the orphan socket + console.log("[OpenCode] Waiting for port to be released (up to 15s)..."); + for (let i = 0; i < 30; i++) { + await this.sleep(500); + const checkPid = await findPid(this.settings.port); + if (!checkPid) { + console.log("[OpenCode] Port", this.settings.port, "released after waiting"); + return; + } + } + + // Port still stuck — find a new available port + console.warn("[OpenCode] Port", this.settings.port, "still occupied, finding available port..."); + const newPort = await findAvailablePort(this.settings.port + 1); + if (newPort !== this.settings.port) { + console.log("[OpenCode] Using fallback port:", newPort); + this.settings.port = newPort; + this.emit("portChanged", newPort); + } else { + console.warn("[OpenCode] No alternative port found, proceeding with original port anyway"); + } + } } diff --git a/src/server/process/PosixProcess.ts b/src/server/process/PosixProcess.ts index 56c308f..5985441 100644 --- a/src/server/process/PosixProcess.ts +++ b/src/server/process/PosixProcess.ts @@ -44,6 +44,64 @@ export class PosixProcess implements OpenCodeProcess { } } + /** Find the PID of the process listening on a given port. Returns null if not found. */ + static async findPidOnPort(port: number): Promise { + try { + const { execSync } = require("child_process"); + const output = execSync(`lsof -iTCP:${port} -sTCP:LISTEN -t`, { + encoding: "utf8", + stdio: ["pipe", "pipe", "ignore"], + }); + const pid = parseInt(output.trim().split("\n")[0], 10); + return isNaN(pid) ? null : pid; + } catch { + return null; + } + } + + /** Kill a process by PID (entire process group). Returns true if killed. */ + static async killPid(pid: number): Promise { + try { + process.kill(-pid, "SIGKILL"); + return true; + } catch { + try { + process.kill(pid, "SIGKILL"); + return true; + } catch { + return false; + } + } + } + + /** Check if a process with the given PID actually exists. */ + static async processExists(pid: number): Promise { + try { + process.kill(pid, 0); // Signal 0 = existence check + return true; + } catch { + return false; + } + } + + /** Find an available TCP port starting from `startPort`. */ + static async findAvailablePort(startPort: number): Promise { + const { execSync } = require("child_process"); + for (let port = startPort; port < startPort + 100; port++) { + try { + const output = execSync(`lsof -iTCP:${port} -sTCP:LISTEN -t`, { + encoding: "utf8", + stdio: ["pipe", "pipe", "ignore"], + }); + // If we get here, something is listening on this port + continue; + } catch { + return port; // lsof throws when no listeners found + } + } + return startPort; // Fallback + } + async verifyCommand(command: string): Promise { // Check if command is absolute path - verify it exists and is executable if (command.startsWith('/') || command.startsWith('./')) { diff --git a/src/server/process/WindowsProcess.ts b/src/server/process/WindowsProcess.ts index 02563d7..e1a73f6 100644 --- a/src/server/process/WindowsProcess.ts +++ b/src/server/process/WindowsProcess.ts @@ -33,36 +33,17 @@ export class WindowsProcess implements OpenCodeProcess { console.log("[OpenCode] Stopping server process tree, PID:", pid); - // Method 1: Find and kill child processes (actual node.exe) using PowerShell - // This is necessary because shell: true spawns cmd.exe -> node.exe, and - // killing cmd.exe leaves node.exe orphaned + // Use /T flag to kill the entire process tree (cmd.exe -> node.exe -> opencode.exe) + // This is more reliable than manually finding child processes try { - const { execSync } = require("child_process"); - const output = execSync( - `powershell -Command "Get-CimInstance Win32_Process -Filter \\"ParentProcessId=${pid}\\" | Select-Object ProcessId"`, - { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] } - ); - - const lines = output.split("\n").slice(3); // Skip headers - for (const line of lines) { - const childPid = line.trim(); - if (childPid && !isNaN(parseInt(childPid))) { - try { - execSync(`taskkill /F /PID ${childPid}`, { stdio: "ignore" }); - } catch { - // Child may already be gone - } - } - } + await this.execAsync(`taskkill /F /T /PID ${pid}`); } catch { - // PowerShell lookup failed, continue to other methods - } - - // Method 2: Kill the parent process (cmd.exe) - try { - await this.execAsync(`taskkill /F /PID ${pid}`); - } catch { - // Parent may already be gone + // Process tree may already be gone; try individual kill as fallback + try { + await this.execAsync(`taskkill /F /PID ${pid}`); + } catch { + // Parent may already be gone + } } // Clear stored process @@ -93,37 +74,83 @@ export class WindowsProcess implements OpenCodeProcess { try { const { execSync } = require("child_process"); - // Method 1: Kill child processes using PowerShell + // Kill entire process tree synchronously (for beforeunload handler) try { - const output = execSync( - `powershell -Command "Get-CimInstance Win32_Process -Filter \\"ParentProcessId=${pid}\\" | Select-Object ProcessId"`, - { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] } - ); - - const lines = output.split("\n").slice(3); - for (const line of lines) { - const childPid = line.trim(); - if (childPid && !isNaN(parseInt(childPid))) { - try { - execSync(`taskkill /F /PID ${childPid}`, { stdio: "ignore" }); - } catch { - // Child may already be gone - } - } - } + execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore" }); } catch { - // PowerShell lookup failed + // Fallback: kill just the parent + try { + execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore" }); + } catch { + // Process may already be gone + } } + } catch { + // Process may already be gone + } + } - // Method 2: Kill parent process + /** Find the PID of the process listening on a given port. Returns null if not found. */ + static async findPidOnPort(port: number): Promise { + try { + const { execSync } = require("child_process"); + const output = execSync( + `powershell -Command "(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue).OwningProcess"`, + { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] } + ); + const pid = parseInt(output.trim(), 10); + return isNaN(pid) ? null : pid; + } catch { + return null; + } + } + + /** Kill a process by PID (entire process tree). Returns true if killed. */ + static async killPid(pid: number): Promise { + try { + const { execSync } = require("child_process"); + execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore" }); + return true; + } catch { + return false; + } + } + + /** Check if a process with the given PID actually exists. */ + static async processExists(pid: number): Promise { + try { + const { execSync } = require("child_process"); + execSync(`tasklist /FI "PID eq ${pid}" /NH`, { + encoding: "utf8", + stdio: ["pipe", "pipe", "ignore"], + }); + // tasklist always exits 0; check if the PID appears in output + const output = execSync(`tasklist /FI "PID eq ${pid}" /NH`, { + encoding: "utf8", + stdio: ["pipe", "pipe", "ignore"], + }); + return output.includes(pid.toString()); + } catch { + return false; + } + } + + /** Find an available TCP port starting from `startPort`. */ + static async findAvailablePort(startPort: number): Promise { + const { execSync } = require("child_process"); + for (let port = startPort; port < startPort + 100; port++) { try { - execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore" }); + const output = execSync( + `powershell -Command "(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue).OwningProcess"`, + { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] } + ); + const pid = parseInt(output.trim(), 10); + if (isNaN(pid)) return port; // No listener on this port } catch { - // Parent may already be gone + return port; // Get-NetTCPConnection throws when no connections found } - } catch { - // Process may already be gone } + return startPort; // Fallback } async verifyCommand(command: string): Promise { diff --git a/src/settings/SettingsTab.ts b/src/settings/SettingsTab.ts index a50ed5c..c93e9a3 100644 --- a/src/settings/SettingsTab.ts +++ b/src/settings/SettingsTab.ts @@ -4,6 +4,7 @@ import { homedir } from "os"; import { OpenCodeSettings, ViewLocation } from "../types"; import { ServerManager } from "../server/ServerManager"; import { ExecutableResolver } from "../server/ExecutableResolver"; +import { t, Language } from "./i18n"; function expandTilde(path: string): string { if (path === "~") { @@ -28,15 +29,37 @@ export class OpenCodeSettingTab extends PluginSettingTab { super(app, plugin); } + private lang(): Language { + return this.settings.language ?? "en"; + } + display(): void { const { containerEl } = this; + const lg = this.lang(); containerEl.empty(); - containerEl.createEl("h2", { text: "OpenCode Settings" }); - containerEl.createEl("h3", { text: "Server Configuration" }); + containerEl.createEl("h2", { text: t("settings.title", lg) }); + containerEl.createEl("h3", { text: t("section.language", lg) }); + + new Setting(containerEl) + .setName(t("language.name", lg)) + .setDesc(t("language.desc", lg)) + .addDropdown((dropdown) => + dropdown + .addOption("en", "English") + .addOption("zh", "中文") + .setValue(this.settings.language) + .onChange(async (value) => { + this.settings.language = value as Language; + await this.onSettingsChange(); + this.display(); + }) + ); + + containerEl.createEl("h3", { text: t("section.server", lg) }); new Setting(containerEl) - .setName("Port") - .setDesc("Port number for the OpenCode web server") + .setName(t("port.name", lg)) + .setDesc(t("port.desc", lg)) .addText((text) => text .setPlaceholder("14096") @@ -51,8 +74,8 @@ export class OpenCodeSettingTab extends PluginSettingTab { ); new Setting(containerEl) - .setName("Hostname") - .setDesc("Hostname to bind the server to (usually 127.0.0.1)") + .setName(t("hostname.name", lg)) + .setDesc(t("hostname.desc", lg)) .addText((text) => text .setPlaceholder("127.0.0.1") @@ -64,23 +87,22 @@ export class OpenCodeSettingTab extends PluginSettingTab { ); const customCmdSetting = new Setting(containerEl) - .setName("Use custom command") - .setDesc("Enable to use a custom shell command instead of the executable path") + .setName(t("useCustomCommand.name", lg)) + .setDesc(t("useCustomCommand.desc", lg)) .addToggle((toggle) => toggle .setValue(this.settings.useCustomCommand) .onChange(async (value) => { this.settings.useCustomCommand = value; await this.onSettingsChange(); - // Re-render to show/hide appropriate fields this.display(); }) ); - + const descEl = customCmdSetting.descEl; descEl.createEl("br"); const linkEl = descEl.createEl("a", { - text: "Learn more", + text: t("learnMore", lg), href: "https://github.com/mtymek/opencode-obsidian#custom-command-mode" }); linkEl.addEventListener("click", (e) => { @@ -90,8 +112,8 @@ export class OpenCodeSettingTab extends PluginSettingTab { if (this.settings.useCustomCommand) { new Setting(containerEl) - .setName("Custom command") - .setDesc("Custom shell command to start OpenCode.") + .setName(t("customCommand.name", lg)) + .setDesc(t("customCommand.desc", lg)) .addTextArea((text) => { text .setPlaceholder("opencode serve --port 14096 --hostname 127.0.0.1 --cors app://obsidian.md") @@ -106,7 +128,7 @@ export class OpenCodeSettingTab extends PluginSettingTab { }); } else { const pathSetting = new Setting(containerEl) - .setName("OpenCode executable path") + .setName(t("execPath.name", lg)) .addText((text) => text .setPlaceholder("opencode") @@ -116,16 +138,15 @@ export class OpenCodeSettingTab extends PluginSettingTab { await this.onSettingsChange(); }) ); - + pathSetting.addButton((button) => { button - .setButtonText("Autodetect") + .setButtonText(t("autodetect", lg)) .onClick(async () => { const detectedPath = ExecutableResolver.resolve("opencode"); if (detectedPath && detectedPath !== "opencode") { this.settings.opencodePath = detectedPath; await this.onSettingsChange(); - // Refresh the text input this.display(); new Notice(`OpenCode executable found at ${detectedPath}`); } else { @@ -136,16 +157,13 @@ export class OpenCodeSettingTab extends PluginSettingTab { } new Setting(containerEl) - .setName("Project directory") - .setDesc( - "Override the starting directory for OpenCode. Leave empty to use the vault root." - ) + .setName(t("projectDir.name", lg)) + .setDesc(t("projectDir.desc", lg)) .addText((text) => text .setPlaceholder("/path/to/project or ~/project") .setValue(this.settings.projectDirectory) .onChange((value) => { - // Debounce validation to avoid spamming notices on every keypress if (this.validateTimeout) { clearTimeout(this.validateTimeout); } @@ -155,13 +173,11 @@ export class OpenCodeSettingTab extends PluginSettingTab { }) ); - containerEl.createEl("h3", { text: "Behavior" }); + containerEl.createEl("h3", { text: t("section.behavior", lg) }); new Setting(containerEl) - .setName("Auto-start server") - .setDesc( - "Automatically start the OpenCode server when Obsidian opens (not recommended for faster startup)" - ) + .setName(t("autoStart.name", lg)) + .setDesc(t("autoStart.desc", lg)) .addToggle((toggle) => toggle .setValue(this.settings.autoStart) @@ -172,14 +188,12 @@ export class OpenCodeSettingTab extends PluginSettingTab { ); 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" - ) + .setName(t("viewLocation.name", lg)) + .setDesc(t("viewLocation.desc", lg)) .addDropdown((dropdown) => dropdown - .addOption("sidebar", "Sidebar") - .addOption("main", "Main window") + .addOption("sidebar", t("viewLocation.sidebar", lg)) + .addOption("main", t("viewLocation.main", lg)) .setValue(this.settings.defaultViewLocation) .onChange(async (value) => { this.settings.defaultViewLocation = value as ViewLocation; @@ -187,13 +201,11 @@ export class OpenCodeSettingTab extends PluginSettingTab { }) ); - containerEl.createEl("h3", { text: "Workspace Context" }); + containerEl.createEl("h3", { text: t("section.context", lg) }); new Setting(containerEl) - .setName("Inject workspace context") - .setDesc( - "Includes open note paths and selected text in OpenCode when the view is focused" - ) + .setName(t("injectContext.name", lg)) + .setDesc(t("injectContext.desc", lg)) .addToggle((toggle) => toggle .setValue(this.settings.injectWorkspaceContext) @@ -204,8 +216,8 @@ export class OpenCodeSettingTab extends PluginSettingTab { ); new Setting(containerEl) - .setName("Max notes in context") - .setDesc("Limit how many open notes are included") + .setName(t("maxNotes.name", lg)) + .setDesc(t("maxNotes.desc", lg)) .addSlider((slider) => slider .setLimits(1, 50, 1) @@ -218,8 +230,8 @@ export class OpenCodeSettingTab extends PluginSettingTab { ); new Setting(containerEl) - .setName("Max selection length") - .setDesc("Truncate selected text to avoid oversized context") + .setName(t("maxSelection.name", lg)) + .setDesc(t("maxSelection.desc", lg)) .addSlider((slider) => slider .setLimits(500, 5000, 100) @@ -231,7 +243,23 @@ export class OpenCodeSettingTab extends PluginSettingTab { }) ); - containerEl.createEl("h3", { text: "Server Status" }); + containerEl.createEl("h3", { text: t("section.sessions", lg) }); + + new Setting(containerEl) + .setName(t("maxSessions.name", lg)) + .setDesc(t("maxSessions.desc", lg)) + .addSlider((slider) => + slider + .setLimits(1, 20, 1) + .setValue(this.settings.maxSessions) + .setDynamicTooltip() + .onChange(async (value) => { + this.settings.maxSessions = value; + await this.onSettingsChange(); + }) + ); + + containerEl.createEl("h3", { text: t("section.status", lg) }); const statusContainer = containerEl.createDiv({ cls: "opencode-settings-status" }); this.renderServerStatus(statusContainer); @@ -240,14 +268,12 @@ export class OpenCodeSettingTab extends PluginSettingTab { private async validateAndSetProjectDirectory(value: string): Promise { const trimmed = value.trim(); - // Empty value is valid - means use vault root if (!trimmed) { this.serverManager.updateProjectDirectory(""); await this.onSettingsChange(); 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; @@ -276,13 +302,14 @@ export class OpenCodeSettingTab extends PluginSettingTab { private renderServerStatus(container: HTMLElement): void { container.empty(); + const lg = this.lang(); const state = this.serverManager.getState(); - const statusText = { - stopped: "Stopped", - starting: "Starting...", - running: "Running", - error: "Error", + const statusText: Record = { + stopped: t("status.stopped", lg), + starting: t("status.starting", lg), + running: t("status.running", lg), + error: t("status.error", lg), }; const statusClass = { @@ -293,9 +320,9 @@ export class OpenCodeSettingTab extends PluginSettingTab { }; const statusEl = container.createDiv({ cls: "opencode-status-line" }); - statusEl.createSpan({ text: "Status: " }); + statusEl.createSpan({ text: t("status.label", lg) }); statusEl.createSpan({ - text: statusText[state], + text: statusText[state] ?? state, cls: `opencode-status-badge ${statusClass[state]}`, }); @@ -312,7 +339,7 @@ export class OpenCodeSettingTab extends PluginSettingTab { if (state === "running") { const urlEl = container.createDiv({ cls: "opencode-status-line" }); - urlEl.createSpan({ text: "URL: " }); + urlEl.createSpan({ text: t("url.label", lg) }); const serverUrl = this.serverManager.getUrl(); const linkEl = urlEl.createEl("a", { text: serverUrl, @@ -328,7 +355,7 @@ export class OpenCodeSettingTab extends PluginSettingTab { if (state === "stopped" || state === "error") { const startButton = buttonContainer.createEl("button", { - text: "Start Server", + text: t("btn.start", lg), cls: "mod-cta", }); startButton.addEventListener("click", async () => { @@ -339,7 +366,7 @@ export class OpenCodeSettingTab extends PluginSettingTab { if (state === "running") { const stopButton = buttonContainer.createEl("button", { - text: "Stop Server", + text: t("btn.stop", lg), }); stopButton.addEventListener("click", () => { this.serverManager.stop(); @@ -347,7 +374,7 @@ export class OpenCodeSettingTab extends PluginSettingTab { }); const restartButton = buttonContainer.createEl("button", { - text: "Restart Server", + text: t("btn.restart", lg), cls: "mod-warning", }); restartButton.addEventListener("click", async () => { @@ -359,7 +386,7 @@ export class OpenCodeSettingTab extends PluginSettingTab { if (state === "starting") { buttonContainer.createSpan({ - text: "Please wait...", + text: t("status.waiting", lg), cls: "opencode-status-waiting", }); } diff --git a/src/settings/i18n.ts b/src/settings/i18n.ts new file mode 100644 index 0000000..455bf90 --- /dev/null +++ b/src/settings/i18n.ts @@ -0,0 +1,130 @@ +export type Language = "en" | "zh"; + +const translations: Record> = { + en: { + // Page titles + "settings.title": "OpenCode Settings", + "section.server": "Server Configuration", + "section.behavior": "Behavior", + "section.context": "Workspace Context", + "section.sessions": "Sessions", + "section.status": "Server Status", + "section.language": "Language", + + // Server config + "port.name": "Port", + "port.desc": "Port number for the OpenCode web server", + "hostname.name": "Hostname", + "hostname.desc": "Hostname to bind the server to (usually 127.0.0.1)", + "useCustomCommand.name": "Use custom command", + "useCustomCommand.desc": "Enable to use a custom shell command instead of the executable path", + "customCommand.name": "Custom command", + "customCommand.desc": "Custom shell command to start OpenCode.", + "execPath.name": "OpenCode executable path", + "projectDir.name": "Project directory", + "projectDir.desc": "Override the starting directory for OpenCode. Leave empty to use the vault root.", + "learnMore": "Learn more", + "autodetect": "Autodetect", + + // Behavior + "autoStart.name": "Auto-start server", + "autoStart.desc": "Automatically start the OpenCode server when Obsidian opens (not recommended for faster startup)", + "viewLocation.name": "Default view location", + "viewLocation.desc": "Where to open the OpenCode panel: sidebar opens in the right panel, main opens as a tab in the editor area", + "viewLocation.sidebar": "Sidebar", + "viewLocation.main": "Main window", + + // Context + "injectContext.name": "Inject workspace context", + "injectContext.desc": "Includes open note paths and selected text in OpenCode when the view is focused", + "maxNotes.name": "Max notes in context", + "maxNotes.desc": "Limit how many open notes are included", + "maxSelection.name": "Max selection length", + "maxSelection.desc": "Truncate selected text to avoid oversized context", + + // Sessions + "maxSessions.name": "Maximum sessions", + "maxSessions.desc": "Maximum number of simultaneous OpenCode sessions you can open", + + // Language + "language.name": "Display language", + "language.desc": "Language for the settings page", + + // Status + "status.label": "Status: ", + "status.stopped": "Stopped", + "status.starting": "Starting...", + "status.running": "Running", + "status.error": "Error", + "url.label": "URL: ", + "btn.start": "Start Server", + "btn.stop": "Stop Server", + "btn.restart": "Restart Server", + "status.waiting": "Please wait...", + }, + zh: { + // Page titles + "settings.title": "OpenCode 设置", + "section.server": "服务配置", + "section.behavior": "行为", + "section.context": "工作区上下文", + "section.sessions": "会话管理", + "section.status": "服务状态", + "section.language": "语言", + + // Server config + "port.name": "端口", + "port.desc": "OpenCode Web 服务的端口号", + "hostname.name": "主机名", + "hostname.desc": "服务绑定的主机名(通常为 127.0.0.1)", + "useCustomCommand.name": "使用自定义命令", + "useCustomCommand.desc": "启用后将使用自定义 shell 命令启动,而非可执行文件路径", + "customCommand.name": "自定义命令", + "customCommand.desc": "用于启动 OpenCode 的自定义 shell 命令。", + "execPath.name": "OpenCode 可执行文件路径", + "projectDir.name": "项目目录", + "projectDir.desc": "覆盖 OpenCode 的启动目录。留空则使用 Vault 根目录。", + "learnMore": "了解更多", + "autodetect": "自动检测", + + // Behavior + "autoStart.name": "自动启动服务", + "autoStart.desc": "Obsidian 启动时自动启动 OpenCode 服务(不推荐,会影响启动速度)", + "viewLocation.name": "默认面板位置", + "viewLocation.desc": "OpenCode 面板的打开位置:侧边栏在右侧面板打开,主窗口作为标签页在编辑区域打开", + "viewLocation.sidebar": "侧边栏", + "viewLocation.main": "主窗口", + + // Context + "injectContext.name": "注入工作区上下文", + "injectContext.desc": "当视图获得焦点时,自动将打开的笔记路径和选中文本注入 OpenCode", + "maxNotes.name": "上下文中的最大笔记数", + "maxNotes.desc": "限制包含的打开笔记数量", + "maxSelection.name": "选中文本最大长度", + "maxSelection.desc": "截断选中文本以避免上下文过大", + + // Sessions + "maxSessions.name": "最大会话数", + "maxSessions.desc": "可以同时打开的 OpenCode 会话数量上限", + + // Language + "language.name": "显示语言", + "language.desc": "设置页面的显示语言", + + // Status + "status.label": "状态:", + "status.stopped": "已停止", + "status.starting": "启动中...", + "status.running": "运行中", + "status.error": "错误", + "url.label": "地址:", + "btn.start": "启动服务", + "btn.stop": "停止服务", + "btn.restart": "重启服务", + "status.waiting": "请稍候...", + }, +}; + +export function t(key: string, lang: Language): string { + return translations[lang]?.[key] ?? translations.en[key] ?? key; +} diff --git a/src/types.ts b/src/types.ts index 601e645..1183514 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,8 @@ export interface OpenCodeSettings { maxSelectionLength: number; customCommand: string; useCustomCommand: boolean; + maxSessions: number; + language: "en" | "zh"; } export const DEFAULT_SETTINGS: OpenCodeSettings = { @@ -28,6 +30,8 @@ export const DEFAULT_SETTINGS: OpenCodeSettings = { maxSelectionLength: 2000, customCommand: "", useCustomCommand: false, + maxSessions: 5, + language: "en", }; -export const OPENCODE_VIEW_TYPE = "opencode-view"; +export const OPENCODE_VIEW_TYPE = "opencodian-view"; diff --git a/src/ui/OpenCodeView.ts b/src/ui/OpenCodeView.ts index db49d3e..997e5a2 100644 --- a/src/ui/OpenCodeView.ts +++ b/src/ui/OpenCodeView.ts @@ -1,253 +1,482 @@ import { ItemView, WorkspaceLeaf, setIcon } from "obsidian"; import { OPENCODE_VIEW_TYPE } from "../types"; import { OPENCODE_ICON_NAME } from "../icons"; +import { OpenCodeClient, SessionInfo } from "../client/OpenCodeClient"; import type OpenCodePlugin from "../main"; import type { ServerState } from "../server/types"; +interface SessionTab { + id: string; + sessionId: string | null; + iframeUrl: string | null; + isBusy: boolean; +} + export class OpenCodeView extends ItemView { plugin: OpenCodePlugin; - private iframeEl: HTMLIFrameElement | null = null; + private iframePool = new Map(); + private iframeContainerEl: HTMLElement | null = null; private currentState: ServerState = "stopped"; private unsubscribeStateChange: (() => void) | null = null; + client: OpenCodeClient; + + private sessions: SessionTab[] = []; + private activeIndex = 0; + private nextId = 1; + private tabBarEl: HTMLElement | null = null; + private historyPanelEl: HTMLElement | null = null; + private sessionStatuses: Record = {}; + constructor(leaf: WorkspaceLeaf, plugin: OpenCodePlugin) { super(leaf); this.plugin = plugin; + this.client = new OpenCodeClient( + plugin.getApiBaseUrl(), + plugin.getServerUrl(), + plugin.getProjectDirectory() + ); } - getViewType(): string { - return OPENCODE_VIEW_TYPE; - } + getViewType(): string { return OPENCODE_VIEW_TYPE; } + getDisplayText(): string { return "OpenCodian"; } + getIcon(): string { return OPENCODE_ICON_NAME; } - getDisplayText(): string { - return "OpenCode"; + getActiveSessionId(): string | null { + return this.sessions[this.activeIndex]?.sessionId ?? null; } + getAllSessionTabs(): SessionTab[] { return this.sessions; } + getActiveSessionIndex(): number { return this.activeIndex; } - getIcon(): string { - return OPENCODE_ICON_NAME; + /** Called by main.ts to push real-time session status updates. */ + updateSessionStatuses(statuses: Record): void { + this.sessionStatuses = statuses; + this.buildTabBar(); } + // ── Lifecycle ── + async onOpen(): Promise { this.contentEl.empty(); this.contentEl.addClass("opencode-container"); - // Subscribe to state changes - this.unsubscribeStateChange = this.plugin.onServerStateChange((state: ServerState) => { - this.currentState = state; + this.unsubscribeStateChange = this.plugin.onServerStateChange((s: ServerState) => { + this.currentState = s; 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(); - } + if (this.currentState === "stopped") this.plugin.startServer(); } async onClose(): Promise { - // 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; - } + this.unsubscribeStateChange?.(); + this.unsubscribeStateChange = null; + this.iframePool.forEach((iframe) => iframe.remove()); + this.iframePool.clear(); + this.iframeContainerEl = null; } + // ── State rendering ── + 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; + case "stopped": this.renderStopped(); break; + case "starting": this.renderStarting(); break; + case "running": this.renderRunning(); break; + case "error": this.renderError(); break; } } - private renderStoppedState(): void { - this.contentEl.empty(); + // ── Tab management ── - const statusContainer = this.contentEl.createDiv({ - cls: "opencode-status-container", - }); + switchToTab(index: number): void { + if (index < 0 || index >= this.sessions.length || index === this.activeIndex) return; + this.activeIndex = index; + this.buildTabBar(); + this.switchIframe(); + this.persistTabs(); + } - const iconEl = statusContainer.createDiv({ cls: "opencode-status-icon" }); - setIcon(iconEl, "power-off"); + async addSession(): Promise { + if (this.sessions.length >= this.plugin.settings.maxSessions) return; + const tab: SessionTab = { id: String(this.nextId++), sessionId: null, iframeUrl: null, isBusy: true }; + this.sessions.push(tab); + this.activeIndex = this.sessions.length - 1; + this.buildTabBar(); + + try { + const sid = await this.client.createSession(); + if (sid) { + tab.sessionId = sid; + tab.iframeUrl = this.client.getSessionUrl(sid); + } + } finally { + tab.isBusy = false; + this.buildTabBar(); + } + if (tab.iframeUrl) { + const iframe = this.createIframe(tab.iframeUrl); + this.iframePool.set(tab.id, iframe); + this.iframeContainerEl?.appendChild(iframe); + this.switchIframe(); + this.persistTabs(); + } + } - statusContainer.createEl("h3", { text: "OpenCode is stopped" }); - statusContainer.createEl("p", { - text: "Click the button below to start the OpenCode server.", - cls: "opencode-status-message", - }); + closeTab(index: number): void { + if (index < 0 || index >= this.sessions.length || this.sessions.length <= 1) return; + if (this.sessions[index].isBusy) return; + const removed = this.sessions[index]; + const iframe = this.iframePool.get(removed.id); + iframe?.remove(); + this.iframePool.delete(removed.id); + this.sessions.splice(index, 1); + if (index <= this.activeIndex) this.activeIndex = Math.max(0, this.activeIndex - 1); + this.buildTabBar(); + this.switchIframe(); + this.persistTabs(); + } - const startButton = statusContainer.createEl("button", { - text: "Start OpenCode", - cls: "mod-cta", - }); - startButton.addEventListener("click", () => { - this.plugin.startServer(); + private switchIframe(): void { + const activeTab = this.sessions[this.activeIndex]; + this.iframePool.forEach((iframe, tabId) => { + iframe.style.display = (activeTab && tabId === activeTab.id) ? "flex" : "none"; }); } - private renderStartingState(): void { - this.contentEl.empty(); + private createIframe(url: string): HTMLIFrameElement { + const iframe = document.createElement("iframe"); + iframe.className = "opencode-iframe"; + iframe.setAttribute("src", url); + iframe.setAttribute("frameborder", "0"); + iframe.setAttribute("allow", "clipboard-read; clipboard-write"); + iframe.addEventListener("error", () => console.error("[OpenCode] iframe error")); + return iframe; + } - const statusContainer = this.contentEl.createDiv({ - cls: "opencode-status-container", - }); + // ── Render helpers ── - const loadingEl = statusContainer.createDiv({ cls: "opencode-loading" }); - loadingEl.createDiv({ cls: "opencode-spinner" }); + private renderStopped(): void { + this.contentEl.empty(); + const c = this.contentEl.createDiv({ cls: "opencode-status-container" }); + setIcon(c.createDiv({ cls: "opencode-status-icon" }), "power-off"); + c.createEl("h3", { text: "OpenCode is stopped" }); + c.createEl("p", { text: "Start the server.", cls: "opencode-status-message" }); + const btn = c.createEl("button", { text: "Start OpenCode", cls: "mod-cta" }); + btn.addEventListener("click", () => this.plugin.startServer()); + } - statusContainer.createEl("h3", { text: "Starting OpenCode..." }); - statusContainer.createEl("p", { - text: "Please wait while the server starts up.", - cls: "opencode-status-message", - }); + private renderStarting(): void { + this.contentEl.empty(); + const c = this.contentEl.createDiv({ cls: "opencode-status-container" }); + const l = c.createDiv({ cls: "opencode-loading" }); + l.createDiv({ cls: "opencode-spinner" }); + c.createEl("h3", { text: "Starting OpenCode..." }); } - private renderRunningState(): void { + private renderRunning(): void { this.contentEl.empty(); + this.iframePool.clear(); + this.iframeContainerEl = null; + this.buildTabBar(); - const headerEl = this.contentEl.createDiv({ cls: "opencode-header" }); + // Create iframe container + this.iframeContainerEl = this.contentEl.createDiv({ cls: "opencode-iframe-container" }); - const titleSection = headerEl.createDiv({ cls: "opencode-header-title" }); - const iconEl = titleSection.createSpan(); - setIcon(iconEl, OPENCODE_ICON_NAME); - titleSection.createSpan({ text: "OpenCode" }); + // Try to restore previously saved tabs, otherwise create a new one + this.restoreTabs(); + } - const actionsEl = headerEl.createDiv({ cls: "opencode-header-actions" }); + private async ensureTab(): Promise { + if (this.sessions.length > 0) return; - const reloadButton = actionsEl.createEl("button", { - attr: { "aria-label": "Reload" }, - }); - setIcon(reloadButton, "refresh-cw"); - reloadButton.addEventListener("click", () => { - this.reloadIframe(); - }); + const tab: SessionTab = { id: String(this.nextId++), sessionId: null, iframeUrl: null, isBusy: true }; + this.sessions.push(tab); + this.activeIndex = 0; + this.buildTabBar(); - const stopButton = actionsEl.createEl("button", { - attr: { "aria-label": "Stop server" }, - }); - setIcon(stopButton, "square"); - stopButton.addEventListener("click", () => { - this.plugin.stopServer(); - }); + try { + const sid = await this.client.createSession(); + if (sid) { + tab.sessionId = sid; + tab.iframeUrl = this.client.getSessionUrl(sid); + } + } finally { + tab.isBusy = false; + this.buildTabBar(); + } + if (tab.iframeUrl) { + const iframe = this.createIframe(tab.iframeUrl); + this.iframePool.set(tab.id, iframe); + this.iframeContainerEl?.appendChild(iframe); + this.switchIframe(); + this.persistTabs(); + } + } - const iframeContainer = this.contentEl.createDiv({ - cls: "opencode-iframe-container", - }); + // ── Persistence ── - const iframeUrl = this.plugin.getStoredIframeUrl() ?? this.plugin.getServerUrl(); - console.log("[OpenCode] Loading iframe with URL:", iframeUrl); + private persistTabs(): void { + void this.plugin.saveSessionTabs(this.sessions, this.activeIndex); + } - this.iframeEl = iframeContainer.createEl("iframe", { - cls: "opencode-iframe", - attr: { - src: iframeUrl, - frameborder: "0", - allow: "clipboard-read; clipboard-write", - }, - }); + private async restoreTabs(): Promise { + const saved = await this.plugin.loadSessionTabs(); + if (!saved || saved.tabs.length === 0) { + this.ensureTab(); + return; + } - this.iframeEl.addEventListener("error", () => { - console.error("Failed to load OpenCode iframe"); - }); + // Restore tabs from saved session IDs + for (const savedTab of saved.tabs) { + const tab: SessionTab = { + id: String(this.nextId++), + sessionId: savedTab.sessionId, + iframeUrl: savedTab.iframeUrl, + isBusy: false, + }; + this.sessions.push(tab); + if (tab.iframeUrl) { + const iframe = this.createIframe(tab.iframeUrl); + this.iframePool.set(tab.id, iframe); + this.iframeContainerEl?.appendChild(iframe); + } + } - this.iframeEl.addEventListener("focus", () => { - this.plugin.refreshContextForView(this); - }); + this.activeIndex = saved.activeIndex; + this.buildTabBar(); + this.switchIframe(); + } - this.iframeEl.addEventListener("pointerdown", () => { - this.plugin.refreshContextForView(this); + private renderError(): void { + this.contentEl.empty(); + const c = this.contentEl.createDiv({ cls: "opencode-status-container opencode-error" }); + setIcon(c.createDiv({ cls: "opencode-status-icon" }), "alert-circle"); + c.createEl("h3", { text: "Failed to start OpenCode" }); + const err = this.plugin.getLastError(); + c.createEl("p", { text: err ?? "Unknown error", cls: "opencode-status-message" }); + const bg = c.createDiv({ cls: "opencode-button-group" }); + const retry = bg.createEl("button", { text: "Retry", cls: "mod-cta" }); + retry.addEventListener("click", () => this.plugin.startServer()); + } + + // ── Tab bar ── + + private buildTabBar(): void { + this.contentEl.querySelector(".opencode-tab-bar")?.remove(); + this.tabBarEl = null; + + const bar = document.createElement("div"); + bar.className = "opencode-tab-bar"; + this.contentEl.prepend(bar); + this.tabBarEl = bar; + + if (this.sessions.length < this.plugin.settings.maxSessions) { + const addBtn = document.createElement("button"); + addBtn.className = "opencode-tab opencode-tab-add"; + addBtn.setAttribute("aria-label", "New session"); + setIcon(addBtn, "plus"); + addBtn.addEventListener("click", () => void this.addSession()); + bar.appendChild(addBtn); + } + + if (this.sessions.length > 1) { + this.sessions.forEach((tab, idx) => { + const el = document.createElement("button"); + el.className = "opencode-tab"; + if (idx === this.activeIndex) el.classList.add("opencode-tab-active"); + if (tab.isBusy) { + el.classList.add("opencode-tab-busy"); + } else if (tab.sessionId && this.sessionStatuses[tab.sessionId]?.type === "busy") { + el.classList.add("opencode-tab-busy"); + } else if (tab.sessionId && this.sessionStatuses[tab.sessionId]?.type === "retry") { + el.classList.add("opencode-tab-attention"); + } + el.setAttribute("aria-label", `Session ${idx + 1}`); + + const label = document.createElement("span"); + label.className = "opencode-tab-label"; + label.textContent = `${idx + 1}`; + el.appendChild(label); + + el.addEventListener("click", () => this.switchToTab(idx)); + el.addEventListener("contextmenu", (e) => { + e.preventDefault(); + if (!tab.isBusy && this.sessions.length > 1) this.closeTab(idx); + }); + bar.appendChild(el); + }); + } + + // History button (always visible when server is running) + const historyBtn = document.createElement("button"); + historyBtn.className = "opencode-history-btn"; + historyBtn.setAttribute("aria-label", "Session history"); + setIcon(historyBtn, "clock"); + historyBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.toggleHistoryPanel(); }); + bar.appendChild(historyBtn); + } - void this.plugin.ensureSessionUrl(this); + // ── History panel ── + + private toggleHistoryPanel(): void { + if (this.historyPanelEl) { + this.hideHistoryPanel(); + } else { + void this.showHistoryPanel(); + } } - getIframeUrl(): string | null { - return this.iframeEl?.src ?? null; + private hideHistoryPanel(): void { + this.historyPanelEl?.remove(); + this.historyPanelEl = null; } - setIframeUrl(url: string): void { - if (this.iframeEl && this.iframeEl.src !== url) { - this.iframeEl.src = url; + private async showHistoryPanel(): Promise { + this.hideHistoryPanel(); + + if (!this.tabBarEl) return; + + // Create panel container + const panel = document.createElement("div"); + panel.className = "opencode-history-panel"; + + // Header + const header = panel.createDiv({ cls: "opencode-history-header" }); + header.createEl("span", { text: "历史会话" }); + const closeBtn = header.createEl("button", { cls: "opencode-history-close" }); + setIcon(closeBtn, "x"); + closeBtn.addEventListener("click", () => this.hideHistoryPanel()); + + // Loading state + const listEl = panel.createDiv({ cls: "opencode-history-list" }); + listEl.createDiv({ cls: "opencode-history-loading", text: "加载中..." }); + + // Footer placeholder + const footer = panel.createDiv({ cls: "opencode-history-footer" }); + + // Append to contentEl (NOT tabBarEl — overflow-x: auto clips absolute children) + // Position relative to the container so the panel sits just below the tab bar + this.contentEl.appendChild(panel); + this.historyPanelEl = panel; + + // Close on outside click + const onOutsideClick = (e: MouseEvent) => { + if (!panel.contains(e.target as Node)) { + this.hideHistoryPanel(); + document.removeEventListener("click", onOutsideClick); + } + }; + setTimeout(() => document.addEventListener("click", onOutsideClick), 0); + + // Fetch sessions + const sessions = await this.client.listSessions(); + if (!this.historyPanelEl) return; // Panel was closed during fetch + + listEl.empty(); + + if (!sessions || sessions.length === 0) { + listEl.createDiv({ cls: "opencode-history-empty", text: "暂无历史会话" }); + footer.createDiv({ cls: "opencode-history-count" }); + return; } - } - private renderErrorState(): void { - this.contentEl.empty(); + // Sort by updated time (most recent first) + sessions.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0)); - const statusContainer = this.contentEl.createDiv({ - cls: "opencode-status-container opencode-error", - }); + // Get currently open session IDs + const openIds = new Set(this.sessions.map((t) => t.sessionId).filter(Boolean)); + + // Render items + for (const session of sessions) { + const item = listEl.createDiv({ cls: "opencode-history-item" }); + if (openIds.has(session.id)) { + item.classList.add("opencode-history-item-active"); + } - const iconEl = statusContainer.createDiv({ cls: "opencode-status-icon" }); - setIcon(iconEl, "alert-circle"); + const title = item.createDiv({ cls: "opencode-history-item-title" }); + title.textContent = session.title || session.slug || "Untitled"; - 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 time = item.createDiv({ cls: "opencode-history-item-time" }); + time.textContent = this.formatRelativeTime(session.time?.updated ?? session.time?.created ?? 0); + + item.addEventListener("click", () => { + this.hideHistoryPanel(); + void this.loadSession(session.id); }); } - const buttonContainer = statusContainer.createDiv({ - cls: "opencode-button-group", - }); + // Footer: count + clean button + const activeCount = sessions.filter((s) => openIds.has(s.id)).length; + footer.createDiv({ cls: "opencode-history-count", text: `${sessions.length} 个会话` }); - const retryButton = buttonContainer.createEl("button", { - text: "Retry", - cls: "mod-cta", - }); - retryButton.addEventListener("click", () => { - this.plugin.startServer(); - }); + const cleanableCount = sessions.filter((s) => !openIds.has(s.id)).length; + if (cleanableCount > 0) { + const cleanBtn = footer.createEl("button", { text: `清理 ${cleanableCount} 个旧会话` }); + cleanBtn.addEventListener("click", (e) => { + e.stopPropagation(); + void this.cleanOldSessions(sessions, openIds); + }); + } + } - 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"); - }); + /** Load an existing session into a new tab. */ + private async loadSession(sessionId: string): Promise { + if (this.sessions.length >= this.plugin.settings.maxSessions) { + // Replace current tab instead + this.closeTab(this.activeIndex); + } + + const iframeUrl = this.client.getSessionUrl(sessionId); + const tab: SessionTab = { + id: String(this.nextId++), + sessionId, + iframeUrl, + isBusy: false, + }; + this.sessions.push(tab); + this.activeIndex = this.sessions.length - 1; + + const iframe = this.createIframe(iframeUrl); + this.iframePool.set(tab.id, iframe); + this.iframeContainerEl?.appendChild(iframe); + + this.buildTabBar(); + this.switchIframe(); + this.persistTabs(); } - 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); + /** Delete old sessions that are not currently open. */ + private async cleanOldSessions(sessions: SessionInfo[], openIds: Set): Promise { + const toDelete = sessions.filter((s) => !openIds.has(s.id)); + let deleted = 0; + for (const session of toDelete) { + const ok = await this.client.deleteSession(session.id); + if (ok) deleted++; } + console.log(`[OpenCode] Cleaned ${deleted}/${toDelete.length} old sessions`); + this.hideHistoryPanel(); + // Re-open to show updated list + void this.showHistoryPanel(); + } + + private formatRelativeTime(ts: number): string { + if (!ts) return ""; + const now = Date.now(); + const diff = now - ts; + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return "刚刚"; + if (minutes < 60) return `${minutes}分钟前`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}小时前`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}天前`; + const date = new Date(ts); + return `${date.getMonth() + 1}/${date.getDate()}`; } } diff --git a/src/ui/ViewManager.ts b/src/ui/ViewManager.ts index 0cd6052..d32d201 100644 --- a/src/ui/ViewManager.ts +++ b/src/ui/ViewManager.ts @@ -1,36 +1,26 @@ 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; } @@ -38,6 +28,14 @@ export class ViewManager { this.settings = settings; } + /** Get the single OpenCode view instance, if it exists */ + getView(): OpenCodeView | null { + const leaves = this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE); + if (leaves.length === 0) return null; + const view = leaves[0].view; + return view instanceof OpenCodeView ? view : null; + } + private getExistingLeaf(): WorkspaceLeaf | null { const leaves = this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE); return leaves.length > 0 ? leaves[0] : null; @@ -51,7 +49,6 @@ export class ViewManager { return; } - // Create new leaf based on defaultViewLocation setting let leaf: WorkspaceLeaf | null = null; if (this.settings.defaultViewLocation === "main") { leaf = this.app.workspace.getLeaf("tab"); @@ -72,11 +69,9 @@ export class ViewManager { 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(); @@ -84,37 +79,10 @@ export class ViewManager { this.app.workspace.revealLeaf(existingLeaf); } } else { - // For main area views, just detach (close the tab) existingLeaf.detach(); } } else { await this.activateView(); } } - - async ensureSessionUrl(view: OpenCodeView): Promise { - if (this.getServerState() !== "running") { - return; - } - - const cachedUrl = this.getCachedIframeUrl(); - const existingUrl = cachedUrl ?? view.getIframeUrl(); - if (existingUrl && this.client.resolveSessionId(existingUrl)) { - this.setCachedIframeUrl(existingUrl); - return; - } - - const sessionId = await this.client.createSession(); - if (!sessionId) { - return; - } - - const sessionUrl = this.client.getSessionUrl(sessionId); - this.setCachedIframeUrl(sessionUrl); - view.setIframeUrl(sessionUrl); - - if (this.app.workspace.activeLeaf === view.leaf) { - await this.contextManager.refreshContextForView(view); - } - } } diff --git a/styles.css b/styles.css index 2bce793..27f65b6 100644 --- a/styles.css +++ b/styles.css @@ -8,6 +8,12 @@ overflow: hidden; } +/* iframe fills remaining space after tab bar */ +.opencode-container > iframe { + flex: 1; + min-height: 0; +} + /* Header */ .opencode-header { display: flex; @@ -55,14 +61,18 @@ height: 16px; } -/* iframe container */ +/* ── Iframe container (holds multiple iframes, shows one at a time) ── */ .opencode-iframe-container { flex: 1; - overflow: hidden; + display: flex; + min-height: 0; position: relative; } +/* ── Iframe ── */ .opencode-iframe { + position: absolute; + inset: 0; width: 100%; height: 100%; border: none; @@ -206,3 +216,282 @@ font-size: 0.9em; line-height: 1.4; } + +/* ── Tab bar sits directly above the iframe (no header) ── */ +.opencode-tab-bar { + display: flex; + align-items: stretch; + gap: 2px; + padding: 4px 12px 0; + background: var(--background-secondary); + border-bottom: 1px solid var(--background-modifier-border); + flex-shrink: 0; + overflow-x: auto; + min-height: 32px; +} + +.opencode-tab { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + font-size: 0.8em; + border: 1px solid transparent; + border-bottom: none; + border-radius: 4px 4px 0 0; + cursor: pointer; + background: transparent; + color: var(--text-muted); + white-space: nowrap; + position: relative; + min-width: 28px; + justify-content: center; + transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease; +} + +.opencode-tab:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +/* ── Active tab ── */ +.opencode-tab-active { + background: var(--background-primary); + border-color: var(--interactive-accent); + color: var(--text-normal); + font-weight: 600; +} + +/* ── Busy/streaming tab ── */ +.opencode-tab-busy { + border-color: var(--text-accent); + border-style: dashed; + animation: opencode-tab-pulse 1.5s ease-in-out infinite; +} + +@keyframes opencode-tab-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* ── Attention (error) tab ── */ +.opencode-tab-attention { + border-color: var(--text-error); + color: var(--text-error); +} + +.opencode-tab-label { + pointer-events: none; + line-height: 1; +} + +/* Close × on tabs — visible on hover */ +/* ── Add tab button ── */ +.opencode-tab-add { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.9em; + padding: 3px 8px; + color: var(--text-muted); + border: 1px dashed var(--background-modifier-border); + min-width: 28px; +} + +.opencode-tab-add:hover { + color: var(--text-accent); + border-color: var(--text-accent); +} + +.opencode-tab-add svg { + width: 16px; + height: 16px; +} + +/* ── Ribbon icon busy state ── */ +.opencode-ribbon-busy svg { + color: var(--text-accent) !important; + animation: opencode-ribbon-pulse 2s ease-in-out infinite; +} + +@keyframes opencode-ribbon-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ── History button in tab bar ── */ +.opencode-tab-bar .opencode-history-btn { + margin-left: auto; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 3px 8px; + font-size: 0.8em; + border: none; + border-radius: 4px; + cursor: pointer; + background: transparent; + color: var(--text-muted); + min-width: 28px; + transition: color 0.15s ease, background 0.15s ease; +} + +.opencode-tab-bar .opencode-history-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.opencode-tab-bar .opencode-history-btn svg { + width: 14px; + height: 14px; +} + +/* ── History panel (overlay) ── */ +.opencode-history-panel { + position: absolute; + top: 36px; /* Below the tab bar (min-height: 32px + padding) */ + right: 12px; + width: 320px; + max-height: 420px; + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); + z-index: 100; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.opencode-history-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + flex-shrink: 0; +} + +.opencode-history-header span { + font-size: 0.85em; + font-weight: 600; + color: var(--text-normal); +} + +.opencode-history-close { + background: transparent; + border: none; + cursor: pointer; + color: var(--text-muted); + padding: 2px 4px; + border-radius: 3px; + display: flex; + align-items: center; +} + +.opencode-history-close:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.opencode-history-close svg { + width: 14px; + height: 14px; +} + +.opencode-history-list { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} + +.opencode-history-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid var(--background-modifier-border); + transition: background 0.1s ease; + gap: 8px; +} + +.opencode-history-item:last-child { + border-bottom: none; +} + +.opencode-history-item:hover { + background: var(--background-modifier-hover); +} + +.opencode-history-item-title { + font-size: 0.85em; + color: var(--text-normal); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.opencode-history-item-time { + font-size: 0.75em; + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; +} + +.opencode-history-item-active { + background: var(--background-modifier-hover); +} + +.opencode-history-item-active .opencode-history-item-title::after { + content: " ●"; + color: var(--text-accent); + font-size: 0.7em; +} + +.opencode-history-empty { + padding: 24px; + text-align: center; + color: var(--text-muted); + font-size: 0.85em; +} + +.opencode-history-footer { + padding: 8px 12px; + border-top: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + flex-shrink: 0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.opencode-history-count { + font-size: 0.75em; + color: var(--text-muted); +} + +.opencode-history-footer button { + font-size: 0.8em; + padding: 4px 10px; + border-radius: 4px; + border: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + color: var(--text-muted); + cursor: pointer; +} + +.opencode-history-footer button:hover { + background: var(--background-modifier-hover); + color: var(--text-error); + border-color: var(--text-error); +} + +.opencode-history-loading { + padding: 24px; + text-align: center; + color: var(--text-muted); + font-size: 0.85em; +}