From cf9ca3559c9efbe21734502e37a41a8a39078776 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Tue, 19 Nov 2024 15:47:35 +0000 Subject: [PATCH 01/14] Add performance and caching tests for stdout/stderr --- packages/rendermime/test/factories.spec.ts | 160 ++++++++++++++++++++- 1 file changed, 158 insertions(+), 2 deletions(-) diff --git a/packages/rendermime/test/factories.spec.ts b/packages/rendermime/test/factories.spec.ts index ffe1e59dd108..51f3be7714e0 100644 --- a/packages/rendermime/test/factories.spec.ts +++ b/packages/rendermime/test/factories.spec.ts @@ -570,6 +570,162 @@ describe('rendermime/factories', () => { ); }); + it.each([ + // Note: timeouts are set to 3.5 times more the local performance to allow for slower runs on CI + // + // Local benchmarks: + // - without linkify cache: 12.5s + // - with cache: 1.1s + [ + 'when new content arrives line by line', + '\n' + 'X'.repeat(5000), + 1100 * 3.5 + ], + // Local benchmarks: + // - without cache: 3.8s + // - with cache: 0.8s + [ + 'when new content is added to the same line', + 'test.com ' + 'X'.repeat(2500) + ' www.', + 800 * 3.5 + ] + ])('should be fast %s', async (_, newContent, timeout) => { + let source = ''; + const mimeType = 'application/vnd.jupyter.stderr'; + + const model = createModel(mimeType, source); + const w = errorRendererFactory.createRenderer({ mimeType, ...options }); + + const start = performance.now(); + for (let i = 0; i < 25; i++) { + source += newContent; + model.setData({ + data: { + [mimeType]: source + } + }); + await w.renderModel(model); + } + const end = performance.now(); + + expect(end - start).toBeLessThan(timeout); + }); + + it.each([ + ['arrives in a new line', 'www.example.com', '\n a new line of text'], + ['arrives after a new line', 'www.example.com\n', 'a new line of text'], + ['arrives after a text node', 'www.example.com next line', ' of text'], + ['arrives after a text node', 'www.example.com\nnext line', ' of text'] + ])( + 'should use cached links if new content %s', + async (_, oldSource, addition) => { + const mimeType = 'application/vnd.jupyter.stderr'; + let source = oldSource; + const model = createModel(mimeType, source); + const w = errorRendererFactory.createRenderer({ + mimeType, + ...defaultOptions + }); + // Perform an initial render to populate the cache. + await w.renderModel(model); + const before = w.node.innerHTML; + const cachedLink = w.node.querySelector('a'); + expect(cachedLink).toBe(w.node.childNodes[0].childNodes[0]); + + // Update the source. + source += addition; + model.setData({ + data: { + [mimeType]: source + } + }); + + // Perform a second render which should use the cache. + await w.renderModel(model); + const after = w.node.innerHTML; + const linkAfter = w.node.querySelector('a'); + + // The contents of the node should be updated with the new line. + expect(before).not.toEqual(after); + expect(after).toContain('line of text'); + + // If cached links were used, the anchor nodes will be reused. + expect(cachedLink).not.toBe(null); + expect(cachedLink).toBe(w.node.childNodes[0].childNodes[0]); + expect(cachedLink).toBe(linkAfter); + } + ); + + it('should not use cached links if the new content appends to the link', async () => { + const mimeType = 'application/vnd.jupyter.stderr'; + let source = 'www.example.co'; + const model = createModel(mimeType, source); + const w = errorRendererFactory.createRenderer({ + mimeType, + ...defaultOptions + }); + // Perform an initial render to populate the cache. + await w.renderModel(model); + const before = w.node.innerHTML; + const cachedLink = w.node.querySelector('a'); + + // Update the source. + source += 'm'; + model.setData({ + data: { + [mimeType]: source + } + }); + + // Perform a second render. + await w.renderModel(model); + const after = w.node.innerHTML; + const linkAfter = w.node.querySelector('a'); + + // The contents of the node should be updated with the new line. + expect(before).not.toEqual(after); + + // If cached links were used, the anchor nodes will be reused. + expect(cachedLink).not.toBe(null); + expect(cachedLink).not.toBe(linkAfter); + }); + + it('should use partial cache if a link is created by addition of a new fragment', async () => { + const mimeType = 'application/vnd.jupyter.stderr'; + let source = 'aaa www.one.com bbb www.'; + const model = createModel(mimeType, source); + const w = errorRendererFactory.createRenderer({ + mimeType, + ...defaultOptions + }); + // Perform an initial render to populate the cache. + await w.renderModel(model); + const cachedTextNode = w.node.childNodes[0].childNodes[0]; + const linksBefore = w.node.querySelectorAll('a'); + expect(linksBefore).toHaveLength(1); + + // Update the source. + source += 'two.com'; + model.setData({ + data: { + [mimeType]: source + } + }); + + // Perform a second render. + await w.renderModel(model); + const textNodeAfter = w.node.childNodes[0].childNodes[0]; + const linksAfter = w.node.querySelectorAll('a'); + + // It should not use the second text node (`bbb www.`) from cache and instead + // it should fragment properly linkify the second link + expect(linksAfter).toHaveLength(2); + + // If cached nodes were used, the text nodes will be reused. + expect(cachedTextNode).toBeInstanceOf(Text); + expect(cachedTextNode).toBe(textNodeAfter); + }); + it('should autolink a single known file path', async () => { const f = errorRendererFactory; const urls = [ @@ -642,10 +798,10 @@ describe('rendermime/factories', () => { const source = 'www.example.com'; const expected = '
www.example.com
'; - const f = textRendererFactory; + const f = errorRendererFactory; const mimeType = 'application/vnd.jupyter.stderr'; const model = createModel(mimeType, source); - const w = f.createRenderer({ mimeType, ...options }); + const w = f.createRenderer({ mimeType, ...defaultOptions }); await w.renderModel(model); expect(w.node.innerHTML).toBe(expected); }); From 4422598f845ce1f27ffc045442632e6aaa27d82d Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Tue, 19 Nov 2024 17:27:00 +0000 Subject: [PATCH 02/14] Add linkification cache for stdout/stderr rendering --- packages/rendermime/src/renderers.ts | 177 +++++++++++++++++++++------ 1 file changed, 139 insertions(+), 38 deletions(-) diff --git a/packages/rendermime/src/renderers.ts b/packages/rendermime/src/renderers.ts index 70bd3489f6d0..0e9652a66ad4 100644 --- a/packages/rendermime/src/renderers.ts +++ b/packages/rendermime/src/renderers.ts @@ -787,6 +787,24 @@ function* alignedNodes( * @returns A promise which resolves when rendering is complete. */ export function renderText(options: renderText.IRenderOptions): Promise { + renderTextual(options, { + checkWeb: true, + checkPaths: false + }); + + // Return the rendered promise. + return Promise.resolve(undefined); +} + +/** + * Render the textual representation into a host node. + * + * Implements the shared logic for `renderText` and `renderError`. + */ +function renderTextual( + options: renderText.IRenderOptions, + autoLinkOptions: IAutoLinkOptions +) { // Unpack the options. const { host, sanitizer, source } = options; @@ -801,16 +819,57 @@ export function renderText(options: renderText.IRenderOptions): Promise { const preTextContent = pre.textContent; + let cacheStoreOptions = []; + if (autoLinkOptions.checkWeb) { + cacheStoreOptions.push('web'); + } + if (autoLinkOptions.checkPaths) { + cacheStoreOptions.push('paths'); + } + const cacheStoreKey = cacheStoreOptions.join('-'); + let cacheStore = Private.autoLinkCache.get(cacheStoreKey); + if (!cacheStore) { + cacheStore = new WeakMap(); + Private.autoLinkCache.set(cacheStoreKey, cacheStore); + } + let ret: HTMLPreElement; if (preTextContent) { // Note: only text nodes and span elements should be present after sanitization in the `
` element.
-    const linkedNodes =
-      sanitizer.getAutolink?.() ?? true
-        ? autolink(preTextContent, {
-            checkWeb: true,
-            checkPaths: false
-          })
-        : [document.createTextNode(content)];
+    let linkedNodes: (HTMLAnchorElement | Text)[];
+    if (sanitizer.getAutolink?.() ?? true) {
+      const cache = getApplicableLinkCache(
+        cacheStore.get(host),
+        preTextContent
+      );
+      if (cache) {
+        const { cachedNodes, addedText } = cache;
+        const fromCache = cachedNodes;
+        const newAdditions = autolink(addedText, autoLinkOptions);
+        const lastInCache = fromCache[fromCache.length - 1];
+        const firstNewNode = newAdditions[0];
+
+        if (lastInCache instanceof Text && firstNewNode instanceof Text) {
+          const joiningNode = lastInCache;
+          joiningNode.data += firstNewNode.data;
+          linkedNodes = [
+            ...fromCache.slice(0, -1),
+            joiningNode,
+            ...newAdditions.slice(1)
+          ];
+        } else {
+          linkedNodes = [...fromCache, ...newAdditions];
+        }
+      } else {
+        linkedNodes = autolink(preTextContent, autoLinkOptions);
+      }
+      cacheStore.set(host, {
+        preTextContent,
+        linkedNodes
+      });
+    } else {
+      linkedNodes = [document.createTextNode(content)];
+    }
 
     const preNodes = Array.from(pre.childNodes) as (Text | HTMLSpanElement)[];
     ret = mergeNodes(preNodes, linkedNodes);
@@ -819,9 +878,6 @@ export function renderText(options: renderText.IRenderOptions): Promise {
   }
 
   host.appendChild(ret);
-
-  // Return the rendered promise.
-  return Promise.resolve(undefined);
 }
 
 /**
@@ -854,6 +910,67 @@ export namespace renderText {
   }
 }
 
+interface IAutoLinkCacheEntry {
+  preTextContent: string;
+  linkedNodes: (HTMLAnchorElement | Text)[];
+}
+
+/**
+ * Return the information from the cache that can be used given the cache entry and current text.
+ * If the cache is invalid given the current text (or cannot be used) `null` is returned.
+ */
+function getApplicableLinkCache(
+  cachedResult: IAutoLinkCacheEntry | undefined,
+  preTextContent: string
+): {
+  cachedNodes: IAutoLinkCacheEntry['linkedNodes'];
+  addedText: string;
+} | null {
+  if (!cachedResult) {
+    return null;
+  }
+  if (preTextContent.length < cachedResult.preTextContent.length) {
+    // If the new content is shorter than the cached content
+    // we cannot use the cache as we only support appending.
+    return null;
+  }
+  let addedText = preTextContent.substring(cachedResult.preTextContent.length);
+  let cachedNodes = cachedResult.linkedNodes;
+  const lastCachedNode =
+    cachedResult.linkedNodes[cachedResult.linkedNodes.length - 1];
+
+  // Only use cached nodes if:
+  // - the last cached node is a text node
+  // - the new content starts with a new line
+  // - the old content ends with a new line
+  if (
+    cachedResult.preTextContent.endsWith('\n') ||
+    addedText.startsWith('\n')
+  ) {
+    // continue
+  } else if (lastCachedNode instanceof Text) {
+    // Remove the Text node to re-analyse this text.
+    // This is required when we cached `aaa www.one.com bbb www.`
+    // and the incoming addition is `two.com`. We can still
+    // use text node `aaa ` and anchor node `www.one.com`, but
+    // we need to pass `bbb www.` + `two.com` through linkify again.
+    cachedNodes = cachedNodes.slice(0, -1);
+    addedText = lastCachedNode.textContent + addedText;
+    // continue
+  } else {
+    return null;
+  }
+
+  // Finally check if text has not changed.
+  if (!preTextContent.startsWith(cachedResult.preTextContent)) {
+    return null;
+  }
+  return {
+    cachedNodes,
+    addedText
+  };
+}
+
 /**
  * Render error into a host node.
  *
@@ -865,37 +982,13 @@ export function renderError(
   options: renderError.IRenderOptions
 ): Promise {
   // Unpack the options.
-  const { host, linkHandler, sanitizer, resolver, source } = options;
+  const { host, linkHandler, resolver } = options;
 
-  // Create the HTML content.
-  const content = sanitizer.sanitize(Private.ansiSpan(source), {
-    allowedTags: ['span']
+  renderTextual(options, {
+    checkWeb: true,
+    checkPaths: true
   });
 
-  // Set the sanitized content for the host node.
-  const pre = document.createElement('pre');
-  pre.innerHTML = content;
-
-  const preTextContent = pre.textContent;
-
-  let ret: HTMLPreElement;
-  if (preTextContent) {
-    // Note: only text nodes and span elements should be present after sanitization in the `
` element.
-    const linkedNodes =
-      sanitizer.getAutolink?.() ?? true
-        ? autolink(preTextContent, {
-            checkWeb: true,
-            checkPaths: true
-          })
-        : [document.createTextNode(content)];
-
-    const preNodes = Array.from(pre.childNodes) as (Text | HTMLSpanElement)[];
-    ret = mergeNodes(preNodes, linkedNodes);
-  } else {
-    ret = document.createElement('pre');
-  }
-  host.appendChild(ret);
-
   // Patch the paths if a resolver is available.
   let promise: Promise;
   if (resolver) {
@@ -1012,6 +1105,14 @@ export namespace renderError {
  * The namespace for module implementation details.
  */
 namespace Private {
+  /**
+   * Cache for auto-linking results to provide better performance when streaming outputs.
+   */
+  export const autoLinkCache = new Map<
+    string,
+    WeakMap
+  >();
+
   /**
    * Eval the script tags contained in a host populated by `innerHTML`.
    *

From e9e2b820fede3b19b3e3a0442f13836d83ba3192 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Krassowski?=
 <5832902+krassowski@users.noreply.github.com>
Date: Wed, 20 Nov 2024 09:10:59 +0000
Subject: [PATCH 03/14] Run CI on Python 3.9 and 3.13 (drop 3.8 from testing
 matrix) (#16852)

* Run CI on Python 3.9 and 3.13 (drop 3.8 from testing matrix)

* Bump `setuptools` to fix minimum versions test.

For reference it was:
      The conflict is caused by:
          The user requested setuptools>=40.8.0
          The user requested (constraint) setuptools==40.1.0

* Try `setuptools>=40.9.0`

* Setuptools 41.1.0 is minimum that works with Python 3.9

See https://github.com/pypa/setuptools/blob/e622859e278e1751175ded6f8f41ea3de06e4855/NEWS.rst#v4110
---
 .github/workflows/linuxjs-tests.yml |  5 +----
 .github/workflows/linuxtests.yml    | 26 +++++++++++++-------------
 .github/workflows/macostests.yml    |  4 ++--
 .github/workflows/windowstests.yml  |  1 +
 pyproject.toml                      |  2 +-
 5 files changed, 18 insertions(+), 20 deletions(-)

diff --git a/.github/workflows/linuxjs-tests.yml b/.github/workflows/linuxjs-tests.yml
index 8fe4712b219f..bc649ec51f6a 100644
--- a/.github/workflows/linuxjs-tests.yml
+++ b/.github/workflows/linuxjs-tests.yml
@@ -13,10 +13,7 @@ jobs:
     name: JS
     strategy:
       matrix:
-        # Fix for https://github.com/jupyterlab/jupyterlab/issues/13903
-        include:
-          - group: js-debugger
-            python-version: '3.11'
+        python-version: ["3.13"]
         group:
           [
             js-application,
diff --git a/.github/workflows/linuxtests.yml b/.github/workflows/linuxtests.yml
index 3d4c346552c0..cb5feb71577a 100644
--- a/.github/workflows/linuxtests.yml
+++ b/.github/workflows/linuxtests.yml
@@ -15,7 +15,7 @@ jobs:
       matrix:
         group: [integrity, integrity2, integrity3, release_test, docs, usage, usage2, splice_source, python, examples, interop, nonode, lint]
         # This will be used by the base setup action
-        python-version: ["3.8", "3.12"]
+        python-version: ["3.9", "3.13"]
         include:
           - group: examples
             upload-output: true
@@ -23,27 +23,27 @@ jobs:
             upload-output: true
         exclude:
           - group: integrity
-            python-version: "3.8"
+            python-version: "3.9"
           - group: integrity2
-            python-version: "3.8"
+            python-version: "3.9"
           - group: integrity3
-            python-version: "3.8"
+            python-version: "3.9"
           - group: release_test
-            python-version: "3.8"
+            python-version: "3.9"
           - group: docs
-            python-version: "3.8"
+            python-version: "3.9"
           - group: usage
-            python-version: "3.8"
+            python-version: "3.9"
           - group: usage2
-            python-version: "3.8"
+            python-version: "3.9"
           - group: nonode
-            python-version: "3.8"
+            python-version: "3.9"
           - group: lint
-            python-version: "3.8"
+            python-version: "3.9"
           - group: examples
-            python-version: "3.8"
+            python-version: "3.9"
           - group: splice_source
-            python-version: "3.8"
+            python-version: "3.9"
       fail-fast: false
     timeout-minutes: 45
     runs-on: ubuntu-22.04
@@ -88,7 +88,7 @@ jobs:
       - name: Base Setup
         uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
         with:
-          python_version: "3.8"
+          python_version: "3.9"
           dependency_type: minimum
       - name: Install dependencies
         run: |
diff --git a/.github/workflows/macostests.yml b/.github/workflows/macostests.yml
index 9e7a38365339..ed5ebdc41178 100644
--- a/.github/workflows/macostests.yml
+++ b/.github/workflows/macostests.yml
@@ -14,10 +14,10 @@ jobs:
     strategy:
       matrix:
         group: [integrity, python, usage, usage2]
-        python-version: [3.11]
+        python-version: [3.12]
         include:
           - group: python
-            python-version: 3.12
+            python-version: 3.13
       fail-fast: false
     timeout-minutes: 45
     runs-on: macos-latest
diff --git a/.github/workflows/windowstests.yml b/.github/workflows/windowstests.yml
index e9142e7fa527..d1eb9af9c519 100644
--- a/.github/workflows/windowstests.yml
+++ b/.github/workflows/windowstests.yml
@@ -14,6 +14,7 @@ jobs:
     strategy:
       matrix:
         group: [integrity, python]
+        python-version: ["3.13"]
       fail-fast: false
     runs-on: windows-latest
     timeout-minutes: 40
diff --git a/pyproject.toml b/pyproject.toml
index 4f96a1f6bb75..1d19bc2df2ab 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,7 +47,7 @@ dependencies = [
     "jupyterlab_server>=2.27.1,<3",
     "notebook_shim>=0.2",
     "packaging",
-    "setuptools>=40.1.0",
+    "setuptools>=41.1.0",
     "tomli>=1.2.2;python_version<\"3.11\"",
     "tornado>=6.2.0",
     "traitlets",

From f2ccf6b5f4e2282e1b66c2950005bf522c44a93f Mon Sep 17 00:00:00 2001
From: Nicolas Brichet <32258950+brichet@users.noreply.github.com>
Date: Thu, 21 Nov 2024 16:06:45 +0100
Subject: [PATCH 04/14] Restore viewport `min-height` when not windowing
 (#16979)

---
 packages/ui-components/src/components/windowedlist.ts | 1 +
 1 file changed, 1 insertion(+)

diff --git a/packages/ui-components/src/components/windowedlist.ts b/packages/ui-components/src/components/windowedlist.ts
index fd059bb8992e..782cb6ba3f86 100644
--- a/packages/ui-components/src/components/windowedlist.ts
+++ b/packages/ui-components/src/components/windowedlist.ts
@@ -1315,6 +1315,7 @@ export class WindowedList<
   private _applyNoWindowingStyles() {
     this._viewport.style.position = 'relative';
     this._viewport.style.top = '0px';
+    this._viewport.style.minHeight = '';
     this._innerElement.style.height = '';
   }
   /**

From 006a459698a01a007759316aa08c25fe40f70725 Mon Sep 17 00:00:00 2001
From: Jason Weill <93281816+JasonWeill@users.noreply.github.com>
Date: Thu, 21 Nov 2024 08:34:26 -0800
Subject: [PATCH 05/14] Drag image prompt styling (#16972)

* Revert "Alternate description for disabled filters"

This reverts commit 059fb7eb562defb8040cf1d624dbf0cee7f544e0.

* Revert "Revert "Alternate description for disabled filters""

This reverts commit 27ed1d0779c1dff9e78848a20669de4a06e88be2.

* Diminishes border color when dragging multiple cells

---------

Co-authored-by: Jeremy Tuloup 
---
 packages/notebook/style/base.css | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/packages/notebook/style/base.css b/packages/notebook/style/base.css
index 17be4dc7b1bb..c7b237541996 100644
--- a/packages/notebook/style/base.css
+++ b/packages/notebook/style/base.css
@@ -204,6 +204,7 @@
   flex: 0 0 auto;
   min-width: 36px;
   color: var(--jp-cell-inprompt-font-color);
+  opacity: 0.5;
   padding: var(--jp-code-padding);
   padding-left: 12px;
   font-family: var(--jp-cell-prompt-font-family);
@@ -221,7 +222,13 @@
   top: 8px;
   left: 8px;
   background: var(--jp-layout-color2);
-  border: var(--jp-border-width) solid var(--jp-input-border-color);
+  border-width: var(--jp-border-width);
+  border-style: solid;
+  border-color: color-mix(
+    in srgb,
+    var(--jp-input-border-color) 20%,
+    transparent
+  );
   box-shadow: 2px 2px 4px 0 rgba(0, 0, 0, 0.12);
 }
 

From ebf39b6c1e2bee35b32bb88992d05f0ddb7180db Mon Sep 17 00:00:00 2001
From: David Brochart 
Date: Tue, 26 Nov 2024 14:20:07 +0100
Subject: [PATCH 06/14] Fix emission of `FileBrowserModel.onFileChanged` for
 drives (including `RTC:`) (#16988)

* Fix FileBrowserModel.onFileChanged

* Fix

* Add back prefix before comparing paths

* Add test
---
 packages/filebrowser/src/model.ts       |  9 +++++++--
 packages/filebrowser/test/model.spec.ts | 22 ++++++++++++++++++++++
 2 files changed, 29 insertions(+), 2 deletions(-)

diff --git a/packages/filebrowser/src/model.ts b/packages/filebrowser/src/model.ts
index 3d7832fbd7e0..6b3636b6327f 100644
--- a/packages/filebrowser/src/model.ts
+++ b/packages/filebrowser/src/model.ts
@@ -609,10 +609,15 @@ export class FileBrowserModel implements IDisposable {
     const path = this._model.path;
     const { sessions } = this.manager.services;
     const { oldValue, newValue } = change;
+    const prefix = this.driveName.length > 0 ? this.driveName + ':' : '';
     const value =
-      oldValue && oldValue.path && PathExt.dirname(oldValue.path) === path
+      oldValue &&
+      oldValue.path &&
+      prefix + PathExt.dirname(oldValue.path) === path
         ? oldValue
-        : newValue && newValue.path && PathExt.dirname(newValue.path) === path
+        : newValue &&
+          newValue.path &&
+          prefix + PathExt.dirname(newValue.path) === path
         ? newValue
         : undefined;
 
diff --git a/packages/filebrowser/test/model.spec.ts b/packages/filebrowser/test/model.spec.ts
index 60f8fdd79227..dc3acbb4c5b2 100644
--- a/packages/filebrowser/test/model.spec.ts
+++ b/packages/filebrowser/test/model.spec.ts
@@ -152,6 +152,28 @@ describe('filebrowser/model', () => {
         expect(called).toBe(true);
       });
 
+      it('should be emitted when a file is created in a drive with a name', async () => {
+        await state.clear();
+        const driveName = 'RTC';
+        const modelWithName = new FileBrowserModel({
+          manager,
+          state,
+          driveName
+        });
+
+        let called = false;
+        modelWithName.fileChanged.connect((sender, args) => {
+          expect(sender).toBe(modelWithName);
+          expect(args.type).toBe('new');
+          expect(args.oldValue).toBeNull();
+          expect(args.newValue!.type).toBe('file');
+          called = true;
+        });
+        await manager.newUntitled({ type: 'file' });
+        expect(called).toBe(true);
+        modelWithName.dispose();
+      });
+
       it('should be emitted when a file is renamed', async () => {
         let called = false;
         model.fileChanged.connect((sender, args) => {

From 948ec2231ab6c7bf0e608b903a9cd7b188ecfb2a Mon Sep 17 00:00:00 2001
From: Chiara Marmo 
Date: Tue, 26 Nov 2024 14:53:56 +0100
Subject: [PATCH 07/14] Add forgotten bracket in code sample (#16998)

---
 docs/source/extension/extension_points.rst | 1 +
 1 file changed, 1 insertion(+)

diff --git a/docs/source/extension/extension_points.rst b/docs/source/extension/extension_points.rst
index eabb9cef579f..fe683ba5cc61 100644
--- a/docs/source/extension/extension_points.rst
+++ b/docs/source/extension/extension_points.rst
@@ -74,6 +74,7 @@ Here is a sample block of code that adds a command to the application (given by
       execute: () => {
         console.log(`Executed ${commandID}`);
         toggled = !toggled;
+      }
     });
 
 This example adds a new command, which, when triggered, calls the ``execute`` function.

From d317b2e1b728578bbb6959a412e7be485c987110 Mon Sep 17 00:00:00 2001
From: David Brochart 
Date: Tue, 26 Nov 2024 16:49:00 +0100
Subject: [PATCH 08/14] Fix handling of carriage return in output streams
 (#16999)

* Fix handling of carriage return in output streams

* Lint
---
 packages/outputarea/src/model.ts | 33 ++++++++++++++++++++++++--------
 1 file changed, 25 insertions(+), 8 deletions(-)

diff --git a/packages/outputarea/src/model.ts b/packages/outputarea/src/model.ts
index a56ff05a68a7..72e1c66f363f 100644
--- a/packages/outputarea/src/model.ts
+++ b/packages/outputarea/src/model.ts
@@ -358,7 +358,7 @@ export class OutputAreaModel implements IOutputAreaModel {
       const curText = prev.streamText!;
       const newText =
         typeof value.text === 'string' ? value.text : value.text.join('');
-      Private.addText(curText, newText);
+      this._streamIndex = Private.addText(this._streamIndex, curText, newText);
       return this.length;
     }
 
@@ -366,7 +366,12 @@ export class OutputAreaModel implements IOutputAreaModel {
       if (typeof value.text !== 'string') {
         value.text = value.text.join('');
       }
-      value.text = Private.processText(value.text);
+      const { text, index } = Private.processText(
+        this._streamIndex,
+        value.text
+      );
+      this._streamIndex = index;
+      value.text = text;
     }
 
     // Create the new item.
@@ -480,6 +485,7 @@ export class OutputAreaModel implements IOutputAreaModel {
   private _changed = new Signal(
     this
   );
+  private _streamIndex = 0;
 }
 
 /**
@@ -530,14 +536,20 @@ namespace Private {
   /*
    * Handle backspaces in `newText` and concatenates to `text`, if any.
    */
-  export function processText(newText: string, text?: string): string {
+  export function processText(
+    index: number,
+    newText: string,
+    text?: string
+  ): { text: string; index: number } {
     if (text === undefined) {
       text = '';
     }
     if (!(newText.includes('\b') || newText.includes('\r'))) {
-      return text + newText;
+      text =
+        text.slice(0, index) + newText + text.slice(index + newText.length);
+      return { text, index: index + newText.length };
     }
-    let idx0 = text.length;
+    let idx0 = index;
     let idx1: number = -1;
     let lastEnd: number = 0;
     const regex = /[\n\b\r]/;
@@ -587,14 +599,18 @@ namespace Private {
         throw Error(`This should not happen`);
       }
     }
-    return text;
+    return { text, index: idx0 };
   }
 
   /*
    * Concatenate a string to an observable string, handling backspaces.
    */
-  export function addText(curText: IObservableString, newText: string): void {
-    const text = processText(newText, curText.text);
+  export function addText(
+    prevIndex: number,
+    curText: IObservableString,
+    newText: string
+  ): number {
+    const { text, index } = processText(prevIndex, newText, curText.text);
     // Compute the difference between current text and new text.
     let done = false;
     let idx = 0;
@@ -619,5 +635,6 @@ namespace Private {
         idx++;
       }
     }
+    return index;
   }
 }

From 5ae2757382fddc21136f0bcb029456784399a97b Mon Sep 17 00:00:00 2001
From: krassowski <5832902+krassowski@users.noreply.github.com>
Date: Tue, 26 Nov 2024 20:58:03 +0000
Subject: [PATCH 09/14] Fix interaction with search highlights

---
 packages/rendermime/src/renderers.ts | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/packages/rendermime/src/renderers.ts b/packages/rendermime/src/renderers.ts
index 0e9652a66ad4..65407983d496 100644
--- a/packages/rendermime/src/renderers.ts
+++ b/packages/rendermime/src/renderers.ts
@@ -865,7 +865,11 @@ function renderTextual(
       }
       cacheStore.set(host, {
         preTextContent,
-        linkedNodes
+        // Clone the nodes before storing them in the cache in case if another component
+        // attempts to modify (e.g. dispose of) them - which is the case for search highlights!
+        linkedNodes: linkedNodes.map(
+          node => node.cloneNode() as HTMLAnchorElement | Text
+        )
       });
     } else {
       linkedNodes = [document.createTextNode(content)];

From db3a3c0348648780f93647ac007983621285bced Mon Sep 17 00:00:00 2001
From: krassowski <5832902+krassowski@users.noreply.github.com>
Date: Tue, 26 Nov 2024 22:11:09 +0000
Subject: [PATCH 10/14] Use deep cloning so that link label is preserved

---
 packages/rendermime/src/renderers.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/rendermime/src/renderers.ts b/packages/rendermime/src/renderers.ts
index 65407983d496..3ea482738d2b 100644
--- a/packages/rendermime/src/renderers.ts
+++ b/packages/rendermime/src/renderers.ts
@@ -868,7 +868,7 @@ function renderTextual(
         // Clone the nodes before storing them in the cache in case if another component
         // attempts to modify (e.g. dispose of) them - which is the case for search highlights!
         linkedNodes: linkedNodes.map(
-          node => node.cloneNode() as HTMLAnchorElement | Text
+          node => node.cloneNode(true) as HTMLAnchorElement | Text
         )
       });
     } else {

From 48908a8b07128b651faab72434f8d442b99bc91c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Krassowski?=
 <5832902+krassowski@users.noreply.github.com>
Date: Wed, 27 Nov 2024 20:22:09 +0000
Subject: [PATCH 11/14] Pin Python version for visual regression testing to
 3.11 (#16989)

---
 .github/workflows/galata.yml | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/.github/workflows/galata.yml b/.github/workflows/galata.yml
index fcf28f674dc3..662cab947922 100644
--- a/.github/workflows/galata.yml
+++ b/.github/workflows/galata.yml
@@ -23,6 +23,8 @@ jobs:
 
       - name: Base Setup
         uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
+        with:
+          python_version: "3.11"
 
       - name: Set up browser cache
         uses: actions/cache@v4

From c95b919314fce029068b217a71989983d04af1cb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Krassowski?=
 <5832902+krassowski@users.noreply.github.com>
Date: Thu, 28 Nov 2024 09:54:43 +0000
Subject: [PATCH 12/14] Apply suggestions from code review

Co-authored-by: M Bussonnier 
---
 packages/rendermime/src/renderers.ts | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

diff --git a/packages/rendermime/src/renderers.ts b/packages/rendermime/src/renderers.ts
index 3ea482738d2b..65c2a31331b5 100644
--- a/packages/rendermime/src/renderers.ts
+++ b/packages/rendermime/src/renderers.ts
@@ -804,7 +804,7 @@ export function renderText(options: renderText.IRenderOptions): Promise {
 function renderTextual(
   options: renderText.IRenderOptions,
   autoLinkOptions: IAutoLinkOptions
-) {
+): void {
   // Unpack the options.
   const { host, sanitizer, source } = options;
 
@@ -819,7 +819,7 @@ function renderTextual(
 
   const preTextContent = pre.textContent;
 
-  let cacheStoreOptions = [];
+  const cacheStoreOptions = [];
   if (autoLinkOptions.checkWeb) {
     cacheStoreOptions.push('web');
   }
@@ -843,8 +843,7 @@ function renderTextual(
         preTextContent
       );
       if (cache) {
-        const { cachedNodes, addedText } = cache;
-        const fromCache = cachedNodes;
+        const { cachedNodes: fromCache, addedText } = cache;
         const newAdditions = autolink(addedText, autoLinkOptions);
         const lastInCache = fromCache[fromCache.length - 1];
         const firstNewNode = newAdditions[0];
@@ -951,16 +950,17 @@ function getApplicableLinkCache(
     cachedResult.preTextContent.endsWith('\n') ||
     addedText.startsWith('\n')
   ) {
-    // continue
+    // Second or third condition is met, we can use the cached nodes
+    // (this is a no-op, we just continue execution).
   } else if (lastCachedNode instanceof Text) {
-    // Remove the Text node to re-analyse this text.
+    // The first condition is met, we can use the cached nodes,
+    // but first we remove the Text node to re-analyse its text.
     // This is required when we cached `aaa www.one.com bbb www.`
     // and the incoming addition is `two.com`. We can still
     // use text node `aaa ` and anchor node `www.one.com`, but
     // we need to pass `bbb www.` + `two.com` through linkify again.
     cachedNodes = cachedNodes.slice(0, -1);
     addedText = lastCachedNode.textContent + addedText;
-    // continue
   } else {
     return null;
   }

From 1aaebbd1d5de5d176dd7d3b58e6958cd76450cab Mon Sep 17 00:00:00 2001
From: krassowski <5832902+krassowski@users.noreply.github.com>
Date: Fri, 29 Nov 2024 10:16:55 +0000
Subject: [PATCH 13/14] Fix tests

---
 packages/rendermime/test/factories.spec.ts | 44 ++++++++++++++++++----
 1 file changed, 37 insertions(+), 7 deletions(-)

diff --git a/packages/rendermime/test/factories.spec.ts b/packages/rendermime/test/factories.spec.ts
index 51f3be7714e0..d9d3db2463e2 100644
--- a/packages/rendermime/test/factories.spec.ts
+++ b/packages/rendermime/test/factories.spec.ts
@@ -538,6 +538,25 @@ describe('rendermime/factories', () => {
     };
 
     describe('#createRenderer()', () => {
+      // Mock creation of DOM nodes to distinguish cached
+      /// (cloned) nodes from nodes created from scratch.
+      beforeEach(() => {
+        const originalCloneNode = Node.prototype.cloneNode;
+
+        Node.prototype.cloneNode = function (...args: any) {
+          const clonedNode = originalCloneNode.apply(this, args);
+
+          // Annotate as a node created by cloning.
+          clonedNode.wasCloned = true;
+
+          return clonedNode;
+        };
+      });
+
+      afterEach(() => {
+        jest.restoreAllMocks();
+      });
+
       it('should output the correct HTML', async () => {
         const f = errorRendererFactory;
         const mimeType = 'application/vnd.jupyter.stderr';
@@ -649,10 +668,13 @@ describe('rendermime/factories', () => {
           expect(before).not.toEqual(after);
           expect(after).toContain('line of text');
 
-          // If cached links were used, the anchor nodes will be reused.
           expect(cachedLink).not.toBe(null);
-          expect(cachedLink).toBe(w.node.childNodes[0].childNodes[0]);
-          expect(cachedLink).toBe(linkAfter);
+          expect(cachedLink).not.toHaveProperty('wasCloned');
+
+          // If cached links were reused those would be cloned
+          expect(linkAfter).not.toBe(null);
+          expect(linkAfter).toEqual(cachedLink);
+          expect(linkAfter).toHaveProperty('wasCloned', true);
         }
       );
 
@@ -685,9 +707,14 @@ describe('rendermime/factories', () => {
         // The contents of the node should be updated with the new line.
         expect(before).not.toEqual(after);
 
-        // If cached links were used, the anchor nodes will be reused.
         expect(cachedLink).not.toBe(null);
-        expect(cachedLink).not.toBe(linkAfter);
+        expect(cachedLink!.textContent).toEqual('www.example.co');
+        expect(cachedLink).not.toHaveProperty('wasCloned');
+
+        // If cached links were reused those would be cloned
+        expect(linkAfter).not.toBe(null);
+        expect(linkAfter!.textContent).toEqual('www.example.com');
+        expect(linkAfter).not.toHaveProperty('wasCloned');
       });
 
       it('should use partial cache if a link is created by addition of a new fragment', async () => {
@@ -721,9 +748,12 @@ describe('rendermime/factories', () => {
         // it should fragment properly linkify the second link
         expect(linksAfter).toHaveLength(2);
 
-        // If cached nodes were used, the text nodes will be reused.
         expect(cachedTextNode).toBeInstanceOf(Text);
-        expect(cachedTextNode).toBe(textNodeAfter);
+        expect(cachedTextNode).not.toHaveProperty('wasCloned');
+
+        // If cached nodes were reused those would be cloned
+        expect(textNodeAfter).toEqual(cachedTextNode);
+        expect(textNodeAfter).toHaveProperty('wasCloned', true);
       });
 
       it('should autolink a single known file path', async () => {

From f28b4145d421560b95ee95988c1310d13eafab14 Mon Sep 17 00:00:00 2001
From: David Brochart 
Date: Fri, 29 Nov 2024 11:17:33 +0100
Subject: [PATCH 14/14] Bump httpx v0.28.0 (#17013)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* Bump httpx v0.28.0

* Use `~=` for httpx

---------

Co-authored-by: MichaƂ Krassowski <5832902+krassowski@users.noreply.github.com>
---
 jupyterlab/extensions/pypi.py | 6 +++---
 pyproject.toml                | 2 +-
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/jupyterlab/extensions/pypi.py b/jupyterlab/extensions/pypi.py
index 413023222639..e97f0a6f55ae 100644
--- a/jupyterlab/extensions/pypi.py
+++ b/jupyterlab/extensions/pypi.py
@@ -65,8 +65,8 @@ def make_connection(self, host):
     proxy_host, _, proxy_port = http_proxy.netloc.partition(":")
 
     proxies = {
-        "http://": http_proxy_url,
-        "https://": https_proxy_url,
+        "http://": httpx.HTTPTransport(proxy=http_proxy_url),
+        "https://": httpx.HTTPTransport(proxy=https_proxy_url),
     }
 
     xmlrpc_transport_override = ProxiedTransport()
@@ -131,7 +131,7 @@ def __init__(
         parent: Optional[config.Configurable] = None,
     ) -> None:
         super().__init__(app_options, ext_options, parent)
-        self._httpx_client = httpx.AsyncClient(proxies=proxies)
+        self._httpx_client = httpx.AsyncClient(mounts=proxies)
         # Set configurable cache size to fetch function
         self._fetch_package_metadata = partial(_fetch_package_metadata, self._httpx_client)
         self._observe_package_metadata_cache_size({"new": self.package_metadata_cache_size})
diff --git a/pyproject.toml b/pyproject.toml
index 1d19bc2df2ab..33c89595858f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,7 +36,7 @@ classifiers = [
 ]
 dependencies = [
     "async_lru>=1.0.0",
-    "httpx>=0.25.0",
+    "httpx~=0.28.0",
     "importlib-metadata>=4.8.3;python_version<\"3.10\"",
     "importlib-resources>=1.4;python_version<\"3.9\"",
     "ipykernel>=6.5.0",