handler.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import { CodeEditor } from '@jupyterlab/codeeditor';
  4. import { IDataConnector, Text } from '@jupyterlab/coreutils';
  5. import { ReadonlyJSONObject, JSONObject, JSONArray } from '@phosphor/coreutils';
  6. import { IDisposable } from '@phosphor/disposable';
  7. import { Message, MessageLoop } from '@phosphor/messaging';
  8. import { Signal } from '@phosphor/signaling';
  9. import { Completer } from './widget';
  10. /**
  11. * A class added to editors that can host a completer.
  12. */
  13. const COMPLETER_ENABLED_CLASS: string = 'jp-mod-completer-enabled';
  14. /**
  15. * A class added to editors that have an active completer.
  16. */
  17. const COMPLETER_ACTIVE_CLASS: string = 'jp-mod-completer-active';
  18. /**
  19. * A completion handler for editors.
  20. */
  21. export class CompletionHandler implements IDisposable {
  22. /**
  23. * Construct a new completion handler for a widget.
  24. */
  25. constructor(options: CompletionHandler.IOptions) {
  26. this.completer = options.completer;
  27. this.completer.selected.connect(this.onCompletionSelected, this);
  28. this.completer.visibilityChanged.connect(this.onVisibilityChanged, this);
  29. this._connector = options.connector;
  30. }
  31. /**
  32. * The completer widget managed by the handler.
  33. */
  34. readonly completer: Completer;
  35. /**
  36. * The data connector used to populate completion requests.
  37. *
  38. * #### Notes
  39. * The only method of this connector that will ever be called is `fetch`, so
  40. * it is acceptable for the other methods to be simple functions that return
  41. * rejected promises.
  42. */
  43. get connector(): IDataConnector<
  44. CompletionHandler.IReply,
  45. void,
  46. CompletionHandler.IRequest
  47. > {
  48. return this._connector;
  49. }
  50. set connector(
  51. connector: IDataConnector<
  52. CompletionHandler.IReply,
  53. void,
  54. CompletionHandler.IRequest
  55. >
  56. ) {
  57. this._connector = connector;
  58. }
  59. /**
  60. * The editor used by the completion handler.
  61. */
  62. get editor(): CodeEditor.IEditor | null {
  63. return this._editor;
  64. }
  65. set editor(newValue: CodeEditor.IEditor | null) {
  66. if (newValue === this._editor) {
  67. return;
  68. }
  69. let editor = this._editor;
  70. // Clean up and disconnect from old editor.
  71. if (editor && !editor.isDisposed) {
  72. const model = editor.model;
  73. editor.host.classList.remove(COMPLETER_ENABLED_CLASS);
  74. editor.host.classList.remove(COMPLETER_ACTIVE_CLASS);
  75. model.selections.changed.disconnect(this.onSelectionsChanged, this);
  76. model.value.changed.disconnect(this.onTextChanged, this);
  77. }
  78. // Reset completer state.
  79. this.completer.reset();
  80. this.completer.editor = newValue;
  81. // Update the editor and signal connections.
  82. editor = this._editor = newValue;
  83. if (editor) {
  84. const model = editor.model;
  85. this._enabled = false;
  86. model.selections.changed.connect(this.onSelectionsChanged, this);
  87. model.value.changed.connect(this.onTextChanged, this);
  88. // On initial load, manually check the cursor position.
  89. this.onSelectionsChanged();
  90. }
  91. }
  92. /**
  93. * Get whether the completion handler is disposed.
  94. */
  95. get isDisposed(): boolean {
  96. return this._isDisposed;
  97. }
  98. /**
  99. * Dispose of the resources used by the handler.
  100. */
  101. dispose(): void {
  102. if (this.isDisposed) {
  103. return;
  104. }
  105. this._isDisposed = true;
  106. Signal.clearData(this);
  107. }
  108. /**
  109. * Invoke the handler and launch a completer.
  110. */
  111. invoke(): void {
  112. MessageLoop.sendMessage(this, CompletionHandler.Msg.InvokeRequest);
  113. }
  114. /**
  115. * Process a message sent to the completion handler.
  116. */
  117. processMessage(msg: Message): void {
  118. switch (msg.type) {
  119. case CompletionHandler.Msg.InvokeRequest.type:
  120. this.onInvokeRequest(msg);
  121. break;
  122. default:
  123. break;
  124. }
  125. }
  126. /**
  127. * Get the state of the text editor at the given position.
  128. */
  129. protected getState(
  130. editor: CodeEditor.IEditor,
  131. position: CodeEditor.IPosition
  132. ): Completer.ITextState {
  133. return {
  134. text: editor.model.value.text,
  135. lineHeight: editor.lineHeight,
  136. charWidth: editor.charWidth,
  137. line: position.line,
  138. column: position.column
  139. };
  140. }
  141. /**
  142. * Handle a completion selected signal from the completion widget.
  143. */
  144. protected onCompletionSelected(completer: Completer, value: string): void {
  145. const model = completer.model;
  146. const editor = this._editor;
  147. if (!editor || !model) {
  148. return;
  149. }
  150. const patch = model.createPatch(value);
  151. if (!patch) {
  152. return;
  153. }
  154. const { offset, text } = patch;
  155. editor.model.value.text = text;
  156. const position = editor.getPositionAt(offset);
  157. if (position) {
  158. editor.setCursorPosition(position);
  159. }
  160. }
  161. /**
  162. * Handle `invoke-request` messages.
  163. */
  164. protected onInvokeRequest(msg: Message): void {
  165. // If there is no completer model, bail.
  166. if (!this.completer.model) {
  167. return;
  168. }
  169. // If a completer session is already active, bail.
  170. if (this.completer.model.original) {
  171. return;
  172. }
  173. let editor = this._editor;
  174. if (editor) {
  175. this._makeRequest(editor.getCursorPosition()).catch(reason => {
  176. console.log('Invoke request bailed', reason);
  177. });
  178. }
  179. }
  180. /**
  181. * Handle selection changed signal from an editor.
  182. *
  183. * #### Notes
  184. * If a sub-class reimplements this method, then that class must either call
  185. * its super method or it must take responsibility for adding and removing
  186. * the completer completable class to the editor host node.
  187. *
  188. * Despite the fact that the editor widget adds a class whenever there is a
  189. * primary selection, this method checks independently for two reasons:
  190. *
  191. * 1. The editor widget connects to the same signal to add that class, so
  192. * there is no guarantee that the class will be added before this method
  193. * is invoked so simply checking for the CSS class's existence is not an
  194. * option. Secondarily, checking the editor state should be faster than
  195. * querying the DOM in either case.
  196. * 2. Because this method adds a class that indicates whether completer
  197. * functionality ought to be enabled, relying on the behavior of the
  198. * `jp-mod-has-primary-selection` to filter out any editors that have
  199. * a selection means the semantic meaning of `jp-mod-completer-enabled`
  200. * is obscured because there may be cases where the enabled class is added
  201. * even though the completer is not available.
  202. */
  203. protected onSelectionsChanged(): void {
  204. const model = this.completer.model;
  205. const editor = this._editor;
  206. if (!editor) {
  207. return;
  208. }
  209. const host = editor.host;
  210. // If there is no model, return.
  211. if (!model) {
  212. this._enabled = false;
  213. host.classList.remove(COMPLETER_ENABLED_CLASS);
  214. return;
  215. }
  216. // If we are currently performing a subset match,
  217. // return without resetting the completer.
  218. if (model.subsetMatch) {
  219. return;
  220. }
  221. const position = editor.getCursorPosition();
  222. const line = editor.getLine(position.line);
  223. if (!line) {
  224. this._enabled = false;
  225. model.reset(true);
  226. host.classList.remove(COMPLETER_ENABLED_CLASS);
  227. return;
  228. }
  229. const { start, end } = editor.getSelection();
  230. // If there is a text selection, return.
  231. if (start.column !== end.column || start.line !== end.line) {
  232. this._enabled = false;
  233. model.reset(true);
  234. host.classList.remove(COMPLETER_ENABLED_CLASS);
  235. return;
  236. }
  237. // If the part of the line before the cursor is white space, return.
  238. if (line.slice(0, position.column).match(/^\s*$/)) {
  239. this._enabled = false;
  240. model.reset(true);
  241. host.classList.remove(COMPLETER_ENABLED_CLASS);
  242. return;
  243. }
  244. // Enable completion.
  245. if (!this._enabled) {
  246. this._enabled = true;
  247. host.classList.add(COMPLETER_ENABLED_CLASS);
  248. }
  249. // Dispatch the cursor change.
  250. model.handleCursorChange(this.getState(editor, editor.getCursorPosition()));
  251. }
  252. /**
  253. * Handle a text changed signal from an editor.
  254. */
  255. protected onTextChanged(): void {
  256. const model = this.completer.model;
  257. if (!model || !this._enabled) {
  258. return;
  259. }
  260. // If there is a text selection, no completion is allowed.
  261. const editor = this.editor;
  262. if (!editor) {
  263. return;
  264. }
  265. const { start, end } = editor.getSelection();
  266. if (start.column !== end.column || start.line !== end.line) {
  267. return;
  268. }
  269. // Dispatch the text change.
  270. model.handleTextChange(this.getState(editor, editor.getCursorPosition()));
  271. }
  272. /**
  273. * Handle a visibility change signal from a completer widget.
  274. */
  275. protected onVisibilityChanged(completer: Completer): void {
  276. // Completer is not active.
  277. if (completer.isDisposed || completer.isHidden) {
  278. if (this._editor) {
  279. this._editor.host.classList.remove(COMPLETER_ACTIVE_CLASS);
  280. this._editor.focus();
  281. }
  282. return;
  283. }
  284. // Completer is active.
  285. if (this._editor) {
  286. this._editor.host.classList.add(COMPLETER_ACTIVE_CLASS);
  287. }
  288. }
  289. /**
  290. * Make a completion request.
  291. */
  292. private _makeRequest(position: CodeEditor.IPosition): Promise<void> {
  293. const editor = this.editor;
  294. if (!editor) {
  295. return Promise.reject(new Error('No active editor'));
  296. }
  297. const text = editor.model.value.text;
  298. const offset = Text.jsIndexToCharIndex(editor.getOffsetAt(position), text);
  299. const pending = ++this._pending;
  300. const state = this.getState(editor, position);
  301. const request: CompletionHandler.IRequest = { text, offset };
  302. return this._connector
  303. .fetch(request)
  304. .then(reply => {
  305. if (this.isDisposed) {
  306. throw new Error('Handler is disposed');
  307. }
  308. // If a newer completion request has created a pending request, bail.
  309. if (pending !== this._pending) {
  310. throw new Error('A newer completion request is pending');
  311. }
  312. this._onReply(state, reply);
  313. })
  314. .catch(reason => {
  315. // Completion request failures or negative results fail silently.
  316. const model = this.completer.model;
  317. if (model) {
  318. model.reset(true);
  319. }
  320. });
  321. }
  322. /**
  323. * Receive a completion reply from the connector.
  324. *
  325. * @param state - The state of the editor when completion request was made.
  326. *
  327. * @param reply - The API response returned for a completion request.
  328. */
  329. private _onReply(
  330. state: Completer.ITextState,
  331. reply: CompletionHandler.IReply
  332. ): void {
  333. const model = this.completer.model;
  334. const text = state.text;
  335. if (!model) {
  336. return;
  337. }
  338. // Update the original request.
  339. model.original = state;
  340. // Dedupe the matches.
  341. const matches: string[] = [];
  342. const matchSet = new Set(reply.matches || []);
  343. if (reply.matches) {
  344. matchSet.forEach(match => {
  345. matches.push(match);
  346. });
  347. }
  348. // Extract the optional type map. The current implementation uses
  349. // _jupyter_types_experimental which provide string type names. We make no
  350. // assumptions about the names of the types, so other kernels can provide
  351. // their own types.
  352. // Even though the `metadata` field is required, it has historically not
  353. // been used. Defensively check if it exists.
  354. const metadata = reply.metadata || {};
  355. const types = metadata._jupyter_types_experimental as JSONArray;
  356. const typeMap: Completer.TypeMap = {};
  357. if (types) {
  358. types.forEach((item: JSONObject) => {
  359. // For some reason the _jupyter_types_experimental list has two entries
  360. // for each match, with one having a type of "<unknown>". Discard those
  361. // and use undefined to indicate an unknown type.
  362. const text = item.text as string;
  363. const type = item.type as string;
  364. if (matchSet.has(text) && type !== '<unknown>') {
  365. typeMap[text] = type;
  366. }
  367. });
  368. }
  369. // Update the options, including the type map.
  370. model.setOptions(matches, typeMap);
  371. // Update the cursor.
  372. model.cursor = {
  373. start: Text.charIndexToJsIndex(reply.start, text),
  374. end: Text.charIndexToJsIndex(reply.end, text)
  375. };
  376. }
  377. private _connector: IDataConnector<
  378. CompletionHandler.IReply,
  379. void,
  380. CompletionHandler.IRequest
  381. >;
  382. private _editor: CodeEditor.IEditor | null = null;
  383. private _enabled = false;
  384. private _pending = 0;
  385. private _isDisposed = false;
  386. }
  387. /**
  388. * A namespace for cell completion handler statics.
  389. */
  390. export namespace CompletionHandler {
  391. /**
  392. * The instantiation options for cell completion handlers.
  393. */
  394. export interface IOptions {
  395. /**
  396. * The completion widget the handler will connect to.
  397. */
  398. completer: Completer;
  399. /**
  400. * The data connector used to populate completion requests.
  401. *
  402. * #### Notes
  403. * The only method of this connector that will ever be called is `fetch`, so
  404. * it is acceptable for the other methods to be simple functions that return
  405. * rejected promises.
  406. */
  407. connector: IDataConnector<IReply, void, IRequest>;
  408. }
  409. /**
  410. * A reply to a completion request.
  411. */
  412. export interface IReply {
  413. /**
  414. * The starting index for the substring being replaced by completion.
  415. */
  416. start: number;
  417. /**
  418. * The end index for the substring being replaced by completion.
  419. */
  420. end: number;
  421. /**
  422. * A list of matching completion strings.
  423. */
  424. matches: ReadonlyArray<string>;
  425. /**
  426. * Any metadata that accompanies the completion reply.
  427. */
  428. metadata: ReadonlyJSONObject;
  429. }
  430. /**
  431. * The details of a completion request.
  432. */
  433. export interface IRequest {
  434. /**
  435. * The cursor offset position within the text being completed.
  436. */
  437. offset: number;
  438. /**
  439. * The text being completed.
  440. */
  441. text: string;
  442. }
  443. /**
  444. * A namespace for completion handler messages.
  445. */
  446. export namespace Msg {
  447. /* tslint:disable */
  448. /**
  449. * A singleton `'invoke-request'` message.
  450. */
  451. export const InvokeRequest = new Message('invoke-request');
  452. /* tslint:enable */
  453. }
  454. }