Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,4 @@ dmypy.json
.yarn/

# JupyterLab upgrade script
_temp_extension
_temp_extension
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ node_modules
**/package.json
!/package.json
jupyterlab_quickopen
.venv
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions demo/README.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions demo/overrides.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"jupyterlab-quickopen:plugin": {
"indexingMethod": "frontend"
}
}
3 changes: 3 additions & 0 deletions demo/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
jupyterlite-core
jupyterlite-pyodide-kernel
..
10 changes: 7 additions & 3 deletions jupyterlab_quickopen/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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})
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,8 @@
"node_modules",
"dist",
"coverage",
"**/*.d.ts"
"**/*.d.ts",
".venv"
],
"prettier": {
"singleQuote": true,
Expand Down
14 changes: 14 additions & 0 deletions schema/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
144 changes: 144 additions & 0 deletions src/frontendProvider.ts
Original file line number Diff line number Diff line change
@@ -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<IQuickOpenResponse> {
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<void> {
if (currentDepth >= maxDepth) {
return;
}

try {
const listing = await this._contentsManager.get(dirPath, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this is for JupyterLite support?

Long-term, we may want to extend the contentsManager implementation so that we don't need to make the recursive gets here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, indeed, this one is only for the frontend provider, so the provider making the calls to the contents API one by one.

we may want to extend the contentsManager implementation

You mean extending it directly in Jupyterlite? If so yes, that could be an option. Also the quickopen provider is defined in its own plugin, so it could be replaced by another plugin provided by JupyterLite.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean extending it directly in Jupyterlite

I guess I mean that if the more advanced handler for recursive get gets into jupyterlab-server, then the contentsManager API will be updated to expose this new recursive get. Then JupyterLite will have to implement it and we can get rid of the recursion here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I guess this FrontendQuickOpenProvider is mostly to enable the use of the extension in JupyterLite, and have everything available in the same jupyterlab-quickopen extension.

If we decide to propose this extension for incorporation into core JupyterLab, we can expose the provider token and drop that custom FrontendQuickOpenProvider, since JupyterLite will be able to provide its own.

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;
}
}
46 changes: 0 additions & 46 deletions src/handler.ts

This file was deleted.

Loading