index.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import {
  4. ILayoutRestorer, JupyterLab, JupyterLabPlugin
  5. } from '@jupyterlab/application';
  6. import {
  7. ICommandPalette, InstanceTracker
  8. } from '@jupyterlab/apputils';
  9. import {
  10. CodeEditor, IEditorServices
  11. } from '@jupyterlab/codeeditor';
  12. import {
  13. IConsoleTracker
  14. } from '@jupyterlab/console';
  15. import {
  16. ISettingRegistry, MarkdownCodeBlocks, PathExt
  17. } from '@jupyterlab/coreutils';
  18. import {
  19. IFileBrowserFactory
  20. } from '@jupyterlab/filebrowser';
  21. import {
  22. FileEditor, FileEditorFactory, IEditorTracker
  23. } from '@jupyterlab/fileeditor';
  24. import {
  25. ILauncher
  26. } from '@jupyterlab/launcher';
  27. import {
  28. IEditMenu, IFileMenu, IMainMenu, IRunMenu, IViewMenu
  29. } from '@jupyterlab/mainmenu';
  30. import {
  31. JSONObject
  32. } from '@phosphor/coreutils';
  33. import {
  34. Menu
  35. } from '@phosphor/widgets';
  36. /**
  37. * The class name for the text editor icon from the default theme.
  38. */
  39. const EDITOR_ICON_CLASS = 'jp-TextEditorIcon';
  40. /**
  41. * The name of the factory that creates editor widgets.
  42. */
  43. const FACTORY = 'Editor';
  44. /**
  45. * The command IDs used by the fileeditor plugin.
  46. */
  47. namespace CommandIDs {
  48. export
  49. const createNew = 'fileeditor:create-new';
  50. export
  51. const lineNumbers = 'fileeditor:toggle-line-numbers';
  52. export
  53. const lineWrap = 'fileeditor:toggle-line-wrap';
  54. export
  55. const changeTabs = 'fileeditor:change-tabs';
  56. export
  57. const matchBrackets = 'fileeditor:toggle-match-brackets';
  58. export
  59. const autoClosingBrackets = 'fileeditor:toggle-autoclosing-brackets';
  60. export
  61. const createConsole = 'fileeditor:create-console';
  62. export
  63. const runCode = 'fileeditor:run-code';
  64. export
  65. const markdownPreview = 'fileeditor:markdown-preview';
  66. }
  67. /**
  68. * The editor tracker extension.
  69. */
  70. const plugin: JupyterLabPlugin<IEditorTracker> = {
  71. activate,
  72. id: '@jupyterlab/fileeditor-extension:plugin',
  73. requires: [IConsoleTracker, IEditorServices, IFileBrowserFactory, ILayoutRestorer, ISettingRegistry],
  74. optional: [ICommandPalette, ILauncher, IMainMenu],
  75. provides: IEditorTracker,
  76. autoStart: true
  77. };
  78. /**
  79. * Export the plugins as default.
  80. */
  81. export default plugin;
  82. /**
  83. * Activate the editor tracker plugin.
  84. */
  85. function activate(app: JupyterLab, consoleTracker: IConsoleTracker, editorServices: IEditorServices, browserFactory: IFileBrowserFactory, restorer: ILayoutRestorer, settingRegistry: ISettingRegistry, palette: ICommandPalette, launcher: ILauncher | null, menu: IMainMenu | null): IEditorTracker {
  86. const id = plugin.id;
  87. const namespace = 'editor';
  88. const factory = new FileEditorFactory({
  89. editorServices,
  90. factoryOptions: { name: FACTORY, fileTypes: ['*'], defaultFor: ['*'] }
  91. });
  92. const { commands, restored } = app;
  93. const tracker = new InstanceTracker<FileEditor>({ namespace });
  94. const isEnabled = () => tracker.currentWidget !== null &&
  95. tracker.currentWidget === app.shell.currentWidget;
  96. let config = { ...CodeEditor.defaultConfig };
  97. // Handle state restoration.
  98. restorer.restore(tracker, {
  99. command: 'docmanager:open',
  100. args: widget => ({ path: widget.context.path, factory: FACTORY }),
  101. name: widget => widget.context.path
  102. });
  103. /**
  104. * Update the setting values.
  105. */
  106. function updateSettings(settings: ISettingRegistry.ISettings): void {
  107. let cached =
  108. settings.get('editorConfig').composite as Partial<CodeEditor.IConfig>;
  109. Object.keys(config).forEach((key: keyof CodeEditor.IConfig) => {
  110. config[key] = cached[key] === null ?
  111. CodeEditor.defaultConfig[key] : cached[key];
  112. });
  113. }
  114. /**
  115. * Update the settings of the current tracker instances.
  116. */
  117. function updateTracker(): void {
  118. tracker.forEach(widget => { updateWidget(widget); });
  119. }
  120. /**
  121. * Update the settings of a widget.
  122. */
  123. function updateWidget(widget: FileEditor): void {
  124. const editor = widget.editor;
  125. Object.keys(config).forEach((key: keyof CodeEditor.IConfig) => {
  126. editor.setOption(key, config[key]);
  127. });
  128. }
  129. // Fetch the initial state of the settings.
  130. Promise.all([settingRegistry.load(id), restored]).then(([settings]) => {
  131. updateSettings(settings);
  132. updateTracker();
  133. settings.changed.connect(() => {
  134. updateSettings(settings);
  135. updateTracker();
  136. });
  137. }).catch((reason: Error) => {
  138. console.error(reason.message);
  139. updateTracker();
  140. });
  141. factory.widgetCreated.connect((sender, widget) => {
  142. widget.title.icon = EDITOR_ICON_CLASS;
  143. // Notify the instance tracker if restore data needs to update.
  144. widget.context.pathChanged.connect(() => { tracker.save(widget); });
  145. tracker.add(widget);
  146. updateWidget(widget);
  147. });
  148. app.docRegistry.addWidgetFactory(factory);
  149. // Handle the settings of new widgets.
  150. tracker.widgetAdded.connect((sender, widget) => {
  151. updateWidget(widget);
  152. });
  153. commands.addCommand(CommandIDs.lineNumbers, {
  154. execute: () => {
  155. config.lineNumbers = !config.lineNumbers;
  156. return settingRegistry.set(id, 'editorConfig', config)
  157. .catch((reason: Error) => {
  158. console.error(`Failed to set ${id}: ${reason.message}`);
  159. });
  160. },
  161. isEnabled,
  162. isToggled: () => config.lineNumbers,
  163. label: 'Line Numbers'
  164. });
  165. commands.addCommand(CommandIDs.lineWrap, {
  166. execute: () => {
  167. config.lineWrap = !config.lineWrap;
  168. return settingRegistry.set(id, 'editorConfig', config)
  169. .catch((reason: Error) => {
  170. console.error(`Failed to set ${id}: ${reason.message}`);
  171. });
  172. },
  173. isEnabled,
  174. isToggled: () => config.lineWrap,
  175. label: 'Word Wrap'
  176. });
  177. commands.addCommand(CommandIDs.changeTabs, {
  178. label: args => args['name'] as string,
  179. execute: args => {
  180. config.tabSize = args['size'] as number || 4;
  181. config.insertSpaces = !!args['insertSpaces'];
  182. return settingRegistry.set(id, 'editorConfig', config)
  183. .catch((reason: Error) => {
  184. console.error(`Failed to set ${id}: ${reason.message}`);
  185. });
  186. },
  187. isEnabled,
  188. isToggled: args => {
  189. const insertSpaces = !!args['insertSpaces'];
  190. const size = args['size'] as number || 4;
  191. return config.insertSpaces === insertSpaces && config.tabSize === size;
  192. }
  193. });
  194. commands.addCommand(CommandIDs.matchBrackets, {
  195. execute: () => {
  196. config.matchBrackets = !config.matchBrackets;
  197. return settingRegistry.set(id, 'editorConfig', config)
  198. .catch((reason: Error) => {
  199. console.error(`Failed to set ${id}: ${reason.message}`);
  200. });
  201. },
  202. label: 'Match Brackets',
  203. isEnabled,
  204. isToggled: () => config.matchBrackets
  205. });
  206. commands.addCommand(CommandIDs.autoClosingBrackets, {
  207. execute: () => {
  208. config.autoClosingBrackets = !config.autoClosingBrackets;
  209. return settingRegistry.set(id, 'editorConfig', config)
  210. .catch((reason: Error) => {
  211. console.error(`Failed to set ${id}: ${reason.message}`);
  212. });
  213. },
  214. label: 'Auto Close Brackets for Text Editor',
  215. isEnabled,
  216. isToggled: () => config.autoClosingBrackets
  217. });
  218. commands.addCommand(CommandIDs.createConsole, {
  219. execute: args => {
  220. const widget = tracker.currentWidget;
  221. if (!widget) {
  222. return;
  223. }
  224. return commands.execute('console:create', {
  225. activate: args['activate'],
  226. path: widget.context.path,
  227. preferredLanguage: widget.context.model.defaultKernelLanguage,
  228. ref: widget.id,
  229. insertMode: 'split-bottom'
  230. });
  231. },
  232. isEnabled,
  233. label: 'Create Console for Editor'
  234. });
  235. commands.addCommand(CommandIDs.runCode, {
  236. execute: () => {
  237. // Run the appropriate code, taking into account a ```fenced``` code block.
  238. const widget = tracker.currentWidget;
  239. if (!widget) {
  240. return;
  241. }
  242. let code = '';
  243. const editor = widget.editor;
  244. const path = widget.context.path;
  245. const extension = PathExt.extname(path);
  246. const selection = editor.getSelection();
  247. const { start, end } = selection;
  248. let selected = start.column !== end.column || start.line !== end.line;
  249. if (selected) {
  250. // Get the selected code from the editor.
  251. const start = editor.getOffsetAt(selection.start);
  252. const end = editor.getOffsetAt(selection.end);
  253. code = editor.model.value.text.substring(start, end);
  254. } else if (MarkdownCodeBlocks.isMarkdown(extension)) {
  255. const { text } = editor.model.value;
  256. const blocks = MarkdownCodeBlocks.findMarkdownCodeBlocks(text);
  257. for (let block of blocks) {
  258. if (block.startLine <= start.line && start.line <= block.endLine) {
  259. code = block.code;
  260. selected = true;
  261. break;
  262. }
  263. }
  264. }
  265. if (!selected) {
  266. // no selection, submit whole line and advance
  267. code = editor.getLine(selection.start.line);
  268. const cursor = editor.getCursorPosition();
  269. if (cursor.line + 1 === editor.lineCount) {
  270. let text = editor.model.value.text;
  271. editor.model.value.text = text + '\n';
  272. }
  273. editor.setCursorPosition({ line: cursor.line + 1, column: cursor.column });
  274. }
  275. const activate = false;
  276. if (code) {
  277. return commands.execute('console:inject', { activate, code, path });
  278. } else {
  279. return Promise.resolve(void 0);
  280. }
  281. },
  282. isEnabled,
  283. label: 'Run Code'
  284. });
  285. commands.addCommand(CommandIDs.markdownPreview, {
  286. execute: () => {
  287. let widget = tracker.currentWidget;
  288. if (!widget) {
  289. return;
  290. }
  291. let path = widget.context.path;
  292. return commands.execute('markdownviewer:open', { path });
  293. },
  294. isVisible: () => {
  295. let widget = tracker.currentWidget;
  296. return widget && PathExt.extname(widget.context.path) === '.md' || false;
  297. },
  298. label: 'Show Markdown Preview'
  299. });
  300. // Function to create a new untitled text file, given
  301. // the current working directory.
  302. const createNew = (cwd: string) => {
  303. return commands.execute('docmanager:new-untitled', {
  304. path: cwd, type: 'file'
  305. }).then(model => {
  306. return commands.execute('docmanager:open', {
  307. path: model.path, factory: FACTORY
  308. });
  309. });
  310. };
  311. // Add a command for creating a new text file.
  312. commands.addCommand(CommandIDs.createNew, {
  313. label: 'Text File',
  314. caption: 'Create a new text file',
  315. execute: () => {
  316. let cwd = browserFactory.defaultBrowser.model.path;
  317. return createNew(cwd);
  318. }
  319. });
  320. // Add a launcher item if the launcher is available.
  321. if (launcher) {
  322. launcher.add({
  323. displayName: 'Text Editor',
  324. category: 'Other',
  325. rank: 1,
  326. iconClass: EDITOR_ICON_CLASS,
  327. callback: createNew
  328. });
  329. }
  330. if (palette) {
  331. let args: JSONObject = {
  332. insertSpaces: false, size: 4, name: 'Indent with Tab'
  333. };
  334. let command = 'fileeditor:change-tabs';
  335. palette.addItem({ command, args, category: 'Text Editor' });
  336. for (let size of [1, 2, 4, 8]) {
  337. let args: JSONObject = {
  338. insertSpaces: true, size, name: `Spaces: ${size} `
  339. };
  340. palette.addItem({ command, args, category: 'Text Editor' });
  341. }
  342. }
  343. if (menu) {
  344. // Add the editing commands to the settings menu.
  345. const tabMenu = new Menu({ commands });
  346. tabMenu.title.label = 'Text Editor Indentation';
  347. let args: JSONObject = {
  348. insertSpaces: false, size: 4, name: 'Indent with Tab'
  349. };
  350. let command = 'fileeditor:change-tabs';
  351. tabMenu.addItem({ command, args });
  352. for (let size of [1, 2, 4, 8]) {
  353. let args: JSONObject = {
  354. insertSpaces: true, size, name: `Spaces: ${size} `
  355. };
  356. tabMenu.addItem({ command, args });
  357. }
  358. menu.settingsMenu.addGroup([
  359. { type: 'submenu', submenu: tabMenu },
  360. { command: CommandIDs.autoClosingBrackets }
  361. ], 30);
  362. // Add new text file creation to the file menu.
  363. menu.fileMenu.newMenu.addGroup([{ command: CommandIDs.createNew }], 30);
  364. // Add undo/redo hooks to the edit menu.
  365. menu.editMenu.undoers.add({
  366. tracker,
  367. undo: widget => { widget.editor.undo(); },
  368. redo: widget => { widget.editor.redo(); }
  369. } as IEditMenu.IUndoer<FileEditor>);
  370. // Add editor view options.
  371. menu.viewMenu.editorViewers.add({
  372. tracker,
  373. toggleLineNumbers: widget => {
  374. const lineNumbers = !widget.editor.getOption('lineNumbers');
  375. widget.editor.setOption('lineNumbers', lineNumbers);
  376. },
  377. toggleWordWrap: widget => {
  378. const wordWrap = !widget.editor.getOption('lineWrap');
  379. widget.editor.setOption('lineWrap', wordWrap);
  380. },
  381. toggleMatchBrackets: widget => {
  382. const matchBrackets = !widget.editor.getOption('matchBrackets');
  383. widget.editor.setOption('matchBrackets', matchBrackets);
  384. },
  385. lineNumbersToggled: widget => widget.editor.getOption('lineNumbers'),
  386. wordWrapToggled: widget => widget.editor.getOption('lineWrap'),
  387. matchBracketsToggled: widget => widget.editor.getOption('matchBrackets')
  388. } as IViewMenu.IEditorViewer<FileEditor>);
  389. // Add a console creator the the Kernel menu.
  390. menu.fileMenu.consoleCreators.add({
  391. tracker,
  392. name: 'Editor',
  393. createConsole: current => {
  394. const options = {
  395. path: current.context.path,
  396. preferredLanguage: current.context.model.defaultKernelLanguage
  397. };
  398. return commands.execute('console:create', options);
  399. }
  400. } as IFileMenu.IConsoleCreator<FileEditor>);
  401. // Add a code runner to the Run menu.
  402. menu.runMenu.codeRunners.add({
  403. tracker,
  404. noun: 'Code',
  405. isEnabled: current => {
  406. let found = false;
  407. consoleTracker.forEach(console => {
  408. if (console.console.session.path === current.context.path) {
  409. found = true;
  410. }
  411. });
  412. return found;
  413. },
  414. run: () => commands.execute(CommandIDs.runCode)
  415. } as IRunMenu.ICodeRunner<FileEditor>);
  416. }
  417. app.contextMenu.addItem({
  418. command: CommandIDs.createConsole, selector: '.jp-FileEditor'
  419. });
  420. app.contextMenu.addItem({
  421. command: CommandIDs.markdownPreview, selector: '.jp-FileEditor'
  422. });
  423. return tracker;
  424. }