Procházet zdrojové kódy

Rename simple interface documents from the title widget (#10140)

* replaced simple interface title h1 with an input

* added TitleWidgetHandler

* added rename to Context

* added ability to rename documents from simple interface title widget

* Package integrity updates

* updated number of expected console errors

* added tests for rename

Co-authored-by: Cameron Toy <cameron-toy@users.noreply.github.com>
Cameron Toy před 4 roky
rodič
revize
6d58574510

+ 1 - 0
packages/application/package.json

@@ -45,6 +45,7 @@
     "@fortawesome/fontawesome-free": "^5.12.0",
     "@jupyterlab/apputils": "^3.1.0-alpha.5",
     "@jupyterlab/coreutils": "^5.1.0-alpha.5",
+    "@jupyterlab/docmanager": "^3.1.0-alpha.5",
     "@jupyterlab/docregistry": "^3.1.0-alpha.5",
     "@jupyterlab/rendermime": "^3.1.0-alpha.5",
     "@jupyterlab/rendermime-interfaces": "^3.1.0-alpha.5",

+ 147 - 12
packages/application/src/shell.ts

@@ -32,6 +32,8 @@ import {
 
 import { JupyterFrontEnd } from './frontend';
 
+import { isValidFileName } from '@jupyterlab/docmanager';
+
 /**
  * The class name added to AppShell instances.
  */
@@ -324,14 +326,14 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
     );
 
     // Setup single-document-mode title bar
-    const titleWidget = (this._titleWidget = new Widget());
-    titleWidget.id = 'jp-title-panel-title';
-    titleWidget.node.appendChild(document.createElement('h1'));
-    this.add(titleWidget, 'top', { rank: 100 });
+    const titleWidgetHandler = (this._titleWidgetHandler = new Private.TitleWidgetHandler(
+      this
+    ));
+    this.add(titleWidgetHandler.titleWidget, 'top', { rank: 100 });
 
     if (this._dockPanel.mode === 'multiple-document') {
       this._topHandler.addWidget(this._menuHandler.panel, 100);
-      titleWidget.hide();
+      titleWidgetHandler.hide();
     } else {
       rootLayout.insertWidget(2, this._menuHandler.panel);
     }
@@ -471,7 +473,7 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
       // Adjust menu and title
       // this.add(this._menuHandler.panel, 'top', {rank: 100});
       (this.layout as BoxLayout).insertWidget(2, this._menuHandler.panel);
-      this._titleWidget.show();
+      this._titleWidgetHandler.show();
       this._updateTitlePanelTitle();
 
       this._modeChanged.emit(mode);
@@ -519,7 +521,7 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
     // Adjust menu and title
     this.add(this._menuHandler.panel, 'top', { rank: 100 });
     // this._topHandler.addWidget(this._menuHandler.panel, 100)
-    this._titleWidget.hide();
+    this._titleWidgetHandler.hide();
 
     // Emit the mode changed signal
     this._modeChanged.emit(mode);
@@ -695,7 +697,7 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
       return;
     }
     this._layoutDebouncer.dispose();
-    this._titleWidget.dispose();
+    this._titleWidgetHandler.dispose();
     super.dispose();
   }
 
@@ -865,9 +867,10 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
    */
   private _updateTitlePanelTitle() {
     let current = this.currentWidget;
-    const h1 = this._titleWidget.node.children[0] as HTMLHeadElement;
-    h1.textContent = current ? current.title.label : '';
-    h1.title = current ? current.title.caption : '';
+    const inputElement = this._titleWidgetHandler.titleWidget.node
+      .children[0] as HTMLInputElement;
+    inputElement.value = current ? current.title.label : '';
+    inputElement.title = current ? current.title.caption : '';
   }
 
   /**
@@ -1210,7 +1213,7 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
   private _headerPanel: Panel;
   private _topHandler: Private.PanelHandler;
   private _menuHandler: Private.PanelHandler;
-  private _titleWidget: Widget;
+  private _titleWidgetHandler: Private.TitleWidgetHandler;
   private _bottomPanel: Panel;
   private _mainOptionsCache = new Map<Widget, DocumentRegistry.IOpenOptions>();
   private _sideOptionsCache = new Map<Widget, DocumentRegistry.IOpenOptions>();
@@ -1547,4 +1550,136 @@ namespace Private {
     private _stackedPanel: StackedPanel;
     private _lastCurrent: Widget | null;
   }
+
+  export class TitleWidgetHandler {
+    /**
+     * Construct a new title widget handler.
+     */
+    constructor(shell: ILabShell) {
+      this._shell = shell;
+      const titleWidget = (this._titleWidget = new Widget());
+      titleWidget.id = 'jp-title-panel-title';
+      const titleInput = document.createElement('input');
+      titleInput.type = 'text';
+      titleInput.addEventListener('keyup', this);
+      titleInput.addEventListener('click', this);
+      titleInput.addEventListener('blur', this);
+
+      titleWidget.node.appendChild(titleInput);
+    }
+
+    handleEvent(event: Event): void {
+      switch (event.type) {
+        case 'keyup':
+          void this._evtKeyUp(event as KeyboardEvent);
+          break;
+        case 'click':
+          this._evtClick(event as MouseEvent);
+          break;
+        case 'blur':
+          this._selected = false;
+          break;
+      }
+    }
+
+    /**
+     * Handle `keyup` events on the handler.
+     */
+    private async _evtKeyUp(event: KeyboardEvent): Promise<void> {
+      if (event.key == 'Enter') {
+        const widget = this._shell.currentWidget;
+        if (widget instanceof DocumentWidget) {
+          const oldName = widget.context.path.split('/').pop()!;
+          const inputElement = this.titleWidget.node
+            .children[0] as HTMLInputElement;
+          const newName = inputElement.value;
+          inputElement.blur();
+          if (newName !== oldName && isValidFileName(newName)) {
+            await widget.context.rename(newName);
+          } else {
+            inputElement.value = oldName;
+          }
+        }
+      }
+    }
+
+    /**
+     * Handle `click` events on the handler.
+     */
+    private _evtClick(event: MouseEvent): void {
+      // only handle primary button clicks
+      if (event.button !== 0 || this._selected) {
+        return;
+      }
+
+      const currentWidget = this._shell.currentWidget;
+      const inputElement = this.titleWidget.node
+        .children[0] as HTMLInputElement;
+      if (currentWidget == null || !(currentWidget instanceof DocumentWidget)) {
+        inputElement.readOnly = true;
+        return;
+      } else {
+        inputElement.removeAttribute('readOnly');
+      }
+
+      event.preventDefault();
+      event.stopPropagation();
+
+      this._selected = true;
+
+      const selectEnd = inputElement.value.indexOf('.');
+      if (selectEnd === -1) {
+        inputElement.select();
+      } else {
+        inputElement.setSelectionRange(0, selectEnd);
+      }
+    }
+
+    /**
+     * Get the input element managed by the handler.
+     */
+    get titleWidget(): Widget {
+      return this._titleWidget;
+    }
+
+    /**
+     * Dispose of the handler and the resources it holds.
+     */
+    dispose(): void {
+      if (this.isDisposed) {
+        return;
+      }
+      this._isDisposed = true;
+      this._titleWidget.node.removeEventListener('keyup', this);
+      this._titleWidget.node.removeEventListener('click', this);
+      this._titleWidget.node.removeEventListener('blur', this);
+      this._titleWidget.dispose();
+    }
+
+    /**
+     * Hide the title widget.
+     */
+    hide(): void {
+      this._titleWidget.hide();
+    }
+
+    /**
+     * Show the title widget.
+     */
+    show(): void {
+      this._titleWidget.show();
+    }
+
+    /**
+     * Test whether the handler has been disposed.
+     */
+    get isDisposed(): boolean {
+      return this._isDisposed;
+    }
+
+    private _titleWidget: Widget;
+    private _shell: ILabShell;
+    private _isDisposed: boolean = false;
+    private _selected: boolean = false;
+  }
 }

+ 1 - 0
packages/application/style/index.css

@@ -10,5 +10,6 @@
 @import url('~@jupyterlab/ui-components/style/index.css');
 @import url('~@jupyterlab/apputils/style/index.css');
 @import url('~@jupyterlab/docregistry/style/index.css');
+@import url('~@jupyterlab/docmanager/style/index.css');
 
 @import url('./base.css');

+ 1 - 0
packages/application/style/index.js

@@ -10,5 +10,6 @@ import '@lumino/widgets/style/index.js';
 import '@jupyterlab/ui-components/style/index.js';
 import '@jupyterlab/apputils/style/index.js';
 import '@jupyterlab/docregistry/style/index.js';
+import '@jupyterlab/docmanager/style/index.js';
 
 import './base.css';

+ 11 - 3
packages/application/style/titlepanel.css

@@ -18,11 +18,19 @@
   margin-left: 8px;
 }
 
-#jp-title-panel-title h1 {
-  font-size: 18px;
-  color: var(--jp-ui-font-color0);
+#jp-title-panel-title input {
+  background: transparent;
   margin: 0;
+  height: 28px;
+  box-sizing: border-box;
+  border: none;
+  font-size: 18px;
   font-weight: normal;
   font-family: var(--jp-ui-font-family);
   line-height: var(--jp-private-title-panel-height);
+  color: var(--jp-ui-font-color0);
+  outline: none;
+  appearance: none;
+  -webkit-appearance: none;
+  -moz-appearance: none;
 }

+ 1 - 1
packages/application/test/shell.spec.ts

@@ -26,7 +26,7 @@ describe('LabShell', () => {
 
   beforeAll(() => {
     console.debug(
-      'Expecting 5 console errors logged in this suite: "Widgets added to app shell must have unique id property."'
+      'Expecting 6 console errors logged in this suite: "Widgets added to app shell must have unique id property."'
     );
   });
 

+ 3 - 0
packages/application/tsconfig.json

@@ -12,6 +12,9 @@
     {
       "path": "../coreutils"
     },
+    {
+      "path": "../docmanager"
+    },
     {
       "path": "../docregistry"
     },

+ 6 - 0
packages/application/tsconfig.test.json

@@ -8,6 +8,9 @@
     {
       "path": "../coreutils"
     },
+    {
+      "path": "../docmanager"
+    },
     {
       "path": "../docregistry"
     },
@@ -41,6 +44,9 @@
     {
       "path": "../coreutils"
     },
+    {
+      "path": "../docmanager"
+    },
     {
       "path": "../docregistry"
     },

+ 1 - 1
packages/docmanager-extension/style/index.css

@@ -8,6 +8,6 @@
 @import url('~@jupyterlab/apputils/style/index.css');
 @import url('~@jupyterlab/statusbar/style/index.css');
 @import url('~@jupyterlab/docregistry/style/index.css');
-@import url('~@jupyterlab/application/style/index.css');
 @import url('~@jupyterlab/docmanager/style/index.css');
+@import url('~@jupyterlab/application/style/index.css');
 @import url('~@jupyterlab/mainmenu/style/index.css');

+ 1 - 1
packages/docmanager-extension/style/index.js

@@ -8,6 +8,6 @@ import '@lumino/widgets/style/index.js';
 import '@jupyterlab/apputils/style/index.js';
 import '@jupyterlab/statusbar/style/index.js';
 import '@jupyterlab/docregistry/style/index.js';
-import '@jupyterlab/application/style/index.js';
 import '@jupyterlab/docmanager/style/index.js';
+import '@jupyterlab/application/style/index.js';
 import '@jupyterlab/mainmenu/style/index.js';

+ 30 - 0
packages/docregistry/src/context.ts

@@ -244,6 +244,19 @@ export class Context<
     }
   }
 
+  /**
+   * Rename the document.
+   *
+   * @param newName - the new name for the document.
+   */
+  rename(newName: string): Promise<void> {
+    return this.ready.then(() => {
+      return this._manager.ready.then(() => {
+        return this._rename(newName);
+      });
+    });
+  }
+
   /**
    * Save the document contents to disk.
    */
@@ -499,6 +512,23 @@ export class Context<
     });
   }
 
+  /**
+   * Rename the document.
+   *
+   * @param newName - the new name for the document.
+   */
+  private async _rename(newName: string): Promise<void> {
+    const splitPath = this.path.split('/');
+    splitPath[splitPath.length - 1] = newName;
+    const newPath = splitPath.join('/');
+
+    await this._manager.contents.rename(this.path, newPath);
+    await this.sessionContext.session?.setPath(newPath);
+    await this.sessionContext.session?.setName(newName);
+
+    this._pathChanged.emit(this._path);
+  }
+
   /**
    * Save the document contents to disk.
    */

+ 5 - 0
packages/docregistry/src/registry.ts

@@ -901,6 +901,11 @@ export namespace DocumentRegistry {
      */
     readonly ready: Promise<void>;
 
+    /**
+     * Rename the document.
+     */
+    rename(newName: string): Promise<void>;
+
     /**
      * Save the document contents to disk.
      */

+ 22 - 0
packages/docregistry/test/context.spec.ts

@@ -278,6 +278,28 @@ describe('docregistry/context', () => {
       });
     });
 
+    describe('#rename()', () => {
+      it('should change the name of the file to the new name', async () => {
+        await context.initialize(true);
+        context.model.fromString('foo');
+
+        const newName = UUID.uuid4() + '.txt';
+
+        await context.rename(newName);
+        await context.save();
+
+        const opts: Contents.IFetchOptions = {
+          format: factory.fileFormat,
+          type: factory.contentType,
+          content: true
+        };
+
+        const model = await manager.contents.get(newName, opts);
+
+        expect(model.content).toBe('foo');
+      });
+    });
+
     describe('#save()', () => {
       it('should save the contents of the file to disk', async () => {
         await context.initialize(true);

+ 1 - 1
packages/filebrowser-extension/style/index.css

@@ -8,8 +8,8 @@
 @import url('~@jupyterlab/ui-components/style/index.css');
 @import url('~@jupyterlab/apputils/style/index.css');
 @import url('~@jupyterlab/statusbar/style/index.css');
-@import url('~@jupyterlab/application/style/index.css');
 @import url('~@jupyterlab/docmanager/style/index.css');
+@import url('~@jupyterlab/application/style/index.css');
 @import url('~@jupyterlab/filebrowser/style/index.css');
 @import url('~@jupyterlab/launcher/style/index.css');
 @import url('~@jupyterlab/mainmenu/style/index.css');

+ 1 - 1
packages/filebrowser-extension/style/index.js

@@ -8,8 +8,8 @@ import '@lumino/widgets/style/index.js';
 import '@jupyterlab/ui-components/style/index.js';
 import '@jupyterlab/apputils/style/index.js';
 import '@jupyterlab/statusbar/style/index.js';
-import '@jupyterlab/application/style/index.js';
 import '@jupyterlab/docmanager/style/index.js';
+import '@jupyterlab/application/style/index.js';
 import '@jupyterlab/filebrowser/style/index.js';
 import '@jupyterlab/launcher/style/index.js';
 import '@jupyterlab/mainmenu/style/index.js';

+ 1 - 1
packages/notebook-extension/style/index.css

@@ -10,8 +10,8 @@
 @import url('~@jupyterlab/codeeditor/style/index.css');
 @import url('~@jupyterlab/statusbar/style/index.css');
 @import url('~@jupyterlab/rendermime/style/index.css');
-@import url('~@jupyterlab/application/style/index.css');
 @import url('~@jupyterlab/docmanager/style/index.css');
+@import url('~@jupyterlab/application/style/index.css');
 @import url('~@jupyterlab/filebrowser/style/index.css');
 @import url('~@jupyterlab/cells/style/index.css');
 @import url('~@jupyterlab/launcher/style/index.css');

+ 1 - 1
packages/notebook-extension/style/index.js

@@ -10,8 +10,8 @@ import '@jupyterlab/apputils/style/index.js';
 import '@jupyterlab/codeeditor/style/index.js';
 import '@jupyterlab/statusbar/style/index.js';
 import '@jupyterlab/rendermime/style/index.js';
-import '@jupyterlab/application/style/index.js';
 import '@jupyterlab/docmanager/style/index.js';
+import '@jupyterlab/application/style/index.js';
 import '@jupyterlab/filebrowser/style/index.js';
 import '@jupyterlab/cells/style/index.js';
 import '@jupyterlab/launcher/style/index.js';

+ 1 - 1
packages/rendermime-extension/style/index.css

@@ -6,5 +6,5 @@
 /* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */
 @import url('~@jupyterlab/apputils/style/index.css');
 @import url('~@jupyterlab/rendermime/style/index.css');
-@import url('~@jupyterlab/application/style/index.css');
 @import url('~@jupyterlab/docmanager/style/index.css');
+@import url('~@jupyterlab/application/style/index.css');

+ 1 - 1
packages/rendermime-extension/style/index.js

@@ -6,5 +6,5 @@
 /* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */
 import '@jupyterlab/apputils/style/index.js';
 import '@jupyterlab/rendermime/style/index.js';
-import '@jupyterlab/application/style/index.js';
 import '@jupyterlab/docmanager/style/index.js';
+import '@jupyterlab/application/style/index.js';

+ 1 - 1
packages/toc-extension/style/index.css

@@ -6,8 +6,8 @@
 /* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */
 @import url('~@jupyterlab/ui-components/style/index.css');
 @import url('~@jupyterlab/rendermime/style/index.css');
-@import url('~@jupyterlab/application/style/index.css');
 @import url('~@jupyterlab/docmanager/style/index.css');
+@import url('~@jupyterlab/application/style/index.css');
 @import url('~@jupyterlab/fileeditor/style/index.css');
 @import url('~@jupyterlab/markdownviewer/style/index.css');
 @import url('~@jupyterlab/notebook/style/index.css');

+ 1 - 1
packages/toc-extension/style/index.js

@@ -6,8 +6,8 @@
 /* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */
 import '@jupyterlab/ui-components/style/index.js';
 import '@jupyterlab/rendermime/style/index.js';
-import '@jupyterlab/application/style/index.js';
 import '@jupyterlab/docmanager/style/index.js';
+import '@jupyterlab/application/style/index.js';
 import '@jupyterlab/fileeditor/style/index.js';
 import '@jupyterlab/markdownviewer/style/index.js';
 import '@jupyterlab/notebook/style/index.js';

+ 1 - 1
yarn.lock

@@ -11131,7 +11131,7 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1:
   resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
   integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=
 
-minimatch@3.0.4, minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4:
+minimatch@3.0.4, minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.4:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
   integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==