Repository: jpmorganchase/jupyter-fs (language: python)

Issue:
Add `Copy Path` to context menu for folders
As it says in the title!

This functionality would enable easily opening a terminal at the specified location.

An `Open in Terminal` context menu entry would also be very convenient but a simple `Copy Path` gets you 90% of the way there as you can manually open a terminal and paste the path.




Discussion / hints:
Thanks! I was considering whether to add a concept of template actions per resource. E.g. so that each file's context menu has a sub menu ("Use file...") with entries e.g.
- File path 
- Read contents (py)
- Write contents (py)

Clicking these menu items can then fill in the template, and copy the resulting string to the clipboard (or insert into active cell? Not sure about the ux).
That sounds like an interesting UI. I can imagine wanting to do different things for different files.

For Python files you might want `Execute in Kernel` for a markdown file `Show Preview` etc...

Fix patch:
diff --git a/js/package.json b/js/package.json
index d4f4738a..c4d99f52 100644
--- a/js/package.json
+++ b/js/package.json
@@ -63,12 +63,14 @@
     "jszip": "^3.7.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
+    "semver": "^7.5.4",
     "tree-finder": "^0.0.13"
   },
   "devDependencies": {
     "@babel/core": "^7.0.0",
     "@babel/preset-env": "^7.20.2",
     "@jupyterlab/builder": "^4.0.0",
+    "@rjsf/utils": "^5.13.2",
     "@types/file-saver": "^2.0.1",
     "@types/jest": "^29.5.1",
     "@types/jszip": "^3.4.1",
diff --git a/js/schema/plugin.json b/js/schema/plugin.json
index 545a08c6..fa480969 100644
--- a/js/schema/plugin.json
+++ b/js/schema/plugin.json
@@ -5,7 +5,6 @@
   "jupyter.lab.setting-icon": "jfs:drive",
   "jupyter.lab.setting-icon-label": "jupyter-fs",
   "type": "object",
-  "additionalProperties": false,
 
   "jupyter.lab.shortcuts": [
     {
@@ -39,7 +38,6 @@
     "resource": {
       "description": "Specification for an fs resource",
       "type": "object",
-      "additionalProperties": false,
       "properties": {
         "name": {
           "description": "Display name of resource",
@@ -47,6 +45,11 @@
         },
         "url": {
           "description": "A url pointing to an fs resource, as per the PyFilesystem fsurl specification",
+          "type": "string",
+          "pattern": "^.+?:\/\/([^:]*:.*@.*|[^@]*)$"
+        },
+        "preferred_dir": {
+          "description": "Directory to be first opened (e.g., myDir/mySubdir)",
           "type": "string"
         },
         "auth": {
@@ -61,6 +64,30 @@
           "default": true
         }
       }
+    },
+    "snippet": {
+      "description": "Per entry snippets for how to use it, e.g. a snippet for how to open a file from a given resource",
+      "type": "object",
+      "properties": {
+        "label": {
+          "description": "The designator to show to users",
+          "type": "string"
+        },
+        "caption": {
+          "description": "An optional, longer description to show to users",
+          "type": "string",
+          "default": ""
+        },
+        "pattern": {
+          "description": "A regular expression to match against the full URL of the entry, indicating if this snippet is valid for it",
+          "type": "string",
+          "default": ""
+        },
+        "template": {
+          "description": "A template string to build up the snippet",
+          "type": "string"
+        }
+      }
     }
   },
 
@@ -72,11 +99,78 @@
       "type": "array",
       "default": []
     },
+    "snippets": {
+      "title": "Snippets",
+      "description": "A list of usage snippets",
+      "items": { "$ref": "#/definitions/snippet" },
+      "type": "array",
+      "default": [
+        {
+          "label": "Read CSV as dataframe",
+          "caption": "Read the contents of this CSV file as a pandas DataFrame",
+          "pattern": "\\.csv$",
+          "template": "# uses https://docs.pyfilesystem.org/en/latest/reference/base.html#fs.base.FS.openbin \nfrom jupyterfs import open_fs\nimport pandas\nwith open_fs(\"{{url}}\") as fs_instance:\n    df = pandas.read_csv(fs_instance.openbin(\"{{path}}\"))"
+        },
+        {
+          "label": "Read contents",
+          "caption": "Read the contents of this file with PyFilesystem",
+          "pattern": "^(?!osfs://)(.*/)?[^/]+$",
+          "template": "# uses https://docs.pyfilesystem.org/en/latest/reference/base.html#fs.base.FS.readbytes \nfrom jupyterfs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n    contents = fs_instance.readbytes(\"{{path}}\")"
+        },
+        {
+          "label": "Read contents (local)",
+          "caption": "Read the contents of this local file",
+          "pattern": "^(?=osfs://)(.*/)?[^/]+$",
+          "template": "import pathlib;\ncontents = pathlib.Path(\"{{path}}\").read_bytes()"
+        },
+        {
+          "label": "Read text",
+          "caption": "Read the contents of this file as text with PyFilesystem",
+          "pattern": "^(?!osfs://)(.*/)?[^/]+$",
+          "template": "# uses https://docs.pyfilesystem.org/en/latest/reference/base.html#fs.base.FS.readtext \nfrom jupyterfs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n    contents = fs_instance.readtext(\"{{path}}\")"
+        },
+        {
+          "label": "List directory contents",
+          "caption": "List the entries of this directory with PyFilesystem",
+          "pattern": "^.*/$",
+          "template": "# uses https://docs.pyfilesystem.org/en/latest/reference/base.html#fs.base.FS.listdir \nfrom jupyterfs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n    entries = fs_instance.listdir(\"{{path}}\")"
+        },
+        {
+          "label": "Read Excel as dataframe",
+          "caption": "Read the contents of this Excel file as a pandas DataFrame",
+          "pattern": "\\.(xls|xlsx|xlsm|xlsb)$",
+          "template": "# uses https://docs.pyfilesystem.org/en/latest/reference/base.html#fs.base.FS.open \nfrom jupyterfs import open_fs\nimport pandas\nwith open_fs(\"{{url}}\") as fs_instance:\n    df = pandas.read_excel(fs_instance.open(\"{{path}}\", mode=\"rb\"))"
+        },
+        {
+          "label": "Write dataframe as CSV",
+          "caption": "Save a pandas DF from the session to the given CSV file with PyFilesystem",
+          "pattern": "\\.csv$",
+          "template": "# uses https://docs.pyfilesystem.org/en/latest/reference/base.html#fs.base.FS.writetext \nfrom jupyterfs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n    fs_instance.writetext(\"{{path}}\", YOUR_DF_VARIABLE.to_csv())"
+        },
+        {
+          "label": "Write contents",
+          "caption": "Write the given contents (bytes) to the specified file with PyFilesystem",
+          "pattern": "^(?!osfs://)(.*/)?[^/]+$",
+          "template": "# uses https://docs.pyfilesystem.org/en/latest/reference/base.html#fs.base.FS.writebytes \nfrom jupyterfs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n    fs_instance.writebytes(\"{{path}}\", YOUR_BYTES_CONTENT)"
+        },
+        {
+          "label": "Write contents (local)",
+          "caption": "Write the contents to this local file",
+          "pattern": "^(?=osfs://)(.*/)?[^/]+$",
+          "template": "import pathlib;\ncontents = pathlib.Path(\"{{path}}\").write_bytes(YOUR_BYTES_CONTENT)"
+        },
+        {
+          "label": "Write text",
+          "caption": "Write the given text to the specified file with PyFilesystem",
+          "pattern": "^(?!osfs://)(.*/)?[^/]+$",
+          "template": "# uses https://docs.pyfilesystem.org/en/latest/reference/base.html#fs.base.FS.writetext \nfrom jupyterfs import open_fs\nwith open_fs(\"{{url}}\") as fs_instance:\n    fs_instance.writetext(\"{{path}}\", YOUR_TEXT_CONTENT)"
+        }
+      ]
+    },
     "options": {
       "title": "Options",
       "description": "Global options for jupyter-fs",
       "type": "object",
-      "additionalProperties": false,
       "properties": {
         "cache": {
           "description": "If true, only recreate the actual resources when necessary (ie on initial load or changes to 'resources')",
@@ -87,6 +181,10 @@
           "description": "If true, jupyter-fs will output helpful info messages to the console",
           "type": "boolean",
           "default": false
+        },
+        "writtenVersion": {
+          "description": "The version of the schema these settings were written with (do not edit)",
+          "type": "string"
         }
       },
       "default": {}
diff --git a/js/src/auth.tsx b/js/src/auth.tsx
index 0ed05fa2..bcb09940 100644
--- a/js/src/auth.tsx
+++ b/js/src/auth.tsx
@@ -20,6 +20,7 @@ import {
   TextField,
   InputAdornment,
   IconButton,
+  Tooltip,
 } from "@material-ui/core";
 import * as React from "react";
 
@@ -54,8 +55,8 @@ function tokensFromUrl(url: string): string[] {
   return new DoubleBraceTemplate(url).tokens();
 }
 
-function _askRequired(spec: IFSResource) {
-  return spec.auth === "ask" && !spec.init;
+function _askRequired(spec: IFSResource): boolean {
+  return spec.auth === "ask" && !spec.init && !!(spec.missingTokens?.length);
 }
 
 export function askRequired(specs: IFSResource[]) {
@@ -131,6 +132,7 @@ export class AskDialog<
   protected _formInner() {
     return this.props.resources.map(resource => {
       // ask for credentials if needed, or state why not
+      const decodedUrl = decodeURIComponent(resource.url);
       const askReq = _askRequired(resource);
       const inputs = askReq ? this._inputs(resource.url) : [];
       const tokens = tokensFromUrl(resource.url);
@@ -153,7 +155,11 @@ export class AskDialog<
         >
           <ExpansionPanelSummary className="jfs-ask jfs-ask-panel-summary">
             <Typography>{summary}</Typography>
-            {!reason && <Typography>{resource.url}</Typography>}
+            {!reason &&
+              <Tooltip title={decodedUrl}>
+                <Typography noWrap={true}>{decodedUrl}</Typography>
+              </Tooltip>
+            }
           </ExpansionPanelSummary>
           <ExpansionPanelDetails className="jfs-ask jfs-ask-panel-details">
             {inputs}
diff --git a/js/src/commands.ts b/js/src/commands.ts
index 05d312ba..7b811e61 100644
--- a/js/src/commands.ts
+++ b/js/src/commands.ts
@@ -18,6 +18,7 @@ import {
   filterListIcon,
   pasteIcon,
   refreshIcon,
+  fileIcon,
   newFolderIcon,
 } from "@jupyterlab/ui-components";
 import { DisposableSet, IDisposable } from "@lumino/disposable";
@@ -30,9 +31,10 @@ import { TreeFinderSidebar } from "./treefinder";
 import type { IFSResource } from "./filesystem";
 import type { ContentsProxy } from "./contents_proxy";
 import type { TreeFinderTracker } from "./treefinder";
-import { getContentParent, getRefreshTargets, revealAndSelectPath } from "./contents_utils";
+import { getContentParent, getRefreshTargets, openDirRecursive, revealAndSelectPath, splitPathstrDrive } from "./contents_utils";
 import { ISettingRegistry } from "@jupyterlab/settingregistry";
 import { showErrorMessage } from "@jupyterlab/apputils";
+import { getAllSnippets, instantiateSnippet, Snippet } from "./snippets";
 
 // define the command ids as a constant tuple
 export const commandNames = [
@@ -45,10 +47,11 @@ export const commandNames = [
   "rename",
   "download",
   "create_folder",
-  // "create_file",
+  "create_file",
   // "navigate",
   "copyFullPath",
   "copyRelativePath",
+  "restore",
   "toggleColumnPath",
   "toggleColumn",
 ] as const;
@@ -99,41 +102,37 @@ function _getRelativePaths(selectedFiles: Array<Content<ContentsProxy.IJupyterCo
 }
 
 
-export function createCommands(
+async function _digestString(value: string): Promise<string> {
+  const encoded = new TextEncoder().encode(value); // encode as (utf-8) Uint8Array
+  const buffer = await crypto.subtle.digest("SHA-256", encoded); // hash the message
+  const hash = Array.from(new Uint8Array(buffer)); // convert buffer to byte array
+  return hash
+    .map(b => b.toString(16).padStart(2, "0"))
+    .join(""); // convert bytes to hex string
+}
+
+
+async function _commandKeyForSnippet(snippet: Snippet): Promise<string>  {
+  return `jupyterfs:snippet-${snippet.label}-${await _digestString(snippet.label + snippet.caption + snippet.pattern.source + snippet.template)}`;
+}
+
+
+function _normalizedUrlForSnippet(content: Content<ContentsProxy.IJupyterContentRow>, baseUrl: string): string {
+  const path = splitPathstrDrive(content.pathstr)[1];
+  return `${baseUrl}/${path}${content.hasChildren ? "/" : ""}`;
+}
+
+
+/**
+ * Create commands that will have the same IDs indepent of settings/resources
+ *
+ * These commands do not need to be recreated on settings/resource updates
+ */
+export function createStaticCommands(
   app: JupyterFrontEnd,
   tracker: TreeFinderTracker,
   clipboard: JupyterClipboard,
-  resources: IFSResource[],
-  settings?: ISettingRegistry.ISettings,
 ): IDisposable {
-  const selector = ".jp-tree-finder-sidebar";
-  const submenu = new Menu({ commands: app.commands });
-  submenu.title.label = "Show/Hide Columns";
-  submenu.title.icon = filterListIcon;
-  submenu.addItem({ command: commandIDs.toggleColumnPath });
-  for (const column of COLUMN_NAMES) {
-    submenu.addItem({ command: toggleColumnCommandId(column) });
-  }
-
-  // const toggleState: {[key: string]: {[key: string]: boolean}} = {};
-  // for (let resource of resources) {
-  //   const colsToDisplay = resource.displayColumns as string[] ?? ['size'];
-  //   const id = idFromResource(resource);
-  //   toggleState[id] = {};
-  //   const state = toggleState[id];
-  //   for (let key of COLUMN_NAMES) {
-  //     state[key] = colsToDisplay.includes(key);
-  //   }
-  // }
-  const toggleState: {[key: string]: boolean} = {};
-  const colsToDisplay = settings?.composite.display_columns as string[] ?? ["size"];
-  for (const key of COLUMN_NAMES) {
-    toggleState[key] = colsToDisplay.includes(key);
-  }
-
-  let contextMenuRank = 1;
-
-  // globally accessible jupyter commands[
   return [
     app.commands.addCommand(commandIDs.copy, {
       execute: args => clipboard.model.copySelection(tracker.currentWidget!.treefinder.model!),
@@ -218,7 +217,9 @@ export function createCommands(
       },
       icon: downloadIcon,
       label: "Download",
-      isEnabled: () => !!(tracker.currentWidget?.treefinder.model?.selection),
+      isEnabled: () => !!(
+        tracker.currentWidget?.treefinder.model?.selection?.some(s => !s.hasChildren)
+      ),
     }),
     app.commands.addCommand(commandIDs.create_folder, {
       execute: async args =>  {
@@ -254,6 +255,40 @@ export function createCommands(
       label: "New Folder",
       isEnabled: () => !!tracker.currentWidget,
     }),
+    app.commands.addCommand(commandIDs.create_file, {
+      execute: async args =>  {
+        const widget = tracker.currentWidget!;
+        const model = widget.treefinder.model!;
+        let target = model.selectedLast ?? model.root;
+        if (!target.hasChildren) {
+          target = await getContentParent(target, model.root);
+        }
+        const path = Path.fromarray(target.row.path);
+        let row: ContentsProxy.IJupyterContentRow;
+        try {
+          row = await widget.treefinder.contentsProxy.newUntitled({
+            type: "file",
+            path,
+          });
+        } catch (e) {
+          void showErrorMessage("Could not create file", e as string);
+          return;
+        }
+        target.invalidate();
+        const content = await revealAndSelectPath(model, row.path);
+        // Is this really needed?
+        model.refreshSub.next(getRefreshTargets([target.row], model.root));
+        // Scroll into view if not visible
+        await TreeFinderSidebar.scrollIntoView(widget.treefinder, content.pathstr);
+        const newContent = await TreeFinderSidebar.doRename(widget, content);
+        model.renamerSub.next( { name: newContent.name, target: content } );
+        // TODO: Model state of TreeFinderWidget should be updated by renamerSub process.
+        content.row = newContent;
+      },
+      icon: fileIcon,
+      label: "New File",
+      isEnabled: () => !!tracker.currentWidget,
+    }),
     app.commands.addCommand(commandIDs.refresh, {
       execute: args => {
         if (args["selection"]) {
@@ -285,14 +320,57 @@ export function createCommands(
       label: "Copy Relative Path",
       isEnabled: () => !!tracker.currentWidget,
     }),
-
     app.commands.addCommand(commandIDs.toggleColumnPath, {
       execute: args => { /* no-op */ },
       label: "path",
       isEnabled: () => false,
       isToggled: () => true,
     }),
-    ...COLUMN_NAMES.map((column: keyof ContentsProxy.IJupyterContentRow) => app.commands.addCommand(toggleColumnCommandId(column), {
+    app.commands.addCommand(commandIDs.restore, {
+      execute: async args => {
+        const rootPath = args.rootPath as string;
+        const dirsToOpen = rootPath.split("/");
+        const sidebar = tracker.findByDrive(args.id as string);
+        if (!sidebar) {
+          throw new Error(`Could not restore JupyterFS browser: ${args.id}`);
+        }
+        const treefinderwidget = sidebar.treefinder;
+        const model = treefinderwidget.model!;
+
+        // If preferredDir not specified, proceed with the restore
+        if (!sidebar.preferredDir) {
+          await openDirRecursive(model, dirsToOpen);
+          await tracker.save(sidebar);
+        }
+      },
+    }),
+  ].reduce((set: DisposableSet, d) => {
+    set.add(d); return set;
+  }, new DisposableSet());
+}
+
+
+/**
+ * Create commands whose count/IDs depend on settings/resources
+ */
+export async function createDynamicCommands(
+  app: JupyterFrontEnd,
+  tracker: TreeFinderTracker,
+  clipboard: JupyterClipboard,
+  resources: IFSResource[],
+  settings?: ISettingRegistry.ISettings,
+): Promise<IDisposable> {
+  const columnCommands = [];
+  const toggleState: {[key: string]: boolean} = {};
+  const colsToDisplay = settings?.composite.display_columns as string[] ?? ["size"];
+  const columnsMenu = new Menu({ commands: app.commands });
+  columnsMenu.title.label = "Show/Hide Columns";
+  columnsMenu.title.icon = filterListIcon;
+  columnsMenu.addItem({ command: commandIDs.toggleColumnPath });
+  for (const column of COLUMN_NAMES) {
+    columnsMenu.addItem({ command: toggleColumnCommandId(column) });
+    toggleState[column] = colsToDisplay.includes(column);
+    columnCommands.push(app.commands.addCommand(toggleColumnCommandId(column), {
       execute: async args => {
         toggleState[column] = !toggleState[column];
         await settings?.set("display_columns", COLUMN_NAMES.filter(k => toggleState[k]));
@@ -300,7 +378,49 @@ export function createCommands(
       label: column,
       isToggleable: true,
       isToggled: () => toggleState[column],
-    })),
+    }));
+  }
+
+
+  const snippetsMenu = new Menu({ commands: app.commands });
+  snippetsMenu.title.label = "Snippets";
+  const snippets = await getAllSnippets(settings);
+  const snippetCommands = [] as IDisposable[];
+  const snippetIds = new Set<string>();
+  for (const snippet of snippets) {
+    const key = await _commandKeyForSnippet(snippet);
+    if (snippetIds.has(key)) {
+      console.warn("Discarding duplicate snippet", snippet);
+      continue;
+    }
+    snippetIds.add(key);
+    snippetsMenu.addItem({ command: key });
+    snippetCommands.push(app.commands.addCommand(key, {
+      execute: async (args: unknown) => {
+        const sidebar = tracker.currentWidget!;
+        const content = sidebar.treefinder.selection![0];
+        const instantiated = instantiateSnippet(snippet.template, sidebar.url, content.pathstr);
+        await navigator.clipboard.writeText(instantiated);
+      },
+      label: snippet.label,
+      caption: snippet.caption,
+      isVisible: () => {
+        const sidebar = tracker.currentWidget;
+        const selection = sidebar?.treefinder.selection;
+        if (selection?.length) {
+          return snippet.pattern.test(_normalizedUrlForSnippet(selection[0], sidebar!.url));
+        }
+        return false;
+      },
+    }));
+  }
+
+  const selector = ".jp-tree-finder-sidebar";
+  let contextMenuRank = 1;
+
+  return [
+    ...columnCommands,
+    ...snippetCommands,
 
     // context menu items
     app.contextMenu.addItem({
@@ -354,6 +474,27 @@ export function createCommands(
       selector,
       rank: contextMenuRank++,
     }),
+    app.contextMenu.addItem({
+      type: "submenu",
+      submenu: snippetsMenu,
+      selector,
+      rank: contextMenuRank++,
+    }),
+    app.contextMenu.addItem({
+      type: "separator",
+      selector,
+      rank: contextMenuRank++,
+    }),
+    app.contextMenu.addItem({
+      command: commandIDs.create_file,
+      selector,
+      rank: contextMenuRank++,
+    }),
+    app.contextMenu.addItem({
+      command: commandIDs.create_folder,
+      selector,
+      rank: contextMenuRank++,
+    }),
     app.contextMenu.addItem({
       type: "separator",
       selector,
@@ -361,7 +502,7 @@ export function createCommands(
     }),
     app.contextMenu.addItem({
       type: "submenu",
-      submenu,
+      submenu: columnsMenu,
       selector,
       rank: contextMenuRank++,
     }),
diff --git a/js/src/contents_proxy.ts b/js/src/contents_proxy.ts
index 7eece66b..4797dd05 100644
--- a/js/src/contents_proxy.ts
+++ b/js/src/contents_proxy.ts
@@ -1,6 +1,8 @@
 
+
+import { PromiseDelegate } from "@lumino/coreutils";
 import { showErrorMessage } from "@jupyterlab/apputils";
-import { Contents, ContentsManager } from "@jupyterlab/services";
+import { Contents } from "@jupyterlab/services";
 import { IContentRow, Path } from "tree-finder";
 
 
@@ -8,30 +10,31 @@ import { IContentRow, Path } from "tree-finder";
  * Wrapper for a drive onto the contents manager.
  */
 export class ContentsProxy {
-  constructor(contentsManager: ContentsManager, drive?: string) {
+  constructor(contentsManager: Contents.IManager, drive?: string, onGetChildren?: ContentsProxy.GetChildrenCallback) {
     this.contentsManager = contentsManager;
     this.drive = drive;
+    this.onGetChildren = onGetChildren;
   }
 
   async get(path: string, options?: Contents.IFetchOptions) {
     path = ContentsProxy.toFullPath(path, this.drive);
-    return ContentsProxy.toJupyterContentRow(await this.contentsManager.get(path, options), this.contentsManager, this.drive);
+    return ContentsProxy.toJupyterContentRow(await this.contentsManager.get(path, options), this.contentsManager, this.drive, this.onGetChildren);
   }
 
   async save(path: string, options?: Partial<Contents.IModel>) {
     path = ContentsProxy.toFullPath(path, this.drive);
-    return ContentsProxy.toJupyterContentRow(await this.contentsManager.save(path, options), this.contentsManager, this.drive);
+    return ContentsProxy.toJupyterContentRow(await this.contentsManager.save(path, options), this.contentsManager, this.drive, this.onGetChildren);
   }
 
   async rename(path: string, newPath: string) {
     path = ContentsProxy.toFullPath(path, this.drive);
     newPath = ContentsProxy.toFullPath(newPath, this.drive);
-    return ContentsProxy.toJupyterContentRow(await this.contentsManager.rename(path, newPath), this.contentsManager, this.drive);
+    return ContentsProxy.toJupyterContentRow(await this.contentsManager.rename(path, newPath), this.contentsManager, this.drive, this.onGetChildren);
   }
 
   async newUntitled(options: Contents.ICreateOptions) {
     options.path = options.path && ContentsProxy.toFullPath(options.path, this.drive);
-    return ContentsProxy.toJupyterContentRow(await this.contentsManager.newUntitled(options), this.contentsManager, this.drive);
+    return ContentsProxy.toJupyterContentRow(await this.contentsManager.newUntitled(options), this.contentsManager, this.drive, this.onGetChildren);
   }
 
   async downloadUrl(path: string) {
@@ -39,13 +42,16 @@ export class ContentsProxy {
     return await this.contentsManager.getDownloadUrl(path);
   }
 
-  readonly contentsManager: ContentsManager;
+  readonly contentsManager: Contents.IManager;
   readonly drive?: string;
+  readonly onGetChildren?: ContentsProxy.GetChildrenCallback;
 }
 
 export namespace ContentsProxy {
   export interface IJupyterContentRow extends Omit<Contents.IModel, "path" | "content" | "type">, IContentRow {}
 
+  export type GetChildrenCallback = (path: string, done: Promise<void>) => void;
+
   export function toFullPath(path: string, drive?: string): string {
 
     if (!drive || path.startsWith(`${drive}:`)) {
@@ -65,10 +71,10 @@ export namespace ContentsProxy {
     return [first.split(":").pop(), ...rest].join("/");
   }
 
-  export function toJupyterContentRow(row: Contents.IModel, contentsManager: ContentsManager, drive?: string): IJupyterContentRow {
+  export function toJupyterContentRow(row: Contents.IModel, contentsManager: Contents.IManager, drive?: string, onGetChildren?: ContentsProxy.GetChildrenCallback): IJupyterContentRow {
     const { path, type, ...rest } = row;
 
-    const pathWithDrive = toFullPath(path, drive);
+    const pathWithDrive = toFullPath(path, drive).replace(/\/$/, "");
     const kind = type === "directory" ? "dir" : type;
 
     return {
@@ -78,13 +84,21 @@ export namespace ContentsProxy {
       ...(kind === "dir" ? {
         getChildren: async () => {
           let contents: Contents.IModel;
+          const done = new PromiseDelegate<void>();
+          if (onGetChildren) {
+            const pathstr = Path.toarray(pathWithDrive).join("/");  // maybe clean up the different path formats we have...
+            onGetChildren(pathstr, done.promise);
+          }
+
           try {
             contents = await contentsManager.get(pathWithDrive, { content: true });
+            done.resolve();
           } catch (error) {
             void showErrorMessage("Failed to get directory contents", error as string);
+            done.reject("Failed to get directory contents");
             return [];
           }
-          return (contents.content as Contents.IModel[]).map(c => toJupyterContentRow(c, contentsManager, drive));
+          return (contents.content as Contents.IModel[]).map(c => toJupyterContentRow(c, contentsManager, drive, onGetChildren));
         },
       }: {}),
     };
diff --git a/js/src/contents_utils.ts b/js/src/contents_utils.ts
index 0fe22c26..a4bc94f7 100644
--- a/js/src/contents_utils.ts
+++ b/js/src/contents_utils.ts
@@ -75,6 +75,22 @@ export async function revealAndSelectPath<T extends IContentRow>(contents: Conte
   return node;
 }
 
+
+/**
+ * Recursively opens directories in a contents model
+ *
+ * @param model The contents model where directories are to be opened
+ * @param path Array of directory names to be opened in order
+ */
+export async function openDirRecursive<T extends IContentRow>(model: ContentsModel<T>, path: string[]) {
+  for await (const node of walkPath(path, model.root)) {
+    if (node.pathstr !== model.root.pathstr) {
+      await model.openDir(node.row);
+    }
+  }
+}
+
+
 /**
  * Get the parent contents row for the given contents row.
  *
@@ -121,3 +137,16 @@ export function getRefreshTargets<T extends IContentRow>(
   }
   return invalidateTargets;
 }
+
+
+/**
+ * Split a "pathstr" into its drive and path components
+ */
+export function splitPathstrDrive(pathstr: string): [string, string] {
+  const splitloc = pathstr.indexOf("/");
+  if (splitloc === -1) {
+    return [pathstr, ""];
+  }
+  // split, and trim leading forward slashes on the path component
+  return [pathstr.slice(0, splitloc), pathstr.slice(splitloc + 1).replace(/^[/]*/, "")];
+}
diff --git a/js/src/drag.ts b/js/src/drag.ts
new file mode 100644
index 00000000..38fd4f09
--- /dev/null
+++ b/js/src/drag.ts
@@ -0,0 +1,874 @@
+/******************************************************************************
+ *
+ * Copyright (c) 2019, the jupyter-fs authors.
+ *
+ * This file is part of the jupyter-fs library, distributed under the terms of
+ * the Apache License 2.0.  The full license can be found in the LICENSE file.
+ *
+ */
+
+/*
+Originally copied from jupyter's nbdime package, under the following license terms:
+
+This project is licensed under the terms of the Modified BSD License
+(also known as New or Revised or 3-Clause BSD), as follows:
+
+- Copyright (c) 2001-2015, IPython Development Team
+- Copyright (c) 2015-, Jupyter Development Team
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+Redistributions of source code must retain the above copyright notice, this
+list of conditions and the following disclaimer.
+
+Redistributions in binary form must reproduce the above copyright notice, this
+list of conditions and the following disclaimer in the documentation and/or
+other materials provided with the distribution.
+
+Neither the name of the Jupyter Development Team nor the names of its
+contributors may be used to endorse or promote products derived from this
+software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+## About the Jupyter Development Team
+
+The Jupyter Development Team is the set of all contributors to the Jupyter project.
+This includes all of the Jupyter subprojects.
+
+The core team that coordinates development on GitHub can be found here:
+https://github.com/jupyter/.
+*/
+
+"use strict";
+
+import {
+  Widget,
+} from "@lumino/widgets";
+
+import type {
+  Message,
+} from "@lumino/messaging";
+
+import {
+  MimeData,
+} from "@lumino/coreutils";
+
+import {
+  Drag,
+} from "@lumino/dragdrop";
+
+
+/**
+ * The class name added to the DropWidget
+ */
+const DROP_WIDGET_CLASS = "jfs-DropWidget";
+
+/**
+ * The class name added to the DragWidget
+ */
+const DRAG_WIDGET_CLASS = "jfs-DragWidget";
+
+/**
+ * The class name added to something which can be used to drag a box
+ */
+const DRAG_HANDLE = "jfs-mod-dragHandle";
+
+/**
+ * The class name of the default drag handle
+ */
+const DEFAULT_DRAG_HANDLE_CLASS = "jfs-DragWidget-dragHandle";
+
+/**
+ * The class name added to a drop target.
+ */
+const DROP_TARGET_CLASS = "jfs-mod-dropTarget";
+
+/**
+ * The threshold in pixels to start a drag event.
+ */
+const DRAG_THRESHOLD = 5;
+
+/**
+ * The mime type for a table header drag object.
+ */
+export const TABLE_HEADER_MIME = "application/x-jupyterfs-thead";
+
+
+/**
+ * Determine whether node is equal to or a descendant of our widget, and that it does
+ * not belong to a nested drag widget.
+ */
+export
+function belongsToUs(node: HTMLElement, parentClass: string,
+  parentNode: HTMLElement): boolean {
+  let candidate: HTMLElement | null = node;
+  // Traverse DOM until drag widget encountered:
+  while (candidate && !candidate.classList.contains(parentClass)) {
+    candidate = candidate.parentElement;
+  }
+  return !!candidate && candidate === parentNode;
+}
+
+
+/**
+ * Find the direct child node of `parent`, which has `node` as a descendant.
+ * Alternatively, parent can be a collection of children.
+ *
+ * Returns null if not found.
+ */
+export
+function findChild(parent: HTMLElement | HTMLElement[], node: HTMLElement): HTMLElement | null  {
+  // Work our way up the DOM to an element which has this node as parent
+  const parentIsArray = Array.isArray(parent);
+  const isDirectChild = (element: HTMLElement): boolean => {
+    if (parentIsArray) {
+      return parent.indexOf(element) > -1;
+    } else {
+      return element.parentElement === parent;
+    }
+  };
+  let candidate: HTMLElement | null = node;
+  let child: HTMLElement | null = null;
+  while (candidate && candidate !== parent) {
+    if (isDirectChild(candidate)) {
+      child = candidate;
+      break;
+    }
+    candidate = candidate.parentElement;
+  }
+  return child;
+}
+
+
+/**
+ * A widget class which allows the user to drop mime data onto it.
+ *
+ * To complete the class, the following functions need to be implemented:
+ *  - processDrop: Process pre-screened drop events
+ *
+ * The functionallity of the class can be extended by overriding the following
+ * functions:
+ *  - findDropTarget(): Override if anything other than the direct children
+ *    of the widget's node are to be the drop targets.
+ *
+ * For maximum control, `evtDrop` can be overriden.
+ */
+export
+abstract class DropWidget extends Widget {
+  /**
+   * Construct a drop widget.
+   */
+  constructor(options: DropWidget.IOptions={}) {
+    super(options);
+    this.acceptDropsFromExternalSource =
+      options.acceptDropsFromExternalSource === true;
+    this.addClass(DROP_WIDGET_CLASS);
+    this.acceptedDropMimeTypes = options.acceptedDropMimeTypes ?? [];
+  }
+
+  /**
+   * Whether the widget should accept drops from an external source,
+   * or only accept drops from itself.
+   * Defaults to false, which will disallow all drops unless widget
+   * is also a drag widget.
+   */
+  acceptDropsFromExternalSource: boolean;
+
+
+  /**
+   * Which mimetypes that the widget accepts for drops
+   */
+  acceptedDropMimeTypes: string[];
+
+
+  /**
+   * Handle the DOM events for the widget.
+   *
+   * @param event - The DOM event sent to the widget.
+   *
+   * #### Notes
+   * This method implements the DOM `EventListener` interface and is
+   * called in response to events on the drop widget's node. It should
+   * not be called directly by user code.
+   */
+  handleEvent(event: Event): void {
+    switch (event.type) {
+      case "lm-dragenter":
+        this._evtDragEnter(event as Drag.Event);
+        break;
+      case "lm-dragleave":
+        this._evtDragLeave(event as Drag.Event);
+        break;
+      case "lm-dragover":
+        this._evtDragOver(event as Drag.Event);
+        break;
+      case "lm-drop":
+        this.evtDrop(event as Drag.Event);
+        break;
+      default:
+        break;
+    }
+  }
+
+  protected validateSource(event: Drag.Event) {
+    return this.acceptDropsFromExternalSource || event.source === this;
+  }
+
+  /**
+   * Processes a drop event.
+   *
+   * This function is called after checking:
+   *  - That the `dropTarget` is a valid drop target
+   *  - The value of `event.source` if `acceptDropsFromExternalSource` is false
+   */
+  protected abstract processDrop(dropTarget: HTMLElement, event: Drag.Event): void;
+
+  /**
+   * Find a drop target from a given drag event target.
+   *
+   * Returns null if no valid drop target was found.
+   *
+   * The default implementation returns the direct child that is the parent of
+   * `node`, or `node` if it is itself a direct child. It also checks that the
+   * needed mime type is included
+   */
+  protected findDropTarget(input: HTMLElement, mimeData: MimeData): HTMLElement | null  {
+    if (!this.acceptedDropMimeTypes.some(mimetype => mimeData.hasData(mimetype))) {
+      return null;
+    }
+
+    if (this._isValidTargetHeader(input, mimeData.getData(TABLE_HEADER_MIME) as string)) {
+      input.classList.add(DROP_TARGET_CLASS);
+    } else {
+      return null;
+    }
+
+    // No need to findChild for reordering of columns
+    if (mimeData.types().includes(TABLE_HEADER_MIME)) {
+      return input;
+    } else {
+      return findChild(this.node, input);
+    }
+  }
+
+  /**
+   * Handle the `'lm-drop'` event for the widget.
+   *
+   * Responsible for pre-processing event before calling `processDrop`.
+   *
+   * Should normally only be overriden if you cannot achive your goal by
+   * other overrides.
+   */
+  protected evtDrop(event: Drag.Event): void {
+    let target = event.target as HTMLElement;
+    while (target && target.parentElement) {
+      if (target.classList.contains(DROP_TARGET_CLASS)) {
+        target.classList.remove(DROP_TARGET_CLASS);
+        break;
+      }
+      target = target.parentElement;
+    }
+    if (!target || !belongsToUs(target, DROP_WIDGET_CLASS, this.node)) {
+      // Ignore event
+      return;
+    }
+
+    // If configured to, only accept internal moves:
+    if (!this.validateSource(event)) {
+      event.dropAction = "none";
+      event.preventDefault();
+      event.stopPropagation();
+      return;
+    }
+
+    this.processDrop(target, event);
+  }
+
+  /**
+   * Handle `after_attach` messages for the widget.
+   */
+  protected onAfterAttach(msg: Message): void {
+    const node = this.node;
+    node.addEventListener("lm-dragenter", this);
+    node.addEventListener("lm-dragleave", this);
+    node.addEventListener("lm-dragover", this);
+    node.addEventListener("lm-drop", this);
+  }
+
+  /**
+   * Handle `before_detach` messages for the widget.
+   */
+  protected onBeforeDetach(msg: Message): void {
+    const node = this.node;
+    node.removeEventListener("lm-dragenter", this);
+    node.removeEventListener("lm-dragleave", this);
+    node.removeEventListener("lm-dragover", this);
+    node.removeEventListener("lm-drop", this);
+  }
+
+
+  /**
+   * Handle the `'lm-dragenter'` event for the widget.
+   */
+  private _evtDragEnter(event: Drag.Event): void {
+    if (!this.validateSource(event)) {
+      return;
+    }
+    const target = this.findDropTarget(event.target as HTMLElement, event.mimeData);
+    if (target === null) {
+      return;
+    }
+    this._clearDropTarget();
+    target.classList.add(DROP_TARGET_CLASS);
+    event.preventDefault();
+    event.stopPropagation();
+  }
+
+  /**
+   * Handle the `'lm-dragleave'` event for the widget.
+   */
+  private _evtDragLeave(event: Drag.Event): void {
+    event.preventDefault();
+    event.stopPropagation();
+    this._clearDropTarget();
+  }
+
+  /**
+   * Handle the `'lm-dragover'` event for the widget.
+   */
+  private _evtDragOver(event: Drag.Event): void {
+    if (!this.validateSource(event)) {
+      return;
+    }
+    this._clearDropTarget();
+    const target = this.findDropTarget(event.target as HTMLElement, event.mimeData);
+    if (target === null) {
+      return;
+    }
+    event.preventDefault();
+    event.stopPropagation();
+    event.dropAction = event.proposedAction;
+  }
+
+  /**
+   * Checks if the target is a header, and it is not 'path' or itself
+   */
+  private _isValidTargetHeader(target: HTMLElement, draggedColumn: string) {
+    return target.classList.contains("tf-header-name") &&
+            target.innerText !== draggedColumn &&
+            target.innerText !== "path";
+  }
+
+  /**
+   * Clear existing drop target from out children.
+   *
+   * #### Notes
+   * This function assumes there are only one active drop target
+   */
+  private _clearDropTarget(): void {
+    const elements = this.node.getElementsByClassName(DROP_TARGET_CLASS);
+    if (elements.length) {
+      (elements[0] as HTMLElement).classList.remove(DROP_TARGET_CLASS);
+    }
+  }
+}
+
+/**
+ * An internal base class for implementing drag operations on top
+ * of drop class.
+ */
+export
+abstract class DragDropWidgetBase extends DropWidget {
+
+  /**
+   * Construct a drag and drop base widget.
+   */
+  constructor(options: DragDropWidget.IOptions={}) {
+    super(options);
+    this.addClass(DRAG_WIDGET_CLASS);
+  }
+
+  /**
+   * Dispose of the resources held by the directory listing.
+   */
+  dispose(): void {
+    this.drag = null;
+    this._clickData = null;
+    super.dispose();
+  }
+
+  /**
+   * Handle the DOM events for the widget.
+   *
+   * @param event - The DOM event sent to the widget.
+   *
+   * #### Notes
+   * This method implements the DOM `EventListener` interface and is
+   * called in response to events on the drag widget's node. It should
+   * not be called directly by user code.
+   */
+  handleEvent(event: Event): void {
+    switch (event.type) {
+      case "mousedown":
+        this._evtDragMousedown(event as MouseEvent);
+        break;
+      case "mouseup":
+        this._evtDragMouseup(event as MouseEvent);
+        break;
+      case "mousemove":
+        this._evtDragMousemove(event as MouseEvent);
+        break;
+      default:
+        super.handleEvent(event);
+        break;
+    }
+  }
+
+  /**
+   * Adds mime data representing the drag data to the drag event's MimeData bundle.
+   */
+  protected abstract addMimeData(handle: HTMLElement, mimeData: MimeData): void;
+
+  /**
+   * Finds the drag target (the node to move) from a drag handle.
+   *
+   * Returns null if no valid drag target was found.
+   *
+   * The default implementation returns the handle directly.
+   */
+  protected findDragTarget(handle: HTMLElement): HTMLElement | null {
+    return handle;
+  }
+
+  /**
+   * Returns the drag image to use when dragging using the given handle.
+   *
+   * The default implementation returns a clone of the drag target.
+   */
+  protected getDragImage(handle: HTMLElement): HTMLElement | null {
+    const target = this.findDragTarget(handle);
+    if (target) {
+      return target.cloneNode(true) as HTMLElement;
+    }
+    return null;
+  }
+
+  /**
+   * Called when a drag has completed with this widget as a source
+   */
+  protected onDragComplete(action: Drag.DropAction) {
+    this.drag = null;
+  }
+
+  /**
+   * Handle `after_attach` messages for the widget.
+   */
+  protected onAfterAttach(msg: Message): void {
+    const node = this.node;
+    node.addEventListener("mousedown", this);
+    super.onAfterAttach(msg);
+  }
+
+  /**
+   * Handle `before_detach` messages for the widget.
+   */
+  protected onBeforeDetach(msg: Message): void {
+    const node = this.node;
+    node.removeEventListener("click", this);
+    node.removeEventListener("dblclick", this);
+    document.removeEventListener("mousemove", this, true);
+    document.removeEventListener("mouseup", this, true);
+    super.onBeforeDetach(msg);
+  }
+
+  /**
+   * Start a drag event.
+   *
+   * Called when dragging and DRAG_THRESHOLD is met.
+   *
+   * Should normally only be overriden if you cannot achieve your goal by
+   * other overrides.
+   */
+  protected startDrag(handle: HTMLElement, clientX: number, clientY: number): void {
+    // Create the drag image.
+    const dragImage = this.getDragImage(handle);
+
+    // Set up the drag event.
+    this.drag = new Drag({
+      dragImage: dragImage || undefined,
+      mimeData: new MimeData(),
+      supportedActions: this.defaultSupportedActions,
+      proposedAction: this.defaultProposedAction,
+      source: this,
+    });
+    this.addMimeData(handle, this.drag.mimeData);
+
+    // Start the drag and remove the mousemove listener.
+    void this.drag.start(clientX, clientY).then(action => this.onDragComplete(action));
+    document.removeEventListener("mousemove", this, true);
+    document.removeEventListener("mouseup", this, true);
+  }
+
+  /**
+   * Drag data stored in _startDrag
+   */
+  protected drag: Drag | null = null;
+
+  protected dragHandleClass = DRAG_HANDLE;
+
+  /**
+   * Check if node, or any of nodes ancestors are a drag handle
+   *
+   * If it is a drag handle, it returns the handle, if not returns null.
+   */
+  private _findDragHandle(node: HTMLElement): HTMLElement | null {
+    let handle: HTMLElement | null = null;
+    // Traverse up DOM to check if click is on a drag handle
+    let candidate: HTMLElement | null = node;
+    while (candidate && candidate !== this.node) {
+      if (candidate.classList.contains(this.dragHandleClass)) {
+        handle = candidate;
+        break;
+      }
+      candidate = candidate.parentElement;
+    }
+    // Finally, check that handle does not belong to a nested drag widget
+    if (handle !== null && !belongsToUs(
+      handle, DRAG_WIDGET_CLASS, this.node)) {
+      // Handle belongs to a nested drag widget:
+      handle = null;
+    }
+    return handle;
+  }
+
+  /**
+   * Handle the `'mousedown'` event for the widget.
+   */
+  private _evtDragMousedown(event: MouseEvent): void {
+    const target = event.target as HTMLElement;
+    const handle = this._findDragHandle(target);
+    if (handle === null) {
+      return;
+    }
+
+    // Left mouse press for drag start.
+    if (event.button === 0) {
+      this._clickData = { pressX: event.clientX, pressY: event.clientY,
+        handle };
+      document.addEventListener("mouseup", this, true);
+      document.addEventListener("mousemove", this, true);
+      event.preventDefault();
+    }
+  }
+
+
+  /**
+   * Handle the `'mouseup'` event for the widget.
+   */
+  private _evtDragMouseup(event: MouseEvent): void {
+    if (event.button !== 0 || !this.drag) {
+      document.removeEventListener("mousemove", this, true);
+      document.removeEventListener("mouseup", this, true);
+      this.drag = null;
+      return;
+    }
+    event.preventDefault();
+    event.stopPropagation();
+  }
+
+  /**
+   * Handle the `'mousemove'` event for the widget.
+   */
+  private _evtDragMousemove(event: MouseEvent): void {
+    // Bail if we are already dragging.
+    if (this.drag) {
+      return;
+    }
+
+    event.preventDefault();
+    event.stopPropagation();
+
+    // Check for a drag initialization.
+    const data = this._clickData;
+    if (!data) {
+      throw new Error("Missing drag data");
+    }
+    const dx = Math.abs(event.clientX - data.pressX);
+    const dy = Math.abs(event.clientY - data.pressY);
+    if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD) {
+      return;
+    }
+
+    this.startDrag(data.handle, event.clientX, event.clientY);
+    this._clickData = null;
+  }
+
+  protected defaultSupportedActions: Drag.SupportedActions = "all";
+  protected defaultProposedAction: Drag.DropAction = "move";
+
+  /**
+   * Data stored on mouse down to determine if drag treshold has
+   * been overcome, and to initialize drag once it has.
+   */
+  private _clickData: { pressX: number, pressY: number, handle: HTMLElement } | null = null;
+}
+
+/**
+ * A widget which allows the user to initiate drag operations.
+ *
+ * Any descendant element with the drag handle class `'jfs-mod-dragHandle'`
+ * will serve as a handle that can be used for dragging. If DragWidgets are
+ * nested, handles will only belong to the closest parent DragWidget. For
+ * convenience, the functions `makeHandle`, `unmakeHandle` and
+ * `createDefaultHandle` can be used to indicate which elements should be
+ * made handles. `createDefaultHandle` will create a new element as a handle
+ * with a default styling class applied. Optionally, `childrenAreDragHandles`
+ * can be set to indicate that all direct children are themselve drag handles.
+ *
+ * To complete the class, the following functions need to be implemented:
+ * - addMimeData: Adds mime data to new drag events
+ *
+ * The functionallity of the class can be extended by overriding the following
+ * functions:
+ *  - findDragTarget(): Override if anything other than the direct children
+ *    of the widget's node are to be drag targets.
+ *  - getDragImage: Override to change the drag image (the default is a
+ *    copy of the drag target).
+ *  - onDragComplete(): Callback on drag source when a drag has completed.
+ */
+export
+abstract class DragWidget extends DragDropWidgetBase {
+  /**
+   * Construct a drag widget.
+   */
+  constructor(options: DragWidget.IOptions={}) {
+    // Implementation removes DropWidget options
+    super(options);
+  }
+
+  /**
+   * No-op on DragWidget, as it does not support dropping
+   */
+  protected processDrop(dropTarget: HTMLElement, event: Drag.Event): void {
+    // Intentionally empty
+  }
+
+  /**
+   * Simply returns null for DragWidget, as it does not support dropping
+   */
+  protected findDropTarget(input: HTMLElement, mimeData: MimeData): HTMLElement | null {
+    return null;
+  }
+
+}
+
+
+/**
+ * A widget which allows the user to rearrange widgets in the widget by
+ * drag and drop. An internal drag and drop of a widget will cause it
+ * to be inserted (by `insertWidget`) in the index of the widget it was
+ * dropped on.
+ *
+ * Any descendant element with the drag handle class `'jfs-mod-dragHandle'`
+ * will serve as a handle that can be used for dragging. If DragWidgets are
+ * nested, handles will only belong to the closest parent DragWidget. For
+ * convenience, the functions `makeHandle`, `unmakeHandle` and
+ * `createDefaultHandle` can be used to indicate which elements should be
+ * made handles. `createDefaultHandle` will create a new element as a handle
+ * with a default styling class applied. Optionally, `childrenAreDragHandles`
+ * can be set to indicate that all direct children are themselve drag handles.
+ *
+ * The functionallity of the class can be extended by overriding the following
+ * functions:
+ *  - addMimeData: Override to add other drag data to the mime bundle.
+ *    This is often a necessary step for allowing dragging to external
+ *    drop targets.
+ *  - processDrop: Override if you need to handle other mime data than the
+ *    default. For allowing drops from external sources, the field
+ *    `acceptDropsFromExternalSource` should be set as well.
+ *  - findDragTarget(): Override if anything other than the direct children
+ *    of the widget's node are to be drag targets.
+ *  - findDropTarget(): Override if anything other than the direct children
+ *    of the widget's node are to be the drop targets.
+ *  - getIndexOfChildNode(): Override to change the key used to represent
+ *    the drag and drop target (default is index of child widget).
+ *  - move(): Override to change how a move is handled.
+ *  - getDragImage: Override to change the drag image (the default is a
+ *    copy of the drag target).
+ *  - onDragComplete(): Callback on drag source when a drag has completed.
+ *
+ * To drag and drop other things than all direct children, the following functions
+ * should be overriden: `findDragTarget`, `findDropTarget` and possibly
+ * `getIndexOfChildNode` and `move` to allow for custom to/from keys.
+ *
+ * For maximum control, `startDrag` and `evtDrop` can be overriden.
+ */
+export abstract class DragDropWidget extends DragDropWidgetBase {
+  protected abstract move(mimeData: MimeData, target: HTMLElement): Drag.DropAction;
+
+  /**
+   * Adds mime data represeting the drag data to the drag event's MimeData bundle.
+   *
+   * The default implementation adds mime data indicating the index of the direct
+   * child being dragged (as indicated by findDragTarget).
+   *
+   * Override this method if you have data that cannot be communicated well by an
+   * index, for example if the data should be able to be dropped on an external
+   * target that only understands direct mime data.
+   *
+   * As the method simply adds mime data for a specific key, overriders can call
+   * this method before/after adding their own mime data to still support default
+   * dragging behavior.
+   */
+  protected abstract addMimeData(handle: HTMLElement, mimeData: MimeData): void;
+
+  /**
+   * Processes a drop event.
+   *
+   * This function is called after checking:
+   *  - That the `dropTarget` is a valid drop target
+   *  - The value of `event.source` if `acceptDropsFromExternalSource` is false
+   *
+   * The default implementation assumes calling `getIndexOfChildNode` with
+   * `dropTarget` will be valid. It will call `move` with that index as `to`,
+   * and the index stored in the mime data as `from`.
+   *
+   * Override this if you need to handle other mime data than the default.
+   */
+  protected processDrop(dropTarget: HTMLElement, event: Drag.Event): void {
+    if (!DropWidget.isValidAction(event.supportedActions, "move") ||
+        event.proposedAction === "none") {
+      // The default implementation only handles move action
+      // OR Accept proposed none action, and perform no-op
+      event.dropAction = "none";
+      event.preventDefault();
+      event.stopPropagation();
+      return;
+    }
+    if (!this.validateSource(event)) {
+      // Source indicates external drop, incorrect use in subclass
+      throw new Error("Invalid source!");
+    }
+
+    // We have an acceptable drop, handle:
+    const action = this.move(event.mimeData, dropTarget);
+    event.preventDefault();
+    event.stopPropagation();
+    event.dropAction = action;
+  }
+}
+
+
+/**
+ * The namespace for the `DropWidget` class statics.
+ */
+export
+namespace DropWidget {
+  /**
+   * An options object for initializing a drag widget widget.
+   */
+  export
+  interface IOptions extends Widget.IOptions {
+    /**
+     * Whether the lsit should accept drops from an external source.
+     * Defaults to false.
+     *
+     * This option only makes sense to set for subclasses that accept drops from
+     * external sources.
+     */
+    acceptDropsFromExternalSource?: boolean;
+
+    /**
+     * Which mimetypes are acceptable for drops
+     */
+    acceptedDropMimeTypes?: string[];
+  }
+
+  /**
+   * Validate a drop action against a SupportedActions type
+   */
+  export
+  function isValidAction(supported: Drag.SupportedActions, action: Drag.DropAction): boolean {
+    switch (supported) {
+      case "all":
+        return true;
+      case "link-move":
+        return action === "move" || action === "link";
+      case "copy-move":
+        return action === "move" || action === "copy";
+      case "copy-link":
+        return action === "link" || action === "copy";
+      default:
+        return action === supported;
+    }
+  }
+}
+
+/**
+ * The namespace for the `DragWidget` class statics.
+ */
+export
+namespace DragWidget {
+  /**
+   * An options object for initializing a drag widget widget.
+   */
+  export
+  interface IOptions extends Widget.IOptions {
+  }
+
+  /**
+   * Mark a widget as a drag handle.
+   *
+   * Using this, any child-widget can be a drag handle, as long as mouse events
+   * are propagated from it to the DragWidget.
+   */
+  export
+  function makeHandle(handle: Widget) {
+    handle.addClass(DRAG_HANDLE);
+  }
+
+  /**
+   * Unmark a widget as a drag handle
+   */
+  export
+  function unmakeHandle(handle: Widget) {
+    handle.removeClass(DRAG_HANDLE);
+  }
+
+  /**
+   * Create a default handle widget for dragging (see styling in DragWidget.css).
+   *
+   * The handle will need to be styled to ensure a minimum size
+   */
+  export
+  function createDefaultHandle(): Widget {
+    const widget = new Widget();
+    widget.addClass(DEFAULT_DRAG_HANDLE_CLASS);
+    makeHandle(widget);
+    return widget;
+  }
+}
+
+
+/**
+ * The namespace for the `DragDropWidget` class statics.
+ */
+export
+namespace DragDropWidget {
+  export
+  interface IOptions extends DragWidget.IOptions, DropWidget.IOptions {
+  }
+}
diff --git a/js/src/filesystem.ts b/js/src/filesystem.ts
index 9ddabe03..3ea3b4d7 100644
--- a/js/src/filesystem.ts
+++ b/js/src/filesystem.ts
@@ -11,35 +11,71 @@ import { URLExt } from "@jupyterlab/coreutils";
 import { ServerConnection } from "@jupyterlab/services";
 
 export interface IFSOptions {
-  _addServerside: boolean;
+  _addServerside?: boolean;
 
   /**
    * If true, only recreate the actual resource when necessary
    */
-  cache: boolean;
+  cache?: boolean;
 
   /**
    * If true, enable jupyter-fs debug output in both frontend and backend
    */
-  verbose: boolean;
+  verbose?: boolean;
+
+  /**
+   * The version of the package that these settings were written with
+   */
+  writtenVersion?: string;
 }
 
-export interface IFSResource {
+
+/**
+ * A resource configuration as stored in the settings system.
+ */
+export interface IFSSettingsResource {
   /**
    * The name of this resource
    */
-  name: string;
+  name?: string;
 
   /**
    * The fsurl specifying this resource
    */
-  url: string;
+  url?: string;
 
   /**
    * Auth scheme to be used for this resource, or false for none
    */
   auth: "ask" | "env" | false;
 
+  /**
+   * Fallback for determining if resource is writeable. Used only if the underlying PyFilesystem does not provide this information (eg S3)
+   */
+  defaultWritable?: boolean;
+
+  /**
+   * Directory to be first opened
+   */
+  preferred_dir?: string;
+}
+
+
+/**
+ * An object defining an FS resource.
+ */
+export interface IFSResource extends IFSSettingsResource {
+  /**
+   * The name of this resource
+   */
+  name: string;
+
+  /**
+   * The fsurl specifying this resource
+   */
+  url: string;
+
+
   /**
    * The jupyterlab drive name associated with this resource. This is defined
    * on resource initialization
@@ -63,6 +99,11 @@ export interface IFSResource {
    * initialization
    */
   tokenDict?: {[key: string]: string};
+
+  /**
+   * Any errors during initialization
+   */
+  errors?: string[];
 }
 
 export interface IFSComm {
@@ -87,25 +128,12 @@ export interface IFSComm {
 abstract class FSCommBase implements IFSComm {
   protected _settings: ServerConnection.ISettings | undefined = undefined;
 
-  constructor(props: { baseUrl?: string } = {}) {
-    const { baseUrl } = props;
-
-    if (baseUrl) {
-      this.baseUrl = baseUrl;
-    }
-  }
-
   abstract getResourcesRequest(): Promise<IFSResource[]>;
   abstract initResourceRequest(args: {options: IFSOptions; resources: IFSResource[]}): Promise<IFSResource[]>;
 
   get baseUrl(): string {
     return this.settings.baseUrl;
   }
-  set baseUrl(baseUrl: string) {
-    if (baseUrl !== this.baseUrl) {
-      this._settings = ServerConnection.makeSettings({ baseUrl });
-    }
-  }
 
   get resourcesUrl(): string {
     return URLExt.join(this.baseUrl, "jupyterfs/resources");
@@ -121,6 +149,16 @@ abstract class FSCommBase implements IFSComm {
 }
 
 export class FSComm extends FSCommBase {
+
+  private static _instance: FSComm;
+
+  static get instance(): FSComm {
+    if (FSComm._instance === undefined) {
+      FSComm._instance = new FSComm();
+    }
+    return FSComm._instance;
+  }
+
   async getResourcesRequest(): Promise<IFSResource[]> {
     const settings = this.settings;
     const fullUrl = this.resourcesUrl;
@@ -160,7 +198,6 @@ export class FSComm extends FSCommBase {
           throw new ServerConnection.ResponseError(response, data);
         });
       }
-
       return response.json();
     });
   }
diff --git a/js/src/index.tsx b/js/src/index.tsx
index d49bcc20..a88e2145 100644
--- a/js/src/index.tsx
+++ b/js/src/index.tsx
@@ -13,18 +13,19 @@ import { IDocumentManager } from "@jupyterlab/docmanager";
 import { ISettingRegistry } from "@jupyterlab/settingregistry";
 import { IStatusBar } from "@jupyterlab/statusbar";
 import { ITranslator } from "@jupyterlab/translation";
-import { folderIcon, fileIcon } from "@jupyterlab/ui-components";
+import { folderIcon, fileIcon, IFormRendererRegistry } from "@jupyterlab/ui-components";
 import { IDisposable } from "@lumino/disposable";
-import * as React from "react";
-import * as ReactDOM from "react-dom";
+import * as semver from "semver";
 
-import { AskDialog, askRequired } from "./auth";
-import { createCommands, idFromResource } from "./commands";
+import { commandIDs, createDynamicCommands, createStaticCommands, idFromResource } from "./commands";
 import { ContentsProxy } from "./contents_proxy";
-import { FSComm, IFSOptions, IFSResource } from "./filesystem";
+import { IFSOptions, IFSResource, IFSSettingsResource } from "./filesystem";
 import { FileUploadStatus } from "./progress";
+import { migrateSettings, unpartialResource } from "./settings";
+import { snippetFormRender } from "./snippets";
 import { TreeFinderSidebar } from "./treefinder";
 import { ITreeFinderMain } from "./tokens";
+import { initResources } from "./resources";
 
 // tslint:disable: variable-name
 
@@ -41,6 +42,7 @@ export const browser: JupyterFrontEndPlugin<ITreeFinderMain> = {
     ISettingRegistry,
     IThemeManager,
   ],
+  optional: [IFormRendererRegistry],
   provides: ITreeFinderMain,
 
   async activate(
@@ -52,8 +54,8 @@ export const browser: JupyterFrontEndPlugin<ITreeFinderMain> = {
     router: IRouter,
     settingRegistry: ISettingRegistry,
     themeManager: IThemeManager,
+    editorRegistry: IFormRendererRegistry | null
   ): Promise<ITreeFinderMain> {
-    const comm = new FSComm();
     const widgetMap : {[key: string]: TreeFinderSidebar} = {};
     let commands: IDisposable | undefined;
 
@@ -66,6 +68,16 @@ export const browser: JupyterFrontEndPlugin<ITreeFinderMain> = {
       console.warn(`Failed to load settings for the jupyter-fs extension.\n${error}`);
     }
 
+    // Migrate any old settings
+    const initialOptions = settings?.composite.options as unknown as IFSOptions | undefined;
+    if ((settings && semver.lt(initialOptions?.writtenVersion || "0.0.0", settings.version))) {
+      settings = await migrateSettings(settings);
+    }
+
+    if (editorRegistry) {
+      editorRegistry.addRenderer(`${BROWSER_ID}.snippets`, { fieldRenderer: snippetFormRender });
+    }
+
     let columns = settings?.composite.display_columns as Array<keyof ContentsProxy.IJupyterContentRow> ?? ["size"];
 
     const sharedSidebarProps: Omit<TreeFinderSidebar.ISidebarProps, "url"> = {
@@ -76,9 +88,12 @@ export const browser: JupyterFrontEndPlugin<ITreeFinderMain> = {
       restorer,
       router,
       columns,
+      settings,
     };
 
-    function refreshWidgets({ resources, options }: {resources: IFSResource[]; options: IFSOptions}) {
+    createStaticCommands(app, TreeFinderSidebar.tracker, TreeFinderSidebar.clipboard);
+
+    async function refreshWidgets({ resources, options }: {resources: IFSResource[]; options: IFSOptions}) {
       if (options.verbose) {
         // eslint-disable-next-line no-console
         console.info(`jupyter-fs frontend received resources:\n${JSON.stringify(resources)}`);
@@ -93,14 +108,14 @@ export const browser: JupyterFrontEndPlugin<ITreeFinderMain> = {
         const id = idFromResource(r);
         let w = widgetMap[id];
         if (!w || w.isDisposed) {
-          const sidebarProps = { ...sharedSidebarProps, url: r.url };
+          const sidebarProps = { ...sharedSidebarProps, url: r.url, settings: settings! };
           w = TreeFinderSidebar.sidebarFromResource(r, sidebarProps);
           widgetMap[id] = w;
         } else {
           w.treefinder.columns = columns;
         }
       }
-      commands = createCommands(
+      commands = await createDynamicCommands(
         app,
         TreeFinderSidebar.tracker,
         TreeFinderSidebar.clipboard,
@@ -111,8 +126,10 @@ export const browser: JupyterFrontEndPlugin<ITreeFinderMain> = {
 
     async function refresh() {
       // get user settings from json file
-      let resources: IFSResource[] = settings!.composite.resources as any;
-      const options: IFSOptions = settings!.composite.options as any;
+      let resources: IFSResource[] = (
+        settings?.composite.resources as unknown as IFSSettingsResource[] ?? []
+      ).map(unpartialResource);
+      const options: IFSOptions = settings?.composite.options as any ?? {};
 
       function cleanup(all=false) {
         if (commands) {
@@ -129,56 +146,26 @@ export const browser: JupyterFrontEndPlugin<ITreeFinderMain> = {
       }
 
       try {
-        // send user specs to backend; await return containing resources
-        // defined by user settings + resources defined by server config
-        resources = await comm.initResourceRequest({
-          resources,
-          options: {
-            ...options,
-            _addServerside: true,
-          },
-        });
-
-        if (askRequired(resources)) {
-          // ask for url template values, if required
-          const dialogElem = document.createElement("div");
-          document.body.appendChild(dialogElem);
-
-          const handleClose = () => {
-            ReactDOM.unmountComponentAtNode(dialogElem);
-            dialogElem.remove();
-          };
-
-          const handleSubmit = async (values: {[url: string]: {[key: string]: string}}) => {
-            resources = await comm.initResourceRequest({
-              resources: resources.map(r => ({ ...r, tokenDict: values[r.url] })),
-              options,
-            });
-            cleanup();
-            refreshWidgets({ resources, options });
-          };
-
-          ReactDOM.render(
-            <AskDialog
-              handleClose={handleClose}
-              handleSubmit={handleSubmit}
-              options={options}
-              resources={resources}
-            />,
-            dialogElem,
-          );
-        } else {
-          // otherwise, just go ahead and refresh the widgets
-          cleanup();
-          refreshWidgets({ options, resources });
-        }
-      } catch {
+        resources = (await initResources(resources, options)).filter(r => r.init);
+        cleanup();
+        await refreshWidgets({ resources, options });
+      } catch (e) {
+        console.error("Failed to refresh widgets!", e);
         cleanup(true);
       }
     }
 
-    // initial setup when DOM attachment of custom elements is complete.
-    void app.started.then(refresh);
+    // when ready, restore using command
+    const refreshed = refresh();
+    void restorer.restore(TreeFinderSidebar.tracker, {
+      command: commandIDs.restore,
+      args: widget => ({
+        id: widget.id,
+        rootPath: widget.treefinder.model?.root.pathstr,
+      }),
+      name: widget => widget.id,
+      when: refreshed,
+    });
 
     if (settings) {
       // rerun setup whenever relevant settings change
@@ -202,6 +189,7 @@ export const browser: JupyterFrontEndPlugin<ITreeFinderMain> = {
       `;
     }
 
+    let initialThemeLoad = true;
     themeManager.themeChanged.connect(() => {
       // Update SVG icon fills (since we put them in pseudo-elements we cannot style with CSS)
       const primary = getComputedStyle(document.documentElement).getPropertyValue("--jp-ui-font-color1");
@@ -211,9 +199,19 @@ export const browser: JupyterFrontEndPlugin<ITreeFinderMain> = {
       );
 
       // Refresh widgets in case font/border sizes etc have changed
-      void Promise.all(Object.keys(widgetMap).map(
-        key => widgetMap[key].treefinder.nodeInit()
-      ));
+      if (initialThemeLoad) {
+        initialThemeLoad = false;
+        void app.restored.then(() => {
+          // offset it by a timeout to ensure we clear the initial async stack
+          setTimeout(() => void Object.keys(widgetMap).map(
+            key => widgetMap[key].treefinder.nodeInit()
+          ), 0);
+        });
+      } else {
+        Object.keys(widgetMap).map(
+          key => widgetMap[key].treefinder.nodeInit()
+        );
+      }
     });
 
     style.textContent = iconStyleContent(folderIcon.svgstr, fileIcon.svgstr);
@@ -235,7 +233,7 @@ export const progressStatus: JupyterFrontEndPlugin<void> = {
     ITreeFinderMain,
     IStatusBar,
   ],
-  async activate(
+  activate(
     app: JupyterFrontEnd,
     translator: ITranslator,
     main: ITreeFinderMain | null,
diff --git a/js/src/resources.tsx b/js/src/resources.tsx
new file mode 100644
index 00000000..8f80d794
--- /dev/null
+++ b/js/src/resources.tsx
@@ -0,0 +1,74 @@
+import { PromiseDelegate } from "@lumino/coreutils";
+
+import * as React from "react";
+import * as ReactDOM from "react-dom";
+
+import { askRequired, AskDialog } from "./auth";
+import { FSComm, IFSResource, IFSOptions } from "./filesystem";
+
+
+/**
+ * Init resource on the server, and prompt for credentials via a dialog if needed.
+ *
+ * @param resources The resources to initialize to.
+ * @param options The initialization options to use.
+ * @returns All resources, whether inited or not
+ */
+export async function initResources(resources: IFSResource[], options: IFSOptions): Promise<IFSResource[]> {
+  const delegate = new PromiseDelegate<IFSResource[]>();
+  // send user specs to backend; await return containing resources
+  // defined by user settings + resources defined by server config
+  resources = await FSComm.instance.initResourceRequest({
+    resources,
+    options: {
+      ...options,
+      _addServerside: true,
+    },
+  });
+
+  if (askRequired(resources)) {
+    // ask for url template values, if required
+    const dialogElem = document.createElement("div");
+    document.body.appendChild(dialogElem);
+
+    let submitted = false;
+    const handleClose = () => {
+      try {
+        ReactDOM.unmountComponentAtNode(dialogElem);
+        dialogElem.remove();
+        if (!submitted) {
+          delegate.resolve(resources);
+        }
+      } catch (e) {
+        delegate.reject(e);
+      }
+    };
+
+    const handleSubmit = async (values: {[url: string]: {[key: string]: string}}) => {
+      try {
+        submitted = true;
+        resources = await FSComm.instance.initResourceRequest({
+          resources: resources.map(r => ({ ...r, tokenDict: values[r.url] })),
+          options,
+        });
+        delegate.resolve(resources);
+      } catch (e) {
+        delegate.reject(e);
+      }
+    };
+
+    ReactDOM.render(
+      <AskDialog
+        handleClose={handleClose}
+        handleSubmit={handleSubmit}
+        options={options}
+        resources={resources}
+      />,
+      dialogElem,
+    );
+
+  } else {
+    delegate.resolve(resources);
+  }
+  return delegate.promise;
+}
diff --git a/js/src/settings.ts b/js/src/settings.ts
new file mode 100644
index 00000000..6d0d169b
--- /dev/null
+++ b/js/src/settings.ts
@@ -0,0 +1,57 @@
+/******************************************************************************
+ *
+ * Copyright (c) 2019, the jupyter-fs authors.
+ *
+ * This file is part of the jupyter-fs library, distributed under the terms of
+ * the Apache License 2.0.  The full license can be found in the LICENSE file.
+ *
+ */
+
+import { ISettingRegistry } from "@jupyterlab/settingregistry";
+import * as semver from "semver";
+
+import type { IFSOptions, IFSResource, IFSSettingsResource } from "./filesystem";
+import type { RawSnippet } from "./snippets";
+
+/**
+ * Migrate any settings from an older version of the package
+ *
+ * @param settings Our settings to consider for migratation
+ * @returns The modified settings object
+ */
+export async function migrateSettings(settings: ISettingRegistry.ISettings): Promise<ISettingRegistry.ISettings> {
+  const options = settings?.composite.options as unknown as IFSOptions | undefined;
+  if (semver.lt(options?.writtenVersion || "0.0.0", "0.4.0-alpha.8")) {
+    // Migrate snippets to include defaults that were updated after version checked
+    const defaultSnippets = (settings?.default("snippets") ?? []) as unknown as RawSnippet[];
+    const defaultLabels = defaultSnippets.map( snippet => snippet.label );
+    const userSnippets = (settings?.user.snippets ?? []) as unknown as RawSnippet[];
+
+    // add the user defined snippets if they have different label to defaults
+    const raw = userSnippets.reduce((combinedSnippetsArray, snippet) => {
+      if (!defaultLabels.includes(snippet.label)) {
+        combinedSnippetsArray.push(snippet);
+      }
+      return combinedSnippetsArray;
+    }, [...defaultSnippets]) ?? [];
+
+    await settings.set("snippets", raw as Array<Partial<RawSnippet>>);
+  }
+
+  // Update version
+  await settings.set("options", {
+    ...options,
+    writtenVersion: settings.version,
+  });
+  return settings;
+}
+
+
+/**
+ * Ensure undefined string values from settings that are required are translated to empty strings
+ * @param settingsResoruce
+ * @returns A filled in setting object
+ */
+export function unpartialResource(settingsResource: IFSSettingsResource): IFSResource {
+  return { name: "", url: "", ...settingsResource };
+}
diff --git a/js/src/snippets.tsx b/js/src/snippets.tsx
new file mode 100644
index 00000000..196a38cd
--- /dev/null
+++ b/js/src/snippets.tsx
@@ -0,0 +1,144 @@
+/******************************************************************************
+ *
+ * Copyright (c) 2023, the jupyter-fs authors.
+ *
+ * This file is part of the jupyter-fs library, distributed under the terms of
+ * the Apache License 2.0.  The full license can be found in the LICENSE file.
+ *
+ */
+
+import { URLExt } from "@jupyterlab/coreutils";
+import { ServerConnection } from "@jupyterlab/services";
+import { ISettingRegistry } from "@jupyterlab/settingregistry";
+import * as React from "react";
+
+import type { FieldProps } from "@rjsf/utils";
+import { splitPathstrDrive } from "./contents_utils";
+
+function _mknode(obj: any, paths: string[]) {
+  for (const path of paths) {
+    obj = obj[path] = obj[path] ?? {};
+  }
+  return obj;
+}
+
+/**
+ * Trick to set uiSchema on our settings editor form elements.
+ *
+ * We use it to set the "template" to a "textarea" multiline input
+ */
+export function snippetFormRender(props: FieldProps) {
+  const ArrayField = props.registry.fields.ArrayField;
+  const uiSchema = { ...props.uiSchema };
+  const templateUiSchema = _mknode(uiSchema, ["items", "template"]);
+  templateUiSchema["ui:widget"] = "textarea";
+  return <ArrayField {...props} uiSchema={uiSchema} />;
+}
+
+
+export interface RawSnippet {
+  label: string;
+  caption: string;
+  pattern: string;
+  template: string;
+}
+
+interface ISnippetsResponse {
+  snippets: RawSnippet[];
+}
+
+const processUrlRegex = /^(?<protocol>.+?):\/\/(?:[^@]+@)?(?<resource>.*)$/;
+const templateTokenFinders = {} as {[key: string]: RegExp};
+
+
+/**
+ * A usage snippet specification
+ */
+export interface Snippet {
+  /**
+   * The designator to show to users
+   */
+  label: string;
+
+  /**
+   * An optional, longer description to show to users
+   */
+  caption: string;
+
+  /**
+   * A regular expression to match against the full URL of the entry, indicating if this snippet is valid for it
+   */
+  pattern: RegExp;
+
+  /**
+   * A template string to build up the snippet
+   */
+  template: string;
+}
+
+
+/**
+ * Get all the snippet specifications in the settings
+ */
+export function getSettingsSnippets(settings?: ISettingRegistry.ISettings): Snippet[] {
+  const raw = (settings?.composite.snippets ?? []) as any as RawSnippet[];
+  return raw.map(s => ({ ...s, pattern: new RegExp(s.pattern) }));
+}
+
+/**
+ * Gets all the snippet specifications configured on the server
+ */
+export async function getServerSnippets(settings?: ServerConnection.ISettings): Promise<Snippet[]> {
+  if (!settings) {
+    settings = ServerConnection.makeSettings();
+  }
+  return ServerConnection.makeRequest(
+    URLExt.join(settings.baseUrl, "/jupyterfs/snippets"),
+    { method: "GET" },
+    settings
+  ).then(response => {
+    if (!response.ok) {
+      return Promise.reject(response);
+    }
+    return response.json() as Promise<ISnippetsResponse>;
+  }).then(data => data.snippets.map(s => ({ ...s, pattern: new RegExp(s.pattern) })));
+}
+
+/**
+ * Get the snippet specifications from all sources
+ */
+export async function getAllSnippets(settings?: ISettingRegistry.ISettings): Promise<Snippet[]> {
+  return (await getServerSnippets()).concat(getSettingsSnippets(settings));
+}
+
+
+/**
+ * Instantiate the template of a snippet.
+ *
+ * @param template The template to instantiate
+ * @param resource The resource the entry belongs to
+ * @param path The local path of the entry
+ */
+export function instantiateSnippet(template: string, url: string, pathstr: string) {
+  const parsed = processUrlRegex.exec(url);
+  // eslint-disable-next-line prefer-const
+  const [drive, relativePath] = splitPathstrDrive(pathstr);
+  const args = {
+    ...parsed?.groups,
+    url,
+    path: relativePath,
+    full_url: `${url.replace(/\/$/, "")}/${relativePath}`,
+    full_path: `${drive}:/${relativePath}`,
+    drive,
+  };
+
+  let templated = template;
+  for (const key of Object.keys(args) as Array<keyof typeof args>) {
+    if (!(key in templateTokenFinders)) {
+      // match `key` wrapped in double curly-braces (and optionally whitespace padding within the braces)
+      templateTokenFinders[key] = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, "g");
+    }
+    templated = templated.replace(templateTokenFinders[key], args[key]);
+  }
+  return templated;
+}
diff --git a/js/src/treefinder.ts b/js/src/treefinder.ts
index 7868f505..1ba62de0 100644
--- a/js/src/treefinder.ts
+++ b/js/src/treefinder.ts
@@ -33,6 +33,7 @@ import {
 // import JSZip from "jszip";
 import { ArrayExt } from "@lumino/algorithm";
 import { CommandRegistry } from "@lumino/commands";
+import { PromiseDelegate } from "@lumino/coreutils";
 import { Message } from "@lumino/messaging";
 import { PanelLayout, Widget } from "@lumino/widgets";
 import { Content, ContentsModel, Format, Path, TreeFinderGridElement, TreeFinderPanelElement } from "tree-finder";
@@ -40,12 +41,14 @@ import { Content, ContentsModel, Format, Path, TreeFinderGridElement, TreeFinder
 import { JupyterClipboard } from "./clipboard";
 import { commandIDs, idFromResource } from "./commands";
 import { ContentsProxy } from "./contents_proxy";
-import { revealPath } from "./contents_utils";
+import { getContentParent, revealPath, openDirRecursive } from "./contents_utils";
+import { DragDropWidget, TABLE_HEADER_MIME } from "./drag";
 import { IFSResource } from "./filesystem";
 import { fileTreeIcon } from "./icons";
 import { promptRename } from "./utils";
 import { Uploader, UploadButton } from "./upload";
-import { ContentsManager } from "@jupyterlab/services";
+import { MimeData } from "@lumino/coreutils";
+import { Drag } from "@lumino/dragdrop";
 
 
 export class TreeFinderTracker extends WidgetTracker<TreeFinderSidebar> {
@@ -80,30 +83,110 @@ export class TreeFinderTracker extends WidgetTracker<TreeFinderSidebar> {
   private _finders = new Map<string, TreeFinderSidebar>();
 }
 
-export class TreeFinderWidget extends Widget {
+export class TreeFinderWidget extends DragDropWidget {
   constructor({
     app,
     columns,
     rootPath = "",
     translator,
+    settings,
   }: TreeFinderWidget.IOptions) {
     const { commands, serviceManager: { contents } } = app;
 
     const node = document.createElement<ContentsProxy.IJupyterContentRow>("tree-finder-panel");
-    super({ node });
+    const acceptedDropMimeTypes = [TABLE_HEADER_MIME];
+    super({ node, acceptedDropMimeTypes });
     this.addClass("jp-tree-finder");
 
-    this.contentsProxy = new ContentsProxy(contents as ContentsManager, rootPath);
+    // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+    this.contentsProxy = new ContentsProxy(contents, rootPath, this.onGetChildren.bind(this));
+    this.settings = settings;
 
     this.translator = translator || nullTranslator;
     this._trans = this.translator.load("jupyterlab");
-    this._commands = commands;
 
+    this._commands = commands;
+    this._expanding =  new Map<string, number>();
     this._columns = columns;
     this.rootPath = rootPath === "" ? rootPath : rootPath + ":";
-    // CAREFUL: tree-finder currently REQUIRES the node to be added to the DOM before init can be called!
-    this._ready = this.nodeInit();
-    this._ready.catch(reason => showErrorMessage("Failed to init browser", reason as string));
+    this._initialLoad = true;
+
+    this._readyDelegate = new PromiseDelegate<void>();
+    void this._readyDelegate.promise.catch(reason => showErrorMessage("Failed to init browser", reason as string));
+    void this._readyDelegate.promise.then(() => {
+      // TODO: Model state of TreeFinderWidget should be updated by renamerSub process.
+      //       Currently we hard-code the refresh here, but should be moved upstream!
+      const contentsModel = this.model!;
+      contentsModel.renamerSub.subscribe(({ name, target }) => {
+        void contentsModel.sort();
+      });
+    });
+  }
+  protected move(mimeData: MimeData, target: HTMLElement): Drag.DropAction {
+    const source = mimeData.getData(TABLE_HEADER_MIME) as (keyof ContentsProxy.IJupyterContentRow);
+    const dest = target.innerText as (keyof ContentsProxy.IJupyterContentRow);
+    void this._reorderColumns(source, dest);
+    void this.nodeInit();
+    return "move";
+  }
+
+  protected addMimeData(handle: HTMLElement, mimeData: MimeData): void {
+    const columnName = handle.innerText;
+    mimeData.setData(TABLE_HEADER_MIME, columnName);
+  }
+
+  protected getDragImage(handle: HTMLElement): HTMLElement | null {
+    const target = this.findDragTarget(handle);
+    let img = null;
+    if (target) {
+      img = target.cloneNode(true) as HTMLElement;
+      img.classList.add("jp-thead-drag-image");
+    }
+    return img;
+  }
+
+  protected onGetChildren(path: string, done: Promise<void>) {
+    if (this._initialLoad) {
+      const rootPathstr = Path.toarray(this.rootPath).join("/");
+      if (path === rootPathstr) {
+        done.finally(() => {
+          this._initialLoad = false;
+          this.draw();
+        });
+      }
+    }
+    this._expanding.set(path, (this._expanding.get(path) || 0) + 1);
+    // only redraw if bumped up from 0
+    if (this._expanding.get(path) === 1) {
+      this.draw();
+    }
+    done.finally(() => {
+      this._expanding.set(path, (this._expanding.get(path) || 1) - 1);
+      // only redraw if bumped down to 0
+      if (this._expanding.get(path) === 0) {
+        this.draw();
+      }
+    });
+  }
+
+  /**
+   * Reorders the columns according to given inputs and saves to user settings
+   * If `source` is dragged from left to right, it will be inserted to the right side of `dest`
+   * Else if `source` is dragged from right to left, it will be inserted to the left side of `dest`
+   */
+  private async _reorderColumns(source: (keyof ContentsProxy.IJupyterContentRow), dest: (keyof ContentsProxy.IJupyterContentRow)) {
+    const sIndex = this._columns.indexOf(source);
+    const dIndex = this._columns.indexOf(dest);
+
+    if (sIndex < dIndex) {
+      this._columns.splice(dIndex + 1, 0, source);
+      this._columns.splice(sIndex, 1);
+    } else if (sIndex > dIndex) {
+      this._columns.splice(sIndex, 1);
+      this._columns.splice(dIndex, 0, source);
+    }
+
+    await this.settings?.set("display_columns", this._columns);
   }
 
   draw() {
@@ -115,7 +198,10 @@ export class TreeFinderWidget extends Widget {
   }
 
   async nodeInit() {
-    await this.contentsProxy.get(this.rootPath).then(root => this.node.init({
+    // The contents of root passed to node.init is not (currently) considered, so do not ask for it.
+    const root = await this.contentsProxy.get(this.rootPath, { content: false });
+    this._currentFolder = this.model?.root.pathstr;
+    await this.node.init({
       root,
       gridOptions: {
         columnFormatters: {
@@ -129,49 +215,77 @@ export class TreeFinderWidget extends Widget {
       modelOptions: {
         columnNames: this.columns,
       },
-    })).then(() => {
-      const grid = this.node.querySelector<TreeFinderGridElement<ContentsProxy.IJupyterContentRow>>("tree-finder-grid");
-      grid?.addStyleListener(() => {
-        // Fix corner cleanup (workaround for underlying bug where we end up with two resize handles)
-        const resizeSpans = grid.querySelectorAll(`thead tr > th:first-child > span.rt-column-resize`);
-        const nHeaderRows = grid.querySelectorAll("thead tr").length;
-        if (resizeSpans.length > nHeaderRows) {
-          // something went wrong, and we ended up with double resize handles. Clear the classes from the first one:
-          for (const span of grid.querySelectorAll(`thead tr > th:first-child > span.rt-column-resize:first-child`)) {
-            span.removeAttribute("class");
+    });
+
+    const grid = this.node.querySelector<TreeFinderGridElement<ContentsProxy.IJupyterContentRow>>("tree-finder-grid");
+    grid?.addStyleListener(() => {
+      // Set root-level load indicator
+      grid.classList.toggle("jfs-mod-loading", this._initialLoad);
+
+      // Fix corner cleanup (workaround for underlying bug where we end up with two resize handles)
+      const resizeSpans = grid.querySelectorAll(`thead tr > th:first-child > span.rt-column-resize`);
+      const nHeaderRows = grid.querySelectorAll("thead tr").length;
+      if (resizeSpans.length > nHeaderRows) {
+        // something went wrong, and we ended up with double resize handles. Clear the classes from the first one:
+        for (const span of grid.querySelectorAll(`thead tr > th:first-child > span.rt-column-resize:first-child`)) {
+          span.removeAttribute("class");
+        }
+      }
+
+      // Fix focus and tabbing
+      let lastSelectIdx = this.model?.selectedLast ? this.model?.contents.indexOf(this.model.selectedLast) : -1;
+      const lostFocus = document.activeElement === document.body;
+      for (const rowHeader of grid.querySelectorAll<HTMLTableCellElement>("tr > th")) {
+        const tableHeader = rowHeader.querySelector<HTMLSpanElement>("span.tf-header-name");
+
+        if (tableHeader) {
+          // If tableheader is path, do not make it draggable
+          if (tableHeader.innerText !== "path") {
+            tableHeader.classList.add(this.dragHandleClass);
           }
         }
 
-        // Fix focus and tabbing
-        let lastSelectIdx = this.model?.selectedLast ? this.model?.contents.indexOf(this.model.selectedLast) : -1;
-        for (const rowHeader of grid.querySelectorAll<HTMLTableCellElement>("tr > th")) {
-          const nameElement = rowHeader.querySelector<HTMLSpanElement>("span.rt-group-name");
-          // Ensure we can tab to all items
-          nameElement?.setAttribute("tabindex", "0");
-          // Ensure last selected element retains focus after redraw:
-          if (nameElement && lastSelectIdx !== -1) {
-            const meta = grid.getMeta(rowHeader);
-            if (meta && meta.y === lastSelectIdx) {
-              nameElement.focus();
-              lastSelectIdx = -1;
-            }
+        const nameElement = rowHeader.querySelector<HTMLSpanElement>("span.rt-group-name");
+        // Ensure we can tab to all items
+        nameElement?.setAttribute("tabindex", "0");
+        // Ensure last selected element retains focus after redraw:
+        if (lostFocus && nameElement && lastSelectIdx !== -1) {
+          const meta = grid.getMeta(rowHeader);
+          if (meta && meta.y === lastSelectIdx) {
+            nameElement.focus();
+            lastSelectIdx = -1;
+          }
+        }
+
+        // Add "loading" indicator for folders that are fetching children
+        if (nameElement) {
+          const meta = grid.getMeta(rowHeader);
+          const content = meta?.y ? this.model?.contents[meta.y] : undefined;
+          if (content) {
+            rowHeader.classList.toggle("jfs-mod-loading", !!nameElement && (this._expanding.get(content.pathstr) || 0) > 0);
           }
         }
+      }
+    });
+    if (this.uploader) {
+      this.uploader.model = this.model!;
+    } else {
+      this.uploader = new Uploader({
+        contentsProxy: this.contentsProxy,
+        model: this.model!,
       });
-      if (this.uploader) {
-        this.uploader.model = this.model!;
+    }
+    if (this._currentFolder) {
+      await openDirRecursive(this.model!, this._currentFolder.split("/"));
+    }
+    this.model!.openSub.subscribe(rows => rows.forEach(row => {
+      if (!row.getChildren) {
+        void this._commands.execute("docmanager:open", { path: Path.fromarray(row.path) });
       } else {
-        this.uploader = new Uploader({
-          contentsProxy: this.contentsProxy,
-          model: this.model!,
-        });
+        const widget = TreeFinderSidebar.tracker.findByDrive(this.parent!.id)!;
+        void TreeFinderSidebar.tracker.save(widget);
       }
-      this.model!.openSub.subscribe(rows => rows.forEach(row => {
-        if (!row.getChildren) {
-          void this._commands.execute("docmanager:open", { path: Path.fromarray(row.path) });
-        }
-      }));
-    });
+    }));
   }
 
   get columns(): Array<keyof ContentsProxy.IJupyterContentRow> {
@@ -192,7 +306,7 @@ export class TreeFinderWidget extends Widget {
   }
 
   get ready(): Promise<void> {
-    return this._ready;
+    return this._readyDelegate.promise;
   }
 
 
@@ -224,6 +338,7 @@ export class TreeFinderWidget extends Widget {
         this.evtKeydown(event as KeyboardEvent);
         break;
       case "dragenter":
+        this.evtNativeDragOverEnter(event as DragEvent);
       case "dragover":
         this.evtNativeDragOverEnter(event as DragEvent);
         break;
@@ -235,6 +350,7 @@ export class TreeFinderWidget extends Widget {
         this.evtNativeDrop(event as DragEvent);
         break;
       default:
+        super.handleEvent(event);
         break;
     }
   }
@@ -246,6 +362,12 @@ export class TreeFinderWidget extends Widget {
   protected onAfterAttach(msg: Message): void {
     super.onAfterAttach(msg);
     const node = this.node;
+    const initPromise = this.nodeInit();
+    if (this._initialLoad) {
+      void initPromise.then(() => {
+        this._readyDelegate.resolve();
+      });
+    }
     node.addEventListener("keydown", this);
     node.addEventListener("dragenter", this);
     node.addEventListener("dragover", this);
@@ -317,6 +439,10 @@ export class TreeFinderWidget extends Widget {
   }
 
   protected evtKeydown(event: KeyboardEvent): void {
+    // handle any keys unaffacted by renaming status above this check:
+    if (this.parent?.node.classList.contains("jfs-mod-renaming")) {
+      return;
+    }
     switch (event.key) {
       case "ArrowDown":
       case "ArrowUp":
@@ -324,6 +450,64 @@ export class TreeFinderWidget extends Widget {
         event.preventDefault();
         this.selectNeighbour(event.key === "ArrowUp" ? -1 : 1, event.shiftKey);
         break;
+      case "ArrowLeft":
+        if (this.model?.selectedLast) {
+          event.stopPropagation();
+          event.preventDefault();
+          const selectedLast = this.model.selectedLast;
+          // don't allow expansion or up/down nav if in select range mode:
+          if (!event.shiftKey) {
+            if (selectedLast.isExpand) {
+              void this.model.collapse(this.model.contents.indexOf(selectedLast));
+            } else {
+              // navigate the selection to the next up (exluding to root)
+              void getContentParent(selectedLast, this.model.root).then(parent => {
+                if (parent !== this.model?.root) {
+                  this.model?.selectionModel.select(parent);
+                  return TreeFinderSidebar.scrollIntoView(this, parent.pathstr);
+                }
+              });
+            }
+          }
+        }
+        break;
+      case "ArrowRight":
+        if (this.model?.selectedLast) {
+          event.stopPropagation();
+          event.preventDefault();
+          const selectedLast = this.model.selectedLast;
+          // don't allow expansion or up/down nav if in select range mode:
+          if (!event.shiftKey) {
+            if (!selectedLast.isExpand) {
+              void this.model.expand(this.model.contents.indexOf(selectedLast));
+            } else if (selectedLast.hasChildren) {
+              // navigate the selection to the first child
+              void selectedLast.getChildren().then(children => {
+                if (children && children.length > 0) {
+                  this.model?.selectionModel.select(children[0]);
+                  return TreeFinderSidebar.scrollIntoView(this, children[0].pathstr);
+                }
+              });
+            }
+          }
+        }
+        break;
+      case " ":  // space key
+        // Toggle expansion if dir
+        if (this.model?.selectedLast) {
+          event.stopPropagation();
+          event.preventDefault();
+          const selectedLast = this.model.selectedLast;
+          if (selectedLast.hasChildren) {
+            const selectedIdx = this.model.contents.indexOf(selectedLast);
+            if (selectedLast.isExpand) {
+              void this.model.collapse(selectedIdx);
+            } else {
+              void this.model.expand(selectedIdx);
+            }
+          }
+        }
+        break;
     }
   }
 
@@ -394,9 +578,12 @@ export class TreeFinderWidget extends Widget {
 
   readonly translator: ITranslator;
 
-  private _ready: Promise<void>;
+  private _readyDelegate: PromiseDelegate<void>;
   private _trans: TranslationBundle;
   private _commands: CommandRegistry;
+  private _expanding: Map<string, number>;
+  private _initialLoad: boolean;
+  private _currentFolder: string | undefined;
 }
 
 export namespace TreeFinderWidget {
@@ -406,6 +593,7 @@ export namespace TreeFinderWidget {
     rootPath: string;
 
     translator?: ITranslator;
+    settings?: ISettingRegistry.ISettings;
   }
 }
 
@@ -417,6 +605,8 @@ export class TreeFinderSidebar extends Widget {
     rootPath = "",
     caption = "TreeFinder",
     id = "jupyterlab-tree-finder",
+    settings,
+    preferredDir,
   }: TreeFinderSidebar.IOptions) {
     super();
     this.id = id;
@@ -428,8 +618,9 @@ export class TreeFinderSidebar extends Widget {
 
     this.toolbar = new Toolbar();
     this.toolbar.addClass("jp-tree-finder-toolbar");
+    this.preferredDir = preferredDir;
 
-    this.treefinder = new TreeFinderWidget({ app, rootPath, columns });
+    this.treefinder = new TreeFinderWidget({ app, rootPath, columns, settings });
 
     this.layout = new PanelLayout();
     (this.layout as PanelLayout).addWidget(this.toolbar);
@@ -438,26 +629,6 @@ export class TreeFinderSidebar extends Widget {
 
   restore() { // restore expansion prior to rebuild
     void this.treefinder.ready.then(() => this.treefinder.refresh());
-    // const array: Array<Promise<any>> = [];
-    // Object.keys(this.controller).forEach(key => {
-    //   if (this.controller[key].open && (key !== "")) {
-    //     const promise = this.cm.get(this.basepath + key);
-    //     promise.catch(res => {
-    //       // eslint-disable-next-line no-console
-    //       console.log(res);
-    //     });
-    //     array.push(promise);
-    //   }
-    // });
-    // Promise.all(array).then(results => {
-    //   for (const r in results) {
-    //     const row_element = this.node.querySelector("[id='" + u_btoa(results[r].path.replace(this.basepath, "")) + "']");
-    //     this.buildTableContents(results[r].content, 1 + results[r].path.split("/").length, row_element);
-    //   }
-    // }).catch(reasons => {
-    //   // eslint-disable-next-line no-console
-    //   console.log(reasons);
-    // });
   }
 
   async download(path: string, folder: boolean): Promise<void> {
@@ -502,6 +673,7 @@ export class TreeFinderSidebar extends Widget {
     this.treefinder.draw();
   }
 
+  preferredDir: string | undefined;
   toolbar: Toolbar;
   treefinder: TreeFinderWidget;
 
@@ -510,7 +682,7 @@ export class TreeFinderSidebar extends Widget {
 
 // eslint-disable-next-line @typescript-eslint/no-namespace
 export namespace TreeFinderSidebar {
-  const namespace = "jupyter-fs:TreeFinder";
+  const namespace = "jupyter-fs-treefinder";
 
   export const tracker = new TreeFinderTracker({ namespace });
   export const clipboard = new JupyterClipboard(tracker);
@@ -519,11 +691,12 @@ export namespace TreeFinderSidebar {
     app: JupyterFrontEnd;
     columns: Array<keyof ContentsProxy.IJupyterContentRow>;
     url: string;
-
     rootPath?: string;
     caption?: string;
     id?: string;
     translator?: ITranslator;
+    settings?: ISettingRegistry.ISettings;
+    preferredDir?: string;
   }
 
   export interface ISidebarProps extends IOptions {
@@ -532,8 +705,8 @@ export namespace TreeFinderSidebar {
     resolver: IWindowResolver;
     restorer: ILayoutRestorer;
     router: IRouter;
-
     side?: string;
+    settings?: ISettingRegistry.ISettings;
   }
 
   export function sidebarFromResource(resource: IFSResource, props: TreeFinderSidebar.ISidebarProps): TreeFinderSidebar {
@@ -542,6 +715,7 @@ export namespace TreeFinderSidebar {
       rootPath: resource.drive,
       caption: `${resource.name}\nFile Tree`,
       id: idFromResource(resource),
+      preferredDir: resource.preferred_dir,
       url: resource.url,
     });
   }
@@ -555,15 +729,16 @@ export namespace TreeFinderSidebar {
     restorer,
     url,
     columns,
+    settings,
+    preferredDir,
 
     rootPath = "",
     caption = "TreeFinder",
     id = "jupyterlab-tree-finder",
     side = "left",
   }: TreeFinderSidebar.ISidebarProps): TreeFinderSidebar {
-    const widget = new TreeFinderSidebar({ app, rootPath, columns, caption, id, url });
+    const widget = new TreeFinderSidebar({ app, rootPath, columns, caption, id, url, settings, preferredDir });
     void widget.treefinder.ready.then(() => tracker.add(widget));
-    restorer.add(widget, widget.id);
     app.shell.add(widget, side);
 
     const new_file_button = new ToolbarButton({
@@ -596,6 +771,17 @@ export namespace TreeFinderSidebar {
     widget.toolbar.addItem("upload", uploader_button);
     widget.toolbar.addItem("refresh", refresh_button);
 
+    if (preferredDir) {
+      void widget.treefinder.ready.then(async () => {
+        let path = preferredDir.split("/");
+        if (preferredDir.startsWith("/")) {
+          path = path.slice(1);
+        }
+        path.unshift(rootPath);
+        await openDirRecursive(widget.treefinder.model!, path);
+      });
+    }
+
     // // remove context highlight on context menu exit
     // document.ondblclick = () => {
     //   app.commands.execute((widget.commandIDs.set_context + ":" + widget.id), { path: "" });
diff --git a/js/style/auth.css b/js/style/auth.css
index 33f52b14..5dabb986 100644
--- a/js/style/auth.css
+++ b/js/style/auth.css
@@ -41,3 +41,11 @@
 .jfs-ask-panel-summary > div {
   flex-direction: column;
 }
+
+/* Elide the typography fields (mainly URL) */
+.jfs-ask-panel.MuiExpansionPanel-root p { 
+  white-space: nowrap;
+  max-width: 520px;
+  text-overflow: ellipsis;
+  overflow: hidden;
+}
diff --git a/js/style/index.css b/js/style/index.css
index 9a959686..e6d51721 100644
--- a/js/style/index.css
+++ b/js/style/index.css
@@ -16,4 +16,5 @@
 
 @import url('./auth.css');
 @import url('./filetree.css');
+@import url('./snippets.css');
 @import url('./treefinder.css');
diff --git a/js/style/snippets.css b/js/style/snippets.css
new file mode 100644
index 00000000..da2b3fca
--- /dev/null
+++ b/js/style/snippets.css
@@ -0,0 +1,8 @@
+
+/**
+ * Remove this is lab ever starts styling textareas itself:
+ */
+.jp-SettingsPanel textarea.form-control {
+  min-height: 3lh;  /* minimum height of 3 lines */
+  width: 80ch;  /* initial width of 80 characters */
+}
diff --git a/js/style/treefinder.css b/js/style/treefinder.css
index a8c54707..3f1f4251 100644
--- a/js/style/treefinder.css
+++ b/js/style/treefinder.css
@@ -84,10 +84,78 @@ tree-finder-panel tree-finder-grid table {
   font-weight: normal;
 }
 
+.jp-tree-finder th.jfs-mod-loading span.rt-row-header-icon::before {
+  content: "";
+  background-color: var(--tf-background);
+  border: solid 3px var(--jp-ui-font-color1);
+  border-bottom-color: var(--jp-brand-color1);
+  border-radius: 50%;
+
+  position: absolute;
+  width: 1ex;
+  height: 1ex;
+
+  animation: load3 1s infinite linear
+}
+
+.jp-tree-finder tr.tf-mod-select th.jfs-mod-loading span.rt-row-header-icon::before {
+  background-color: var(--tf-select-background);
+}
+
+.jp-tree-finder tbody tr:hover:not(.tf-mod-select) th.jfs-mod-loading span.rt-row-header-icon::before {
+  background-color: var(--tf-row-hover-background);
+}
+
+.jp-tree-finder tree-finder-grid.jfs-mod-loading tbody::before {
+  content: "";
+  border: solid 5px var(--jp-brand-color1);
+  border-radius: 50%;
+  border-bottom-color: var(--jp-layout-color1);
+
+  position: absolute;
+  width: 3em;
+  height: 3em;
+  left: calc(50% - 2em);
+  top: 4lh;
+
+  animation:
+    load3 1s infinite linear,
+    fadeIn 1s;
+}
+
+
+@keyframes fadeIn {
+  0% {
+    opacity: 0;
+  }
+
+  100% {
+    opacity: 1;
+  }
+}
+
+@keyframes load3 {
+  0% {
+    transform: rotate(0deg);
+  }
+
+  100% {
+    transform: rotate(360deg);
+  }
+}
+
 .jp-tree-finder thead th {
   font-weight: 500;
 }
 
+.jp-thead-drag-image {
+  top: 0px;
+  left: 0px;
+  color: var(--jp-ui-font-color1);
+  font-size: var(--jp-ui-font-size1);
+  font-weight: 500; 
+}
+
 .jp-tree-finder .tf-filter-input {
   background-color: transparent;
   color: var(--jp-ui-font-color1);
diff --git a/jupyterfs/__init__.py b/jupyterfs/__init__.py
index ae6f53ae..dbae97e3 100644
--- a/jupyterfs/__init__.py
+++ b/jupyterfs/__init__.py
@@ -16,6 +16,16 @@
     data = json.load(fid)
 
 
+def open_fs(fs_url, **kwargs):
+    """Wrapper around fs.open_fs with {{variable}} substitution"""
+    import fs
+    from .auth import stdin_prompt
+
+    # substitute credential variables via `getpass` queries
+    fs_url = stdin_prompt(fs_url)
+    return fs.open_fs(fs_url, **kwargs)
+
+
 def _jupyter_labextension_paths():
     return [
         {
diff --git a/jupyterfs/auth.py b/jupyterfs/auth.py
index ce3ac17d..a4e2b770 100644
--- a/jupyterfs/auth.py
+++ b/jupyterfs/auth.py
@@ -7,9 +7,9 @@
 #
 import os
 from string import Template
+import urllib.parse
 
 __all__ = [
-    "BraceTemplate",
     "DoubleBraceTemplate",
     "substituteAsk",
     "substituteEnv",
@@ -22,20 +22,6 @@ def tokens(self):
         return [m[0] for m in self.pattern.findall(self.template)]
 
 
-class BraceTemplate(_BaseTemplate):
-    """Template subclass that will replace any '{VAR}'"""
-
-    delimiter = ""
-    pattern = r"""
-    (?:
-      [^{]{(?P<braced>\S+?)}[^}]    | # match anything in single braces
-      (?P<escaped>a^)              | # match nothing
-      (?P<named>a^)                | # match nothing
-      (?P<invalid>a^)                # match nothing
-    )
-    """
-
-
 class DoubleBraceTemplate(_BaseTemplate):
     """Template subclass that will replace any '{{VAR}}'"""
 
@@ -50,9 +36,48 @@ class DoubleBraceTemplate(_BaseTemplate):
     """
 
 
+if not hasattr(DoubleBraceTemplate, "get_identifiers"):
+    # back-fill of 3.11 method. Th function body is copied from CPython under the
+    # Python Software Foundation License Version 2. And is subject to the below copy right:
+    # Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
+    # 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation;
+    # All Rights Reserved
+
+    def get_identifiers(self):
+        ids = []
+        for mo in self.pattern.finditer(self.template):
+            named = mo.group("named") or mo.group("braced")
+            if named is not None and named not in ids:
+                # add a named group only the first time it appears
+                ids.append(named)
+            elif (
+                named is None
+                and mo.group("invalid") is None
+                and mo.group("escaped") is None
+            ):
+                # If all the groups are None, there must be
+                # another group we're not expecting
+                raise ValueError("Unrecognized named group in pattern", self.pattern)
+        return ids
+
+    setattr(DoubleBraceTemplate, "get_identifiers", get_identifiers)
+
+
+def stdin_prompt(url):
+    from getpass import getpass
+
+    template = DoubleBraceTemplate(url)
+    subs = {}
+    for ident in template.get_identifiers():
+        subs[ident] = urllib.parse.quote(getpass(f"Enter value for {ident!r}: "))
+    return template.safe_substitute(subs)
+
+
 def substituteAsk(resource):
     if "tokenDict" in resource:
-        url = DoubleBraceTemplate(resource["url"]).substitute(resource.pop("tokenDict"))
+        url = DoubleBraceTemplate(resource["url"]).safe_substitute(
+            {k: urllib.parse.quote(v) for k, v in resource.pop("tokenDict").items()}
+        )
     else:
         url = resource["url"]
 
@@ -61,7 +86,7 @@ def substituteAsk(resource):
 
 
 def substituteEnv(resource):
-    url = DoubleBraceTemplate(resource["url"]).substitute(os.environ)
+    url = DoubleBraceTemplate(resource["url"]).safe_substitute(os.environ)
 
     # return the substituted string and the names of any missing vars
     return url, DoubleBraceTemplate(url).tokens()
diff --git a/jupyterfs/config.py b/jupyterfs/config.py
index 83e406a0..be12ad1a 100644
--- a/jupyterfs/config.py
+++ b/jupyterfs/config.py
@@ -8,7 +8,7 @@
 from jupyter_server.services.contents.largefilemanager import LargeFileManager
 from jupyter_server.services.contents.manager import ContentsManager
 from jupyter_server.transutils import _i18n
-from traitlets import Bool, List, Type, Unicode
+from traitlets import Bool, Dict, List, Type, Unicode
 from traitlets.config import Configurable
 
 __all__ = ["JupyterFs"]
@@ -39,9 +39,34 @@ class JupyterFs(Configurable):
 
     resource_validators = List(
         config=True,
-        default_value=[".*"],
         trait=Unicode(),
         help=_i18n(
             "regular expressions to match against resource URLs. At least one must match"
         ),
     )
+
+    surface_init_errors = Bool(
+        default_value=False,
+        config=True,
+        help=_i18n("whether to surface init errors to the client"),
+    )
+
+    snippets = List(
+        config=True,
+        per_key_traits=Dict(
+            {
+                "label": Unicode(help="The designator to show to users"),
+                "caption": Unicode(
+                    "", help="An optional, longer description to show to users"
+                ),
+                "pattern": Unicode(
+                    "",
+                    help="A regular expression to match against the full URL of the entry, indicating if this snippet is valid for it",
+                ),
+                "template": Unicode(help="A template string to build up the snippet"),
+            }
+        ),
+        help=_i18n(
+            "per entry snippets for how to use it, e.g. a snippet for how to open a file from a given resource"
+        ),
+    )
diff --git a/jupyterfs/extension.py b/jupyterfs/extension.py
index e84f4724..9414a94b 100644
--- a/jupyterfs/extension.py
+++ b/jupyterfs/extension.py
@@ -5,14 +5,13 @@
 # This file is part of the jupyter-fs library, distributed under the terms of
 # the Apache License 2.0.  The full license can be found in the LICENSE file.
 #
-from __future__ import print_function
-
 import warnings
 
 from jupyter_server.utils import url_path_join
 
 from ._version import __version__  # noqa: F401
 from .metamanager import MetaManager, MetaManagerHandler
+from .snippets import SnippetsHandler
 
 _mm_config_warning_msg = """Misconfiguration of MetaManager. Please add:
 
@@ -56,5 +55,9 @@ def _load_jupyter_server_extension(serverapp):
         % url_path_join(base_url, resources_url)
     )
     web_app.add_handlers(
-        host_pattern, [(url_path_join(base_url, resources_url), MetaManagerHandler)]
+        host_pattern,
+        [
+            (url_path_join(base_url, resources_url), MetaManagerHandler),
+            (url_path_join(base_url, "jupyterfs/snippets"), SnippetsHandler),
+        ],
     )
diff --git a/jupyterfs/fsmanager.py b/jupyterfs/fsmanager.py
index 3063b992..7d3081e7 100644
--- a/jupyterfs/fsmanager.py
+++ b/jupyterfs/fsmanager.py
@@ -11,7 +11,6 @@
 from fs import errors, open_fs
 from fs.base import FS
 from fs.errors import NoSysPath, ResourceNotFound, PermissionDenied
-import fs.path
 import mimetypes
 import pathlib
 import stat
@@ -146,10 +145,11 @@ def __init__(self, pyfs, *args, default_writable=True, parent=None, **kwargs):
     def _checkpoints_class_default(self):
         return NullCheckpoints
 
-    def _is_path_hidden(self, path):
+    def _is_path_hidden(self, path, info):
         """Does the specific API style path correspond to a hidden node?
         Args:
             path (str): The path to check.
+            info (<Info>): FS Info object for file/dir at path
         Returns:
             hidden (bool): Whether the path is hidden.
         """
@@ -161,7 +161,9 @@ def _is_path_hidden(self, path):
             return True
 
         try:
-            info = self._pyfilesystem_instance.getinfo(path, namespaces=("stat",))
+            if not info:
+                info = self._pyfilesystem_instance.getinfo(path, namespaces=("stat",))
+
             # Check Windows flag:
             if info.get("stat", "st_file_attributes", 0) & stat.FILE_ATTRIBUTE_HIDDEN:
                 return True
@@ -174,7 +176,9 @@ def _is_path_hidden(self, path):
                 import os
 
                 syspath = self._pyfilesystem_instance.getsyspath(path)
-                if not os.access(syspath, os.X_OK | os.R_OK):
+                if os.path.exists(syspath) and not os.access(
+                    syspath, os.X_OK | os.R_OK
+                ):
                     return True
 
         except ResourceNotFound:
@@ -187,10 +191,11 @@ def _is_path_hidden(self, path):
             self.log.exception(f"Failed to check if path is hidden: {path!r}")
         return False
 
-    def is_hidden(self, path):
+    def is_hidden(self, path, info=None):
         """Does the API style path correspond to a hidden directory or file?
         Args:
             path (str): The path to check.
+            info (<Info>): FS Info object for file/dir at path
         Returns:
             hidden (bool): Whether the path or any of its parents are hidden.
         """
@@ -199,7 +204,7 @@ def is_hidden(self, path):
         if any(part.startswith(".") for part in ppath.parts):
             return True
         while ppath.parents:
-            if self._is_path_hidden(str(path)):
+            if self._is_path_hidden(str(path), info):
                 return True
             ppath = ppath.parent
         return False
@@ -231,12 +236,12 @@ def exists(self, path):
         """
         return self._pyfilesystem_instance.exists(path)
 
-    def _base_model(self, path):
-        """Build the common base of a contents model"""
-        info = self._pyfilesystem_instance.getinfo(
-            path, namespaces=["details", "access"]
-        )
+    def _base_model(self, path, info):
+        """
+        Build the common base of a contents model
 
+        info (<Info>): FS Info object for file/dir at path -- used for values and reduces needed network calls
+        """
         try:
             # size of file
             size = info.size
@@ -256,7 +261,7 @@ def _base_model(self, path):
 
         # Create the base model.
         model = {}
-        model["name"] = path.rsplit("/", 1)[-1]
+        model["name"] = info.name
         model["path"] = path
         model["last_modified"] = last_modified
         model["created"] = created
@@ -288,38 +293,40 @@ def _base_model(self, path):
             model["writable"] = False
         return model
 
-    def _dir_model(self, path, content=True):
+    def _dir_model(self, path, info, content=True):
         """Build a model for a directory
         if content is requested, will include a listing of the directory
+        info (<Info>): FS Info object for file/dir at path
         """
         four_o_four = "directory does not exist: %r" % path
 
-        if not self._pyfilesystem_instance.isdir(path):
+        if not info.is_dir:
             raise web.HTTPError(404, four_o_four)
-        elif not self.allow_hidden and self.is_hidden(path):
+
+        elif not self.allow_hidden and self.is_hidden(path, info):
             self.log.debug("Refusing to serve hidden directory %r, via 404 Error", path)
             raise web.HTTPError(404, four_o_four)
 
-        model = self._base_model(path)
+        model = self._base_model(path, info)
         model["type"] = "directory"
         model["size"] = None
         if content:
             model["content"] = contents = []
-            for name in self._pyfilesystem_instance.listdir(path):
-                os_path = fs.path.join(path, name)
+
+            for dir_entry in self._pyfilesystem_instance.scandir(
+                path, namespaces=("basic", "access", "details", "stat")
+            ):
                 try:
-                    if (
-                        not self._pyfilesystem_instance.islink(os_path)
-                        and not self._pyfilesystem_instance.isfile(os_path)
-                        and not self._pyfilesystem_instance.isdir(os_path)
-                    ):
-                        self.log.debug("%s not a regular file", os_path)
-                        continue
-
-                    if self.should_list(name):
-                        if self.allow_hidden or not self._is_path_hidden(name):
+                    if self.should_list(dir_entry.name):
+                        if self.allow_hidden or not self._is_path_hidden(
+                            dir_entry.make_path(path), dir_entry
+                        ):
                             contents.append(
-                                self.get(path="%s/%s" % (path, name), content=False)
+                                self.get(
+                                    path="%s/%s" % (path, dir_entry.name),
+                                    content=False,
+                                    info=dir_entry,
+                                )
                             )
                 except PermissionDenied:
                     pass  # Don't provide clues about protected files
@@ -328,11 +335,14 @@ def _dir_model(self, path, content=True):
                     # us from listing other entries
                     pass
                 except Exception as e:
-                    self.log.warning("Error stat-ing %s: %s", os_path, e)
+                    self.log.warning(
+                        "Error stat-ing %s: %s", dir_entry.make_path(path), e
+                    )
+
             model["format"] = "json"
         return model
 
-    def _read_file(self, path, format):
+    def _read_file(self, path, format, info):
         """Read a non-notebook file.
         Args:
             path (str): The path to be read.
@@ -340,9 +350,10 @@ def _read_file(self, path, format):
                 If 'text', the contents will be decoded as UTF-8.
                 If 'base64', the raw bytes contents will be encoded as base64.
                 If not specified, try to decode as UTF-8, and fall back to base64
+            info (<Info>): FS Info object for file at path
         """
         with self.perm_to_403(path):
-            if not self._pyfilesystem_instance.isfile(path):
+            if not info.is_file:
                 raise web.HTTPError(400, "Cannot read non-file %s" % path)
 
             bcontent = self._pyfilesystem_instance.readbytes(path)
@@ -361,25 +372,27 @@ def _read_file(self, path, format):
                     )
         return encodebytes(bcontent).decode("ascii"), "base64"
 
-    def _read_notebook(self, path, as_version=4):
+    def _read_notebook(self, path, info, as_version=4):
         """Read a notebook from a path."""
-        nb, format = self._read_file(path, "text")
+        nb, format = self._read_file(path, "text", info)
         return nbformat.reads(nb, as_version=as_version)
 
-    def _file_model(self, path, content=True, format=None):
+    def _file_model(self, path, info, content=True, format=None):
         """Build a model for a file
         if content is requested, include the file contents.
         format:
           If 'text', the contents will be decoded as UTF-8.
           If 'base64', the raw bytes contents will be encoded as base64.
           If not specified, try to decode as UTF-8, and fall back to base64
+
+        info (<Info>): FS Info object for file at path
         """
-        model = self._base_model(path)
+        model = self._base_model(path, info)
         model["type"] = "file"
         model["mimetype"] = mimetypes.guess_type(path)[0]
 
         if content:
-            content, format = self._read_file(path, format)
+            content, format = self._read_file(path, format, info)
             if model["mimetype"] is None:
                 default_mime = {
                     "text": "text/plain",
@@ -394,50 +407,62 @@ def _file_model(self, path, content=True, format=None):
 
         return model
 
-    def _notebook_model(self, path, content=True):
+    def _notebook_model(self, path, info, content=True):
         """Build a notebook model
         if content is requested, the notebook content will be populated
         as a JSON structure (not double-serialized)
+        info (<Info>): FS Info object for file at path
         """
-        model = self._base_model(path)
+        model = self._base_model(path, info)
         model["type"] = "notebook"
         if content:
-            nb = self._read_notebook(path, as_version=4)
+            nb = self._read_notebook(path, info, as_version=4)
             self.mark_trusted_cells(nb, path)
             model["content"] = nb
             model["format"] = "json"
             self.validate_notebook_model(model)
         return model
 
-    def get(self, path, content=True, type=None, format=None):
+    def get(self, path, content=True, type=None, format=None, info=None):
         """Takes a path for an entity and returns its model
         Args:
             path (str): the API path that describes the relative path for the target
             content (bool): Whether to include the contents in the reply
-            type (str): The requested type - 'file', 'notebook', or 'directory'. Will raise HTTPError 400 if the content doesn't match.
-            format (str): The requested format for file contents. 'text' or 'base64'. Ignored if this returns a notebook or directory model.
+            type (str): The requested type - 'file', 'notebook', or 'directory'.
+                Will raise HTTPError 400 if the content doesn't match.
+            format (str):
+                The requested format for file contents. 'text' or 'base64'. Ignored if this returns a notebook or directory model.
+            info (fs Info object):
+                Optional FS Info. If present, it needs to include the following namespaces: "basic", "stat", "access", "details".
+                Including it can avoid extraneous networkcalls.
         Returns
             model (dict): the contents model. If content=True, returns the contents of the file or directory as well.
         """
         path = path.strip("/")
 
-        if not self.exists(path):
-            raise web.HTTPError(404, "No such file or directory: %s" % path)
+        # gather info - by doing here can minimise further network requests from underlying fs functions
+        if not info:
+            try:
+                info = self._pyfilesystem_instance.getinfo(
+                    path, namespaces=("basic", "stat", "access", "details")
+                )
+            except Exception:
+                raise web.HTTPError(404, "No such file or directory: %s" % path)
 
-        if self._pyfilesystem_instance.isdir(path):
+        if info.is_dir:
             if type not in (None, "directory"):
                 raise web.HTTPError(
                     400, "%s is a directory, not a %s" % (path, type), reason="bad type"
                 )
-            model = self._dir_model(path, content=content)
+            model = self._dir_model(path, content=content, info=info)
         elif type == "notebook" or (type is None and path.endswith(".ipynb")):
-            model = self._notebook_model(path, content=content)
+            model = self._notebook_model(path, content=content, info=info)
         else:
             if type == "directory":
                 raise web.HTTPError(
                     400, "%s is not a directory" % path, reason="bad type"
                 )
-            model = self._file_model(path, content=content, format=format)
+            model = self._file_model(path, content=content, format=format, info=info)
         return model
 
     def _save_directory(self, path, model):
diff --git a/jupyterfs/metamanager.py b/jupyterfs/metamanager.py
index a76f523f..97792ace 100644
--- a/jupyterfs/metamanager.py
+++ b/jupyterfs/metamanager.py
@@ -78,6 +78,7 @@ def initResource(self, *resources, options={}):
             _hash = md5(resource["url"].encode("utf-8")).hexdigest()[:8]
             init = False
             missingTokens = None
+            errors = []
 
             if _hash in self._managers and cache:
                 # reuse existing cm
@@ -108,18 +109,20 @@ def initResource(self, *resources, options={}):
                             parent=self,
                             **self._pyfs_kw,
                         )
-                    except (FSError, OpenerError, ParseError):
+                        init = True
+                    except (FSError, OpenerError, ParseError) as e:
                         self.log.exception(
                             "Failed to create manager for resource %r",
                             resource.get("name"),
                         )
-                        continue
-                    init = True
+                        errors.append(str(e))
 
             # assemble resource from spec + hash
             newResource = {}
             newResource.update(resource)
             newResource.update({"drive": _hash, "init": init})
+            if self._jupyterfsConfig.surface_init_errors:
+                newResource["errors"] = errors
             if missingTokens is not None:
                 newResource["missingTokens"] = missingTokens
 
diff --git a/jupyterfs/snippets.py b/jupyterfs/snippets.py
new file mode 100644
index 00000000..873b15a8
--- /dev/null
+++ b/jupyterfs/snippets.py
@@ -0,0 +1,29 @@
+# *****************************************************************************
+#
+# Copyright (c) 2023, the jupyter-fs authors.
+#
+# This file is part of the jupyter-fs library, distributed under the terms of
+# the Apache License 2.0.  The full license can be found in the LICENSE file.
+#
+
+from jupyter_server.base.handlers import APIHandler
+from tornado import web
+
+from .config import JupyterFs as JupyterFsConfig
+
+
+class SnippetsHandler(APIHandler):
+    _jupyterfsConfig = None
+
+    @property
+    def fsconfig(self):
+        # TODO: This pattern will not pick up changes to config after this!
+        if self._jupyterfsConfig is None:
+            self._jupyterfsConfig = JupyterFsConfig(config=self.config)
+
+        return self._jupyterfsConfig
+
+    @web.authenticated
+    def get(self):
+        """Get the server-side configured snippets"""
+        self.write({"snippets": self.fsconfig.snippets})
diff --git a/yarn.lock b/yarn.lock
index 36baa3eb..0f87657e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1404,9 +1404,9 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@eslint/eslintrc@npm:^2.1.2":
-  version: 2.1.2
-  resolution: "@eslint/eslintrc@npm:2.1.2"
+"@eslint/eslintrc@npm:^2.1.3":
+  version: 2.1.3
+  resolution: "@eslint/eslintrc@npm:2.1.3"
   dependencies:
     ajv: ^6.12.4
     debug: ^4.3.2
@@ -1417,14 +1417,14 @@ __metadata:
     js-yaml: ^4.1.0
     minimatch: ^3.1.2
     strip-json-comments: ^3.1.1
-  checksum: bc742a1e3b361f06fedb4afb6bf32cbd27171292ef7924f61c62f2aed73048367bcc7ac68f98c06d4245cd3fabc43270f844e3c1699936d4734b3ac5398814a7
+  checksum: 5c6c3878192fe0ddffa9aff08b4e2f3bcc8f1c10d6449b7295a5f58b662019896deabfc19890455ffd7e60a5bd28d25d0eaefb2f78b2d230aae3879af92b89e5
   languageName: node
   linkType: hard
 
-"@eslint/js@npm:8.52.0":
-  version: 8.52.0
-  resolution: "@eslint/js@npm:8.52.0"
-  checksum: 490893b8091a66415f4ac98b963d23eb287264ea3bd6af7ec788f0570705cf64fd6ab84b717785980f55e39d08ff5c7fde6d8e4391ccb507169370ce3a6d091a
+"@eslint/js@npm:8.53.0":
+  version: 8.53.0
+  resolution: "@eslint/js@npm:8.53.0"
+  checksum: e0d5cfb0000aaee237c8e6d6d6e366faa60b1ef7f928ce17778373aa44d3b886368f6d5e1f97f913f0f16801aad016db8b8df78418c9d18825c15590328028af
   languageName: node
   linkType: hard
 
@@ -1791,19 +1791,19 @@ __metadata:
   linkType: hard
 
 "@jupyterlab/application@npm:^4.0.0":
-  version: 4.0.7
-  resolution: "@jupyterlab/application@npm:4.0.7"
+  version: 4.0.8
+  resolution: "@jupyterlab/application@npm:4.0.8"
   dependencies:
     "@fortawesome/fontawesome-free": ^5.12.0
-    "@jupyterlab/apputils": ^4.1.7
-    "@jupyterlab/coreutils": ^6.0.7
-    "@jupyterlab/docregistry": ^4.0.7
-    "@jupyterlab/rendermime": ^4.0.7
-    "@jupyterlab/rendermime-interfaces": ^3.8.7
-    "@jupyterlab/services": ^7.0.7
-    "@jupyterlab/statedb": ^4.0.7
-    "@jupyterlab/translation": ^4.0.7
-    "@jupyterlab/ui-components": ^4.0.7
+    "@jupyterlab/apputils": ^4.1.8
+    "@jupyterlab/coreutils": ^6.0.8
+    "@jupyterlab/docregistry": ^4.0.8
+    "@jupyterlab/rendermime": ^4.0.8
+    "@jupyterlab/rendermime-interfaces": ^3.8.8
+    "@jupyterlab/services": ^7.0.8
+    "@jupyterlab/statedb": ^4.0.8
+    "@jupyterlab/translation": ^4.0.8
+    "@jupyterlab/ui-components": ^4.0.8
     "@lumino/algorithm": ^2.0.1
     "@lumino/application": ^2.2.1
     "@lumino/commands": ^2.1.3
@@ -1814,23 +1814,23 @@ __metadata:
     "@lumino/properties": ^2.0.1
     "@lumino/signaling": ^2.1.2
     "@lumino/widgets": ^2.3.0
-  checksum: 4684edfcf7dfe9724e86938cf50a45a3518650dba3535bea9d13e024dcc9cd80a5862d2c1564b6498f6f086253766c0952eded677c93ce56b8b7265d739892c4
+  checksum: e6c50720992d50f8d6151752a31bf8dda2a21e912e896bbe9037f72086bddb515836fb9554603df8773313b27f45a39e01bda7e7b75cb2aca70ef15bcae1bc5e
   languageName: node
   linkType: hard
 
-"@jupyterlab/apputils@npm:^4.0.0, @jupyterlab/apputils@npm:^4.1.7":
-  version: 4.1.7
-  resolution: "@jupyterlab/apputils@npm:4.1.7"
-  dependencies:
-    "@jupyterlab/coreutils": ^6.0.7
-    "@jupyterlab/observables": ^5.0.7
-    "@jupyterlab/rendermime-interfaces": ^3.8.7
-    "@jupyterlab/services": ^7.0.7
-    "@jupyterlab/settingregistry": ^4.0.7
-    "@jupyterlab/statedb": ^4.0.7
-    "@jupyterlab/statusbar": ^4.0.7
-    "@jupyterlab/translation": ^4.0.7
-    "@jupyterlab/ui-components": ^4.0.7
+"@jupyterlab/apputils@npm:^4.0.0, @jupyterlab/apputils@npm:^4.1.8":
+  version: 4.1.8
+  resolution: "@jupyterlab/apputils@npm:4.1.8"
+  dependencies:
+    "@jupyterlab/coreutils": ^6.0.8
+    "@jupyterlab/observables": ^5.0.8
+    "@jupyterlab/rendermime-interfaces": ^3.8.8
+    "@jupyterlab/services": ^7.0.8
+    "@jupyterlab/settingregistry": ^4.0.8
+    "@jupyterlab/statedb": ^4.0.8
+    "@jupyterlab/statusbar": ^4.0.8
+    "@jupyterlab/translation": ^4.0.8
+    "@jupyterlab/ui-components": ^4.0.8
     "@lumino/algorithm": ^2.0.1
     "@lumino/commands": ^2.1.3
     "@lumino/coreutils": ^2.1.2
@@ -1843,13 +1843,13 @@ __metadata:
     "@types/react": ^18.0.26
     react: ^18.2.0
     sanitize-html: ~2.7.3
-  checksum: d8a3739ea4b74244b0e14e6a9bced973cc2fc8eb645fe25d36da960e3413492c5451332f44975ba601daecbe6b1e80073f36860f65482da16e94ed24f11a5947
+  checksum: 1b028893ac0358d9f90585edd5fbb89a4fe251c31789cf6d809fb316b91c958c6ba33884d463dbe78dfdd864b579535e1e1849bcb9b16853002271a71418d31e
   languageName: node
   linkType: hard
 
 "@jupyterlab/builder@npm:^4.0.0":
-  version: 4.0.7
-  resolution: "@jupyterlab/builder@npm:4.0.7"
+  version: 4.0.8
+  resolution: "@jupyterlab/builder@npm:4.0.8"
   dependencies:
     "@lumino/algorithm": ^2.0.1
     "@lumino/application": ^2.2.1
@@ -1884,22 +1884,22 @@ __metadata:
     worker-loader: ^3.0.2
   bin:
     build-labextension: lib/build-labextension.js
-  checksum: 67b034c7843a41f63b314304a224480583d02b4d958fd874b3ea4b7fd9a2ec8df110edaaf0379937a7a1850cb19cf1178fbbadfe535f3dbd9acdc0c3a96b8f8a
+  checksum: 9a1feeba36ba85ac0f1538f8df1b5a2140e5d1786530b7351880b8fb45b2902e961a48c2625d619c0b5c09b68299d9fea045adf139e439fd0f7f3cce41794662
   languageName: node
   linkType: hard
 
-"@jupyterlab/codeeditor@npm:^4.0.7":
-  version: 4.0.7
-  resolution: "@jupyterlab/codeeditor@npm:4.0.7"
+"@jupyterlab/codeeditor@npm:^4.0.8":
+  version: 4.0.8
+  resolution: "@jupyterlab/codeeditor@npm:4.0.8"
   dependencies:
     "@codemirror/state": ^6.2.0
     "@jupyter/ydoc": ^1.0.2
-    "@jupyterlab/coreutils": ^6.0.7
-    "@jupyterlab/nbformat": ^4.0.7
-    "@jupyterlab/observables": ^5.0.7
-    "@jupyterlab/statusbar": ^4.0.7
-    "@jupyterlab/translation": ^4.0.7
-    "@jupyterlab/ui-components": ^4.0.7
+    "@jupyterlab/coreutils": ^6.0.8
+    "@jupyterlab/nbformat": ^4.0.8
+    "@jupyterlab/observables": ^5.0.8
+    "@jupyterlab/statusbar": ^4.0.8
+    "@jupyterlab/translation": ^4.0.8
+    "@jupyterlab/ui-components": ^4.0.8
     "@lumino/coreutils": ^2.1.2
     "@lumino/disposable": ^2.1.2
     "@lumino/dragdrop": ^2.1.3
@@ -1907,13 +1907,13 @@ __metadata:
     "@lumino/signaling": ^2.1.2
     "@lumino/widgets": ^2.3.0
     react: ^18.2.0
-  checksum: d6c1c072b77f0afdc4c61ed9392297b43afa5ef0a3279e05631ead870122f9195eb9d5b6182b1ee984aa4fa7aee56051e710d601c550e43af27d43fc3397c333
+  checksum: 151be40c60bcedf463d01b9c53466afc4b4747dd341fb4d5c2b9fc8b3c181af2ba391f867e10e78bb948848cdd300139b4b112634dec9f8aac9c36c5a13e1654
   languageName: node
   linkType: hard
 
-"@jupyterlab/coreutils@npm:^6.0.0, @jupyterlab/coreutils@npm:^6.0.7":
-  version: 6.0.7
-  resolution: "@jupyterlab/coreutils@npm:6.0.7"
+"@jupyterlab/coreutils@npm:^6.0.0, @jupyterlab/coreutils@npm:^6.0.8":
+  version: 6.0.8
+  resolution: "@jupyterlab/coreutils@npm:6.0.8"
   dependencies:
     "@lumino/coreutils": ^2.1.2
     "@lumino/disposable": ^2.1.2
@@ -1921,21 +1921,21 @@ __metadata:
     minimist: ~1.2.0
     path-browserify: ^1.0.0
     url-parse: ~1.5.4
-  checksum: 18a14e0bc957bf087c3de3e86c5dc7ee568027906edf5dc820d9c794af6c9dece84d0b396e837786849f9144bb156746e3d4f2e838fd023a42eee94ebeb9014f
+  checksum: b56e3b95c0ce52745b79549ef5b18a27e620086b87cf997b3a743b59d18dc529e403c812751b7e294a4abc60ac957381301e14327e1a4b9c1afb232f181f3a4d
   languageName: node
   linkType: hard
 
-"@jupyterlab/docmanager@npm:^4.0.0, @jupyterlab/docmanager@npm:^4.0.7":
-  version: 4.0.7
-  resolution: "@jupyterlab/docmanager@npm:4.0.7"
-  dependencies:
-    "@jupyterlab/apputils": ^4.1.7
-    "@jupyterlab/coreutils": ^6.0.7
-    "@jupyterlab/docregistry": ^4.0.7
-    "@jupyterlab/services": ^7.0.7
-    "@jupyterlab/statusbar": ^4.0.7
-    "@jupyterlab/translation": ^4.0.7
-    "@jupyterlab/ui-components": ^4.0.7
+"@jupyterlab/docmanager@npm:^4.0.0, @jupyterlab/docmanager@npm:^4.0.8":
+  version: 4.0.8
+  resolution: "@jupyterlab/docmanager@npm:4.0.8"
+  dependencies:
+    "@jupyterlab/apputils": ^4.1.8
+    "@jupyterlab/coreutils": ^6.0.8
+    "@jupyterlab/docregistry": ^4.0.8
+    "@jupyterlab/services": ^7.0.8
+    "@jupyterlab/statusbar": ^4.0.8
+    "@jupyterlab/translation": ^4.0.8
+    "@jupyterlab/ui-components": ^4.0.8
     "@lumino/algorithm": ^2.0.1
     "@lumino/coreutils": ^2.1.2
     "@lumino/disposable": ^2.1.2
@@ -1944,24 +1944,24 @@ __metadata:
     "@lumino/signaling": ^2.1.2
     "@lumino/widgets": ^2.3.0
     react: ^18.2.0
-  checksum: 4ccbcfa431563cb0cdfa12d0f1ffed107816b8bcd420de5b6dc85e6c124ff1f691e72ce421102663880dc340717bfb71bdceb25eb8fc4074e08adb58ae6ba371
+  checksum: 70eea965bb9edd6a4042c92d2cb98f8b1b145b6727a354e12e3f5ab84ff2ab7207c5d9433c43c03d01f4377f9dc901359d789d0f47d2c725e56f41c487295550
   languageName: node
   linkType: hard
 
-"@jupyterlab/docregistry@npm:^4.0.0, @jupyterlab/docregistry@npm:^4.0.7":
-  version: 4.0.7
-  resolution: "@jupyterlab/docregistry@npm:4.0.7"
+"@jupyterlab/docregistry@npm:^4.0.0, @jupyterlab/docregistry@npm:^4.0.8":
+  version: 4.0.8
+  resolution: "@jupyterlab/docregistry@npm:4.0.8"
   dependencies:
     "@jupyter/ydoc": ^1.0.2
-    "@jupyterlab/apputils": ^4.1.7
-    "@jupyterlab/codeeditor": ^4.0.7
-    "@jupyterlab/coreutils": ^6.0.7
-    "@jupyterlab/observables": ^5.0.7
-    "@jupyterlab/rendermime": ^4.0.7
-    "@jupyterlab/rendermime-interfaces": ^3.8.7
-    "@jupyterlab/services": ^7.0.7
-    "@jupyterlab/translation": ^4.0.7
-    "@jupyterlab/ui-components": ^4.0.7
+    "@jupyterlab/apputils": ^4.1.8
+    "@jupyterlab/codeeditor": ^4.0.8
+    "@jupyterlab/coreutils": ^6.0.8
+    "@jupyterlab/observables": ^5.0.8
+    "@jupyterlab/rendermime": ^4.0.8
+    "@jupyterlab/rendermime-interfaces": ^3.8.8
+    "@jupyterlab/services": ^7.0.8
+    "@jupyterlab/translation": ^4.0.8
+    "@jupyterlab/ui-components": ^4.0.8
     "@lumino/algorithm": ^2.0.1
     "@lumino/coreutils": ^2.1.2
     "@lumino/disposable": ^2.1.2
@@ -1969,23 +1969,23 @@ __metadata:
     "@lumino/properties": ^2.0.1
     "@lumino/signaling": ^2.1.2
     "@lumino/widgets": ^2.3.0
-  checksum: 1d420696305dc17b2e96b22bf31af2caf2b16e31529c57b824bf859c71ac5caecb5a0a00d32ebc34ca1af65f720cec2c442d786c0460da60d7f65deb402dd891
+  checksum: 280697f97ca146cc711c5dafa1b27eaa89d96bc53fc92ade7d4b78c44c997feb9d2495b392c31e75ed3c836797865e2f3fa6ea8f3207f46a4ab2d26061dc9498
   languageName: node
   linkType: hard
 
 "@jupyterlab/filebrowser@npm:^4.0.0":
-  version: 4.0.7
-  resolution: "@jupyterlab/filebrowser@npm:4.0.7"
-  dependencies:
-    "@jupyterlab/apputils": ^4.1.7
-    "@jupyterlab/coreutils": ^6.0.7
-    "@jupyterlab/docmanager": ^4.0.7
-    "@jupyterlab/docregistry": ^4.0.7
-    "@jupyterlab/services": ^7.0.7
-    "@jupyterlab/statedb": ^4.0.7
-    "@jupyterlab/statusbar": ^4.0.7
-    "@jupyterlab/translation": ^4.0.7
-    "@jupyterlab/ui-components": ^4.0.7
+  version: 4.0.8
+  resolution: "@jupyterlab/filebrowser@npm:4.0.8"
+  dependencies:
+    "@jupyterlab/apputils": ^4.1.8
+    "@jupyterlab/coreutils": ^6.0.8
+    "@jupyterlab/docmanager": ^4.0.8
+    "@jupyterlab/docregistry": ^4.0.8
+    "@jupyterlab/services": ^7.0.8
+    "@jupyterlab/statedb": ^4.0.8
+    "@jupyterlab/statusbar": ^4.0.8
+    "@jupyterlab/translation": ^4.0.8
+    "@jupyterlab/ui-components": ^4.0.8
     "@lumino/algorithm": ^2.0.1
     "@lumino/coreutils": ^2.1.2
     "@lumino/disposable": ^2.1.2
@@ -1997,87 +1997,87 @@ __metadata:
     "@lumino/virtualdom": ^2.0.1
     "@lumino/widgets": ^2.3.0
     react: ^18.2.0
-  checksum: 586b8a07fbe0a9bb0b0cd13a9d6fb083e797831a41fc5273d70124bb2daeeeb641e6b4584fc752a4799a5961bb14acc1379fd09847ef7f38b2908516b9f254e3
+  checksum: 907dade3b9ab6bde667cdf2acc76c0fb2d631c06d794115a456cb6b1995a3b89b2a9f5c53a75824b337833cd05967c55fd919ca1311f24279aa5f067f948378a
   languageName: node
   linkType: hard
 
-"@jupyterlab/nbformat@npm:^3.0.0 || ^4.0.0-alpha.21 || ^4.0.0, @jupyterlab/nbformat@npm:^4.0.7":
-  version: 4.0.7
-  resolution: "@jupyterlab/nbformat@npm:4.0.7"
+"@jupyterlab/nbformat@npm:^3.0.0 || ^4.0.0-alpha.21 || ^4.0.0, @jupyterlab/nbformat@npm:^4.0.8":
+  version: 4.0.8
+  resolution: "@jupyterlab/nbformat@npm:4.0.8"
   dependencies:
     "@lumino/coreutils": ^2.1.2
-  checksum: 32a14a6a3e6d068fa34aec385090531100170480869681156dfb510ea9154141277e678031a0df770af8ae5a0f06dc7c00570089c9187485552e1aeba5130ca8
+  checksum: 2d8255ac7c7c20dbfa8497ce4d8d2f5840568adefb2feaec8eb8ddbb4892f50706ce60e8c4719113485c5523f720802f7e4e7b63ed43fac90f870ff1134bed7a
   languageName: node
   linkType: hard
 
-"@jupyterlab/observables@npm:^5.0.7":
-  version: 5.0.7
-  resolution: "@jupyterlab/observables@npm:5.0.7"
+"@jupyterlab/observables@npm:^5.0.8":
+  version: 5.0.8
+  resolution: "@jupyterlab/observables@npm:5.0.8"
   dependencies:
     "@lumino/algorithm": ^2.0.1
     "@lumino/coreutils": ^2.1.2
     "@lumino/disposable": ^2.1.2
     "@lumino/messaging": ^2.0.1
     "@lumino/signaling": ^2.1.2
-  checksum: 459ec3ec77a12534cd16864892d8d3af3ead32a56956daeb89ab68e16c53651c8f20021e088e5a601b214eed46398bbbaea8bc3dc23f23b2700660558fa7c317
+  checksum: 833c6af7f66a338d53e4ebfae2c10c57a55b8a1710730eed89e7a0103a4dd27b7b5634d0e7cf9c7db47d891fd4c8e72235de9816833482ef68356846200613be
   languageName: node
   linkType: hard
 
-"@jupyterlab/rendermime-interfaces@npm:^3.8.7":
-  version: 3.8.7
-  resolution: "@jupyterlab/rendermime-interfaces@npm:3.8.7"
+"@jupyterlab/rendermime-interfaces@npm:^3.8.8":
+  version: 3.8.8
+  resolution: "@jupyterlab/rendermime-interfaces@npm:3.8.8"
   dependencies:
     "@lumino/coreutils": ^1.11.0 || ^2.1.2
     "@lumino/widgets": ^1.37.2 || ^2.3.0
-  checksum: 8095fc99f89e49ef6793e37d7864511cc182fd2260219d3fe94dc974ac34411d4daf898f237279bd5e097aea19cca04196356bf31bd774e94c77b54894baf71b
+  checksum: b356cc18acedd7eebbf9e6f03329ad58f0aadb676ef7ef6b64dec610857a53593662df54752bb58780d34f39938ec35c6940918513e3a3cef7c5893bd0909684
   languageName: node
   linkType: hard
 
-"@jupyterlab/rendermime@npm:^4.0.7":
-  version: 4.0.7
-  resolution: "@jupyterlab/rendermime@npm:4.0.7"
-  dependencies:
-    "@jupyterlab/apputils": ^4.1.7
-    "@jupyterlab/coreutils": ^6.0.7
-    "@jupyterlab/nbformat": ^4.0.7
-    "@jupyterlab/observables": ^5.0.7
-    "@jupyterlab/rendermime-interfaces": ^3.8.7
-    "@jupyterlab/services": ^7.0.7
-    "@jupyterlab/translation": ^4.0.7
+"@jupyterlab/rendermime@npm:^4.0.8":
+  version: 4.0.8
+  resolution: "@jupyterlab/rendermime@npm:4.0.8"
+  dependencies:
+    "@jupyterlab/apputils": ^4.1.8
+    "@jupyterlab/coreutils": ^6.0.8
+    "@jupyterlab/nbformat": ^4.0.8
+    "@jupyterlab/observables": ^5.0.8
+    "@jupyterlab/rendermime-interfaces": ^3.8.8
+    "@jupyterlab/services": ^7.0.8
+    "@jupyterlab/translation": ^4.0.8
     "@lumino/coreutils": ^2.1.2
     "@lumino/messaging": ^2.0.1
     "@lumino/signaling": ^2.1.2
     "@lumino/widgets": ^2.3.0
     lodash.escape: ^4.0.1
-  checksum: 8e7bc7dc8d569fa8748783d0d23b716deb64af530d2f6861f6a08fe66ace5ff0d75e3cc67eb4ebb50b2089574917fe0b65da0dcf5368c3539fdb78f595560885
+  checksum: c1f9ebffc746fdc13c1b14a148fd2ae10132b5ca4e1eab27d18ac5bf3d3ae70cf2850b06f6c05a799f2c769792d81dab1447885d0cda7206c7cf63af10bbe4f2
   languageName: node
   linkType: hard
 
-"@jupyterlab/services@npm:^7.0.0, @jupyterlab/services@npm:^7.0.7":
-  version: 7.0.7
-  resolution: "@jupyterlab/services@npm:7.0.7"
+"@jupyterlab/services@npm:^7.0.0, @jupyterlab/services@npm:^7.0.8":
+  version: 7.0.8
+  resolution: "@jupyterlab/services@npm:7.0.8"
   dependencies:
     "@jupyter/ydoc": ^1.0.2
-    "@jupyterlab/coreutils": ^6.0.7
-    "@jupyterlab/nbformat": ^4.0.7
-    "@jupyterlab/settingregistry": ^4.0.7
-    "@jupyterlab/statedb": ^4.0.7
+    "@jupyterlab/coreutils": ^6.0.8
+    "@jupyterlab/nbformat": ^4.0.8
+    "@jupyterlab/settingregistry": ^4.0.8
+    "@jupyterlab/statedb": ^4.0.8
     "@lumino/coreutils": ^2.1.2
     "@lumino/disposable": ^2.1.2
     "@lumino/polling": ^2.1.2
     "@lumino/properties": ^2.0.1
     "@lumino/signaling": ^2.1.2
     ws: ^8.11.0
-  checksum: 203f9e9eeab55eac9251c43d14ebaad881e8152a1337156ed7f2abbada54177237128c108f91a49f45df00226df8ba6a374d02afbd3bbd80ebb795cb5bc62e23
+  checksum: b0112854d3014eff9d9855a6840d1efd0d866d4c011e7a9c4c1c5fba404dd13107b62de6ce845902d12cc6404aafdfee95127a2af43560ade53a00fc7b73378a
   languageName: node
   linkType: hard
 
-"@jupyterlab/settingregistry@npm:^4.0.0, @jupyterlab/settingregistry@npm:^4.0.7":
-  version: 4.0.7
-  resolution: "@jupyterlab/settingregistry@npm:4.0.7"
+"@jupyterlab/settingregistry@npm:^4.0.0, @jupyterlab/settingregistry@npm:^4.0.8":
+  version: 4.0.8
+  resolution: "@jupyterlab/settingregistry@npm:4.0.8"
   dependencies:
-    "@jupyterlab/nbformat": ^4.0.7
-    "@jupyterlab/statedb": ^4.0.7
+    "@jupyterlab/nbformat": ^4.0.8
+    "@jupyterlab/statedb": ^4.0.8
     "@lumino/commands": ^2.1.3
     "@lumino/coreutils": ^2.1.2
     "@lumino/disposable": ^2.1.2
@@ -2087,28 +2087,28 @@ __metadata:
     json5: ^2.2.3
   peerDependencies:
     react: ">=16"
-  checksum: f13dd888c42ccbcb1764037e94ea6b9ee6aa82a232cbb0d8b506212b9e9d5d58965215768110f83a310585482d71dfb649a7c2bbb187553d39dd1292b5919dbe
+  checksum: e9661539357edae60e4b300dff68b369e95e96acb343aeb25e23bdbcd6964c59dd40118ce3a856afaf969833958f3872c480e75cc488a5e882546cb88587c461
   languageName: node
   linkType: hard
 
-"@jupyterlab/statedb@npm:^4.0.7":
-  version: 4.0.7
-  resolution: "@jupyterlab/statedb@npm:4.0.7"
+"@jupyterlab/statedb@npm:^4.0.8":
+  version: 4.0.8
+  resolution: "@jupyterlab/statedb@npm:4.0.8"
   dependencies:
     "@lumino/commands": ^2.1.3
     "@lumino/coreutils": ^2.1.2
     "@lumino/disposable": ^2.1.2
     "@lumino/properties": ^2.0.1
     "@lumino/signaling": ^2.1.2
-  checksum: 4f4217fa1fceb40be8837cb450b1e66d4f6758531603c82ac277412ec43e3b94a5207bdeb74339307509a1b059ae6436d653beaff2fadfbc8136434ff0967190
+  checksum: bfd016e91158daf47e07e760126c0c2c3f6d01ecc8e9cad3e17241e5873decbc5fdfce82bf039fa83633b8760245af8003008f38272dafba56b73ac24768a99f
   languageName: node
   linkType: hard
 
-"@jupyterlab/statusbar@npm:^4.0.0, @jupyterlab/statusbar@npm:^4.0.7":
-  version: 4.0.7
-  resolution: "@jupyterlab/statusbar@npm:4.0.7"
+"@jupyterlab/statusbar@npm:^4.0.0, @jupyterlab/statusbar@npm:^4.0.8":
+  version: 4.0.8
+  resolution: "@jupyterlab/statusbar@npm:4.0.8"
   dependencies:
-    "@jupyterlab/ui-components": ^4.0.7
+    "@jupyterlab/ui-components": ^4.0.8
     "@lumino/algorithm": ^2.0.1
     "@lumino/coreutils": ^2.1.2
     "@lumino/disposable": ^2.1.2
@@ -2116,31 +2116,31 @@ __metadata:
     "@lumino/signaling": ^2.1.2
     "@lumino/widgets": ^2.3.0
     react: ^18.2.0
-  checksum: 7a2f75215789722a7b9a63548e91a1b179e8c315513d1b8741b508a58937569d723f2207bf542400674767246ad871432a09d1e87779151e43fa3749aa1ade06
+  checksum: a07345a173e1c4500e5ce9aca6c8d619e5fecd928de0f6e88fd29241b39c09b85b26722279cc8119031d3015f2b32a0d3b9d85fd3cf9370c7605ebcd37d0d31a
   languageName: node
   linkType: hard
 
-"@jupyterlab/translation@npm:^4.0.0, @jupyterlab/translation@npm:^4.0.7":
-  version: 4.0.7
-  resolution: "@jupyterlab/translation@npm:4.0.7"
+"@jupyterlab/translation@npm:^4.0.0, @jupyterlab/translation@npm:^4.0.8":
+  version: 4.0.8
+  resolution: "@jupyterlab/translation@npm:4.0.8"
   dependencies:
-    "@jupyterlab/coreutils": ^6.0.7
-    "@jupyterlab/rendermime-interfaces": ^3.8.7
-    "@jupyterlab/services": ^7.0.7
-    "@jupyterlab/statedb": ^4.0.7
+    "@jupyterlab/coreutils": ^6.0.8
+    "@jupyterlab/rendermime-interfaces": ^3.8.8
+    "@jupyterlab/services": ^7.0.8
+    "@jupyterlab/statedb": ^4.0.8
     "@lumino/coreutils": ^2.1.2
-  checksum: 15ad212d9447049f5d77d24681018efd52e61b861e73cdba4b09f4530801bdfa317c0eadde0b71016a9f45b68fbf91f723f9a63de9cbbe568c88923a9676ffab
+  checksum: 998d42d85ccd779237ac69abfaf2e341d865374ed5a1a4d234470337f498636511eec0562c741ad44a6a75fae930a510a0a76e176f72665499be2b7edb0dc5f8
   languageName: node
   linkType: hard
 
-"@jupyterlab/ui-components@npm:^4.0.0, @jupyterlab/ui-components@npm:^4.0.7":
-  version: 4.0.7
-  resolution: "@jupyterlab/ui-components@npm:4.0.7"
+"@jupyterlab/ui-components@npm:^4.0.0, @jupyterlab/ui-components@npm:^4.0.8":
+  version: 4.0.8
+  resolution: "@jupyterlab/ui-components@npm:4.0.8"
   dependencies:
-    "@jupyterlab/coreutils": ^6.0.7
-    "@jupyterlab/observables": ^5.0.7
-    "@jupyterlab/rendermime-interfaces": ^3.8.7
-    "@jupyterlab/translation": ^4.0.7
+    "@jupyterlab/coreutils": ^6.0.8
+    "@jupyterlab/observables": ^5.0.8
+    "@jupyterlab/rendermime-interfaces": ^3.8.8
+    "@jupyterlab/translation": ^4.0.8
     "@lumino/algorithm": ^2.0.1
     "@lumino/commands": ^2.1.3
     "@lumino/coreutils": ^2.1.2
@@ -2158,7 +2158,7 @@ __metadata:
     typestyle: ^2.0.4
   peerDependencies:
     react: ^18.2.0
-  checksum: 92e722b8b4fe96a1df6644de8f955fdf48f2bf568a5aaf5f450f721659afc0ecdd9c89f833d73cbad8684849caec4316d4c6b6b0575e7da5a6c3058f5e99d03e
+  checksum: 7bf11f5ee3c1f88656175c0d3b290be0670d7787076a1eba944875e4780bc2b34c0b9a3af038806ff925620b3056cee36daff08f3ff91acc6c46fd1438bf004d
   languageName: node
   linkType: hard
 
@@ -2170,13 +2170,13 @@ __metadata:
   linkType: hard
 
 "@lumino/application@npm:^2.2.1":
-  version: 2.2.1
-  resolution: "@lumino/application@npm:2.2.1"
+  version: 2.3.0
+  resolution: "@lumino/application@npm:2.3.0"
   dependencies:
-    "@lumino/commands": ^2.1.3
+    "@lumino/commands": ^2.2.0
     "@lumino/coreutils": ^2.1.2
-    "@lumino/widgets": ^2.3.0
-  checksum: a33e661703728440bc7d2ddb4674261f4de0d20eb8c9846646cbd6debac03b5c65e78d739a500903550fd83b8f47b47fa82ec178c97bc9967ca3ac4014075cde
+    "@lumino/widgets": ^2.3.1
+  checksum: 9d1eb5bc972ed158bf219604a53bbac1262059bc5b0123d3e041974486b9cbb8288abeeec916f3b62f62d7c32e716cccf8b73e4832ae927e4f9dd4e4b0cd37ed
   languageName: node
   linkType: hard
 
@@ -2189,9 +2189,9 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@lumino/commands@npm:^2.0.0, @lumino/commands@npm:^2.1.3":
-  version: 2.1.3
-  resolution: "@lumino/commands@npm:2.1.3"
+"@lumino/commands@npm:^2.0.0, @lumino/commands@npm:^2.1.3, @lumino/commands@npm:^2.2.0":
+  version: 2.2.0
+  resolution: "@lumino/commands@npm:2.2.0"
   dependencies:
     "@lumino/algorithm": ^2.0.1
     "@lumino/coreutils": ^2.1.2
@@ -2200,7 +2200,7 @@ __metadata:
     "@lumino/keyboard": ^2.0.1
     "@lumino/signaling": ^2.1.2
     "@lumino/virtualdom": ^2.0.1
-  checksum: e4e3ee279f2a5e8d68e4ce142c880333f5542f90c684972402356936ecb5cf5e07163800b59e7cb8c911cbdb4e5089edcc5dd2990bc8db10c87517268de1fc5d
+  checksum: 093e9715491e5cef24bc80665d64841417b400f2fa595f9b60832a3b6340c405c94a6aa276911944a2c46d79a6229f3cc087b73f50852bba25ece805abd0fae9
   languageName: node
   linkType: hard
 
@@ -2227,13 +2227,13 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@lumino/dragdrop@npm:^2.1.3":
-  version: 2.1.3
-  resolution: "@lumino/dragdrop@npm:2.1.3"
+"@lumino/dragdrop@npm:^2.1.3, @lumino/dragdrop@npm:^2.1.4":
+  version: 2.1.4
+  resolution: "@lumino/dragdrop@npm:2.1.4"
   dependencies:
     "@lumino/coreutils": ^2.1.2
     "@lumino/disposable": ^2.1.2
-  checksum: d5f7eb4cc9f9a084cb9af10f02d6741b25d683350878ecbc324e24ba9d4b5246451a410e2ca5fff227aab1c191d1e73a2faf431f93e13111d67a4e426e126258
+  checksum: 43d82484b13b38b612e7dfb424a840ed6a38d0db778af10655c4ba235c67b5b12db1683929b35a36ab2845f77466066dfd1ee25c1c273e8e175677eba9dc560d
   languageName: node
   linkType: hard
 
@@ -2291,22 +2291,22 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@lumino/widgets@npm:^1.37.2 || ^2.3.0, @lumino/widgets@npm:^2.0.0, @lumino/widgets@npm:^2.3.0":
-  version: 2.3.0
-  resolution: "@lumino/widgets@npm:2.3.0"
+"@lumino/widgets@npm:^1.37.2 || ^2.3.0, @lumino/widgets@npm:^2.0.0, @lumino/widgets@npm:^2.3.0, @lumino/widgets@npm:^2.3.1":
+  version: 2.3.1
+  resolution: "@lumino/widgets@npm:2.3.1"
   dependencies:
     "@lumino/algorithm": ^2.0.1
-    "@lumino/commands": ^2.1.3
+    "@lumino/commands": ^2.2.0
     "@lumino/coreutils": ^2.1.2
     "@lumino/disposable": ^2.1.2
     "@lumino/domutils": ^2.0.1
-    "@lumino/dragdrop": ^2.1.3
+    "@lumino/dragdrop": ^2.1.4
     "@lumino/keyboard": ^2.0.1
     "@lumino/messaging": ^2.0.1
     "@lumino/properties": ^2.0.1
     "@lumino/signaling": ^2.1.2
     "@lumino/virtualdom": ^2.0.1
-  checksum: a8559bd3574b7fc16e7679e05994c515b0d3e78dada35786d161f67c639941d134e92ce31d95c2e4ac06709cdf83b0e7fb4b6414a3f7779579222a2fb525d025
+  checksum: ba7b8f8839c1cd2a41dbda13281094eb6981a270cccf4f25a0cf83686dcc526a2d8044a20204317630bb7dd4a04d65361408c7623a921549c781afca84b91c67
   languageName: node
   linkType: hard
 
@@ -2470,8 +2470,8 @@ __metadata:
   linkType: hard
 
 "@rjsf/core@npm:^5.1.0":
-  version: 5.13.3
-  resolution: "@rjsf/core@npm:5.13.3"
+  version: 5.13.4
+  resolution: "@rjsf/core@npm:5.13.4"
   dependencies:
     lodash: ^4.17.21
     lodash-es: ^4.17.21
@@ -2481,13 +2481,13 @@ __metadata:
   peerDependencies:
     "@rjsf/utils": ^5.12.x
     react: ^16.14.0 || >=17
-  checksum: ba7c855d2985bad845e75aadd1d5f227dde8715f14f9d1f2111cc502717eead93bc3430662fd9b04489a130e4100dc43eeb73a9c7d1c028655436dd7ca67eb4f
+  checksum: a263eca0064a62815a1e6a0492072c70834415a35dd67dd24a17f5ca77866c5878b57a1867b216a9a11e56f115b18822b4e20423dec984e22d0d7a8eed52eb22
   languageName: node
   linkType: hard
 
-"@rjsf/utils@npm:^5.1.0":
-  version: 5.13.3
-  resolution: "@rjsf/utils@npm:5.13.3"
+"@rjsf/utils@npm:^5.1.0, @rjsf/utils@npm:^5.13.2":
+  version: 5.13.4
+  resolution: "@rjsf/utils@npm:5.13.4"
   dependencies:
     json-schema-merge-allof: ^0.8.1
     jsonpointer: ^5.0.1
@@ -2496,7 +2496,7 @@ __metadata:
     react-is: ^18.2.0
   peerDependencies:
     react: ^16.14.0 || >=17
-  checksum: 7b2d3c7791a6f10b620f5cec9820994f6a2a728604848423c8d7534d762ff4f6ae64132077a7c9c99c7ec3c8166667fa911d3012c4971eea8387758233070ab1
+  checksum: 9c55dd102a850cded70edc9de1a68b1d0b181d04abe59cbfd9b1cd42500dae6a271ff4e991925843447f881a579790ba28bc578a1373133fd164cad40b8f826e
   languageName: node
   linkType: hard
 
@@ -2727,13 +2727,13 @@ __metadata:
   linkType: hard
 
 "@types/react@npm:*, @types/react@npm:^18.0.0, @types/react@npm:^18.0.26":
-  version: 18.2.33
-  resolution: "@types/react@npm:18.2.33"
+  version: 18.2.36
+  resolution: "@types/react@npm:18.2.36"
   dependencies:
     "@types/prop-types": "*"
     "@types/scheduler": "*"
     csstype: ^3.0.2
-  checksum: 75903c4d53898c69dd23d0b2730eac4676dc5ade15c25c793dec855f0d7c650cb823832bb1dd881efe8895724f15b06d4bf7081ea0b82391aa3059512ad49ccf
+  checksum: 561fab294117983f3d245a63730bcffb423fc2a1b0f27d20c870abc5d980bc206a74f741cb11b5170fcdf0e747ac05448369cd930fbf345f74ed567f8fef3a9e
   languageName: node
   linkType: hard
 
@@ -3735,9 +3735,9 @@ __metadata:
   linkType: hard
 
 "caniuse-lite@npm:^1.0.30001541":
-  version: 1.0.30001559
-  resolution: "caniuse-lite@npm:1.0.30001559"
-  checksum: 17c7af10244dca2c7ca41c884df19fc4c7313b9b03ed1f9b1f352bffbc871701f35431f766838f669ebe1f9d20627c0c6f2d760a0837538bd1d21de5833020f4
+  version: 1.0.30001561
+  resolution: "caniuse-lite@npm:1.0.30001561"
+  checksum: 949829fe037e23346595614e01d362130245920503a12677f2506ce68e1240360113d6383febed41e8aa38cd0f5fd9c69c21b0af65a71c0246d560db489f1373
   languageName: node
   linkType: hard
 
@@ -4317,9 +4317,9 @@ __metadata:
   linkType: hard
 
 "electron-to-chromium@npm:^1.4.535":
-  version: 1.4.572
-  resolution: "electron-to-chromium@npm:1.4.572"
-  checksum: f7f111908febb03ba775dff02be288c0f9e3fe1766c22b915d6b40fdba30ee4aa899234513ca64028b56dd5d5a051b25e3e42fda557f57d149416fc325147706
+  version: 1.4.576
+  resolution: "electron-to-chromium@npm:1.4.576"
+  checksum: cf26065d95b0ecdd01f6daeec15b1e684a3b39680ae9f6d5d33c1391a6c1aa5ea2cb81cce699878e4fbb942328fc1076726f2d9ab1e8df9fbe02c84f1a5bbf1f
   languageName: node
   linkType: hard
 
@@ -4726,13 +4726,13 @@ __metadata:
   linkType: hard
 
 "eslint@npm:^8.29.0":
-  version: 8.52.0
-  resolution: "eslint@npm:8.52.0"
+  version: 8.53.0
+  resolution: "eslint@npm:8.53.0"
   dependencies:
     "@eslint-community/eslint-utils": ^4.2.0
     "@eslint-community/regexpp": ^4.6.1
-    "@eslint/eslintrc": ^2.1.2
-    "@eslint/js": 8.52.0
+    "@eslint/eslintrc": ^2.1.3
+    "@eslint/js": 8.53.0
     "@humanwhocodes/config-array": ^0.11.13
     "@humanwhocodes/module-importer": ^1.0.1
     "@nodelib/fs.walk": ^1.2.8
@@ -4769,7 +4769,7 @@ __metadata:
     text-table: ^0.2.0
   bin:
     eslint: bin/eslint.js
-  checksum: fd22d1e9bd7090e31b00cbc7a3b98f3b76020a4c4641f987ae7d0c8f52e1b88c3b268bdfdabac2e1a93513e5d11339b718ff45cbff48a44c35d7e52feba510ed
+  checksum: 2da808655c7aa4b33f8970ba30d96b453c3071cc4d6cd60d367163430677e32ff186b65270816b662d29139283138bff81f28dddeb2e73265495245a316ed02c
   languageName: node
   linkType: hard
 
@@ -4892,15 +4892,15 @@ __metadata:
   linkType: hard
 
 "fast-glob@npm:^3.2.9":
-  version: 3.3.1
-  resolution: "fast-glob@npm:3.3.1"
+  version: 3.3.2
+  resolution: "fast-glob@npm:3.3.2"
   dependencies:
     "@nodelib/fs.stat": ^2.0.2
     "@nodelib/fs.walk": ^1.2.3
     glob-parent: ^5.1.2
     merge2: ^1.3.0
     micromatch: ^4.0.4
-  checksum: b6f3add6403e02cf3a798bfbb1183d0f6da2afd368f27456010c0bc1f9640aea308243d4cb2c0ab142f618276e65ecb8be1661d7c62a7b4e5ba774b9ce5432e5
+  checksum: 900e4979f4dbc3313840078419245621259f349950411ca2fa445a2f9a1a6d98c3b5e7e0660c5ccd563aa61abe133a21765c6c0dec8e57da1ba71d8000b05ec1
   languageName: node
   linkType: hard
 
@@ -5946,9 +5946,9 @@ __metadata:
   linkType: hard
 
 "istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0":
-  version: 3.2.0
-  resolution: "istanbul-lib-coverage@npm:3.2.0"
-  checksum: a2a545033b9d56da04a8571ed05c8120bf10e9bce01cf8633a3a2b0d1d83dff4ac4fe78d6d5673c27fc29b7f21a41d75f83a36be09f82a61c367b56aa73c1ff9
+  version: 3.2.1
+  resolution: "istanbul-lib-coverage@npm:3.2.1"
+  checksum: 382d5f698fed81de5c32a32d91848cba29df097bfce162f3cdd7fb66de7feeace9873d75c9d6bf3e34b1a4cda6be5bd819ec41c4b532c584dbff7c69db85448e
   languageName: node
   linkType: hard
 
@@ -6859,6 +6859,7 @@ __metadata:
     "@lumino/signaling": ^2.0.0
     "@lumino/widgets": ^2.0.0
     "@material-ui/core": ^4.11.3
+    "@rjsf/utils": ^5.13.2
     "@types/file-saver": ^2.0.1
     "@types/jest": ^29.5.1
     "@types/jszip": ^3.4.1
@@ -6883,6 +6884,7 @@ __metadata:
     react: ^18.2.0
     react-dom: ^18.2.0
     rimraf: ^5.0.1
+    semver: ^7.5.4
     shx: ^0.3.3
     source-map-loader: ^4.0.1
     tree-finder: ^0.0.13
@@ -7356,11 +7358,11 @@ __metadata:
   linkType: hard
 
 "nanoid@npm:^3.3.6":
-  version: 3.3.6
-  resolution: "nanoid@npm:3.3.6"
+  version: 3.3.7
+  resolution: "nanoid@npm:3.3.7"
   bin:
     nanoid: bin/nanoid.cjs
-  checksum: 7d0eda657002738aa5206107bd0580aead6c95c460ef1bdd0b1a87a9c7ae6277ac2e9b945306aaa5b32c6dcb7feaf462d0f552e7f8b5718abfc6ead5c94a71b3
+  checksum: d36c427e530713e4ac6567d488b489a36582ef89da1d6d4e3b87eded11eb10d7042a877958c6f104929809b2ab0bafa17652b076cdf84324aa75b30b722204f2
   languageName: node
   linkType: hard
 
@@ -7407,8 +7409,8 @@ __metadata:
   linkType: hard
 
 "node-gyp@npm:latest":
-  version: 10.0.0
-  resolution: "node-gyp@npm:10.0.0"
+  version: 10.0.1
+  resolution: "node-gyp@npm:10.0.1"
   dependencies:
     env-paths: ^2.2.0
     exponential-backoff: ^3.1.1
@@ -7422,7 +7424,7 @@ __metadata:
     which: ^4.0.0
   bin:
     node-gyp: bin/node-gyp.js
-  checksum: 65fa5d9f8ef03fa22c5f2d34da23435a63d3743400ca941a4394eb943cf340796456697a7797af1451606dbbeecb663be9328995dadc0b99e58dd583dc3a7a0f
+  checksum: 60a74e66d364903ce02049966303a57f898521d139860ac82744a5fdd9f7b7b3b61f75f284f3bfe6e6add3b8f1871ce305a1d41f775c7482de837b50c792223f
   languageName: node
   linkType: hard
 
@@ -9416,9 +9418,9 @@ __metadata:
   linkType: hard
 
 "universalify@npm:^2.0.0":
-  version: 2.0.0
-  resolution: "universalify@npm:2.0.0"
-  checksum: 2406a4edf4a8830aa6813278bab1f953a8e40f2f63a37873ffa9a3bc8f9745d06cc8e88f3572cb899b7e509013f7f6fcc3e37e8a6d914167a5381d8440518c44
+  version: 2.0.1
+  resolution: "universalify@npm:2.0.1"
+  checksum: ecd8469fe0db28e7de9e5289d32bd1b6ba8f7183db34f3bfc4ca53c49891c2d6aa05f3fb3936a81285a905cc509fb641a0c3fc131ec786167eff41236ae32e60
   languageName: node
   linkType: hard
 


Test patch:
diff --git a/js/tests/activate.test.ts b/js/tests/activate.test.ts
index ea15cdda..3a7f56fb 100644
--- a/js/tests/activate.test.ts
+++ b/js/tests/activate.test.ts
@@ -4,7 +4,7 @@ import { browser, progressStatus } from "../src/index";
 
 describe("Checks activate", () => {
   test("Check activate", () => {
-    expect(browser);
+    expect(browser.activate);
     expect(progressStatus);
   });
 });
diff --git a/js/tests/jest-setup.js b/js/tests/jest-setup.js
new file mode 100644
index 00000000..20d710b8
--- /dev/null
+++ b/js/tests/jest-setup.js
@@ -0,0 +1,6 @@
+/* eslint-disable no-undef */
+//global.fetch = require("jest-fetch-mock");
+const version = process.version.match(/^v((\d+)\.(\d+))/).slice(2, 4).map(v => parseInt(v));
+if (version[0] === 18 && version[1] <= 16) {
+  globalThis.crypto = require("crypto");
+}
diff --git a/js/tests/settings.test.ts b/js/tests/settings.test.ts
new file mode 100644
index 00000000..e54e7e83
--- /dev/null
+++ b/js/tests/settings.test.ts
@@ -0,0 +1,17 @@
+import "isomorphic-fetch";
+
+import { unpartialResource } from "../src/settings";
+import { IFSSettingsResource } from "../src/filesystem";
+
+describe("unpartialResource", () => {
+
+  it("should replace non-existing fields with empty string", () => {
+
+    // blank resource missing name and url (like after pressing "add")
+    const resource: IFSSettingsResource = { auth: "ask", defaultWritable: true };
+    const unpartialed = unpartialResource(resource);
+
+    expect(unpartialed.name).toEqual("");
+    expect(unpartialed.url).toEqual("");
+  });
+});
diff --git a/js/tests/snippets.test.ts b/js/tests/snippets.test.ts
new file mode 100644
index 00000000..23154da2
--- /dev/null
+++ b/js/tests/snippets.test.ts
@@ -0,0 +1,177 @@
+import "isomorphic-fetch";
+
+import {
+  ISettingRegistry,
+  SettingRegistry,
+} from "@jupyterlab/settingregistry";
+import { StateDB } from "@jupyterlab/statedb";
+import { getServerSnippets, getSettingsSnippets, instantiateSnippet, snippetFormRender } from "../src/snippets";
+
+import * as importedSchema from "../schema/plugin.json";
+import { ServerConnection } from "@jupyterlab/services";
+
+describe("instantiateSnippet", () => {
+  const knownArgs = [
+    "url",
+    "path",
+    "full_url",
+    "full_path",
+    "drive",
+    "protocol",
+    "resource",
+  ];
+
+  it("should replace known parameters", () => {
+    const pathstr = "drivename/full/path/file.txt";
+    const url = "scheme://_:{{creds}}@url/path";
+
+    const template = `{\n  ${knownArgs.map(arg => `"${arg}": "{{${arg}}}"`).join(",\n  ")}\n}`;
+
+    const snippet = instantiateSnippet(template, url, pathstr);
+    expect(snippet).toEqual(`{
+  "url": "scheme://_:{{creds}}@url/path",
+  "path": "full/path/file.txt",
+  "full_url": "scheme://_:{{creds}}@url/path/full/path/file.txt",
+  "full_path": "drivename:/full/path/file.txt",
+  "drive": "drivename",
+  "protocol": "scheme",
+  "resource": "url/path"
+}`);
+  });
+
+  it("should handle root path", () => {
+    const pathstr = "drivename";
+    const url = "scheme://";
+
+    const template = `{\n  ${knownArgs.map(arg => `"${arg}": "{{${arg}}}"`).join(",\n  ")}\n}`;
+
+    const snippet = instantiateSnippet(template, url, pathstr);
+    expect(snippet).toEqual(`{
+  "url": "scheme://",
+  "path": "",
+  "full_url": "scheme://",
+  "full_path": "drivename:/",
+  "drive": "drivename",
+  "protocol": "scheme",
+  "resource": ""
+}`);
+  });
+
+});
+
+
+class TestConnector extends StateDB {
+  schemas: { [key: string]: ISettingRegistry.ISchema } = {
+    "jupyter-fs": importedSchema as ISettingRegistry.ISchema,
+  };
+
+  async fetch(id: string): Promise<ISettingRegistry.IPlugin | undefined> {
+    const fetched = await super.fetch(id);
+    if (!fetched && !this.schemas[id]) {
+      return undefined;
+    }
+
+    const schema = importedSchema as ISettingRegistry.ISchema;
+    const composite = {};
+    const user = {};
+    const raw = (fetched as string) || "{ }";
+    const version = "test";
+    return { id, data: { composite, user }, raw, schema, version };
+  }
+
+  async list(): Promise<any> {
+    return Promise.reject("list method not implemented");
+  }
+}
+
+
+describe("getSettingsSnippets", () => {
+
+  const connector = new TestConnector();
+  const timeout = 500;
+  let registry: SettingRegistry;
+
+
+  afterEach(() => connector.clear());
+
+  beforeEach(() => {
+    registry = new SettingRegistry({ connector, timeout });
+  });
+
+
+  it("should return snippets with regexp instances", async () => {
+    const settings = await registry.load("jupyter-fs");
+    const snippets = getSettingsSnippets(settings);
+    expect(snippets.map(s => s.label)).toEqual(importedSchema.properties.snippets.default.map(d => d.label));
+    expect(snippets.map(s => s.caption)).toEqual(importedSchema.properties.snippets.default.map(d => d.caption));
+    expect(snippets.map(s => s.template)).toEqual(importedSchema.properties.snippets.default.map(d => d.template));
+    const patterns = snippets.map(s => s.pattern);
+    for (const p of patterns) {
+      expect(p).toBeInstanceOf(RegExp);
+    }
+  });
+
+
+  it("should handle missing settings", () => {
+    const snippets = getSettingsSnippets();
+    expect(snippets).toEqual([]);
+  });
+
+});
+
+
+describe("getServerSnippets", () => {
+
+
+  it("should return snippets with regexp instances", async () => {
+    const originalSnippets = importedSchema.properties.snippets.default;
+    const settings = ServerConnection.makeSettings({
+      fetch: async (input: RequestInfo, init?: RequestInit): Promise<Response> => new Response(JSON.stringify({ snippets: originalSnippets })),
+    });
+    const snippets = await getServerSnippets(settings);
+    expect(snippets.map(s => s.label)).toEqual(importedSchema.properties.snippets.default.map(d => d.label));
+    expect(snippets.map(s => s.caption)).toEqual(importedSchema.properties.snippets.default.map(d => d.caption));
+    expect(snippets.map(s => s.template)).toEqual(importedSchema.properties.snippets.default.map(d => d.template));
+    const patterns = snippets.map(s => s.pattern);
+    for (const p of patterns) {
+      expect(p).toBeInstanceOf(RegExp);
+    }
+  });
+
+
+  it("should handle network errors", async () => {
+    const settings = ServerConnection.makeSettings({
+      fetch: async (input: RequestInfo, init?: RequestInit): Promise<Response> => new Response(null, { status: 500 }),
+    });
+    const snippetsPromise = getServerSnippets(settings);
+    await expect(snippetsPromise).rejects.toBeInstanceOf(Response);
+  });
+
+});
+
+describe("snippetFormRender", () => {
+
+  it("should populate the uiSchema", () => {
+    function mockField(props: any): any {
+      // no-op
+    }
+    // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+    const comp = snippetFormRender({
+      registry: {
+        fields: {
+          ArrayField: mockField,
+        },
+      } as any,
+      uiSchema: {},
+    } as any);
+
+    expect(comp.props.uiSchema).toEqual({
+      items: {
+        template: {
+          "ui:widget": "textarea",
+        },
+      },
+    });
+  });
+
+});
diff --git a/jupyterfs/tests/test_auth.py b/jupyterfs/tests/test_auth.py
new file mode 100644
index 00000000..2f120e1d
--- /dev/null
+++ b/jupyterfs/tests/test_auth.py
@@ -0,0 +1,60 @@
+import os
+import unittest.mock
+
+from fs.opener.parse import parse_fs_url
+from jupyterfs.auth import substituteAsk, substituteEnv
+import pytest
+
+
+# we use `prefix_username` as `username` is often present in os.environ
+
+urls = [
+    "foo://bar",
+    "foo://username@bar",
+    "foo://username:pword@bar",
+    "foo://{{prefix_username}}:pword@bar",
+    "foo://{{prefix_username}}:{{pword}}@bar",
+]
+
+token_dicts = [
+    {},
+    {"prefix_username": "username"},
+    {"pword": "pword"},
+    {"prefix_username": "username", "pword": "pword"},
+    {"prefix_username": "user:na@me", "pword": "pwo@r:d"},
+]
+
+
+def _url_tokens_pair():
+    for url in urls:
+        for token_dict in token_dicts:
+            yield url, token_dict
+
+
+@pytest.fixture(params=_url_tokens_pair())
+def any_url_token_ask_resource(request):
+    url, token_dict = request.param
+    return dict(url=url, tokenDict=token_dict)
+
+
+@pytest.fixture(params=_url_tokens_pair())
+def any_url_token_env_resource(request):
+    url, token_dict = request.param
+    with unittest.mock.patch.dict(os.environ, token_dict):
+        yield dict(url=url)
+
+
+def test_ensure_ask_validates(any_url_token_ask_resource):
+    url, missing = substituteAsk(any_url_token_ask_resource)
+    if missing:
+        return pytest.xfail(f"tokens are not sufficient, missing: {missing}")
+    # simply ensure it doesn't throw:
+    parse_fs_url(url)
+
+
+def test_ensure_env_validates(any_url_token_env_resource):
+    url, missing = substituteEnv(any_url_token_env_resource)
+    if missing:
+        return pytest.xfail(f"tokens are not sufficient, missing: {missing}")
+    # simply ensure it doesn't throw:
+    parse_fs_url(url)
diff --git a/jupyterfs/tests/test_init.py b/jupyterfs/tests/test_init.py
index 9b0a6138..38a12f57 100644
--- a/jupyterfs/tests/test_init.py
+++ b/jupyterfs/tests/test_init.py
@@ -6,10 +6,35 @@
 # the Apache License 2.0.  The full license can be found in the LICENSE file.
 #
 
-# for Coverage
+from jupyterfs import _jupyter_labextension_paths, open_fs
 from jupyterfs.extension import _jupyter_server_extension_points
 
+from unittest.mock import patch
+
 
 class TestInit:
+    # for Coverage
     def test__jupyter_server_extension_paths(self):
         assert _jupyter_server_extension_points() == [{"module": "jupyterfs.extension"}]
+
+    # for Coverage
+    def test__jupyter_labextension_paths(self):
+        assert _jupyter_labextension_paths() == [
+            {
+                "src": "labextension",
+                "dest": "jupyter-fs",
+            }
+        ]
+
+    @patch("fs.open_fs")
+    @patch("getpass.getpass", return_value="test return getpass <>/|")
+    def test_open_fs(self, mock_getpass, mock_fs_open_fs):
+        open_fs("osfs://foo/bar.txt")
+        mock_getpass.assert_not_called()
+        mock_fs_open_fs.assert_called_with("osfs://foo/bar.txt")
+
+        open_fs("osfs://{{foo}}/bar.txt")
+        mock_getpass.assert_called_with("Enter value for 'foo': ")
+        mock_fs_open_fs.assert_called_with(
+            "osfs://test%20return%20getpass%20%3C%3E/%7C/bar.txt"
+        )
diff --git a/jupyterfs/tests/test_metamanager.py b/jupyterfs/tests/test_metamanager.py
index e26b24b0..cd16f1a2 100644
--- a/jupyterfs/tests/test_metamanager.py
+++ b/jupyterfs/tests/test_metamanager.py
@@ -120,7 +120,7 @@ async def test_resource_validators(tmp_path, jp_fetch, jp_server_config):
             },
         ]
     )
-    names = set(map(lambda r: r["name"], resources))
+    names = {r["name"] for r in resources if r["init"]}
     assert names == {"valid-1", "valid-2"}