From aebf263d987bf3f3e29472743ab434e27ac292dc Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Mon, 1 Sep 2025 15:36:35 +0200 Subject: [PATCH 01/10] frontend indexing --- .prettierignore | 1 + jupyterlab_quickopen/handler.py | 10 ++- package.json | 3 +- schema/plugin.json | 14 +++ src/defaultProvider.ts | 36 -------- src/frontendProvider.ts | 144 ++++++++++++++++++++++++++++++ src/handler.ts | 46 ---------- src/index.ts | 153 +++++++++----------------------- src/serverProvider.ts | 56 ++++++++++++ src/tokens.ts | 15 +++- src/widget.ts | 110 +++++++++++++++++++++++ 11 files changed, 388 insertions(+), 200 deletions(-) delete mode 100644 src/defaultProvider.ts create mode 100644 src/frontendProvider.ts delete mode 100644 src/handler.ts create mode 100644 src/serverProvider.ts create mode 100644 src/widget.ts diff --git a/.prettierignore b/.prettierignore index 77896f2..7f8fec2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,4 @@ node_modules **/package.json !/package.json jupyterlab_quickopen +.venv diff --git a/jupyterlab_quickopen/handler.py b/jupyterlab_quickopen/handler.py index 51f525a..be42bff 100644 --- a/jupyterlab_quickopen/handler.py +++ b/jupyterlab_quickopen/handler.py @@ -47,14 +47,16 @@ async def should_hide(self, entry, excludes): ) ) - async def scan_disk(self, path, excludes, on_disk=None): + async def scan_disk(self, path, excludes, on_disk=None, max_depth=None, current_depth=0): if on_disk is None: on_disk = {} + if max_depth is not None and current_depth >= max_depth: + return on_disk for entry in os.scandir(path): if await self.should_hide(entry, excludes): continue elif entry.is_dir(): - await self.scan_disk(entry.path, excludes, on_disk) + await self.scan_disk(entry.path, excludes, on_disk, max_depth, current_depth + 1) elif entry.is_file(): parent = os.path.relpath(os.path.dirname(entry.path), self.root_dir) on_disk.setdefault(parent, []).append(entry.name) @@ -78,12 +80,14 @@ async def get(self): """ excludes = set(self.get_arguments("excludes")) current_path = self.get_argument("path") + depth_arg = self.get_argument("depth", default=None) + max_depth = int(depth_arg) if depth_arg is not None else None start_ts = time.time() if current_path: full_path = os.path.join(self.root_dir, current_path) else: full_path = self.root_dir - contents_by_path = await self.scan_disk(full_path, excludes) + contents_by_path = await self.scan_disk(full_path, excludes, max_depth=max_depth) delta_ts = time.time() - start_ts self.write( json_encode({"scan_seconds": delta_ts, "contents": contents_by_path}) diff --git a/package.json b/package.json index 4dba89c..aaa5932 100644 --- a/package.json +++ b/package.json @@ -178,7 +178,8 @@ "node_modules", "dist", "coverage", - "**/*.d.ts" + "**/*.d.ts", + ".venv" ], "prettier": { "singleQuote": true, diff --git a/schema/plugin.json b/schema/plugin.json index c3b9b2b..beb6728 100644 --- a/schema/plugin.json +++ b/schema/plugin.json @@ -16,6 +16,20 @@ "description": "Whether to search from currently selected directory", "type": "boolean", "default": false + }, + "indexingMethod": { + "title": "Indexing Method", + "description": "Method to use for file indexing: 'server' uses the backend endpoint, 'frontend' uses the JupyterLab Contents API", + "type": "string", + "enum": ["server", "frontend"], + "default": "server" + }, + "depth": { + "title": "Search Depth", + "description": "Maximum directory depth to search", + "type": "number", + "minimum": 1, + "default": 20 } }, "jupyter.lab.menus": { diff --git a/src/defaultProvider.ts b/src/defaultProvider.ts deleted file mode 100644 index 57256f3..0000000 --- a/src/defaultProvider.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { URLExt } from '@jupyterlab/coreutils'; -import { ServerConnection } from '@jupyterlab/services'; -import { IQuickOpenProvider, IQuickOpenResponse } from './tokens'; - -/** - * Default implementation of the quick open provider that the server endpoint. - */ -export class DefaultQuickOpenProvider implements IQuickOpenProvider { - async fetchContents( - path: string, - excludes: string[] - ): Promise { - const query = excludes - .map(exclude => { - return 'excludes=' + encodeURIComponent(exclude); - }) - .join('&'); - - const settings = ServerConnection.makeSettings(); - const fullUrl = - URLExt.join(settings.baseUrl, 'jupyterlab-quickopen', 'api', 'files') + - '?' + - query + - '&path=' + - path; - const response = await ServerConnection.makeRequest( - fullUrl, - { method: 'GET' }, - settings - ); - if (response.status !== 200) { - throw new ServerConnection.ResponseError(response); - } - return await response.json(); - } -} diff --git a/src/frontendProvider.ts b/src/frontendProvider.ts new file mode 100644 index 0000000..3c66c36 --- /dev/null +++ b/src/frontendProvider.ts @@ -0,0 +1,144 @@ +import { PathExt } from '@jupyterlab/coreutils'; +import { Contents } from '@jupyterlab/services'; +import { + IQuickOpenOptions, + IQuickOpenProvider, + IQuickOpenResponse +} from './tokens'; + +/** + * Frontend implementation of the quick open provider that uses the Contents API. + */ +export class FrontendQuickOpenProvider implements IQuickOpenProvider { + /** + * Create a new frontend quick open provider. + * @param options Options for creating the provider + */ + constructor(options: FrontendQuickOpenProvider.IOptions) { + this._contentsManager = options.contentsManager; + } + + /** + * Fetch contents from the filesystem using the Contents API. + * @param options Options for the fetch operation + * @returns Promise resolving to contents and scan time + */ + async fetchContents(options: IQuickOpenOptions): Promise { + const { path, excludes, depth } = options; + const startTime = performance.now(); + const contents: { [key: string]: string[] } = {}; + + try { + const maxDepth = depth ?? Infinity; + await this._walkDirectory(path, excludes, contents, maxDepth); + } catch (error) { + console.warn('Error walking directory:', error); + } + + const scanSeconds = (performance.now() - startTime) / 1000; + return { contents, scanSeconds }; + } + + /** + * Recursively walk a directory and collect file listings. + * @param dirPath The directory path to walk + * @param excludes Array of patterns to exclude + * @param contents Object to accumulate results in + * @param maxDepth Maximum recursion depth + * @param currentDepth Current recursion depth + */ + private async _walkDirectory( + dirPath: string, + excludes: string[], + contents: { [key: string]: string[] }, + maxDepth: number = Infinity, + currentDepth: number = 0 + ): Promise { + if (currentDepth >= maxDepth) { + return; + } + + try { + const listing = await this._contentsManager.get(dirPath, { + content: true, + type: 'directory' + }); + + if (!listing.content) { + return; + } + + for (const item of listing.content) { + const itemPath = dirPath ? PathExt.join(dirPath, item.name) : item.name; + + // Check if item should be excluded + if (this._shouldExclude(item.name, itemPath, excludes)) { + continue; + } + + if (item.type === 'directory') { + // Recursively walk subdirectories + await this._walkDirectory( + itemPath, + excludes, + contents, + maxDepth, + currentDepth + 1 + ); + } else if (item.type === 'file') { + // Add file to contents under its directory category + const category = dirPath || '.'; + if (!contents[category]) { + contents[category] = []; + } + contents[category].push(item.name); + } + } + } catch (error) { + // Silently skip directories we can't access + console.debug(`Skipping directory ${dirPath}:`, error); + } + } + + /** + * Check if a file should be excluded from results. + * @param filename The filename to check + * @param fullPath The full path to check + * @param excludes Array of exclusion patterns + * @returns True if the file should be excluded + */ + private _shouldExclude( + filename: string, + fullPath: string, + excludes: string[] + ): boolean { + for (const exclude of excludes) { + // Simple pattern matching - can be enhanced with glob patterns + if ( + filename.includes(exclude) || + fullPath.includes(exclude) || + (filename.startsWith('.') && exclude === '.*') + ) { + return true; + } + } + return false; + } + + private _contentsManager: Contents.IManager; +} + +/** + * A namespace for the frontend quick open provider statics. + */ +export namespace FrontendQuickOpenProvider { + /** + * Options for creating a frontend quick open provider. + */ + export interface IOptions { + /** + * The contents manager to use for file operations + */ + contentsManager: Contents.IManager; + } +} diff --git a/src/handler.ts b/src/handler.ts deleted file mode 100644 index ba1af3d..0000000 --- a/src/handler.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { URLExt } from '@jupyterlab/coreutils'; - -import { ServerConnection } from '@jupyterlab/services'; - -/** - * Call the API extension - * - * @param endPoint API REST end point for the extension - * @param init Initial values for the request - * @returns The response body interpreted as JSON - */ -export async function requestAPI( - endPoint = '', - init: RequestInit = {} -): Promise { - // Make request to Jupyter API - const settings = ServerConnection.makeSettings(); - const requestUrl = URLExt.join( - settings.baseUrl, - 'jupyterlab-quickopen', // API Namespace - endPoint - ); - - let response: Response; - try { - response = await ServerConnection.makeRequest(requestUrl, init, settings); - } catch (error) { - throw new ServerConnection.NetworkError(error as any); - } - - let data: any = await response.text(); - - if (data.length > 0) { - try { - data = JSON.parse(data); - } catch (error) { - console.log('Not a JSON response body.', response); - } - } - - if (!response.ok) { - throw new ServerConnection.ResponseError(response, data.message || data); - } - - return data; -} diff --git a/src/index.ts b/src/index.ts index c9e25da..1d169da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,113 +5,17 @@ import { import { ICommandPalette, ModalCommandPalette } from '@jupyterlab/apputils'; import { PathExt } from '@jupyterlab/coreutils'; import { IDocumentManager } from '@jupyterlab/docmanager'; +import { IDefaultFileBrowser } from '@jupyterlab/filebrowser'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; -import { FileBrowser, IDefaultFileBrowser } from '@jupyterlab/filebrowser'; import { ITranslator, nullTranslator } from '@jupyterlab/translation'; import { CommandRegistry } from '@lumino/commands'; -import { ReadonlyPartialJSONObject } from '@lumino/coreutils'; -import { IDisposable } from '@lumino/disposable'; -import { Message } from '@lumino/messaging'; -import { ISignal, Signal } from '@lumino/signaling'; -import { CommandPalette } from '@lumino/widgets'; +import { FrontendQuickOpenProvider } from './frontendProvider'; +import { ServerQuickOpenProvider } from './serverProvider'; import { IQuickOpenProvider } from './tokens'; -import { DefaultQuickOpenProvider } from './defaultProvider'; +import { QuickOpenWidget } from './widget'; /** - * Shows files nested under directories in the root notebooks directory configured on the server. - */ -class QuickOpenWidget extends CommandPalette { - private _pathSelected = new Signal(this); - private _settings: ReadonlyPartialJSONObject; - private _fileBrowser: FileBrowser; - private _provider: IQuickOpenProvider; - private _disposables: IDisposable[] = []; - - constructor( - defaultBrowser: IDefaultFileBrowser, - settings: ReadonlyPartialJSONObject, - provider: IQuickOpenProvider, - options: CommandPalette.IOptions - ) { - super(options); - - this.id = 'jupyterlab-quickopen'; - this.title.iconClass = 'jp-SideBar-tabIcon jp-SearchIcon'; - this.title.caption = 'Quick Open'; - - this._settings = settings; - this._fileBrowser = defaultBrowser; - this._provider = provider; - } - - /** Signal when a selected path is activated. */ - get pathSelected(): ISignal { - return this._pathSelected; - } - - /** Current extension settings */ - set settings(settings: ReadonlyPartialJSONObject) { - this._settings = settings; - } - - /** - * Dispose of tracked disposables and clean up commands. - */ - dispose(): void { - if (this.isDisposed) { - return; - } - - // Clean up all tracked disposables - this._disposables.forEach(disposable => disposable.dispose()); - this._disposables.length = 0; - - super.dispose(); - } - - /** - * Refreshes the widget with the paths of files on the server. - */ - protected async onActivateRequest(msg: Message): Promise { - super.onActivateRequest(msg); - - // Fetch the current contents from the server - const path = this._settings.relativeSearch - ? this._fileBrowser.model.path - : ''; - const response = await this._provider.fetchContents( - path, - this._settings.excludes as string[] - ); - - // Clean up previous commands and remove all paths from the view - this._disposables.forEach(disposable => disposable.dispose()); - this._disposables.length = 0; - this.clearItems(); - - for (const category in response.contents) { - for (const fn of response.contents[category]) { - // Creates commands that are relative file paths on the server - const command = `${category}/${fn}`; - if (!this.commands.hasCommand(command)) { - const disposable = this.commands.addCommand(command, { - label: fn, - execute: () => { - // Emit a selection signal - this._pathSelected.emit(command); - } - }); - this._disposables.push(disposable); - } - // Make the file visible under its parent directory heading - this.addItem({ command, category }); - } - } - } -} - -/** - * Initialization data for the jupyterlab-quickopen extension. + * The main quickopen plugin. */ const extension: JupyterFrontEndPlugin = { id: 'jupyterlab-quickopen:plugin', @@ -147,23 +51,17 @@ const extension: JupyterFrontEndPlugin = { } ); - // Listen for path selection signals and show the selected files in the appropriate - // editor/viewer widget.pathSelected.connect((_sender: QuickOpenWidget, path: string) => { docManager.openOrReveal(PathExt.normalize(path)); }); - // Listen for setting changes and apply them to the widget settings.changed.connect((settings: ISettingRegistry.ISettings) => { widget.settings = settings.composite; }); - // Add the quick open widget as a modal palette const modalPalette = new ModalCommandPalette({ commandPalette: widget }); modalPalette.attach(); - // Add a command to activate the quickopen sidebar so that the user can find it in the command - // palette, assign a hotkey, etc. const command = 'quickopen:activate'; app.commands.addCommand(command, { label: trans.__('Quick Open'), @@ -177,6 +75,7 @@ const extension: JupyterFrontEndPlugin = { } } }); + if (palette) { palette.addItem({ command, category: 'File Operations' }); } @@ -184,15 +83,47 @@ const extension: JupyterFrontEndPlugin = { }; /** - * Plugin that provides the default quick open provider + * Plugin that provides the quick open provider */ const providerPlugin: JupyterFrontEndPlugin = { id: 'jupyterlab-quickopen:provider', - description: 'Provides the default quick open provider', + description: 'Provides the quick open provider', autoStart: true, provides: IQuickOpenProvider, - activate: (_app: JupyterFrontEnd): IQuickOpenProvider => { - return new DefaultQuickOpenProvider(); + requires: [ISettingRegistry], + activate: ( + app: JupyterFrontEnd, + settingRegistry: ISettingRegistry + ): IQuickOpenProvider => { + let currentProvider: IQuickOpenProvider = new ServerQuickOpenProvider(); + + const updateProvider = async () => { + const settings = await settingRegistry.load( + 'jupyterlab-quickopen:plugin' + ); + const indexingMethod = settings.get('indexingMethod').composite as string; + + if (indexingMethod === 'frontend') { + currentProvider = new FrontendQuickOpenProvider({ + contentsManager: app.serviceManager.contents + }); + } else { + currentProvider = new ServerQuickOpenProvider(); + } + }; + + updateProvider(); + + settingRegistry.load('jupyterlab-quickopen:plugin').then(settings => { + settings.changed.connect(updateProvider); + }); + + // Return a wrapper that delegates to the current provider + return { + fetchContents: options => { + return currentProvider.fetchContents(options); + } + }; } }; diff --git a/src/serverProvider.ts b/src/serverProvider.ts new file mode 100644 index 0000000..3d8a079 --- /dev/null +++ b/src/serverProvider.ts @@ -0,0 +1,56 @@ +import { URLExt } from '@jupyterlab/coreutils'; +import { ServerConnection } from '@jupyterlab/services'; +import { + IQuickOpenProvider, + IQuickOpenResponse, + IQuickOpenOptions +} from './tokens'; + +/** + * Default implementation of the quick open provider that the server endpoint. + */ +export class ServerQuickOpenProvider implements IQuickOpenProvider { + /** + * Fetch contents from the server endpoint. + */ + async fetchContents(options: IQuickOpenOptions): Promise { + const { path, excludes, depth } = options; + console.log( + 'Debug: ServerProvider received depth =', + depth, + 'typeof =', + typeof depth + ); + const queryParams = excludes.map( + exclude => 'excludes=' + encodeURIComponent(exclude) + ); + + console.log('Debug: depth !== undefined?', depth !== undefined); + console.log('Debug: depth !== Infinity?', depth !== Infinity); + if (depth !== undefined && depth !== Infinity) { + console.log('Debug: Adding depth to query params, depth =', depth); + queryParams.push('depth=' + depth); + } else { + console.log('Debug: NOT adding depth to query params'); + } + + const query = queryParams.join('&'); + + const settings = ServerConnection.makeSettings(); + const fullUrl = + URLExt.join(settings.baseUrl, 'jupyterlab-quickopen', 'api', 'files') + + '?' + + query + + '&path=' + + path; + const response = await ServerConnection.makeRequest( + fullUrl, + { method: 'GET' }, + settings + ); + if (response.status !== 200) { + throw new ServerConnection.ResponseError(response); + } + return await response.json(); + } +} diff --git a/src/tokens.ts b/src/tokens.ts index 4b5829e..4187710 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -6,15 +6,24 @@ export interface IQuickOpenResponse { readonly scanSeconds: number; } +/** Options for fetching quick open contents */ +export interface IQuickOpenOptions { + /** The path to search in */ + path: string; + /** Array of patterns to exclude from results */ + excludes: string[]; + /** Maximum directory depth to search (Infinity for unlimited) */ + depth?: number; +} + /** Interface for quick open content providers */ export interface IQuickOpenProvider { /** * Fetch contents from the provider - * @param path The path to search in - * @param excludes Array of patterns to exclude + * @param options Options for the fetch operation * @returns Promise with the response containing file contents */ - fetchContents(path: string, excludes: string[]): Promise; + fetchContents(options: IQuickOpenOptions): Promise; } /** Token for the quick open provider */ diff --git a/src/widget.ts b/src/widget.ts new file mode 100644 index 0000000..7b409f6 --- /dev/null +++ b/src/widget.ts @@ -0,0 +1,110 @@ +import { IDefaultFileBrowser } from '@jupyterlab/filebrowser'; +import { ReadonlyPartialJSONObject } from '@lumino/coreutils'; +import { IDisposable } from '@lumino/disposable'; +import { Message } from '@lumino/messaging'; +import { ISignal, Signal } from '@lumino/signaling'; +import { CommandPalette } from '@lumino/widgets'; +import { IQuickOpenProvider } from './tokens'; + +/** + * Shows files nested under directories in the root notebooks directory configured on the server. + */ +export class QuickOpenWidget extends CommandPalette { + private _pathSelected = new Signal(this); + private _settings: ReadonlyPartialJSONObject; + private _fileBrowser: IDefaultFileBrowser; + private _provider: IQuickOpenProvider; + private _disposables: IDisposable[] = []; + + constructor( + defaultBrowser: IDefaultFileBrowser, + settings: ReadonlyPartialJSONObject, + provider: IQuickOpenProvider, + options: CommandPalette.IOptions + ) { + super(options); + + this.id = 'jupyterlab-quickopen'; + this.title.iconClass = 'jp-SideBar-tabIcon jp-SearchIcon'; + this.title.caption = 'Quick Open'; + + this._settings = settings; + this._fileBrowser = defaultBrowser; + this._provider = provider; + } + + /** Signal when a selected path is activated. */ + get pathSelected(): ISignal { + return this._pathSelected; + } + + /** Current extension settings */ + set settings(settings: ReadonlyPartialJSONObject) { + this._settings = settings; + } + + /** + * Dispose of tracked disposables and clean up commands. + */ + dispose(): void { + if (this.isDisposed) { + return; + } + + // Clean up all tracked disposables + this._disposables.forEach(disposable => disposable.dispose()); + this._disposables.length = 0; + + super.dispose(); + } + + /** + * Refreshes the widget with the paths of files on the server. + */ + protected async onActivateRequest(msg: Message): Promise { + super.onActivateRequest(msg); + + // Fetch the current contents from the server + const path = this._settings.relativeSearch + ? this._fileBrowser.model.path + : ''; + console.log('Debug: Full settings object =', this._settings); + console.log( + 'Debug: this._settings.depth =', + this._settings.depth, + 'typeof =', + typeof this._settings.depth + ); + const depth = this._settings.depth as number; + console.log('Debug: depth after cast =', depth, 'typeof =', typeof depth); + const response = await this._provider.fetchContents({ + path, + excludes: this._settings.excludes as string[], + depth: depth + }); + + // Clean up previous commands and remove all paths from the view + this._disposables.forEach(disposable => disposable.dispose()); + this._disposables.length = 0; + this.clearItems(); + + for (const category in response.contents) { + for (const fn of response.contents[category]) { + // Creates commands that are relative file paths on the server + const command = `${category}/${fn}`; + if (!this.commands.hasCommand(command)) { + const disposable = this.commands.addCommand(command, { + label: fn, + execute: () => { + // Emit a selection signal + this._pathSelected.emit(command); + } + }); + this._disposables.push(disposable); + } + // Make the file visible under its parent directory heading + this.addItem({ command, category }); + } + } + } +} From 43e5b41d3d35cbdc82ff3a96497302cb050284fb Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Mon, 1 Sep 2025 17:20:46 +0200 Subject: [PATCH 02/10] add deploy workflow --- .github/workflows/deploy.yml | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..21bd037 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,50 @@ +name: Build and Deploy + +on: + push: + branches: + - main + pull_request: + branches: + - '*' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install the dependencies + run: | + python -m pip install jupyterlite-core + python -m pip install . + - name: Build the JupyterLite site + run: | + mkdir content + cp README.md content + jupyter lite build --contents content --output-dir dist + - name: Upload artifact + uses: actions/upload-pages-artifact@v4 + with: + path: ./dist + + deploy: + needs: build + if: github.ref == 'refs/heads/main' + permissions: + pages: write + id-token: write + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + runs-on: ubuntu-latest + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 From 05dae92a4607175c0c949f30ddd71e515868cfc7 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Mon, 1 Sep 2025 17:33:32 +0200 Subject: [PATCH 03/10] demo folder --- .github/workflows/deploy.yml | 16 ++++++++++------ demo/README.md | 12 ++++++++++++ demo/overrides.json | 5 +++++ demo/requirements.txt | 3 +++ 4 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 demo/README.md create mode 100644 demo/overrides.json create mode 100644 demo/requirements.txt diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 21bd037..ca42a88 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,17 +20,21 @@ jobs: python-version: '3.12' - name: Install the dependencies run: | - python -m pip install jupyterlite-core - python -m pip install . + python -m pip install -r requirements.txt + working-directory: ./demo + - name: Clone jupyterlab-demo for example content + run: | + git clone --depth 1 https://github.com/jupyterlab/jupyterlab-demo + rm -rf jupyterlab-demo/.git + working-directory: ./demo - name: Build the JupyterLite site run: | - mkdir content - cp README.md content - jupyter lite build --contents content --output-dir dist + jupyter lite build --contents jupyterlab-demo --output-dir dist + working-directory: ./demo - name: Upload artifact uses: actions/upload-pages-artifact@v4 with: - path: ./dist + path: ./demo/dist deploy: needs: build diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..87de1a5 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,12 @@ +# Demo + +This directory contains configuration files for running a JupyterLite demo with the quickopen extension. + +## Files + +- `requirements.txt` - JupyterLite dependencies +- `overrides.json` - Extension configuration to use frontend indexing method + +## Usage + +Build and serve a JupyterLite instance with the quickopen extension configured for frontend-based file indexing. \ No newline at end of file diff --git a/demo/overrides.json b/demo/overrides.json new file mode 100644 index 0000000..d90d623 --- /dev/null +++ b/demo/overrides.json @@ -0,0 +1,5 @@ +{ + "jupyterlab-quickopen:plugin": { + "indexingMethod": "frontend" + } +} \ No newline at end of file diff --git a/demo/requirements.txt b/demo/requirements.txt new file mode 100644 index 0000000..2176187 --- /dev/null +++ b/demo/requirements.txt @@ -0,0 +1,3 @@ +jupyterlite-core +jupyterlite-pyodide-kernel +. From 396e0cb6586f8cc7c112395e58c9fcae853fbf61 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Mon, 1 Sep 2025 17:34:11 +0200 Subject: [PATCH 04/10] fix typo --- demo/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/requirements.txt b/demo/requirements.txt index 2176187..d8bca48 100644 --- a/demo/requirements.txt +++ b/demo/requirements.txt @@ -1,3 +1,3 @@ jupyterlite-core jupyterlite-pyodide-kernel -. +.. From 02a2ca91c0d365f1790bef78ce14f398ac28799c Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Mon, 1 Sep 2025 17:38:12 +0200 Subject: [PATCH 05/10] fixes --- .github/workflows/deploy.yml | 4 +++- demo/README.md | 2 +- demo/overrides.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ca42a88..835a4e9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -25,7 +25,9 @@ jobs: - name: Clone jupyterlab-demo for example content run: | git clone --depth 1 https://github.com/jupyterlab/jupyterlab-demo - rm -rf jupyterlab-demo/.git + + # remove hidden directories + rm -rf jupyterlab-demo/.* working-directory: ./demo - name: Build the JupyterLite site run: | diff --git a/demo/README.md b/demo/README.md index 87de1a5..adf5acb 100644 --- a/demo/README.md +++ b/demo/README.md @@ -9,4 +9,4 @@ This directory contains configuration files for running a JupyterLite demo with ## Usage -Build and serve a JupyterLite instance with the quickopen extension configured for frontend-based file indexing. \ No newline at end of file +Build and serve a JupyterLite instance with the quickopen extension configured for frontend-based file indexing. diff --git a/demo/overrides.json b/demo/overrides.json index d90d623..6248aa8 100644 --- a/demo/overrides.json +++ b/demo/overrides.json @@ -2,4 +2,4 @@ "jupyterlab-quickopen:plugin": { "indexingMethod": "frontend" } -} \ No newline at end of file +} From 4a35eda08b0e8ffb2d3470b4301b408ca882fdea Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Mon, 1 Sep 2025 17:51:06 +0200 Subject: [PATCH 06/10] fix frontend indexing --- .gitignore | 2 +- src/frontendProvider.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 2fe1058..10ed9e4 100644 --- a/.gitignore +++ b/.gitignore @@ -121,4 +121,4 @@ dmypy.json .yarn/ # JupyterLab upgrade script -_temp_extension \ No newline at end of file +_temp_extension diff --git a/src/frontendProvider.ts b/src/frontendProvider.ts index 3c66c36..5039568 100644 --- a/src/frontendProvider.ts +++ b/src/frontendProvider.ts @@ -85,7 +85,7 @@ export class FrontendQuickOpenProvider implements IQuickOpenProvider { maxDepth, currentDepth + 1 ); - } else if (item.type === 'file') { + } else { // Add file to contents under its directory category const category = dirPath || '.'; if (!contents[category]) { From 212c20a8c577e1b14853ce1ad7198c057cdce769 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Mon, 1 Sep 2025 18:04:47 +0200 Subject: [PATCH 07/10] cleanup --- src/serverProvider.ts | 11 ----------- src/widget.ts | 23 +++++++++-------------- 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/src/serverProvider.ts b/src/serverProvider.ts index 3d8a079..2f40ca8 100644 --- a/src/serverProvider.ts +++ b/src/serverProvider.ts @@ -15,23 +15,12 @@ export class ServerQuickOpenProvider implements IQuickOpenProvider { */ async fetchContents(options: IQuickOpenOptions): Promise { const { path, excludes, depth } = options; - console.log( - 'Debug: ServerProvider received depth =', - depth, - 'typeof =', - typeof depth - ); const queryParams = excludes.map( exclude => 'excludes=' + encodeURIComponent(exclude) ); - console.log('Debug: depth !== undefined?', depth !== undefined); - console.log('Debug: depth !== Infinity?', depth !== Infinity); if (depth !== undefined && depth !== Infinity) { - console.log('Debug: Adding depth to query params, depth =', depth); queryParams.push('depth=' + depth); - } else { - console.log('Debug: NOT adding depth to query params'); } const query = queryParams.join('&'); diff --git a/src/widget.ts b/src/widget.ts index 7b409f6..f4283a2 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -10,12 +10,9 @@ import { IQuickOpenProvider } from './tokens'; * Shows files nested under directories in the root notebooks directory configured on the server. */ export class QuickOpenWidget extends CommandPalette { - private _pathSelected = new Signal(this); - private _settings: ReadonlyPartialJSONObject; - private _fileBrowser: IDefaultFileBrowser; - private _provider: IQuickOpenProvider; - private _disposables: IDisposable[] = []; - + /** + * Create a new QuickOpenWidget. + */ constructor( defaultBrowser: IDefaultFileBrowser, settings: ReadonlyPartialJSONObject, @@ -68,15 +65,7 @@ export class QuickOpenWidget extends CommandPalette { const path = this._settings.relativeSearch ? this._fileBrowser.model.path : ''; - console.log('Debug: Full settings object =', this._settings); - console.log( - 'Debug: this._settings.depth =', - this._settings.depth, - 'typeof =', - typeof this._settings.depth - ); const depth = this._settings.depth as number; - console.log('Debug: depth after cast =', depth, 'typeof =', typeof depth); const response = await this._provider.fetchContents({ path, excludes: this._settings.excludes as string[], @@ -107,4 +96,10 @@ export class QuickOpenWidget extends CommandPalette { } } } + + private _pathSelected = new Signal(this); + private _settings: ReadonlyPartialJSONObject; + private _fileBrowser: IDefaultFileBrowser; + private _provider: IQuickOpenProvider; + private _disposables: IDisposable[] = []; } From a179f588a4669db6b9f1846d90c99f3607934de1 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Mon, 1 Sep 2025 18:14:44 +0200 Subject: [PATCH 08/10] improve the loading of settings --- src/index.ts | 47 ++++++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1d169da..8e8a0b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,7 @@ import { QuickOpenWidget } from './widget'; /** * The main quickopen plugin. */ -const extension: JupyterFrontEndPlugin = { +const quickopenPlugin: JupyterFrontEndPlugin = { id: 'jupyterlab-quickopen:plugin', description: 'Provides a quick open file dialog', autoStart: true, @@ -40,7 +40,7 @@ const extension: JupyterFrontEndPlugin = { const trans = (translator ?? nullTranslator).load('jupyterlab-quickopen'); const commands: CommandRegistry = new CommandRegistry(); const settings: ISettingRegistry.ISettings = await settingRegistry.load( - extension.id + quickopenPlugin.id ); const widget: QuickOpenWidget = new QuickOpenWidget( defaultFileBrowser, @@ -90,33 +90,34 @@ const providerPlugin: JupyterFrontEndPlugin = { description: 'Provides the quick open provider', autoStart: true, provides: IQuickOpenProvider, - requires: [ISettingRegistry], + optional: [ISettingRegistry], activate: ( app: JupyterFrontEnd, settingRegistry: ISettingRegistry ): IQuickOpenProvider => { let currentProvider: IQuickOpenProvider = new ServerQuickOpenProvider(); - const updateProvider = async () => { - const settings = await settingRegistry.load( - 'jupyterlab-quickopen:plugin' - ); - const indexingMethod = settings.get('indexingMethod').composite as string; + if (settingRegistry) { + void Promise.all([ + settingRegistry.load(quickopenPlugin.id), + app.restored + ]).then(([settings]) => { + const updateProvider = () => { + const indexingMethod = settings.get('indexingMethod') + .composite as string; - if (indexingMethod === 'frontend') { - currentProvider = new FrontendQuickOpenProvider({ - contentsManager: app.serviceManager.contents - }); - } else { - currentProvider = new ServerQuickOpenProvider(); - } - }; - - updateProvider(); - - settingRegistry.load('jupyterlab-quickopen:plugin').then(settings => { - settings.changed.connect(updateProvider); - }); + if (indexingMethod === 'frontend') { + currentProvider = new FrontendQuickOpenProvider({ + contentsManager: app.serviceManager.contents + }); + } else { + currentProvider = new ServerQuickOpenProvider(); + } + }; + updateProvider(); + settings.changed.connect(updateProvider); + }); + } // Return a wrapper that delegates to the current provider return { @@ -128,7 +129,7 @@ const providerPlugin: JupyterFrontEndPlugin = { }; // export plugins as defaults -export default [extension, providerPlugin]; +export default [quickopenPlugin, providerPlugin]; // also export tokens export * from './tokens'; From 9d024aa5ea7162e6b8c8182103cf7f354d9f678c Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Mon, 1 Sep 2025 18:19:30 +0200 Subject: [PATCH 09/10] add TODO --- src/frontendProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontendProvider.ts b/src/frontendProvider.ts index 5039568..d125add 100644 --- a/src/frontendProvider.ts +++ b/src/frontendProvider.ts @@ -113,7 +113,7 @@ export class FrontendQuickOpenProvider implements IQuickOpenProvider { excludes: string[] ): boolean { for (const exclude of excludes) { - // Simple pattern matching - can be enhanced with glob patterns + // TODO: support globs instead of simple string matching if ( filename.includes(exclude) || fullPath.includes(exclude) || From de8f0dd0aaf9c3e1f3fddff862f2fa3328587af7 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Tue, 2 Sep 2025 07:28:38 +0200 Subject: [PATCH 10/10] add jupyterlite section to the README --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index bfe9aaa..c206be5 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,23 @@ area to override the default values. ![Screenshot of the quick open settings editor](./doc/settings.png) +### JupyterLite + +This extension is compatible with JupyterLite when using the client-side indexing mode. Open the +_Advanced Settings Editor_ (_Settings → Advanced Settings Editor_), select the _Quick Open_ settings +and set the `indexingMethod` to `"frontend"`. That enables the extension to index files via the +JupyterLab Contents API on the client (suitable for JupyterLite deployments). + +You can add the following `overrides.json` file before building your JupyterLite site: + +```json +{ + "jupyterlab-quickopen:plugin": { + "indexingMethod": "frontend" + } +} +``` + ### Development install Note: You will need NodeJS to build the extension package.