diff --git a/src/__tests__/opencode_cell_footer.spec.ts b/src/__tests__/opencode_cell_footer.spec.ts deleted file mode 100644 index 84fe9fb..0000000 --- a/src/__tests__/opencode_cell_footer.spec.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Unit tests for OpenCodeCellFooter DOM rendering and button disabled states. - * - * Mocks @jupyterlab/cells, @jupyterlab/apputils, and @lumino/widgets at the - * test boundary so the real ESM packages are not loaded (Jest + pnpm can't - * transform the hoisted @jupyterlab/* packages out of the box). - */ - -// Mock the heavy JupyterLab modules BEFORE importing the component. -// (jest.mock is hoisted by Jest, but listing it before imports is clearer.) -jest.mock('@jupyterlab/cells', () => ({ - CodeCell: class CodeCell {} -})); - -jest.mock('@jupyterlab/apputils', () => ({ - Notification: { - info: jest.fn(), - error: jest.fn(), - success: jest.fn(), - warning: jest.fn() - } -})); - -jest.mock('@lumino/widgets', () => { - class Widget { - public node: HTMLElement = document.createElement('div'); - public addClass(cls: string): void { - this.node.classList.add(cls); - } - public id: string = ''; - public parent: Widget | null = null; - protected onAfterAttach(_msg: unknown): void { - /* overridden by subclass */ - } - protected onBeforeDetach(_msg: unknown): void { - /* overridden by subclass */ - } - } - return { Widget }; -}); - -import { OpenCodeCellFooter } from '../components/opencode_cell_footer'; -import { CodeCell } from '@jupyterlab/cells'; - -function makeFakeOutputs(items: any[]): any { - return { - get length() { - return items.length; - }, - get(i: number) { - return items[i]; - } - }; -} - -function makeFakeCell( - source: string, - errorOutputs: any[] = [], - notebookPath = 'foo.ipynb', - cellIndex = 0, - totalCells = 1 -): CodeCell { - const model: any = { - id: 'cell-test', - type: 'code', - sharedModel: { - getSource: () => source - }, - outputs: makeFakeOutputs(errorOutputs), - // Signal stubs so the component can connect/disconnect without crashing. - contentChanged: { connect: jest.fn(), disconnect: jest.fn() }, - stateChanged: { connect: jest.fn(), disconnect: jest.fn() } - }; - const cell = new (CodeCell as unknown as new () => CodeCell)(); - (cell as any).model = model; - - // Build a fake parent chain: cell -> notebook -> panel. - const notebook: any = { widgets: [] as CodeCell[] }; - const panel: any = { - context: { path: notebookPath }, - content: notebook - }; - for (let i = 0; i < cellIndex; i++) { - notebook.widgets.push({ model: { sharedModel: { getSource: () => '' } } } as any); - } - notebook.widgets.push(cell); - for (let i = cellIndex + 1; i < totalCells; i++) { - notebook.widgets.push({ model: { sharedModel: { getSource: () => '' } } } as any); - } - - // The footer's helper walks `cell.parent` looking for a widget with - // `context` + `content.widgets`. The cell's grandparent (notebook) is what - // matches in real JupyterLab. We mirror that here. - Object.defineProperty(cell, 'parent', { value: notebook, configurable: true }); - Object.defineProperty(notebook, 'parent', { value: panel, configurable: true }); - - return cell as CodeCell; -} - -describe('OpenCodeCellFooter', () => { - it('renders 3 buttons', () => { - const footer = new OpenCodeCellFooter(); - const btns = footer.node.querySelectorAll('button'); - expect(btns.length).toBe(3); - expect(btns[0].textContent).toBe('✨ 优化'); - expect(btns[1].textContent).toBe('🐛 排错'); - expect(btns[2].textContent).toBe('🪄 编辑'); - }); - - it('disables all buttons when no cell is attached', () => { - const footer = new OpenCodeCellFooter(); - const btns = footer.node.querySelectorAll('button'); - btns.forEach((b: HTMLButtonElement) => { - expect(b.disabled).toBe(true); - }); - }); - - it('enables optimize and edit when a cell is attached; fix stays disabled without an error', () => { - const cell = makeFakeCell('x = 1', [], 'foo.ipynb', 0, 1); - const footer = new OpenCodeCellFooter(); - Object.defineProperty(footer, 'parent', { value: cell, configurable: true }); - (footer as any).onAfterAttach({} as any); - - const btns = footer.node.querySelectorAll('button'); - const optimize = btns[0] as HTMLButtonElement; - const fix = btns[1] as HTMLButtonElement; - const edit = btns[2] as HTMLButtonElement; - expect(optimize.disabled).toBe(false); - expect(fix.disabled).toBe(true); - expect(edit.disabled).toBe(false); - }); - - it('enables the fix button when the attached cell has an error output', () => { - const cell = makeFakeCell( - '1/0', - [ - { - type: 'error', - ename: 'ZeroDivisionError', - evalue: 'div by zero', - traceback: [] - } - ], - 'foo.ipynb', - 0, - 1 - ); - const footer = new OpenCodeCellFooter(); - Object.defineProperty(footer, 'parent', { value: cell, configurable: true }); - (footer as any).onAfterAttach({} as any); - - const btns = footer.node.querySelectorAll('button'); - const fix = btns[1] as HTMLButtonElement; - expect(fix.disabled).toBe(false); - }); -}); diff --git a/src/components/opencode_cell_factory.ts b/src/components/opencode_cell_factory.ts deleted file mode 100644 index 05c92a9..0000000 --- a/src/components/opencode_cell_factory.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Custom Cell.ContentFactory that adds our OpenCodeCellFooter to every cell. - * - * One factory instance per notebook; install via `installOpenCodeInNotebook`. - */ -import { ICellFooter } from '@jupyterlab/cells'; -import { Notebook } from '@jupyterlab/notebook'; - -import { OpenCodeCellFooter } from './opencode_cell_footer'; - -export class OpenCodeCellContentFactory extends Notebook.ContentFactory { - createCellFooter(): ICellFooter { - return new OpenCodeCellFooter(); - } -} diff --git a/src/components/opencode_cell_footer.ts b/src/components/opencode_cell_footer.ts deleted file mode 100644 index a6d1f69..0000000 --- a/src/components/opencode_cell_footer.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Per-cell footer widget: renders 3 buttons (optimize / fix / edit) and - * wires them to the opencode-bridge server. - * - * Resolves its owning CodeCell at onAfterAttach via `this.parent`. - * Subscribes to the cell model's contentChanged and stateChanged signals - * to keep the context fresh. - */ -import { CodeCell } from '@jupyterlab/cells'; -import { Notification } from '@jupyterlab/apputils'; -import type { NotebookPanel } from '@jupyterlab/notebook'; -import { ServerConnection } from '@jupyterlab/services'; -import { Widget } from '@lumino/widgets'; - -import { callOpenCodeEdit } from '../api/opencode_client'; -import { extractCellContext } from '../context/cell_context'; -import type { - CellContext, - OpenCodeMode, - OpenCodeRequest, - OpenCodeResponse, - OpenCodeSettings, -} from '../types'; - -// Module-level runtime injection (set by index.ts after settings load). -let _settings: OpenCodeSettings | null = null; -let _serverSettings: ServerConnection.ISettings | null = null; - -export function setOpenCodeRuntime(args: { - settings: OpenCodeSettings; - serverSettings: ServerConnection.ISettings; -}): void { - _settings = args.settings; - _serverSettings = args.serverSettings; -} - -type Status = 'idle' | 'loading' | 'error'; - -export class OpenCodeCellFooter extends Widget { - private _cell: CodeCell | null = null; - private _context: CellContext | null = null; - private _status: Status = 'idle'; - private _errorMessage: string | null = null; - - constructor() { - super(); - this.addClass('opencode-cell-footer'); - this._render(); - } - - protected onAfterAttach(_msg: unknown): void { - const parent = this.parent; - if (parent instanceof CodeCell) { - this._cell = parent; - this._cell.model.contentChanged.connect(this._onModelChange, this); - this._cell.model.stateChanged.connect(this._onModelChange, this); - this._onModelChange(); - } - } - - protected onBeforeDetach(_msg: unknown): void { - if (this._cell) { - this._cell.model.contentChanged.disconnect(this._onModelChange, this); - this._cell.model.stateChanged.disconnect(this._onModelChange, this); - } - this._cell = null; - } - - private _onModelChange(): void { - this._context = this._cell ? extractCellContextFromCell(this._cell) : null; - this._render(); - } - - private _render(): void { - const node = this.node; - node.textContent = ''; - - const hasError = this._context?.error != null; - const loading = this._status === 'loading'; - const baseDisabled = !this._context || loading; - - const mkBtn = ( - label: string, - mode: OpenCodeMode, - disabled: boolean - ): HTMLButtonElement => { - const b = document.createElement('button'); - b.className = `opencode-btn opencode-btn-${mode}`; - b.textContent = label; - b.disabled = disabled; - b.title = disabled - ? loading - ? 'OpenCode: 请求中…' - : 'OpenCode: 等待 cell 上下文…' - : label; - b.addEventListener('click', () => { - void this._onClick(mode); - }); - return b; - }; - - node.appendChild(mkBtn('✨ 优化', 'optimize', baseDisabled)); - node.appendChild(mkBtn('🐛 排错', 'fix', baseDisabled || !hasError)); - node.appendChild(mkBtn('🪄 编辑', 'edit', baseDisabled)); - - if (this._status === 'error' && this._errorMessage) { - const err = document.createElement('span'); - err.className = 'opencode-error'; - err.textContent = this._errorMessage; - err.title = this._errorMessage; - node.appendChild(err); - } - } - - private async _onClick(mode: OpenCodeMode): Promise { - if (!this._context) { - return; - } - if (!_settings || !_serverSettings) { - Notification.error('OpenCode 运行时未初始化,请检查 settings'); - return; - } - - let prompt = ''; - if (mode === 'edit') { - const input = window.prompt('请输入编辑指令:'); - if (input === null) { - return; - } - prompt = input; - } - - const request: OpenCodeRequest = { - mode, - prompt, - context: this._context, - providerId: _settings.opencodeProvider || undefined, - modelId: _settings.opencodeModel || undefined - }; - - this._status = 'loading'; - this._errorMessage = null; - this._render(); - - try { - const resp = await callOpenCodeEdit(request, _serverSettings); - this._handleResponse(resp); - } catch (e) { - this._status = 'error'; - this._errorMessage = (e as Error).message; - this._render(); - Notification.error(`OpenCode ${mode} 失败: ${this._errorMessage}`); - } - } - - private _handleResponse(resp: OpenCodeResponse): void { - if (resp.ok) { - this._status = 'idle'; - this._render(); - const len = resp.finalSource.length; - Notification.info( - `OpenCode ${resp.mode} 完成 (${len} chars). ` + - `Diff 面板将在 Slice 4 中提供。Session: ${resp.sessionId.slice(0, 8)}…` - ); - } else { - this._status = 'error'; - this._errorMessage = resp.error; - this._render(); - Notification.error(`OpenCode 错误: ${resp.error}`); - } - } -} - -/** - * Resolve a CodeCell's parent NotebookPanel by walking the parent chain, - * then build the CellContext. The footer uses this rather than `Widget.findParent` - * because that helper was removed in @lumino/widgets 2.x. - */ -function extractCellContextFromCell(cell: CodeCell): CellContext | null { - let node: Widget | null = cell.parent; - let notebookPanel: NotebookPanel | null = null; - while (node) { - // Use duck-typing: a notebook panel has `context.path` and `content.widgets`. - const candidate = node as any; - if ( - candidate.context && - candidate.content && - Array.isArray(candidate.content.widgets) - ) { - notebookPanel = candidate; - break; - } - node = node.parent; - } - if (!notebookPanel) { - return null; - } - return extractCellContext(cell, notebookPanel); -} diff --git a/src/components/opencode_installer.ts b/src/components/opencode_installer.ts deleted file mode 100644 index eb45c77..0000000 --- a/src/components/opencode_installer.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Install the OpenCode cell factory on every notebook tracked by INotebookTracker. - * - * For already-open notebooks: swap their contentFactory in place. Cells created - * after this point will use the new factory. (Cells already created retain their - * old factory — that's a known limitation; users must reload the notebook to see - * the footer in cells that were created before activation.) - * - * For future notebooks: listen to `tracker.widgetAdded` and swap on addition. - */ -import type { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook'; - -import { OpenCodeCellContentFactory } from './opencode_cell_factory'; - -export function installOpenCodeInNotebook(panel: NotebookPanel): void { - if (panel.content.contentFactory instanceof OpenCodeCellContentFactory) { - return; // already installed - } - const existing = panel.content.contentFactory; - const editorFactory = (existing as any).editorFactory; - const factory = new OpenCodeCellContentFactory({ editorFactory }); - // contentFactory is readonly in the typings but writable at runtime. - (panel.content as any).contentFactory = factory; -} - -export function installOpenCodeEverywhere(tracker: INotebookTracker): void { - // Patch already-open notebooks. - tracker.forEach(panel => { - installOpenCodeInNotebook(panel); - }); - // Patch future notebooks. - tracker.widgetAdded.connect((_, panel) => { - installOpenCodeInNotebook(panel); - }); -} diff --git a/src/index.ts b/src/index.ts index 049e5e3..c90eabe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,11 +3,15 @@ import { JupyterFrontEndPlugin } from '@jupyterlab/application'; -import { INotebookTracker } from '@jupyterlab/notebook'; +import { IToolbarWidgetRegistry } from '@jupyterlab/apputils'; +import { Cell, CodeCell } from '@jupyterlab/cells'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { Widget } from '@lumino/widgets'; -import { installOpenCodeEverywhere } from './components/opencode_installer'; -import { setOpenCodeRuntime } from './components/opencode_cell_footer'; +import { + OpenCodeCellActions, + setOpenCodeRuntime +} from './components/opencode_cell_actions'; import { requestAPI } from './request'; import { readOpenCodeSettings } from './types'; import { callOpenCodeProviders } from './api/opencode_client'; @@ -19,19 +23,31 @@ const plugin: JupyterFrontEndPlugin = { id: 'opencode_bridge:plugin', description: 'A JupyterLab extension bridging the UI to OpenCode Serve.', autoStart: true, - optional: [ISettingRegistry], - requires: [INotebookTracker], + optional: [ISettingRegistry, IToolbarWidgetRegistry], activate: ( app: JupyterFrontEnd, - tracker: INotebookTracker, - settingRegistry: ISettingRegistry | null + settingRegistry: ISettingRegistry | null, + toolbarRegistry: IToolbarWidgetRegistry | null ) => { console.log('JupyterLab extension opencode_bridge is activated!'); - // Install the per-cell footer factory on every notebook. - installOpenCodeEverywhere(tracker); + // Register the per-cell AI actions into the native Cell toolbar + // (top-right of the active cell, next to move up/down). Non-code + // cells get an empty widget so they show no AI buttons. + if (toolbarRegistry) { + toolbarRegistry.addFactory( + 'Cell', + 'opencode-cell-actions', + (cell: Cell) => { + if (cell instanceof CodeCell) { + return new OpenCodeCellActions(cell); + } + return new Widget(); + } + ); + } - // Load settings + push into toolbar module. + // Load settings + push into the actions module. if (settingRegistry) { void settingRegistry .load(plugin.id)