diff --git a/src/__tests__/opencode_cell_actions.spec.ts b/src/__tests__/opencode_cell_actions.spec.ts new file mode 100644 index 0000000..0067753 --- /dev/null +++ b/src/__tests__/opencode_cell_actions.spec.ts @@ -0,0 +1,161 @@ +/** + * Unit tests for OpenCodeCellActions 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). + */ +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 id = ''; + public parent: Widget | null = null; + private _isDisposed = false; + public get isDisposed(): boolean { + return this._isDisposed; + } + public addClass(cls: string): void { + this.node.classList.add(cls); + } + public dispose(): void { + this._isDisposed = true; + } + } + return { Widget }; +}); + +import { OpenCodeCellActions } from '../components/opencode_cell_actions'; +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), + 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; + + // Parent chain: cell -> notebook -> panel (mirrors real JupyterLab, where + // the cell's grandparent Notebook matches the duck-typed panel lookup). + 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); + } + + Object.defineProperty(cell, 'parent', { value: notebook, configurable: true }); + Object.defineProperty(notebook, 'parent', { value: panel, configurable: true }); + + return cell as CodeCell; +} + +describe('OpenCodeCellActions', () => { + it('renders 3 buttons with the expected labels', () => { + const cell = makeFakeCell('x = 1'); + const actions = new OpenCodeCellActions(cell); + const btns = actions.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 the notebook panel cannot be resolved', () => { + const cell = new (CodeCell as unknown as new () => CodeCell)(); + (cell as any).model = { + id: 'orphan', + type: 'code', + sharedModel: { getSource: () => 'x = 1' }, + outputs: makeFakeOutputs([]), + contentChanged: { connect: jest.fn(), disconnect: jest.fn() }, + stateChanged: { connect: jest.fn(), disconnect: jest.fn() } + }; + // No parent chain: extractCellContextFromCell must return null. + const actions = new OpenCodeCellActions(cell); + const btns = actions.node.querySelectorAll('button'); + btns.forEach((b: HTMLButtonElement) => { + expect(b.disabled).toBe(true); + }); + }); + + it('enables optimize and edit; fix stays disabled without an error', () => { + const cell = makeFakeCell('x = 1', [], 'foo.ipynb', 0, 1); + const actions = new OpenCodeCellActions(cell); + const btns = actions.node.querySelectorAll('button'); + expect((btns[0] as HTMLButtonElement).disabled).toBe(false); + expect((btns[1] as HTMLButtonElement).disabled).toBe(true); + expect((btns[2] as HTMLButtonElement).disabled).toBe(false); + }); + + it('enables the fix button when the cell has an error output', () => { + const cell = makeFakeCell( + '1/0', + [ + { + type: 'error', + ename: 'ZeroDivisionError', + evalue: 'div by zero', + traceback: [] + } + ], + 'foo.ipynb', + 0, + 1 + ); + const actions = new OpenCodeCellActions(cell); + const btns = actions.node.querySelectorAll('button'); + expect((btns[1] as HTMLButtonElement).disabled).toBe(false); + }); + + it('disconnects model signals on dispose', () => { + const cell = makeFakeCell('x = 1'); + const actions = new OpenCodeCellActions(cell); + actions.dispose(); + const model = (cell as any).model; + expect(model.contentChanged.disconnect).toHaveBeenCalled(); + expect(model.stateChanged.disconnect).toHaveBeenCalled(); + }); +}); diff --git a/src/components/opencode_cell_actions.ts b/src/components/opencode_cell_actions.ts new file mode 100644 index 0000000..02b2f5e --- /dev/null +++ b/src/components/opencode_cell_actions.ts @@ -0,0 +1,179 @@ +/** + * Per-cell actions widget rendered inside JupyterLab's native Cell toolbar + * (top-right of the active cell, next to the move up/down buttons). + * + * Registered in index.ts via IToolbarWidgetRegistry.addFactory('Cell', ...); + * the toolbar factory passes the owning Cell, so the cell arrives via the + * constructor instead of being resolved from `this.parent` on attach. + */ +import type { 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'; + +export class OpenCodeCellActions extends Widget { + private _cell: CodeCell; + private _context: CellContext | null = null; + private _status: Status = 'idle'; + + constructor(cell: CodeCell) { + super(); + this._cell = cell; + this.addClass('opencode-cell-actions'); + this._cell.model.contentChanged.connect(this._onModelChange, this); + this._cell.model.stateChanged.connect(this._onModelChange, this); + this._onModelChange(); + } + + dispose(): void { + if (this.isDisposed) { + return; + } + this._cell.model.contentChanged.disconnect(this._onModelChange, this); + this._cell.model.stateChanged.disconnect(this._onModelChange, this); + super.dispose(); + } + + private _onModelChange(): void { + this._context = extractCellContextFromCell(this._cell); + 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)); + } + + 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._render(); + + try { + const resp = await callOpenCodeEdit(request, _serverSettings); + this._handleResponse(resp); + } catch (e) { + this._status = 'idle'; + this._render(); + Notification.error(`OpenCode ${mode} 失败: ${(e as Error).message}`); + } + } + + private _handleResponse(resp: OpenCodeResponse): void { + this._status = 'idle'; + this._render(); + if (resp.ok) { + const len = resp.finalSource.length; + Notification.info( + `OpenCode ${resp.mode} 完成 (${len} chars). ` + + `Diff 面板将在 Slice 4 中提供。Session: ${resp.sessionId.slice(0, 8)}…` + ); + } else { + Notification.error(`OpenCode 错误: ${resp.error}`); + } + } +} + +/** + * Resolve a CodeCell's parent NotebookPanel by walking the parent chain, + * then build the CellContext. (Unchanged from the old footer helper; the + * `Widget.findParent` 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); +}