From 9ee3b2de1ee7014f870d19126ef35cf84ef105fa Mon Sep 17 00:00:00 2001 From: Piotr Plewa Date: Sun, 31 May 2026 13:56:47 +1000 Subject: [PATCH 1/4] allow for dropping notes from the sidebar --- src/ui/OpenCodeView.ts | 96 ++++++++----- src/ui/noteDrop/NoteDropBridge.ts | 225 ++++++++++++++++++++++++++++++ src/ui/noteDrop/pathUtils.ts | 25 ++++ styles.css | 49 ++++++- tests/noteDrop/pathUtils.test.ts | 30 ++++ 5 files changed, 387 insertions(+), 38 deletions(-) create mode 100644 src/ui/noteDrop/NoteDropBridge.ts create mode 100644 src/ui/noteDrop/pathUtils.ts create mode 100644 tests/noteDrop/pathUtils.test.ts diff --git a/src/ui/OpenCodeView.ts b/src/ui/OpenCodeView.ts index db49d3e..95850e8 100644 --- a/src/ui/OpenCodeView.ts +++ b/src/ui/OpenCodeView.ts @@ -3,10 +3,12 @@ import { OPENCODE_VIEW_TYPE } from "../types"; import { OPENCODE_ICON_NAME } from "../icons"; import type OpenCodePlugin from "../main"; import type { ServerState } from "../server/types"; +import { NoteDropBridge, type OpenCodeWebview } from "./noteDrop/NoteDropBridge"; export class OpenCodeView extends ItemView { plugin: OpenCodePlugin; - private iframeEl: HTMLIFrameElement | null = null; + private embedEl: OpenCodeWebview | null = null; + private noteDropBridge: NoteDropBridge | null = null; private currentState: ServerState = "stopped"; private unsubscribeStateChange: (() => void) | null = null; @@ -54,14 +56,16 @@ export class OpenCodeView extends ItemView { this.unsubscribeStateChange = null; } - // Clean up iframe - if (this.iframeEl) { - const iframeUrl = this.iframeEl.src; - if (iframeUrl.includes("/session/")) { - this.plugin.setCachedIframeUrl(iframeUrl); + this.noteDropBridge?.detach(); + this.noteDropBridge = null; + + if (this.embedEl) { + const embedUrl = this.embedEl.getAttribute("src") ?? ""; + if (embedUrl.includes("/session/")) { + this.plugin.setCachedIframeUrl(embedUrl); } - this.iframeEl.src = "about:blank"; - this.iframeEl = null; + this.embedEl.setAttribute("src", "about:blank"); + this.embedEl = null; } } @@ -152,31 +156,36 @@ export class OpenCodeView extends ItemView { this.plugin.stopServer(); }); - const iframeContainer = this.contentEl.createDiv({ - cls: "opencode-iframe-container", + const embedContainer = this.contentEl.createDiv({ + cls: "opencode-embed-container", }); - const iframeUrl = this.plugin.getStoredIframeUrl() ?? this.plugin.getServerUrl(); - console.log("[OpenCode] Loading iframe with URL:", iframeUrl); - - this.iframeEl = iframeContainer.createEl("iframe", { - cls: "opencode-iframe", - attr: { - src: iframeUrl, - frameborder: "0", - allow: "clipboard-read; clipboard-write", - }, + const embedUrl = this.plugin.getStoredIframeUrl() ?? this.plugin.getServerUrl(); + console.log("[OpenCode] Loading OpenCode UI with URL:", embedUrl); + + this.embedEl = document.createElement("webview") as OpenCodeWebview; + this.embedEl.addClass("opencode-webview"); + this.embedEl.setAttribute("src", embedUrl); + this.embedEl.setAttribute("allowpopups", "true"); + embedContainer.appendChild(this.embedEl); + + this.noteDropBridge = new NoteDropBridge( + this.app, + () => this.plugin.getProjectDirectory(), + () => this.embedEl + ); + this.noteDropBridge.attach(embedContainer); + this.noteDropBridge.bindWebview(this.embedEl); + + this.embedEl.addEventListener("did-fail-load", () => { + console.error("Failed to load OpenCode webview"); }); - this.iframeEl.addEventListener("error", () => { - console.error("Failed to load OpenCode iframe"); - }); - - this.iframeEl.addEventListener("focus", () => { + this.embedEl.addEventListener("focus", () => { this.plugin.refreshContextForView(this); }); - this.iframeEl.addEventListener("pointerdown", () => { + this.embedEl.addEventListener("pointerdown", () => { this.plugin.refreshContextForView(this); }); @@ -184,12 +193,18 @@ export class OpenCodeView extends ItemView { } getIframeUrl(): string | null { - return this.iframeEl?.src ?? null; + return this.embedEl?.getAttribute("src") ?? null; } setIframeUrl(url: string): void { - if (this.iframeEl && this.iframeEl.src !== url) { - this.iframeEl.src = url; + if (!this.embedEl) { + return; + } + + const current = this.embedEl.getAttribute("src"); + if (current !== url) { + this.noteDropBridge?.onWebviewNavigate(); + this.embedEl.setAttribute("src", url); } } @@ -240,14 +255,21 @@ export class OpenCodeView extends ItemView { } 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); + if (!this.embedEl) { + return; + } + + const src = this.embedEl.getAttribute("src"); + if (!src) { + return; } + + this.noteDropBridge?.onWebviewNavigate(); + this.embedEl.setAttribute("src", "about:blank"); + setTimeout(() => { + if (this.embedEl) { + this.embedEl.setAttribute("src", src); + } + }, 100); } } diff --git a/src/ui/noteDrop/NoteDropBridge.ts b/src/ui/noteDrop/NoteDropBridge.ts new file mode 100644 index 0000000..33341d8 --- /dev/null +++ b/src/ui/noteDrop/NoteDropBridge.ts @@ -0,0 +1,225 @@ +import { App, Notice, TFile } from "obsidian"; +import { vaultFileToProjectPath } from "./pathUtils"; + +export type OpenCodeWebview = HTMLElement & { + executeJavaScript(code: string, userGesture?: boolean): Promise; +}; + +const FILE_DROP_PREFIX = "file:"; + +function getVaultRoot(app: App): string { + const adapter = app.vault.adapter as { basePath?: string }; + return adapter.basePath ?? ""; +} + +function getFilesFromDrag(app: App): TFile[] { + const draggable = (app as { dragManager?: { draggable?: DraggablePayload } }).dragManager + ?.draggable; + if (!draggable) { + return []; + } + + switch (draggable.type) { + case "file": + case "link": + return draggable.file ? [draggable.file] : []; + case "files": + return draggable.files ?? []; + default: + return []; + } +} + +type DraggablePayload = + | { type: "file" | "link"; file: TFile } + | { type: "files"; files: TFile[] }; + +function buildFileDropScript(paths: string[]): string { + return `(function(){ + const paths = ${JSON.stringify(paths)}; + for (const filePath of paths) { + const dt = new DataTransfer(); + dt.setData("text/plain", ${JSON.stringify(FILE_DROP_PREFIX)} + filePath); + document.dispatchEvent(new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer: dt, + })); + } + })();`; +} + +export class NoteDropBridge { + private overlayEl: HTMLElement | null = null; + private dragActive = false; + private webviewReady = false; + private unregisterDocumentEvents: (() => void) | null = null; + + constructor( + private app: App, + private getProjectDirectory: () => string, + private getWebview: () => OpenCodeWebview | null + ) {} + + attach(container: HTMLElement): void { + this.overlayEl = container.createDiv({ cls: "opencode-drop-overlay" }); + this.overlayEl.createSpan({ cls: "opencode-drop-overlay-label", text: "Drop note to reference in chat" }); + + this.unregisterDocumentEvents = this.registerDragTracking(); + this.registerOverlayEvents(); + } + + detach(): void { + this.unregisterDocumentEvents?.(); + this.unregisterDocumentEvents = null; + this.overlayEl = null; + this.dragActive = false; + this.webviewReady = false; + } + + bindWebview(webview: OpenCodeWebview): void { + this.webviewReady = false; + webview.addEventListener("dom-ready", () => { + this.webviewReady = true; + }); + } + + onWebviewNavigate(): void { + this.webviewReady = false; + } + + private registerDragTracking(): () => void { + const handleDragStart = (): void => { + requestAnimationFrame(() => { + const files = getFilesFromDrag(this.app); + if (files.length === 0) { + return; + } + this.dragActive = true; + this.updateOverlayState(false); + }); + }; + + const handleDragEnd = (): void => { + this.dragActive = false; + this.updateOverlayState(false); + }; + + document.addEventListener("dragstart", handleDragStart, true); + document.addEventListener("dragend", handleDragEnd, true); + + return () => { + document.removeEventListener("dragstart", handleDragStart, true); + document.removeEventListener("dragend", handleDragEnd, true); + }; + } + + private registerOverlayEvents(): void { + const overlay = this.overlayEl; + if (!overlay) { + return; + } + + overlay.addEventListener("dragenter", (event) => { + if (!this.dragActive) { + return; + } + event.preventDefault(); + this.updateOverlayState(true); + }); + + overlay.addEventListener("dragover", (event) => { + if (!this.dragActive) { + return; + } + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "copy"; + } + this.updateOverlayState(true); + }); + + overlay.addEventListener("dragleave", (event) => { + if (!this.dragActive) { + return; + } + if (event.relatedTarget instanceof Node && overlay.contains(event.relatedTarget)) { + return; + } + this.updateOverlayState(false); + }); + + overlay.addEventListener("drop", (event) => { + event.preventDefault(); + event.stopPropagation(); + this.dragActive = false; + this.updateOverlayState(false); + void this.handleDrop(); + }); + } + + private updateOverlayState(highlight: boolean): void { + const overlay = this.overlayEl; + if (!overlay) { + return; + } + + overlay.toggleClass("is-active", this.dragActive); + overlay.toggleClass("is-targeted", this.dragActive && highlight); + } + + private resolveProjectPaths(files: TFile[]): string[] { + const vaultRoot = getVaultRoot(this.app); + const projectDirectory = this.getProjectDirectory(); + const paths: string[] = []; + const skipped: string[] = []; + + for (const file of files) { + const relativePath = vaultFileToProjectPath(file.path, vaultRoot, projectDirectory); + if (!relativePath) { + skipped.push(file.path); + continue; + } + paths.push(relativePath); + } + + if (skipped.length > 0) { + new Notice( + `Skipped ${skipped.length} item(s) outside the OpenCode project directory.`, + 5000 + ); + } + + return paths; + } + + private async handleDrop(): Promise { + const files = getFilesFromDrag(this.app); + if (files.length === 0) { + return; + } + + const paths = this.resolveProjectPaths(files); + if (paths.length === 0) { + return; + } + + const webview = this.getWebview(); + if (!webview) { + new Notice("OpenCode is not ready yet.", 4000); + return; + } + + if (!this.webviewReady) { + new Notice("OpenCode is still loading. Try again in a moment.", 4000); + return; + } + + try { + await webview.executeJavaScript(buildFileDropScript(paths), true); + } catch (error) { + console.error("[OpenCode] Failed to inject file reference(s)", error); + new Notice("Could not add note reference to OpenCode chat.", 5000); + } + } +} diff --git a/src/ui/noteDrop/pathUtils.ts b/src/ui/noteDrop/pathUtils.ts new file mode 100644 index 0000000..bad90e5 --- /dev/null +++ b/src/ui/noteDrop/pathUtils.ts @@ -0,0 +1,25 @@ +import * as path from "path"; + +export function toProjectRelativePath( + absoluteFilePath: string, + projectDirectory: string +): string | null { + const projectRoot = path.resolve(projectDirectory); + const absolutePath = path.resolve(absoluteFilePath); + + const relative = path.relative(projectRoot, absolutePath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return null; + } + + return relative.split(path.sep).join("/"); +} + +export function vaultFileToProjectPath( + vaultRelativePath: string, + vaultRoot: string, + projectDirectory: string +): string | null { + const absolutePath = path.join(vaultRoot, vaultRelativePath); + return toProjectRelativePath(absolutePath, projectDirectory); +} diff --git a/styles.css b/styles.css index 2bce793..f901606 100644 --- a/styles.css +++ b/styles.css @@ -55,7 +55,54 @@ height: 16px; } -/* iframe container */ +/* embed container */ +.opencode-embed-container { + flex: 1; + overflow: hidden; + position: relative; +} + +.opencode-webview { + width: 100%; + height: 100%; + border: none; + background-color: var(--background-primary); +} + +.opencode-drop-overlay { + position: absolute; + inset: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.opencode-drop-overlay.is-active { + pointer-events: auto; +} + +.opencode-drop-overlay.is-targeted { + background-color: hsla(var(--color-accent-hsl, 258 90% 66%) / 0.08); + border: 2px dashed var(--interactive-accent); +} + +.opencode-drop-overlay-label { + display: none; + padding: 8px 12px; + border-radius: 8px; + background-color: var(--background-secondary); + color: var(--text-muted); + font-size: 0.9em; +} + +.opencode-drop-overlay.is-targeted .opencode-drop-overlay-label { + display: block; +} + +/* Legacy class kept for compatibility */ .opencode-iframe-container { flex: 1; overflow: hidden; diff --git a/tests/noteDrop/pathUtils.test.ts b/tests/noteDrop/pathUtils.test.ts new file mode 100644 index 0000000..6f2bbec --- /dev/null +++ b/tests/noteDrop/pathUtils.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import path from "path"; +import { toProjectRelativePath, vaultFileToProjectPath } from "../../src/ui/noteDrop/pathUtils"; + +describe("toProjectRelativePath", () => { + test("returns a forward-slash path for files inside the project", () => { + const project = path.join("/vault"); + const file = path.join("/vault", "Notes", "Daily.md"); + + expect(toProjectRelativePath(file, project)).toBe("Notes/Daily.md"); + }); + + test("returns null for files outside the project", () => { + const project = path.join("/vault"); + const file = path.join("/other", "Notes", "Daily.md"); + + expect(toProjectRelativePath(file, project)).toBeNull(); + }); +}); + +describe("vaultFileToProjectPath", () => { + test("maps vault-relative paths into project-relative paths", () => { + const vaultRoot = path.join("/vault"); + const project = path.join("/vault"); + + expect(vaultFileToProjectPath("Projects/Spec.md", vaultRoot, project)).toBe( + "Projects/Spec.md" + ); + }); +}); From bc5a63d61e4c2eb25139c7be89eb14470927c31a Mon Sep 17 00:00:00 2001 From: Piotr Plewa Date: Fri, 19 Jun 2026 16:37:05 +1000 Subject: [PATCH 2/4] Enhance note drop functionality and UI updates - Added support for appending prompt references when dropping notes. - Improved drag-and-drop handling to ensure only the first note is referenced when multiple notes are dragged. - Updated CSS for better visibility of drop overlay and adjusted styles for specific view types. - Introduced a method to retrieve the OpenCode client instance for better integration. --- src/client/OpenCodeClient.ts | 18 ++++ src/main.ts | 4 + src/ui/OpenCodeView.ts | 3 +- src/ui/noteDrop/NoteDropBridge.ts | 154 +++++++++++++++++++++++------- styles.css | 9 +- 5 files changed, 148 insertions(+), 40 deletions(-) diff --git a/src/client/OpenCodeClient.ts b/src/client/OpenCodeClient.ts index 716b2a7..3a086ae 100644 --- a/src/client/OpenCodeClient.ts +++ b/src/client/OpenCodeClient.ts @@ -62,6 +62,24 @@ export class OpenCodeClient { this.lastPart = null; } + async appendPromptReference(relativePath: string): Promise { + const text = `@${relativePath}`; + try { + const response = await fetch(`${this.apiBaseUrl}/tui/append-prompt`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-opencode-directory": this.projectDirectory, + }, + body: JSON.stringify({ text }), + }); + return response.ok; + } catch (error) { + console.error("[OpenCode] append-prompt failed:", error); + return false; + } + } + async initializeProject(): Promise { try { const url = `${this.apiBaseUrl}/session?directory=${encodeURIComponent(this.projectDirectory)}`; diff --git a/src/main.ts b/src/main.ts index c925699..b7882c3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -276,6 +276,10 @@ export default class OpenCodePlugin extends Plugin { await this.viewManager.ensureSessionUrl(view); } + getClient(): OpenCodeClient { + return this.openCodeClient; + } + getProjectDirectory(): string { if (this.settings.projectDirectory) { console.log("[OpenCode] Using project directory from settings:", this.settings.projectDirectory); diff --git a/src/ui/OpenCodeView.ts b/src/ui/OpenCodeView.ts index 95850e8..804e6a8 100644 --- a/src/ui/OpenCodeView.ts +++ b/src/ui/OpenCodeView.ts @@ -172,7 +172,8 @@ export class OpenCodeView extends ItemView { this.noteDropBridge = new NoteDropBridge( this.app, () => this.plugin.getProjectDirectory(), - () => this.embedEl + () => this.embedEl, + (path) => this.plugin.getClient().appendPromptReference(path) ); this.noteDropBridge.attach(embedContainer); this.noteDropBridge.bindWebview(this.embedEl); diff --git a/src/ui/noteDrop/NoteDropBridge.ts b/src/ui/noteDrop/NoteDropBridge.ts index 33341d8..6f2a6c2 100644 --- a/src/ui/noteDrop/NoteDropBridge.ts +++ b/src/ui/noteDrop/NoteDropBridge.ts @@ -1,4 +1,5 @@ import { App, Notice, TFile } from "obsidian"; +import { OPENCODE_VIEW_TYPE } from "../../types"; import { vaultFileToProjectPath } from "./pathUtils"; export type OpenCodeWebview = HTMLElement & { @@ -12,7 +13,23 @@ function getVaultRoot(app: App): string { return adapter.basePath ?? ""; } -function getFilesFromDrag(app: App): TFile[] { +function getFilesFromDragTarget(app: App, event: DragEvent): TFile[] { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return []; + } + + const fileEl = target.closest(".nav-file"); + const filePath = fileEl?.getAttribute("data-path"); + if (!filePath) { + return []; + } + + const file = app.vault.getAbstractFileByPath(filePath); + return file instanceof TFile ? [file] : []; +} + +function getFilesFromDragManager(app: App): TFile[] { const draggable = (app as { dragManager?: { draggable?: DraggablePayload } }).dragManager ?.draggable; if (!draggable) { @@ -30,6 +47,17 @@ function getFilesFromDrag(app: App): TFile[] { } } +function getFilesFromDrag(app: App, event?: DragEvent): TFile[] { + const fromManager = getFilesFromDragManager(app); + if (fromManager.length > 0) { + return fromManager; + } + if (event) { + return getFilesFromDragTarget(app, event); + } + return []; +} + type DraggablePayload = | { type: "file" | "link"; file: TFile } | { type: "files"; files: TFile[] }; @@ -52,28 +80,37 @@ function buildFileDropScript(paths: string[]): string { export class NoteDropBridge { private overlayEl: HTMLElement | null = null; private dragActive = false; + private pendingPaths: string[] = []; private webviewReady = false; private unregisterDocumentEvents: (() => void) | null = null; + private unregisterOverlayEvents: (() => void) | null = null; constructor( private app: App, private getProjectDirectory: () => string, - private getWebview: () => OpenCodeWebview | null + private getWebview: () => OpenCodeWebview | null, + private appendPromptReference?: (relativePath: string) => Promise ) {} attach(container: HTMLElement): void { this.overlayEl = container.createDiv({ cls: "opencode-drop-overlay" }); - this.overlayEl.createSpan({ cls: "opencode-drop-overlay-label", text: "Drop note to reference in chat" }); + this.overlayEl.createSpan({ + cls: "opencode-drop-overlay-label", + text: "Drop note to reference in chat", + }); this.unregisterDocumentEvents = this.registerDragTracking(); - this.registerOverlayEvents(); + this.unregisterOverlayEvents = this.registerOverlayEvents(); } detach(): void { this.unregisterDocumentEvents?.(); this.unregisterDocumentEvents = null; + this.unregisterOverlayEvents?.(); + this.unregisterOverlayEvents = null; this.overlayEl = null; this.dragActive = false; + this.pendingPaths = []; this.webviewReady = false; } @@ -88,47 +125,70 @@ export class NoteDropBridge { this.webviewReady = false; } + private isOpenCodeViewOpen(): boolean { + return this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE).length > 0; + } + private registerDragTracking(): () => void { - const handleDragStart = (): void => { - requestAnimationFrame(() => { - const files = getFilesFromDrag(this.app); - if (files.length === 0) { - return; - } - this.dragActive = true; - this.updateOverlayState(false); - }); + const handleDragStart = (event: DragEvent): void => { + if (!this.isOpenCodeViewOpen()) { + return; + } + + const files = getFilesFromDrag(this.app, event); + if (files.length === 0) { + return; + } + + const paths = this.resolveProjectPaths(files); + if (paths.length === 0) { + return; + } + + this.pendingPaths = paths; + event.dataTransfer?.setData("text/plain", `${FILE_DROP_PREFIX}${paths[0]}`); + + this.dragActive = true; + this.updateOverlayState(false); + + if (paths.length > 1) { + new Notice( + "Multiple notes were dragged; only the first will be referenced. Drop one note at a time to reference each file.", + 5000 + ); + } }; const handleDragEnd = (): void => { this.dragActive = false; + this.pendingPaths = []; this.updateOverlayState(false); }; - document.addEventListener("dragstart", handleDragStart, true); - document.addEventListener("dragend", handleDragEnd, true); + document.addEventListener("dragstart", handleDragStart, false); + document.addEventListener("dragend", handleDragEnd, false); return () => { - document.removeEventListener("dragstart", handleDragStart, true); - document.removeEventListener("dragend", handleDragEnd, true); + document.removeEventListener("dragstart", handleDragStart, false); + document.removeEventListener("dragend", handleDragEnd, false); }; } - private registerOverlayEvents(): void { + private registerOverlayEvents(): () => void { const overlay = this.overlayEl; if (!overlay) { - return; + return () => undefined; } - overlay.addEventListener("dragenter", (event) => { + const handleDragEnter = (event: DragEvent): void => { if (!this.dragActive) { return; } event.preventDefault(); this.updateOverlayState(true); - }); + }; - overlay.addEventListener("dragover", (event) => { + const handleDragOver = (event: DragEvent): void => { if (!this.dragActive) { return; } @@ -137,9 +197,9 @@ export class NoteDropBridge { event.dataTransfer.dropEffect = "copy"; } this.updateOverlayState(true); - }); + }; - overlay.addEventListener("dragleave", (event) => { + const handleDragLeave = (event: DragEvent): void => { if (!this.dragActive) { return; } @@ -147,15 +207,31 @@ export class NoteDropBridge { return; } this.updateOverlayState(false); - }); + }; - overlay.addEventListener("drop", (event) => { + const handleDrop = (event: DragEvent): void => { + if (!this.dragActive || this.pendingPaths.length === 0) { + return; + } event.preventDefault(); event.stopPropagation(); this.dragActive = false; this.updateOverlayState(false); - void this.handleDrop(); - }); + void this.handleDrop(this.pendingPaths); + this.pendingPaths = []; + }; + + overlay.addEventListener("dragenter", handleDragEnter); + overlay.addEventListener("dragover", handleDragOver); + overlay.addEventListener("dragleave", handleDragLeave); + overlay.addEventListener("drop", handleDrop); + + return () => { + overlay.removeEventListener("dragenter", handleDragEnter); + overlay.removeEventListener("dragover", handleDragOver); + overlay.removeEventListener("dragleave", handleDragLeave); + overlay.removeEventListener("drop", handleDrop); + }; } private updateOverlayState(highlight: boolean): void { @@ -164,6 +240,7 @@ export class NoteDropBridge { return; } + overlay.toggleClass("is-visible", this.dragActive); overlay.toggleClass("is-active", this.dragActive); overlay.toggleClass("is-targeted", this.dragActive && highlight); } @@ -193,13 +270,7 @@ export class NoteDropBridge { return paths; } - private async handleDrop(): Promise { - const files = getFilesFromDrag(this.app); - if (files.length === 0) { - return; - } - - const paths = this.resolveProjectPaths(files); + private async handleDrop(paths: string[]): Promise { if (paths.length === 0) { return; } @@ -217,9 +288,18 @@ export class NoteDropBridge { try { await webview.executeJavaScript(buildFileDropScript(paths), true); + return; } catch (error) { - console.error("[OpenCode] Failed to inject file reference(s)", error); - new Notice("Could not add note reference to OpenCode chat.", 5000); + console.warn("[OpenCode] File drop injection failed, trying append-prompt", error); + } + + if (this.appendPromptReference) { + const appended = await this.appendPromptReference(paths[0]); + if (appended) { + return; + } } + + new Notice("Could not add note reference to OpenCode chat.", 5000); } } diff --git a/styles.css b/styles.css index f901606..b9e5c43 100644 --- a/styles.css +++ b/styles.css @@ -1,5 +1,10 @@ /* OpenCode Plugin Styles */ +.workspace-leaf-content[data-type="opencode-view"] .view-content { + height: 100%; + padding: 0; +} + /* Main container */ .opencode-container { display: flex; @@ -84,7 +89,7 @@ pointer-events: auto; } -.opencode-drop-overlay.is-targeted { +.opencode-drop-overlay.is-visible.is-targeted { background-color: hsla(var(--color-accent-hsl, 258 90% 66%) / 0.08); border: 2px dashed var(--interactive-accent); } @@ -98,7 +103,7 @@ font-size: 0.9em; } -.opencode-drop-overlay.is-targeted .opencode-drop-overlay-label { +.opencode-drop-overlay.is-visible.is-targeted .opencode-drop-overlay-label { display: block; } From 24727459bb2f1e3fd22df010e04264140a000770 Mon Sep 17 00:00:00 2001 From: Piotr Plewa Date: Fri, 19 Jun 2026 20:27:08 +1000 Subject: [PATCH 3/4] Add file link bridge functionality for improved file navigation - Introduced FileLinkBridge to handle vault-relative file links in the OpenCode webview. - Implemented methods for binding webview events and handling navigation and console messages. - Added constants and utility functions for managing file link interactions. - Enhanced OpenCodeView to integrate the new file link bridge, improving user experience when opening files. - Created tests for file link script and path utilities to ensure functionality and reliability. --- src/ui/OpenCodeView.ts | 7 + src/ui/fileLinks/FileLinkBridge.ts | 132 ++++++++++++++++ src/ui/fileLinks/constants.ts | 6 + src/ui/fileLinks/fileLinkScript.ts | 206 +++++++++++++++++++++++++ src/ui/fileLinks/openVaultFile.ts | 32 ++++ src/ui/noteDrop/NoteDropBridge.ts | 7 +- src/ui/noteDrop/pathUtils.ts | 22 +++ tests/fileLinks/fileLinkScript.test.ts | 21 +++ tests/noteDrop/pathUtils.test.ts | 24 ++- 9 files changed, 450 insertions(+), 7 deletions(-) create mode 100644 src/ui/fileLinks/FileLinkBridge.ts create mode 100644 src/ui/fileLinks/constants.ts create mode 100644 src/ui/fileLinks/fileLinkScript.ts create mode 100644 src/ui/fileLinks/openVaultFile.ts create mode 100644 tests/fileLinks/fileLinkScript.test.ts diff --git a/src/ui/OpenCodeView.ts b/src/ui/OpenCodeView.ts index 804e6a8..4099437 100644 --- a/src/ui/OpenCodeView.ts +++ b/src/ui/OpenCodeView.ts @@ -3,12 +3,14 @@ import { OPENCODE_VIEW_TYPE } from "../types"; import { OPENCODE_ICON_NAME } from "../icons"; import type OpenCodePlugin from "../main"; import type { ServerState } from "../server/types"; +import { FileLinkBridge } from "./fileLinks/FileLinkBridge"; import { NoteDropBridge, type OpenCodeWebview } from "./noteDrop/NoteDropBridge"; export class OpenCodeView extends ItemView { plugin: OpenCodePlugin; private embedEl: OpenCodeWebview | null = null; private noteDropBridge: NoteDropBridge | null = null; + private fileLinkBridge: FileLinkBridge | null = null; private currentState: ServerState = "stopped"; private unsubscribeStateChange: (() => void) | null = null; @@ -58,6 +60,8 @@ export class OpenCodeView extends ItemView { this.noteDropBridge?.detach(); this.noteDropBridge = null; + this.fileLinkBridge?.detach(); + this.fileLinkBridge = null; if (this.embedEl) { const embedUrl = this.embedEl.getAttribute("src") ?? ""; @@ -178,6 +182,9 @@ export class OpenCodeView extends ItemView { this.noteDropBridge.attach(embedContainer); this.noteDropBridge.bindWebview(this.embedEl); + this.fileLinkBridge = new FileLinkBridge(this.app, () => this.plugin.getProjectDirectory()); + this.fileLinkBridge.bindWebview(this.embedEl); + this.embedEl.addEventListener("did-fail-load", () => { console.error("Failed to load OpenCode webview"); }); diff --git a/src/ui/fileLinks/FileLinkBridge.ts b/src/ui/fileLinks/FileLinkBridge.ts new file mode 100644 index 0000000..a3c52eb --- /dev/null +++ b/src/ui/fileLinks/FileLinkBridge.ts @@ -0,0 +1,132 @@ +import { App } from "obsidian"; +import type { OpenCodeWebview } from "../noteDrop/NoteDropBridge"; +import { FILE_OPEN_CONSOLE_PREFIX, FILE_OPEN_SCHEME } from "./constants"; +import { buildFileLinkBridgeScript } from "./fileLinkScript"; +import { openVaultBacklinkOrNotice } from "./openVaultFile"; + +type WebviewNavigateEvent = Event & { + url?: string; + preventDefault?: () => void; +}; + +type ConsoleMessageEvent = Event & { + message?: string; +}; + +export class FileLinkBridge { + private boundWebview: OpenCodeWebview | null = null; + private handleDomReady: (() => void) | null = null; + private handleWillNavigate: ((event: WebviewNavigateEvent) => void) | null = null; + private handleConsoleMessage: ((event: ConsoleMessageEvent) => void) | null = null; + + constructor( + private app: App, + private getProjectDirectory: () => string + ) {} + + bindWebview(webview: OpenCodeWebview): void { + this.unbindWebview(); + this.boundWebview = webview; + + this.handleDomReady = () => { + void this.injectBridgeScript(webview); + }; + + this.handleWillNavigate = (event: WebviewNavigateEvent) => { + const url = event.url; + if (!url?.startsWith(`${FILE_OPEN_SCHEME}?`)) { + return; + } + + event.preventDefault?.(); + void this.handleOpenUrl(url); + }; + + this.handleConsoleMessage = (event: ConsoleMessageEvent) => { + const message = event.message; + if (!message?.startsWith(FILE_OPEN_CONSOLE_PREFIX)) { + return; + } + + try { + const payload = JSON.parse(message.slice(FILE_OPEN_CONSOLE_PREFIX.length)) as { + file?: string; + line?: number; + }; + if (payload.file) { + void this.openFile(payload.file, payload.line); + } + } catch (error) { + console.warn("[OpenCode] Failed to parse file open message", error); + } + }; + + webview.addEventListener("dom-ready", this.handleDomReady); + webview.addEventListener("will-navigate", this.handleWillNavigate as EventListener); + webview.addEventListener("console-message", this.handleConsoleMessage as EventListener); + } + + detach(): void { + this.unbindWebview(); + } + + private unbindWebview(): void { + if (!this.boundWebview) { + return; + } + + if (this.handleDomReady) { + this.boundWebview.removeEventListener("dom-ready", this.handleDomReady); + } + if (this.handleWillNavigate) { + this.boundWebview.removeEventListener( + "will-navigate", + this.handleWillNavigate as EventListener + ); + } + if (this.handleConsoleMessage) { + this.boundWebview.removeEventListener( + "console-message", + this.handleConsoleMessage as EventListener + ); + } + + this.boundWebview = null; + this.handleDomReady = null; + this.handleWillNavigate = null; + this.handleConsoleMessage = null; + } + + private async injectBridgeScript(webview: OpenCodeWebview): Promise { + try { + await webview.executeJavaScript( + buildFileLinkBridgeScript({ + projectDirectory: this.getProjectDirectory(), + }), + false + ); + } catch (error) { + console.warn("[OpenCode] Failed to inject file link bridge", error); + } + } + + private async handleOpenUrl(url: string): Promise { + try { + const parsedUrl = new URL(url); + const file = parsedUrl.searchParams.get("file"); + if (!file) { + return; + } + + const lineParam = parsedUrl.searchParams.get("line"); + const line = lineParam ? Number.parseInt(lineParam, 10) : undefined; + await this.openFile(file, Number.isFinite(line) ? line : undefined); + } catch (error) { + console.warn("[OpenCode] Failed to parse file open URL", error); + } + } + + private async openFile(vaultRelativePath: string, line?: number): Promise { + await openVaultBacklinkOrNotice(this.app, vaultRelativePath, line); + } +} diff --git a/src/ui/fileLinks/constants.ts b/src/ui/fileLinks/constants.ts new file mode 100644 index 0000000..f9800c1 --- /dev/null +++ b/src/ui/fileLinks/constants.ts @@ -0,0 +1,6 @@ +export const FILE_OPEN_CONSOLE_PREFIX = "__OC_FILE_OPEN__:"; +export const FILE_OPEN_SCHEME = "opencode-obsidian://open"; + +export type FileLinkBridgeConfig = { + projectDirectory: string; +}; diff --git a/src/ui/fileLinks/fileLinkScript.ts b/src/ui/fileLinks/fileLinkScript.ts new file mode 100644 index 0000000..ddb0903 --- /dev/null +++ b/src/ui/fileLinks/fileLinkScript.ts @@ -0,0 +1,206 @@ +import { + FILE_OPEN_CONSOLE_PREFIX, + FILE_OPEN_SCHEME, + type FileLinkBridgeConfig, +} from "./constants"; + +export function buildFileLinkBridgeScript(config: FileLinkBridgeConfig): string { + const projectDirectory = config.projectDirectory.replace(/\\/g, "/").replace(/\/$/, ""); + return `(function(){ + const CONSOLE_PREFIX = ${JSON.stringify(FILE_OPEN_CONSOLE_PREFIX)}; + const OPEN_SCHEME = ${JSON.stringify(FILE_OPEN_SCHEME)}; + const PROJECT_DIR = ${JSON.stringify(projectDirectory)}; + const VERSION = 2; + + if (window.__ocFileLinks?.version === VERSION) return; + window.__ocFileLinks?.cleanup?.(); + + const PATH_RE = + /(?:@)?(?:[A-Za-z0-9_.][A-Za-z0-9_. -]*(?:[\\\\/][A-Za-z0-9_. -]+)+|[A-Za-z0-9_.][A-Za-z0-9_. -]*)\\.(?:md|markdown|txt|pdf|png|jpe?g|gif|webp|canvas|json|ya?ml|ts|tsx|js|jsx|css|html|csv|excalidraw|svg)(?::\\d+(?:-\\d+)?)?/gi; + + function parseRef(raw) { + let t = (raw || "").trim().replace(/[.,;:!?]+$/, ""); + if (!t || /^https?:\\/\\//i.test(t)) return null; + if (t.startsWith("@")) t = t.slice(1); + if (t.startsWith("file://")) t = t.slice(7); + else if (t.startsWith("file:")) t = t.slice(5); + t = t.replace(/\\\\/g, "/").trim(); + if (!t) return null; + + let line; + const lineMatch = t.match(/^(.+):(\\d+)(?:-\\d+)?$/); + if (lineMatch && !/^[a-zA-Z]:$/.test(lineMatch[1])) { + t = lineMatch[1]; + line = parseInt(lineMatch[2], 10); + } + if (!/\\.[a-z0-9]{1,10}$/i.test(t)) return null; + if (!t.includes("/") && !/\\.(md|markdown|txt)$/i.test(t)) return null; + return { file: t, line: line }; + } + + function toVaultRelative(filePath) { + let p = filePath.replace(/^@/, "").replace(/\\\\/g, "/").trim(); + if (p.startsWith("file:")) p = p.slice(p.startsWith("file://") ? 7 : 5); + const root = PROJECT_DIR.toLowerCase(); + if (p.toLowerCase().startsWith(root + "/")) { + p = p.slice(PROJECT_DIR.length).replace(/^\\/+/, ""); + } + return p; + } + + function notifyOpen(filePath, line) { + const file = toVaultRelative(filePath); + if (!file) return; + const payload = JSON.stringify({ file: file, line: line || undefined }); + console.log(CONSOLE_PREFIX + payload); + const params = new URLSearchParams({ file: file }); + if (line) params.set("line", String(line)); + window.location.href = OPEN_SCHEME + "?" + params.toString(); + } + + function stripMarks(text) { + return (text || "").replace(/[\\u202A-\\u202E\\u2066-\\u2069]/g, ""); + } + + function markPath(el, ref) { + el.setAttribute("data-oc-file", ref.file); + if (ref.line) el.setAttribute("data-oc-line", String(ref.line)); + } + + function decorateTextNode(node) { + const text = node.textContent || ""; + PATH_RE.lastIndex = 0; + if (!PATH_RE.test(text)) return; + + const parent = node.parentElement; + if (!parent || parent.closest("[data-oc-file]")) return; + + PATH_RE.lastIndex = 0; + const frag = document.createDocumentFragment(); + let lastIndex = 0; + let match; + while ((match = PATH_RE.exec(text)) !== null) { + const ref = parseRef(match[0]); + if (!ref) continue; + if (match.index > lastIndex) { + frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index))); + } + const span = document.createElement("span"); + markPath(span, ref); + span.textContent = match[0]; + frag.appendChild(span); + lastIndex = match.index + match[0].length; + } + if (lastIndex === 0) return; + if (lastIndex < text.length) { + frag.appendChild(document.createTextNode(text.slice(lastIndex))); + } + parent.replaceChild(frag, node); + } + + function scanRoot(root) { + if (!(root instanceof Element)) return; + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode: function(node) { + const parent = node.parentElement; + if (!parent || parent.closest("script, style, [data-oc-file]")) { + return NodeFilter.FILTER_REJECT; + } + if (parent.closest('[data-slot="session-turn-diff-path"]')) { + return NodeFilter.FILTER_REJECT; + } + if (parent.closest("code, a, button, [role=button]")) { + return NodeFilter.FILTER_REJECT; + } + PATH_RE.lastIndex = 0; + return PATH_RE.test(node.textContent || "") + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + }); + + const nodes = []; + while (walker.nextNode()) nodes.push(walker.currentNode); + nodes.forEach(decorateTextNode); + } + + function readMarkedReference(target) { + const el = target.closest("[data-oc-file]"); + if (!(el instanceof Element)) return null; + const file = el.getAttribute("data-oc-file"); + if (!file) return null; + const lineValue = el.getAttribute("data-oc-line"); + return { + file: file, + line: lineValue ? parseInt(lineValue, 10) : undefined, + }; + } + + function readDiffFilePath(trigger) { + const item = trigger.closest('[data-slot="accordion-item"]'); + const value = item?.getAttribute("data-value"); + if (value) { + const ref = parseRef(stripMarks(value)); + if (ref) return ref; + } + + const pathEl = trigger.querySelector('[data-slot="session-turn-diff-path"]'); + if (!pathEl) return null; + + const dir = stripMarks(pathEl.querySelector('[data-slot="session-turn-diff-directory"]')?.textContent || ""); + const name = stripMarks(pathEl.querySelector('[data-slot="session-turn-diff-filename"]')?.textContent || ""); + const combined = (dir + name).trim() || stripMarks(pathEl.textContent || "").trim(); + return combined ? parseRef(combined) : null; + } + + function extractFromClick(event) { + const target = event.target; + if (!(target instanceof Element)) return null; + + const marked = readMarkedReference(target); + if (marked) return marked; + + const diffPath = target.closest( + '[data-slot="session-turn-diff-path"], [data-slot="session-turn-diff-filename"], [data-slot="session-turn-diff-directory"]' + ); + if (diffPath) { + const trigger = diffPath.closest('[data-slot="session-turn-diff-trigger"]'); + if (trigger) return readDiffFilePath(trigger); + } + + return null; + } + + function onClick(event) { + const ref = extractFromClick(event); + if (!ref) return; + event.preventDefault(); + event.stopPropagation(); + notifyOpen(ref.file, ref.line); + } + + scanRoot(document.body); + + const observer = new MutationObserver(function(mutations) { + for (const mutation of mutations) { + mutation.addedNodes.forEach(function(node) { + if (node.nodeType === Node.ELEMENT_NODE) scanRoot(node); + else if (node.nodeType === Node.TEXT_NODE && node.parentElement) { + scanRoot(node.parentElement); + } + }); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + document.addEventListener("click", onClick, true); + + window.__ocFileLinks = { + version: VERSION, + cleanup: function() { + observer.disconnect(); + document.removeEventListener("click", onClick, true); + }, + }; + })();`; +} diff --git a/src/ui/fileLinks/openVaultFile.ts b/src/ui/fileLinks/openVaultFile.ts new file mode 100644 index 0000000..de5e2b3 --- /dev/null +++ b/src/ui/fileLinks/openVaultFile.ts @@ -0,0 +1,32 @@ +import { Notice, normalizePath, type App } from "obsidian"; + +export async function openVaultBacklinkOrNotice( + app: App, + vaultRelativePath: string, + line?: number +): Promise { + const normalized = normalizePath(vaultRelativePath.replace(/^@/, "").trim()); + if (!normalized) { + return false; + } + + const candidates = [normalized]; + if (!normalized.endsWith(".md")) { + candidates.push(`${normalized}.md`); + } + + for (const candidate of candidates) { + const resolved = app.metadataCache.getFirstLinkpathDest(candidate, ""); + if (!resolved) { + continue; + } + + const linkText = + line && line > 0 ? `${resolved.path}:${line}` : resolved.path; + await app.workspace.openLinkText(linkText, "", false); + return true; + } + + new Notice(`Note not found in vault: ${normalized}`, 4000); + return false; +} diff --git a/src/ui/noteDrop/NoteDropBridge.ts b/src/ui/noteDrop/NoteDropBridge.ts index 6f2a6c2..ed584ec 100644 --- a/src/ui/noteDrop/NoteDropBridge.ts +++ b/src/ui/noteDrop/NoteDropBridge.ts @@ -1,6 +1,6 @@ import { App, Notice, TFile } from "obsidian"; import { OPENCODE_VIEW_TYPE } from "../../types"; -import { vaultFileToProjectPath } from "./pathUtils"; +import { getVaultRoot, vaultFileToProjectPath } from "./pathUtils"; export type OpenCodeWebview = HTMLElement & { executeJavaScript(code: string, userGesture?: boolean): Promise; @@ -8,11 +8,6 @@ export type OpenCodeWebview = HTMLElement & { const FILE_DROP_PREFIX = "file:"; -function getVaultRoot(app: App): string { - const adapter = app.vault.adapter as { basePath?: string }; - return adapter.basePath ?? ""; -} - function getFilesFromDragTarget(app: App, event: DragEvent): TFile[] { const target = event.target; if (!(target instanceof HTMLElement)) { diff --git a/src/ui/noteDrop/pathUtils.ts b/src/ui/noteDrop/pathUtils.ts index bad90e5..8294265 100644 --- a/src/ui/noteDrop/pathUtils.ts +++ b/src/ui/noteDrop/pathUtils.ts @@ -1,4 +1,10 @@ import * as path from "path"; +import type { App } from "obsidian"; + +export function getVaultRoot(app: App): string { + const adapter = app.vault.adapter as { basePath?: string }; + return adapter.basePath ?? ""; +} export function toProjectRelativePath( absoluteFilePath: string, @@ -23,3 +29,19 @@ export function vaultFileToProjectPath( const absolutePath = path.join(vaultRoot, vaultRelativePath); return toProjectRelativePath(absolutePath, projectDirectory); } + +export function projectPathToVaultPath( + projectRelativePath: string, + vaultRoot: string, + projectDirectory: string +): string | null { + const absolutePath = path.resolve(projectDirectory, projectRelativePath); + const vaultResolved = path.resolve(vaultRoot); + const relative = path.relative(vaultResolved, absolutePath); + + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return null; + } + + return relative.split(path.sep).join("/"); +} diff --git a/tests/fileLinks/fileLinkScript.test.ts b/tests/fileLinks/fileLinkScript.test.ts new file mode 100644 index 0000000..6aeab8d --- /dev/null +++ b/tests/fileLinks/fileLinkScript.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import path from "path"; +import { buildFileLinkBridgeScript } from "../../src/ui/fileLinks/fileLinkScript"; +import { + FILE_OPEN_CONSOLE_PREFIX, + FILE_OPEN_SCHEME, +} from "../../src/ui/fileLinks/constants"; + +describe("buildFileLinkBridgeScript", () => { + test("opens vault-relative paths via console and custom scheme", () => { + const script = buildFileLinkBridgeScript({ + projectDirectory: path.join("/vault"), + }); + + expect(script).toContain(FILE_OPEN_CONSOLE_PREFIX); + expect(script).toContain(FILE_OPEN_SCHEME); + expect(script).toContain("data-oc-file"); + expect(script).toContain("session-turn-diff-path"); + expect(script).not.toContain("cursor:pointer"); + }); +}); diff --git a/tests/noteDrop/pathUtils.test.ts b/tests/noteDrop/pathUtils.test.ts index 6f2bbec..e2f5d5d 100644 --- a/tests/noteDrop/pathUtils.test.ts +++ b/tests/noteDrop/pathUtils.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; import path from "path"; -import { toProjectRelativePath, vaultFileToProjectPath } from "../../src/ui/noteDrop/pathUtils"; +import { + projectPathToVaultPath, + toProjectRelativePath, + vaultFileToProjectPath, +} from "../../src/ui/noteDrop/pathUtils"; describe("toProjectRelativePath", () => { test("returns a forward-slash path for files inside the project", () => { @@ -28,3 +32,21 @@ describe("vaultFileToProjectPath", () => { ); }); }); + +describe("projectPathToVaultPath", () => { + test("maps project-relative paths back into vault-relative paths", () => { + const vaultRoot = path.join("/vault"); + const project = path.join("/vault"); + + expect(projectPathToVaultPath("Projects/Spec.md", vaultRoot, project)).toBe( + "Projects/Spec.md" + ); + }); + + test("maps nested project directories back to vault paths", () => { + const vaultRoot = path.join("/vault"); + const project = path.join("/vault", "Projects"); + + expect(projectPathToVaultPath("Spec.md", vaultRoot, project)).toBe("Projects/Spec.md"); + }); +}); From 3979d022b8e747334686a921b7bf244b8235964e Mon Sep 17 00:00:00 2001 From: pplewa Date: Fri, 9 Oct 2026 13:28:32 +1000 Subject: [PATCH 4/4] Support OpenCode v2 server v2 changed the web UI routes, made authentication mandatory, and moved the HTTP API under /api. The plugin spoke the v1 scheme, so the embedded view crashed with "Unrecognised route!" and every API call returned the HTML shell. - Build session URLs as /server//session/, the only session route v2 accepts. getUrl() now returns the server root, without the encoded project directory in the path. - Generate a server password, pass it to opencode as OPENCODE_SERVER_PASSWORD, seed it into the web UI storage so the view connects without the pairing screen, and use HTTP Basic auth for API calls. - Prefix API requests with /api and pass the directory in the body or query. The x-opencode-directory header is gone in v2. - Replace the removed message-part endpoints with synthetic messages for workspace context. - Drop the append-prompt fallback, which has no v2 equivalent. The drag-drop injection still works and remains the only path. - Report a clear error when a server on the port rejects the configured password, and stop the process rather than leaving the view broken. - Add a Server password setting so it can be inspected and rotated. --- README.md | 12 +- src/client/OpenCodeClient.ts | 197 +++++++++--------------------- src/lib/serverUrl.ts | 38 ++++++ src/main.ts | 30 ++++- src/server/ServerManager.ts | 140 ++++++++++++++++++--- src/settings/SettingsTab.ts | 31 +++++ src/types.ts | 6 + src/ui/OpenCodeView.ts | 38 +++++- src/ui/authSeed.ts | 79 ++++++++++++ src/ui/noteDrop/NoteDropBridge.ts | 17 +-- tests/OpenCodeClient.test.ts | 76 ++++++++++++ tests/ServerManager.test.ts | 152 +++++++++++++++++------ tests/authSeed.test.ts | 111 +++++++++++++++++ tests/serverUrl.test.ts | 68 +++++++++++ 14 files changed, 778 insertions(+), 217 deletions(-) create mode 100644 src/lib/serverUrl.ts create mode 100644 src/ui/authSeed.ts create mode 100644 tests/OpenCodeClient.test.ts create mode 100644 tests/authSeed.test.ts create mode 100644 tests/serverUrl.test.ts diff --git a/README.md b/README.md index 7b0c8a8..c58e4dd 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,19 @@ _Note: plugin author is not afiliated with OpenCode or Obsidian - this is a 3rd ## Requirements - Desktop only (uses Node.js child processes) -- [OpenCode CLI](https://opencode.ai) installed +- [OpenCode CLI](https://opencode.ai) v2 or later installed - [Bun](https://bun.sh) installed +## Server password + +OpenCode v2 requires every client to authenticate. The plugin generates a password on first run, passes it to the server as `OPENCODE_SERVER_PASSWORD`, and reuses it for the embedded web view. You can view or rotate the password in Settings under **Server password**. + +If you start the server outside the plugin, set the same password before you start it: + +```bash +OPENCODE_SERVER_PASSWORD= opencode serve --port 14096 --hostname 127.0.0.1 +``` + ## Installation ### For Users (BRAT - Recommended for Beta Testing) diff --git a/src/client/OpenCodeClient.ts b/src/client/OpenCodeClient.ts index 3a086ae..aa80211 100644 --- a/src/client/OpenCodeClient.ts +++ b/src/client/OpenCodeClient.ts @@ -1,27 +1,4 @@ -type OpenCodePart = { - id: string; - messageID: string; - sessionID: string; - type: string; - text?: string; - ignored?: boolean; - synthetic?: boolean; - metadata?: Record; - time?: { - start: number; - end?: number; - }; -}; - -type OpenCodeMessageInfo = { - id: string; - sessionID: string; -}; - -type OpenCodeMessageWithParts = { - info: OpenCodeMessageInfo; - parts: OpenCodePart[]; -}; +import { buildAuthHeader, buildSessionUrl, normalizeServerUrl } from "../lib/serverUrl"; type OpenCodeSession = { id?: string; @@ -29,74 +6,57 @@ type OpenCodeSession = { type OpenCodeResponse = T | { data?: T } | { message?: T } | null; +const API_PREFIX = "/api"; + export class OpenCodeClient { - private apiBaseUrl: string; - private uiBaseUrl: string; + private serverUrl: string; + private password: string; private projectDirectory: string; - private trackedSessionId: string | null = null; - private lastPart: OpenCodePart | null = null; + private lastInjectedContext: string | null = null; - constructor(apiBaseUrl: string, uiBaseUrl: string, projectDirectory: string) { - this.apiBaseUrl = this.normalizeBaseUrl(apiBaseUrl); - this.uiBaseUrl = this.normalizeBaseUrl(uiBaseUrl); + constructor(serverUrl: string, password: string, projectDirectory: string) { + this.serverUrl = normalizeServerUrl(serverUrl); + this.password = password; this.projectDirectory = projectDirectory; } - updateBaseUrl(apiBaseUrl: string, uiBaseUrl: string, projectDirectory: string): void { - const nextApiUrl = this.normalizeBaseUrl(apiBaseUrl); - const nextUiUrl = this.normalizeBaseUrl(uiBaseUrl); + updateBaseUrl(serverUrl: string, password: string, projectDirectory: string): void { + const nextServerUrl = normalizeServerUrl(serverUrl); if ( - nextApiUrl !== this.apiBaseUrl || - nextUiUrl !== this.uiBaseUrl || + nextServerUrl !== this.serverUrl || + password !== this.password || projectDirectory !== this.projectDirectory ) { - this.apiBaseUrl = nextApiUrl; - this.uiBaseUrl = nextUiUrl; + this.serverUrl = nextServerUrl; + this.password = password; this.projectDirectory = projectDirectory; this.resetTracking(); } } - resetTracking(): void { - this.trackedSessionId = null; - this.lastPart = null; - } - - async appendPromptReference(relativePath: string): Promise { - const text = `@${relativePath}`; - try { - const response = await fetch(`${this.apiBaseUrl}/tui/append-prompt`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-opencode-directory": this.projectDirectory, - }, - body: JSON.stringify({ text }), - }); - return response.ok; - } catch (error) { - console.error("[OpenCode] append-prompt failed:", error); - return false; - } + private resetTracking(): void { + this.lastInjectedContext = null; } + /** + * Touch the session list for the project directory. v2 creates the project + * lazily on first access, so a list request is enough to register it. + */ async initializeProject(): Promise { try { - const url = `${this.apiBaseUrl}/session?directory=${encodeURIComponent(this.projectDirectory)}`; - const response = await fetch(url, { + const path = `${API_PREFIX}/session?directory=${encodeURIComponent(this.projectDirectory)}`; + const response = await fetch(`${this.serverUrl}${path}`, { method: "GET", - headers: { - "x-opencode-directory": this.projectDirectory, - }, + headers: this.buildHeaders(), }); 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; @@ -104,7 +64,7 @@ export class OpenCodeClient { } getSessionUrl(sessionId: string): string { - return `${this.uiBaseUrl}/session/${sessionId}`; + return buildSessionUrl(this.serverUrl, sessionId); } resolveSessionId(iframeUrl: string): string | null { @@ -113,104 +73,65 @@ export class OpenCodeClient { } async createSession(): Promise { - const result = await this.request("POST", "/session", { + const result = await this.request("POST", `${API_PREFIX}/session`, { title: "Obsidian", + location: { directory: this.projectDirectory }, }); - const session = this.unwrap(result); - return session?.id ?? null; + return this.unwrap(result)?.id ?? null; } + /** + * Push the current workspace context into the session. + * + * v2 has no endpoint to update or retract an earlier message, so the context + * is sent as a synthetic message. The same text is never sent twice in a row, + * which keeps editor events from flooding the session. + */ async updateContext(params: { sessionId: string; contextText: string | null; }): Promise { const { sessionId, contextText } = params; - if (this.trackedSessionId && this.trackedSessionId !== sessionId) { - this.resetTracking(); - } - this.trackedSessionId = sessionId; - if (!contextText) { - await this.ignorePreviousPart(); return; } - if (this.lastPart) { - const updated = await this.updatePart(this.lastPart, { text: contextText }); - if (updated) { - return; - } - await this.ignorePreviousPart(); - } - - const message = await this.sendPrompt(sessionId, contextText); - if (message?.info?.id) { - this.lastPart = message.parts?.[0] ?? null; + if (this.lastInjectedContext === contextText) { + return; } - } - private async sendPrompt(sessionId: string, contextText: string): Promise { - const result = await this.request( + const result = await this.request( "POST", - `/session/${sessionId}/message`, - { - noReply: true, - parts: [{ type: "text", text: contextText }], - } + `${API_PREFIX}/session/${sessionId}/synthetic`, + { text: contextText } ); - console.log("[OpenCode] Injected context message"); - console.log(contextText) - - const message = this.unwrap(result); - if (!message) { + if (result) { + this.lastInjectedContext = contextText; + console.log("[OpenCode] Injected context message"); + } else { console.error("[OpenCode] Failed to inject context message"); } - return message; - } - - private async updatePart(part: OpenCodePart, updates: { text?: string; ignored?: boolean }): Promise { - const result = await this.request( - "PATCH", - `/session/${part.sessionID}/message/${part.messageID}/part/${part.id}`, - { - ...part, - ...updates, - } - ); - const updated = this.unwrap(result); - if (updated) { - this.lastPart = updated; - return true; - } - return false; } - private async ignorePreviousPart(): Promise { - if (!this.lastPart) { - return false; + private buildHeaders(hasBody = false): Record { + const headers: Record = {}; + if (hasBody) { + headers["Content-Type"] = "application/json"; } - - const ignored = await this.updatePart(this.lastPart, { ignored: true }); - if (!ignored) { - return false; + if (this.password) { + headers["Authorization"] = buildAuthHeader(this.password); } - - this.lastPart = null; - return true; + return headers; } private async request(method: string, path: string, body?: unknown): Promise> { try { - const url = `${this.apiBaseUrl}${path}`; - const response = await fetch(url, { + const response = await fetch(`${this.serverUrl}${path}`, { method, - headers: { - "Content-Type": "application/json", - "x-opencode-directory": this.projectDirectory, - }, - body: body ? JSON.stringify(body) : undefined, + headers: this.buildHeaders(body !== undefined), + body: body !== undefined ? JSON.stringify(body) : undefined, }); if (!response.ok) { @@ -246,8 +167,4 @@ export class OpenCodeClient { } return result as T; } - - private normalizeBaseUrl(baseUrl: string): string { - return baseUrl.replace(/\/+$/, ""); - } } diff --git a/src/lib/serverUrl.ts b/src/lib/serverUrl.ts new file mode 100644 index 0000000..1e9c0b5 --- /dev/null +++ b/src/lib/serverUrl.ts @@ -0,0 +1,38 @@ +/** + * URL and auth helpers for the OpenCode v2 server. + * + * v2 serves one web UI per server. A session lives at: + * /server//session/ + * where is the base64url encoding of the server URL. + */ + +const AUTH_USERNAME = "opencode"; + +export const STORAGE_KEY_SERVER = "opencode.global.dat:server"; + +/** Strip trailing slashes so a server URL can be joined safely. */ +export function normalizeServerUrl(serverUrl: string): string { + return serverUrl.replace(/\/+$/, ""); +} + +/** Encode a server URL the way the OpenCode web UI expects in a session route. */ +export function encodeServerKey(serverUrl: string): string { + return Buffer.from(normalizeServerUrl(serverUrl), "utf8") + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +export function buildSessionUrl(serverUrl: string, sessionId: string): string { + return `${normalizeServerUrl(serverUrl)}/server/${encodeServerKey(serverUrl)}/session/${sessionId}`; +} + +/** + * v2 authenticates with HTTP Basic. The username is fixed and the password is + * the one the server prints on startup. + */ +export function buildAuthHeader(password: string): string { + const token = Buffer.from(`${AUTH_USERNAME}:${password}`, "utf8").toString("base64"); + return `Basic ${token}`; +} diff --git a/src/main.ts b/src/main.ts index b7882c3..19fa7b4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,16 @@ import { OpenCodeClient } from "./client/OpenCodeClient"; import { ContextManager } from "./context/ContextManager"; import { ExecutableResolver } from "./server/ExecutableResolver"; +/** + * Random password for the OpenCode server. The plugin passes this value to the + * server, so it stays stable between restarts and the web UI stays connected. + */ +function generateServerPassword(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Buffer.from(bytes).toString("base64url"); +} + export default class OpenCodePlugin extends Plugin { settings: OpenCodeSettings = DEFAULT_SETTINGS; private processManager: ServerManager; @@ -48,8 +58,8 @@ export default class OpenCodePlugin extends Plugin { }); this.openCodeClient = new OpenCodeClient( - this.getApiBaseUrl(), this.getServerUrl(), + this.getServerPassword(), projectDirectory ); this.lastBaseUrl = this.getServerUrl(); @@ -155,6 +165,11 @@ export default class OpenCodePlugin extends Plugin { async loadSettings(): Promise { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + + if (!this.settings.serverPassword) { + this.settings.serverPassword = generateServerPassword(); + await this.saveData(this.settings); + } } /** @@ -195,6 +210,8 @@ export default class OpenCodePlugin extends Plugin { const success = await this.processManager.start(); if (success) { new Notice("OpenCode server started"); + // The server may have reported its own password during startup. + this.refreshClientState(); const initialized = await this.openCodeClient.initializeProject(); if (!initialized) { console.warn("[OpenCode] Failed to initialize project on server"); @@ -227,8 +244,8 @@ export default class OpenCodePlugin extends Plugin { return this.processManager.getUrl(); } - getApiBaseUrl(): string { - return `http://${this.settings.hostname}:${this.settings.port}`; + getServerPassword(): string { + return this.processManager.getPassword(); } getStoredIframeUrl(): string | null { @@ -257,9 +274,12 @@ export default class OpenCodePlugin extends Plugin { private refreshClientState(): void { const nextUiBaseUrl = this.getServerUrl(); - const nextApiBaseUrl = this.getApiBaseUrl(); const projectDirectory = this.getProjectDirectory(); - this.openCodeClient.updateBaseUrl(nextApiBaseUrl, nextUiBaseUrl, projectDirectory); + this.openCodeClient.updateBaseUrl( + nextUiBaseUrl, + this.getServerPassword(), + projectDirectory + ); if (this.lastBaseUrl && this.lastBaseUrl !== nextUiBaseUrl) { this.cachedIframeUrl = null; diff --git a/src/server/ServerManager.ts b/src/server/ServerManager.ts index aac8c92..7769f49 100644 --- a/src/server/ServerManager.ts +++ b/src/server/ServerManager.ts @@ -6,9 +6,13 @@ import { OpenCodeProcess } from "./process/OpenCodeProcess"; import { WindowsProcess } from "./process/WindowsProcess"; import { PosixProcess } from "./process/PosixProcess"; import { ExecutableResolver } from "./ExecutableResolver"; +import { buildAuthHeader } from "../lib/serverUrl"; export type { ServerState } from "./types"; +/** Server prints this on stdout when it generated its own password. */ +const PASSWORD_PATTERN = /^server password (.+)$/; + export class ServerManager extends EventEmitter { private process: ChildProcess | null = null; private state: ServerState = "stopped"; @@ -17,6 +21,8 @@ export class ServerManager extends EventEmitter { private settings: OpenCodeSettings; private projectDirectory: string; private processImpl: OpenCodeProcess; + private reportedPassword: string | null = null; + private outputBuffer = ""; constructor(settings: OpenCodeSettings, projectDirectory: string) { super(); @@ -43,9 +49,19 @@ export class ServerManager extends EventEmitter { return this.lastError; } + /** + * Password the running server expects. + * + * The plugin passes OPENCODE_SERVER_PASSWORD on spawn, so the configured + * value is normally correct. A custom command can override that, in which + * case the server prints its own password and we adopt it. + */ + getPassword(): string { + return this.reportedPassword ?? this.settings.serverPassword; + } + getUrl(): string { - const encodedPath = Buffer.from(this.projectDirectory).toString('base64'); - return `http://${this.settings.hostname}:${this.settings.port}/${encodedPath}`; + return `http://${this.settings.hostname}:${this.settings.port}`; } async start(): Promise { @@ -56,6 +72,8 @@ export class ServerManager extends EventEmitter { this.setState("starting"); this.lastError = null; this.earlyExitCode = null; + this.reportedPassword = null; + this.outputBuffer = ""; if (!this.projectDirectory) { return this.setError("Project directory (vault) not configured"); @@ -64,34 +82,43 @@ export class ServerManager extends EventEmitter { // Determine execution mode and resolve executable path let executablePath: string; let spawnOptions: SpawnOptions; - + + const env = { + ...process.env, + NODE_USE_SYSTEM_CA: "1", + ...(this.settings.serverPassword + ? { OPENCODE_SERVER_PASSWORD: this.settings.serverPassword } + : {}), + }; + if (this.settings.useCustomCommand) { // Custom command mode: use custom command directly with shell executablePath = this.settings.customCommand; spawnOptions = { cwd: this.projectDirectory, - env: { ...process.env, NODE_USE_SYSTEM_CA: "1" }, + env, stdio: ["ignore", "pipe", "pipe"], shell: true, }; } else { // Path mode: resolve executable and verify executablePath = ExecutableResolver.resolve(this.settings.opencodePath); - + // Pre-flight check: verify executable exists (only for path mode) const commandError = await this.processImpl.verifyCommand(executablePath); if (commandError) { return this.setError(commandError); } - + spawnOptions = { cwd: this.projectDirectory, - env: { ...process.env, NODE_USE_SYSTEM_CA: "1" }, + env, stdio: ["ignore", "pipe", "pipe"], }; } - if (await this.checkServerHealth()) { + const existing = await this.probeServer(); + if (existing === "ready") { console.log( "[OpenCode] Server already running on port", this.settings.port @@ -100,6 +127,13 @@ export class ServerManager extends EventEmitter { return true; } + if (existing === "unauthorized") { + return this.setError( + `A server is already running on port ${this.settings.port}, but it rejected the configured password. ` + + "Stop that server, or update the Server password setting to match." + ); + } + console.log("[OpenCode] Starting server:", { mode: this.settings.useCustomCommand ? "custom" : "path", command: executablePath, @@ -136,7 +170,7 @@ export class ServerManager extends EventEmitter { console.log("[OpenCode] Process spawned with PID:", this.process.pid); this.process.stdout?.on("data", (data) => { - console.log("[OpenCode]", data.toString().trim()); + this.handleServerOutput(data.toString()); }); this.process.stderr?.on("data", (data) => { @@ -174,8 +208,8 @@ export class ServerManager extends EventEmitter { } }); - const ready = await this.waitForServerOrExit(this.settings.startupTimeout); - if (ready) { + const outcome = await this.waitForServerOrExit(this.settings.startupTimeout); + if (outcome === "ready") { this.setState("running"); return true; } @@ -185,6 +219,14 @@ export class ServerManager extends EventEmitter { } await this.stop(); + + if (outcome === "unauthorized") { + return this.setError( + `OpenCode started on port ${this.settings.port} but rejected the configured password. ` + + "Check the Server password setting." + ); + } + if (this.earlyExitCode !== null) { return this.setError( `Process exited unexpectedly (exit code ${this.earlyExitCode})` @@ -215,6 +257,36 @@ export class ServerManager extends EventEmitter { this.emit("stateChange", state); } + /** + * Read stdout line by line. The server prints its password on startup when it + * does not use OPENCODE_SERVER_PASSWORD. + */ + private handleServerOutput(text: string): void { + this.outputBuffer += text; + + let newlineIndex = this.outputBuffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = this.outputBuffer.slice(0, newlineIndex).trim(); + this.outputBuffer = this.outputBuffer.slice(newlineIndex + 1); + if (line) { + this.handleServerLine(line); + } + newlineIndex = this.outputBuffer.indexOf("\n"); + } + } + + private handleServerLine(line: string): void { + const match = PASSWORD_PATTERN.exec(line); + if (match) { + // Keep the password out of the log. + console.log("[OpenCode] Server reported a password"); + this.reportedPassword = match[1].trim(); + return; + } + + console.log("[OpenCode]", line); + } + private setError(message: string): false { this.lastError = message; console.error("[OpenCode Error]", message); @@ -222,35 +294,63 @@ export class ServerManager extends EventEmitter { return false; } - private async checkServerHealth(): Promise { + /** + * Report what is on the configured port. + * + * - "down": nothing is listening + * - "unauthorized": a server is listening but rejects our password + * - "ready": a server is listening and accepts our password + */ + private async probeServer(): Promise<"down" | "unauthorized" | "ready"> { + const password = this.getPassword(); + const url = password ? `${this.getUrl()}/api/info` : this.getUrl(); + const headers = password ? { Authorization: buildAuthHeader(password) } : undefined; + try { - const response = await fetch(`${this.getUrl()}/global/health`, { + const response = await fetch(url, { method: "GET", + headers, signal: AbortSignal.timeout(2000), }); - return response.ok; + + if (response.ok) { + return "ready"; + } + if (response.status === 401) { + return "unauthorized"; + } + return "down"; } catch { - return false; + return "down"; } } - private async waitForServerOrExit(timeoutMs: number): Promise { + private async waitForServerOrExit( + timeoutMs: number + ): Promise<"ready" | "unauthorized" | "timeout"> { const startTime = Date.now(); const pollInterval = 500; + let sawUnauthorized = false; while (Date.now() - startTime < timeoutMs) { if (!this.process) { console.log("[OpenCode] Process exited before server became ready"); - return false; + return "timeout"; } - if (await this.checkServerHealth()) { - return true; + const state = await this.probeServer(); + if (state === "ready") { + return "ready"; + } + if (state === "unauthorized") { + // The server may report its own password a moment later. Give it a + // chance to arrive before treating this as a failure. + sawUnauthorized = true; } await this.sleep(pollInterval); } - return false; + return sawUnauthorized ? "unauthorized" : "timeout"; } private sleep(ms: number): Promise { diff --git a/src/settings/SettingsTab.ts b/src/settings/SettingsTab.ts index a50ed5c..49b5503 100644 --- a/src/settings/SettingsTab.ts +++ b/src/settings/SettingsTab.ts @@ -15,6 +15,12 @@ function expandTilde(path: string): string { return path; } +function generatePassword(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Buffer.from(bytes).toString("base64url"); +} + export class OpenCodeSettingTab extends PluginSettingTab { private validateTimeout: ReturnType | null = null; @@ -63,6 +69,31 @@ export class OpenCodeSettingTab extends PluginSettingTab { }) ); + new Setting(containerEl) + .setName("Server password") + .setDesc( + "OpenCode v2 requires a password. The plugin passes this value to the server and reuses it for the embedded web UI." + ) + .addText((text) => { + text.inputEl.type = "password"; + text + .setValue(this.settings.serverPassword) + .onChange(async (value) => { + this.settings.serverPassword = value.trim(); + await this.onSettingsChange(); + }); + }) + .addExtraButton((button) => { + button + .setIcon("refresh-cw") + .setTooltip("Generate a new password") + .onClick(async () => { + this.settings.serverPassword = generatePassword(); + await this.onSettingsChange(); + this.display(); + }); + }); + const customCmdSetting = new Setting(containerEl) .setName("Use custom command") .setDesc("Enable to use a custom shell command instead of the executable path") diff --git a/src/types.ts b/src/types.ts index 601e645..d81fc76 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,11 @@ export interface OpenCodeSettings { maxSelectionLength: number; customCommand: string; useCustomCommand: boolean; + /** + * Password for the OpenCode v2 server. The plugin passes it to the server as + * OPENCODE_SERVER_PASSWORD and uses it for API requests and the web UI. + */ + serverPassword: string; } export const DEFAULT_SETTINGS: OpenCodeSettings = { @@ -28,6 +33,7 @@ export const DEFAULT_SETTINGS: OpenCodeSettings = { maxSelectionLength: 2000, customCommand: "", useCustomCommand: false, + serverPassword: "", }; export const OPENCODE_VIEW_TYPE = "opencode-view"; diff --git a/src/ui/OpenCodeView.ts b/src/ui/OpenCodeView.ts index 4099437..1ddebff 100644 --- a/src/ui/OpenCodeView.ts +++ b/src/ui/OpenCodeView.ts @@ -5,6 +5,7 @@ import type OpenCodePlugin from "../main"; import type { ServerState } from "../server/types"; import { FileLinkBridge } from "./fileLinks/FileLinkBridge"; import { NoteDropBridge, type OpenCodeWebview } from "./noteDrop/NoteDropBridge"; +import { buildAuthSeedScript } from "./authSeed"; export class OpenCodeView extends ItemView { plugin: OpenCodePlugin; @@ -176,12 +177,15 @@ export class OpenCodeView extends ItemView { this.noteDropBridge = new NoteDropBridge( this.app, () => this.plugin.getProjectDirectory(), - () => this.embedEl, - (path) => this.plugin.getClient().appendPromptReference(path) + () => this.embedEl ); this.noteDropBridge.attach(embedContainer); this.noteDropBridge.bindWebview(this.embedEl); + this.embedEl.addEventListener("dom-ready", () => { + void this.seedServerCredentials(); + }); + this.fileLinkBridge = new FileLinkBridge(this.app, () => this.plugin.getProjectDirectory()); this.fileLinkBridge.bindWebview(this.embedEl); @@ -204,6 +208,36 @@ export class OpenCodeView extends ItemView { return this.embedEl?.getAttribute("src") ?? null; } + /** + * Give the OpenCode web UI the server password before it finishes booting. + * + * The credential lives in localStorage, so it can only be written once the + * document exists. When the write lands after startup, reload once to pick it + * up. The second pass finds a matching value and does not reload again. + */ + private async seedServerCredentials(): Promise { + const webview = this.embedEl; + if (!webview) { + return; + } + + const password = this.plugin.getServerPassword(); + if (!password) { + return; + } + + try { + const changed = await webview.executeJavaScript( + buildAuthSeedScript(this.plugin.getServerUrl(), password) + ); + if (changed === true && this.embedEl === webview) { + webview.reload(); + } + } catch (error) { + console.warn("[OpenCode] Failed to seed server credentials", error); + } + } + setIframeUrl(url: string): void { if (!this.embedEl) { return; diff --git a/src/ui/authSeed.ts b/src/ui/authSeed.ts new file mode 100644 index 0000000..70c94d8 --- /dev/null +++ b/src/ui/authSeed.ts @@ -0,0 +1,79 @@ +import { STORAGE_KEY_SERVER, normalizeServerUrl } from "../lib/serverUrl"; + +/** + * Script that writes the server credential into the OpenCode web UI storage. + * + * Returns true when it changed anything. The caller then reloads the webview + * once, because the web UI reads this value during startup. On the next pass + * the value already matches, so the script returns false and no reload loop + * can occur. + */ +export function buildAuthSeedScript(serverUrl: string, password: string): string { + const url = normalizeServerUrl(serverUrl); + + return `(function () { + var key = ${JSON.stringify(STORAGE_KEY_SERVER)}; + var url = ${JSON.stringify(url)}; + var password = ${JSON.stringify(password)}; + + var stored = null; + try { + stored = window.localStorage.getItem(key); + } catch (error) { + return false; + } + + var state = null; + try { + state = stored ? JSON.parse(stored) : null; + } catch (error) { + state = null; + } + + if (!state || typeof state !== "object") { + state = {}; + } + if (!Array.isArray(state.list)) { + state.list = []; + } + if (!state.hidden) { + state.hidden = {}; + } + if (!state.projects) { + state.projects = {}; + } + if (!state.lastProject) { + state.lastProject = {}; + } + if (!state.recentlyClosed) { + state.recentlyClosed = {}; + } + + var entry = null; + for (var i = 0; i < state.list.length; i += 1) { + var candidate = state.list[i]; + if (candidate && candidate.type === "http" && candidate.http && candidate.http.url === url) { + entry = candidate; + break; + } + } + + if (entry && entry.http.password === password) { + return false; + } + + if (entry) { + entry.http.password = password; + } else { + state.list.push({ type: "http", http: { url: url, password: password } }); + } + + try { + window.localStorage.setItem(key, JSON.stringify(state)); + } catch (error) { + return false; + } + + return true; +})();`; +} diff --git a/src/ui/noteDrop/NoteDropBridge.ts b/src/ui/noteDrop/NoteDropBridge.ts index ed584ec..14f7eea 100644 --- a/src/ui/noteDrop/NoteDropBridge.ts +++ b/src/ui/noteDrop/NoteDropBridge.ts @@ -4,6 +4,7 @@ import { getVaultRoot, vaultFileToProjectPath } from "./pathUtils"; export type OpenCodeWebview = HTMLElement & { executeJavaScript(code: string, userGesture?: boolean): Promise; + reload(): void; }; const FILE_DROP_PREFIX = "file:"; @@ -83,8 +84,7 @@ export class NoteDropBridge { constructor( private app: App, private getProjectDirectory: () => string, - private getWebview: () => OpenCodeWebview | null, - private appendPromptReference?: (relativePath: string) => Promise + private getWebview: () => OpenCodeWebview | null ) {} attach(container: HTMLElement): void { @@ -283,18 +283,9 @@ export class NoteDropBridge { try { await webview.executeJavaScript(buildFileDropScript(paths), true); - return; } catch (error) { - console.warn("[OpenCode] File drop injection failed, trying append-prompt", error); - } - - if (this.appendPromptReference) { - const appended = await this.appendPromptReference(paths[0]); - if (appended) { - return; - } + console.warn("[OpenCode] File drop injection failed", error); + new Notice("Could not add note reference to OpenCode chat.", 5000); } - - new Notice("Could not add note reference to OpenCode chat.", 5000); } } diff --git a/tests/OpenCodeClient.test.ts b/tests/OpenCodeClient.test.ts new file mode 100644 index 0000000..5dd55a6 --- /dev/null +++ b/tests/OpenCodeClient.test.ts @@ -0,0 +1,76 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { ServerManager } from "../src/server/ServerManager"; +import { OpenCodeClient } from "../src/client/OpenCodeClient"; +import { OpenCodeSettings } from "../src/types"; + +const TEST_PORT = 15100; +const PROJECT_DIR = process.cwd(); +const PASSWORD = "client-integration-password"; + +function createTestSettings(port: number): OpenCodeSettings { + return { + port, + hostname: "127.0.0.1", + autoStart: false, + opencodePath: "opencode", + projectDirectory: "", + startupTimeout: 30000, + defaultViewLocation: "sidebar", + injectWorkspaceContext: false, + maxNotesInContext: 20, + maxSelectionLength: 2000, + customCommand: "", + useCustomCommand: false, + serverPassword: PASSWORD, + }; +} + +let manager: ServerManager; +let client: OpenCodeClient; + +beforeAll(async () => { + manager = new ServerManager(createTestSettings(TEST_PORT), PROJECT_DIR); + const started = await manager.start(); + + if (!started) { + throw new Error(`Failed to start test server: ${manager.getLastError()}`); + } + + client = new OpenCodeClient(manager.getUrl(), manager.getPassword(), PROJECT_DIR); +}, 40000); + +afterAll(async () => { + await manager?.stop(); +}); + +describe("OpenCodeClient against a real v2 server", () => { + test("registers the project directory", async () => { + expect(await client.initializeProject()).toBe(true); + }); + + test("creates a session and returns a v2 session id", async () => { + const sessionId = await client.createSession(); + + expect(sessionId).toBeTruthy(); + expect(sessionId as string).toMatch(/^ses/); + }); + + test("builds the session route the v2 web UI understands", async () => { + const sessionId = await client.createSession(); + expect(sessionId).toBeTruthy(); + + const url = client.getSessionUrl(sessionId as string); + + expect(url).toBe( + `${manager.getUrl()}/server/aHR0cDovLzEyNy4wLjAuMToxNTEwMA/session/${sessionId}` + ); + expect(client.resolveSessionId(url)).toBe(sessionId); + }); + + test("returns null when the server password is wrong", async () => { + const wrongClient = new OpenCodeClient(manager.getUrl(), "not-the-password", PROJECT_DIR); + + expect(await wrongClient.initializeProject()).toBe(false); + expect(await wrongClient.createSession()).toBeNull(); + }); +}); diff --git a/tests/ServerManager.test.ts b/tests/ServerManager.test.ts index 6cf4c1b..4eb3993 100644 --- a/tests/ServerManager.test.ts +++ b/tests/ServerManager.test.ts @@ -27,6 +27,7 @@ function createTestSettings(port: number): OpenCodeSettings { maxSelectionLength: 2000, customCommand: "", useCustomCommand: false, + serverPassword: "test-password", }; } @@ -81,17 +82,15 @@ describe("ServerManager", () => { expect(stateHistory).toContain("running"); }, 30000); // Increased timeout for database migration on first run - test("reports correct server URL with encoded project directory", async () => { + test("reports the server root URL", async () => { const port = getNextPort(); const settings = createTestSettings(port); currentManager = new ServerManager(settings, PROJECT_DIR); const url = currentManager.getUrl(); - const expectedBase = `http://127.0.0.1:${port}`; - const expectedPath = Buffer.from(PROJECT_DIR).toString('base64'); - expect(url).toBe(`${expectedBase}/${expectedPath}`); + expect(url).toBe(`http://127.0.0.1:${port}`); }); test("stops server gracefully and transitions to stopped state", async () => { @@ -190,16 +189,121 @@ describe("ServerManager", () => { await currentManager.start(); - // Verify we can hit the health endpoint + // Verify we can hit the server root (open mode waives authentication) const url = currentManager.getUrl(); - const healthUrl = `${url}/global/health`; - const response = await fetch(healthUrl, { + const response = await fetch(url, { signal: AbortSignal.timeout(2000), }); expect(response.ok).toBe(true); }); + + test("passes the configured password to the server", async () => { + const port = getNextPort(); + const settings = createTestSettings(port); + settings.serverPassword = "integration-secret"; + + currentManager = new ServerManager(settings, PROJECT_DIR); + + await currentManager.start(); + + expect(currentManager.getPassword()).toBe("integration-secret"); + + // The API must accept the password the plugin pinned via the environment. + const token = Buffer.from(`opencode:${settings.serverPassword}`).toString("base64"); + const response = await fetch(`${currentManager.getUrl()}/api/info`, { + headers: { Authorization: `Basic ${token}` }, + signal: AbortSignal.timeout(5000), + }); + + expect(response.status).toBe(200); + const info = (await response.json()) as { version?: string }; + expect(info.version).toBeDefined(); + }); + }); + + describe("password mismatch", () => { + test("reports an error when a server on the port rejects the configured password", async () => { + const port = getNextPort(); + const otherPassword = "someone-elses-password"; + const otherToken = Buffer.from(`opencode:${otherPassword}`).toString("base64"); + + const server = Bun.spawn( + ["opencode", "serve", "--port", String(port), "--hostname", "127.0.0.1"], + { + env: { ...process.env, OPENCODE_SERVER_PASSWORD: otherPassword }, + stdout: "pipe", + stderr: "pipe", + } + ); + + try { + const deadline = Date.now() + 20000; + let up = false; + while (Date.now() < deadline) { + try { + const response = await fetch(`http://127.0.0.1:${port}/api/info`, { + headers: { Authorization: `Basic ${otherToken}` }, + signal: AbortSignal.timeout(1000), + }); + if (response.ok) { + up = true; + break; + } + } catch { + // not listening yet + } + await new Promise((resolve) => setTimeout(resolve, 300)); + } + expect(up).toBe(true); + + const settings = createTestSettings(port); + settings.serverPassword = "different-password"; + const manager = new ServerManager(settings, PROJECT_DIR); + + const success = await manager.start(); + + expect(success).toBe(false); + expect(manager.getState()).toBe("error"); + expect(manager.getLastError()).toContain("rejected the configured password"); + } finally { + server.kill(); + await server.exited; + } + }, 40000); + }); + + describe("custom command that pins a different password", () => { + test("fails cleanly and does not leave the server running", async () => { + const port = getNextPort(); + const settings = createTestSettings(port); + settings.startupTimeout = 4000; + settings.useCustomCommand = true; + settings.serverPassword = "plugin-password"; + settings.customCommand = `OPENCODE_SERVER_PASSWORD=other-secret opencode serve --port ${port} --hostname 127.0.0.1`; + + const manager = new ServerManager(settings, PROJECT_DIR); + + const success = await manager.start(); + + expect(success).toBe(false); + expect(manager.getState()).toBe("error"); + expect(manager.getLastError()).toContain("rejected the configured password"); + + // The failed start must not leak the process it spawned. + await new Promise((resolve) => setTimeout(resolve, 1000)); + let stillListening = false; + try { + const response = await fetch(`http://127.0.0.1:${port}`, { + signal: AbortSignal.timeout(1500), + }); + stillListening = response.ok || response.status === 401; + } catch { + stillListening = false; + } + expect(stillListening).toBe(false); + }, 30000); }); describe("async stop behavior", () => { @@ -254,7 +358,7 @@ describe("ServerManager", () => { await new Promise((resolve) => setTimeout(resolve, 1000)); try { - const response = await fetch(`${url}/global/health`, { + const response = await fetch(url, { signal: AbortSignal.timeout(1000), }); // If we get here, server is still running - test should fail @@ -286,36 +390,12 @@ describe("ServerManager", () => { }); }); - describe("Unicode path support", () => { - test("getUrl handles Chinese characters in project directory", () => { - const settings = createTestSettings(getNextPort()); - const chinesePath = "C:/用户/Notes"; - const manager = new ServerManager(settings, chinesePath); - - const url = manager.getUrl(); - - expect(url).toContain("http://127.0.0.1:"); - expect(url).toContain(Buffer.from(chinesePath).toString('base64')); - }); - - test("getUrl handles Japanese characters in project directory", () => { - const settings = createTestSettings(getNextPort()); - const japanesePath = "/home/ユーザー/ノート"; - const manager = new ServerManager(settings, japanesePath); - - const url = manager.getUrl(); - - expect(url).toContain(Buffer.from(japanesePath).toString('base64')); - }); - - test("getUrl handles emoji in project directory", () => { + describe("server URL", () => { + test("getUrl returns the server root", () => { const settings = createTestSettings(getNextPort()); - const emojiPath = "/home/user/📁Notes"; - const manager = new ServerManager(settings, emojiPath); - - const url = manager.getUrl(); + const manager = new ServerManager(settings, "/home/user/\u{1F4C1}Notes"); - expect(url).toContain(Buffer.from(emojiPath).toString('base64')); + expect(manager.getUrl()).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); }); }); }); diff --git a/tests/authSeed.test.ts b/tests/authSeed.test.ts new file mode 100644 index 0000000..5da25b3 --- /dev/null +++ b/tests/authSeed.test.ts @@ -0,0 +1,111 @@ +import { describe, test, expect } from "bun:test"; +import { buildAuthSeedScript } from "../src/ui/authSeed"; +import { STORAGE_KEY_SERVER } from "../src/lib/serverUrl"; + +type FakeStorage = { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +}; + +function createFakeWindow(initial?: string): { window: { localStorage: FakeStorage }; read: () => string | null } { + const store = new Map(); + if (initial !== undefined) { + store.set(STORAGE_KEY_SERVER, initial); + } + + return { + window: { + localStorage: { + getItem: (key: string) => (store.has(key) ? (store.get(key) as string) : null), + setItem: (key: string, value: string) => { + store.set(key, value); + }, + }, + }, + read: () => (store.has(STORAGE_KEY_SERVER) ? (store.get(STORAGE_KEY_SERVER) as string) : null), + }; +} + +function runSeed(script: string, fake: { window: { localStorage: FakeStorage } }): unknown { + // A browser evaluates the injected expression and returns its value; mirror + // that here so the returned "changed" flag is observable. + const fn = new Function("window", `return ${script}`); + return fn(fake.window); +} + +describe("buildAuthSeedScript", () => { + test("writes the credential and reports a change on first run", () => { + const fake = createFakeWindow(); + const changed = runSeed(buildAuthSeedScript("http://127.0.0.1:14096", "secret"), fake); + + expect(changed).toBe(true); + expect(JSON.parse(fake.read() as string)).toEqual({ + list: [{ type: "http", http: { url: "http://127.0.0.1:14096", password: "secret" } }], + hidden: {}, + projects: {}, + lastProject: {}, + recentlyClosed: {}, + }); + }); + + test("reports no change when the credential already matches", () => { + const fake = createFakeWindow(); + const script = buildAuthSeedScript("http://127.0.0.1:14096", "secret"); + + expect(runSeed(script, fake)).toBe(true); + // A second pass must not report a change, otherwise the webview reloads forever. + expect(runSeed(script, fake)).toBe(false); + }); + + test("updates the password when it changed on the server", () => { + const fake = createFakeWindow(); + runSeed(buildAuthSeedScript("http://127.0.0.1:14096", "old"), fake); + + const changed = runSeed(buildAuthSeedScript("http://127.0.0.1:14096", "new"), fake); + + expect(changed).toBe(true); + const state = JSON.parse(fake.read() as string); + expect(state.list).toHaveLength(1); + expect(state.list[0].http.password).toBe("new"); + }); + + test("keeps other stored servers and existing state", () => { + const other = { + list: [{ type: "http", http: { url: "http://10.0.0.5:4096", password: "other" } }], + hidden: { "http://10.0.0.5:4096": true }, + projects: { "http://10.0.0.5:4096": [{ worktree: "/tmp/x" }] }, + lastProject: {}, + recentlyClosed: {}, + }; + const fake = createFakeWindow(JSON.stringify(other)); + + runSeed(buildAuthSeedScript("http://127.0.0.1:14096", "secret"), fake); + + const state = JSON.parse(fake.read() as string); + expect(state.list).toHaveLength(2); + expect(state.list[0].http.url).toBe("http://10.0.0.5:4096"); + expect(state.list[1].http.url).toBe("http://127.0.0.1:14096"); + expect(state.hidden).toEqual({ "http://10.0.0.5:4096": true }); + expect(state.projects).toEqual({ "http://10.0.0.5:4096": [{ worktree: "/tmp/x" }] }); + }); + + test("recovers from corrupted storage", () => { + const fake = createFakeWindow("not json"); + + const changed = runSeed(buildAuthSeedScript("http://127.0.0.1:14096", "secret"), fake); + + expect(changed).toBe(true); + const state = JSON.parse(fake.read() as string); + expect(state.list).toHaveLength(1); + expect(state.list[0].http.password).toBe("secret"); + }); + + test("normalizes a trailing slash so the server key matches", () => { + const fake = createFakeWindow(); + + runSeed(buildAuthSeedScript("http://127.0.0.1:14096/", "secret"), fake); + + const state = JSON.parse(fake.read() as string); + expect(state.list[0].http.url).toBe("http://127.0.0.1:14096"); + }); +}); diff --git a/tests/serverUrl.test.ts b/tests/serverUrl.test.ts new file mode 100644 index 0000000..9fae150 --- /dev/null +++ b/tests/serverUrl.test.ts @@ -0,0 +1,68 @@ +import { describe, test, expect } from "bun:test"; +import { + buildAuthHeader, + buildSessionUrl, + encodeServerKey, + normalizeServerUrl, + STORAGE_KEY_SERVER, +} from "../src/lib/serverUrl"; + +describe("serverUrl", () => { + describe("normalizeServerUrl", () => { + test("strips trailing slashes", () => { + expect(normalizeServerUrl("http://127.0.0.1:14096/")).toBe("http://127.0.0.1:14096"); + expect(normalizeServerUrl("http://127.0.0.1:14096///")).toBe("http://127.0.0.1:14096"); + }); + + test("keeps a URL without trailing slash unchanged", () => { + expect(normalizeServerUrl("http://127.0.0.1:14096")).toBe("http://127.0.0.1:14096"); + }); + }); + + describe("encodeServerKey", () => { + test("matches the encoding the OpenCode web UI produces", () => { + // Verified against the running v2 web UI. + expect(encodeServerKey("http://127.0.0.1:14096")).toBe("aHR0cDovLzEyNy4wLjAuMToxNDA5Ng"); + }); + + test("uses base64url characters and no padding", () => { + const key = encodeServerKey("http://localhost:1234"); + expect(key).not.toContain("+"); + expect(key).not.toContain("/"); + expect(key).not.toContain("="); + }); + + test("decodes back to the original URL", () => { + const url = "http://127.0.0.1:14096"; + const decoded = Buffer.from(encodeServerKey(url), "base64url").toString("utf8"); + expect(decoded).toBe(url); + }); + + test("ignores a trailing slash", () => { + expect(encodeServerKey("http://127.0.0.1:14096/")).toBe( + encodeServerKey("http://127.0.0.1:14096") + ); + }); + }); + + describe("buildSessionUrl", () => { + test("builds the v2 session route", () => { + expect(buildSessionUrl("http://127.0.0.1:14096", "ses_abc123")).toBe( + "http://127.0.0.1:14096/server/aHR0cDovLzEyNy4wLjAuMToxNDA5Ng/session/ses_abc123" + ); + }); + }); + + describe("buildAuthHeader", () => { + test("uses the opencode username and the server password", () => { + const header = buildAuthHeader("secret"); + expect(header).toBe(`Basic ${Buffer.from("opencode:secret").toString("base64")}`); + }); + }); + + describe("STORAGE_KEY_SERVER", () => { + test("is the key the OpenCode web UI reads", () => { + expect(STORAGE_KEY_SERVER).toBe("opencode.global.dat:server"); + }); + }); +});