diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..835a4e9 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,56 @@ +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 -r requirements.txt + working-directory: ./demo + - name: Clone jupyterlab-demo for example content + run: | + git clone --depth 1 https://github.com/jupyterlab/jupyterlab-demo + + # remove hidden directories + rm -rf jupyterlab-demo/.* + working-directory: ./demo + - name: Build the JupyterLite site + run: | + jupyter lite build --contents jupyterlab-demo --output-dir dist + working-directory: ./demo + - name: Upload artifact + uses: actions/upload-pages-artifact@v4 + with: + path: ./demo/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 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/.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/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. diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..adf5acb --- /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. diff --git a/demo/overrides.json b/demo/overrides.json new file mode 100644 index 0000000..6248aa8 --- /dev/null +++ b/demo/overrides.json @@ -0,0 +1,5 @@ +{ + "jupyterlab-quickopen:plugin": { + "indexingMethod": "frontend" + } +} diff --git a/demo/requirements.txt b/demo/requirements.txt new file mode 100644 index 0000000..d8bca48 --- /dev/null +++ b/demo/requirements.txt @@ -0,0 +1,3 @@ +jupyterlite-core +jupyterlite-pyodide-kernel +.. 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/frontendProvider.ts b/src/frontendProvider.ts new file mode 100644 index 0000000..d125add --- /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 { + // 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) { + // TODO: support globs instead of simple string matching + 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..8e8a0b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,115 +5,19 @@ 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. + * The main quickopen plugin. */ -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. - */ -const extension: JupyterFrontEndPlugin = { +const quickopenPlugin: JupyterFrontEndPlugin = { id: 'jupyterlab-quickopen:plugin', description: 'Provides a quick open file dialog', autoStart: true, @@ -136,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, @@ -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,20 +83,53 @@ 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(); + optional: [ISettingRegistry], + activate: ( + app: JupyterFrontEnd, + settingRegistry: ISettingRegistry + ): IQuickOpenProvider => { + let currentProvider: IQuickOpenProvider = new ServerQuickOpenProvider(); + + 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(); + settings.changed.connect(updateProvider); + }); + } + + // Return a wrapper that delegates to the current provider + return { + fetchContents: options => { + return currentProvider.fetchContents(options); + } + }; } }; // export plugins as defaults -export default [extension, providerPlugin]; +export default [quickopenPlugin, providerPlugin]; // also export tokens export * from './tokens'; diff --git a/src/defaultProvider.ts b/src/serverProvider.ts similarity index 52% rename from src/defaultProvider.ts rename to src/serverProvider.ts index 57256f3..2f40ca8 100644 --- a/src/defaultProvider.ts +++ b/src/serverProvider.ts @@ -1,20 +1,29 @@ import { URLExt } from '@jupyterlab/coreutils'; import { ServerConnection } from '@jupyterlab/services'; -import { IQuickOpenProvider, IQuickOpenResponse } from './tokens'; +import { + IQuickOpenProvider, + IQuickOpenResponse, + IQuickOpenOptions +} 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('&'); +export class ServerQuickOpenProvider implements IQuickOpenProvider { + /** + * Fetch contents from the server endpoint. + */ + async fetchContents(options: IQuickOpenOptions): Promise { + const { path, excludes, depth } = options; + const queryParams = excludes.map( + exclude => 'excludes=' + encodeURIComponent(exclude) + ); + + if (depth !== undefined && depth !== Infinity) { + queryParams.push('depth=' + depth); + } + + const query = queryParams.join('&'); const settings = ServerConnection.makeSettings(); const fullUrl = 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..f4283a2 --- /dev/null +++ b/src/widget.ts @@ -0,0 +1,105 @@ +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 { + /** + * Create a new QuickOpenWidget. + */ + 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 depth = this._settings.depth as number; + 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 }); + } + } + } + + private _pathSelected = new Signal(this); + private _settings: ReadonlyPartialJSONObject; + private _fileBrowser: IDefaultFileBrowser; + private _provider: IQuickOpenProvider; + private _disposables: IDisposable[] = []; +}