Skip to content

Commit

Permalink
Implement openWith, fixes #193
Browse files Browse the repository at this point in the history
  • Loading branch information
painebot committed Feb 18, 2025
1 parent a3749c0 commit 219afaf
Show file tree
Hide file tree
Showing 2 changed files with 143 additions and 18 deletions.
120 changes: 117 additions & 3 deletions js/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import { JupyterFrontEnd } from "@jupyterlab/application";
import { Dialog, showDialog } from "@jupyterlab/apputils";
import { DocumentRegistry } from "@jupyterlab/docregistry";
import {
closeIcon,
copyIcon,
Expand All @@ -20,9 +21,12 @@ import {
refreshIcon,
fileIcon,
newFolderIcon,
RankedMenu,
IDisposableMenuItem,
} from "@jupyterlab/ui-components";
import { map } from "@lumino/algorithm";
import { DisposableSet, IDisposable } from "@lumino/disposable";
import { Menu } from "@lumino/widgets";
import { ContextMenu, Menu } from "@lumino/widgets";
import { Content, Path } from "@tree-finder/base";


Expand All @@ -42,6 +46,7 @@ export const commandNames = [
"cut",
"delete",
"open",
"openWith",
"paste",
"refresh",
"rename",
Expand Down Expand Up @@ -116,13 +121,15 @@ async function _commandKeyForSnippet(snippet: Snippet): Promise<string> {
return `jupyterfs:snippet-${snippet.label}-${await _digestString(snippet.label + snippet.caption + snippet.pattern.source + snippet.template)}`;
}

function _openWithKeyForFactory(factory: string): string {
return `jupyterfs:openwith-${factory}`;
}

function _normalizedUrlForSnippet(content: Content<ContentsProxy.IJupyterContentRow>, baseUrl: string): string {
const path = splitPathstrDrive(content.pathstr)[1];
return `${baseUrl}/${path}${content.hasChildren ? "/" : ""}`;
}


/**
* Create commands that will have the same IDs indepent of settings/resources
*
Expand Down Expand Up @@ -189,6 +196,7 @@ export function createStaticCommands(
label: "Open",
isEnabled: () => !!tracker.currentWidget,
}),

app.commands.addCommand(commandIDs.paste, {
execute: args => clipboard.model.pasteSelection(tracker.currentWidget!.treefinder.model!),
icon: pasteIcon,
Expand Down Expand Up @@ -420,6 +428,64 @@ export async function createDynamicCommands(
}));
}

const openWithCommands = [] as IDisposable[];
const openWithMenu = new Menu({ commands: app.commands });
openWithMenu.title.label = "Open With";
openWithMenu.id = "treefinder:open-with";
const { docRegistry } = app;

let items: IDisposableMenuItem[] = [];

function updateOpenWithMenu(contextMenu: ContextMenu) {
const openWith =
(contextMenu.menu.items.find(
item =>
item.type === "submenu" &&
item.submenu?.id === "treefinder:open-with"
)?.submenu as RankedMenu) ?? null;

if (!openWith) {
return; // Bail early if the open with menu is not displayed
}

// clear the current menu items
// items.forEach(item => item.dispose());
items.length = 0;
// Ensure that the menu is empty
openWith.clearItems();

// clear the commands
openWithCommands.forEach(item => item.dispose());
openWithCommands.length = 0;

// get the widget factories that could be used to open all of the items
// in the current filebrowser selection
const widget = tracker.currentWidget!;
const treefinder = widget.treefinder;
const model = treefinder.model!;
const factories = tracker.currentWidget
? intersection<DocumentRegistry.WidgetFactory>(
map(model.selection, i => getFactories(docRegistry, widget, i))
)
: new Set<DocumentRegistry.WidgetFactory>();

// make new menu items from the widget factories
items = [...factories].map(factory => {
const key = _openWithKeyForFactory(factory.label || factory.name);
const label = factory.label || factory.name;
openWithCommands.push(app.commands.addCommand(key, {
execute: args => Promise.all(Array.from(map(model.selection, item => app.commands.execute("docmanager:open", { path: Path.fromarray(item.row.path), ...args })))),
label,
isVisible: () => true,
}));
return openWith.addItem({
args: { factory: factory.name, label: factory.label || factory.name },
command: key,
});
});
}
app.contextMenu.opened.connect(updateOpenWithMenu);

const selector = ".jp-tree-finder-sidebar";
let contextMenuRank = 1;

Expand All @@ -433,7 +499,12 @@ export async function createDynamicCommands(
selector,
rank: contextMenuRank++,
}),

app.contextMenu.addItem({
type: "submenu",
submenu: openWithMenu,
selector,
rank: contextMenuRank++,
}),
app.contextMenu.addItem({
command: commandIDs.copy,
selector,
Expand Down Expand Up @@ -521,3 +592,46 @@ export async function createDynamicCommands(
set.add(d); return set;
}, new DisposableSet());
}


export function getFactories(
docRegistry: DocumentRegistry,
widget: TreeFinderSidebar,
item: Content<ContentsProxy.IJupyterContentRow>,
): DocumentRegistry.WidgetFactory[] {
const path = [widget.url.trimEnd().replace(/\/+$/, ""), item.getPathAtDepth(1).join("/")].join("/");
const factories = docRegistry.preferredWidgetFactories(path);
const notebookFactory = docRegistry.getWidgetFactory("notebook");
if (
notebookFactory &&
item.row.kind === "notebook" &&
factories.indexOf(notebookFactory) === -1
) {
factories.unshift(notebookFactory);
}
return factories;
}

function intersection<T>(iterables: Iterable<Iterable<T>>): Set<T> {
let accumulator: Set<T> | undefined;
for (const current of iterables) {
// Initialize accumulator.
if (accumulator === undefined) {
accumulator = new Set(current);
continue;
}
// Return early if empty.
if (accumulator.size === 0) {
return accumulator;
}
// Keep the intersection of accumulator and current.
const intersection_set = new Set<T>();
for (const value of current) {
if (accumulator.has(value)) {
intersection_set.add(value);
}
}
accumulator = intersection_set;
}
return accumulator ?? new Set();
}
41 changes: 26 additions & 15 deletions jupyterfs/manager/fsspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,25 @@ def _read_file(self, path, format):
If 'base64', the raw bytes contents will be encoded as base64.
If not specified, try to decode as UTF-8, and fall back to base64
"""
bcontent = self._fs.cat(path)
try:
bcontent = self._fs.cat(path)
except OSError:
# sometimes this causes errors:
# e.g. fir s3fs it causes
# OSError: [Errno 22] Unsupported header 'x-amz-checksum-mode' received for this API call.
# So try non-binary
try:
bcontent = self._fs.open(path, mode="r").read()
return bcontent, "text"
except OSError as e:
raise web.HTTPError(400, path, reason=str(e))

if format is None or format == "text":
# Try to interpret as unicode if format is unknown or if unicode
# was explicitly requested.
try:
return bcontent.decode("utf8"), "text"
except UnicodeError:
except (UnicodeError, UnicodeDecodeError):
if format == "text":
raise web.HTTPError(
400,
Expand Down Expand Up @@ -205,6 +216,14 @@ def _notebook_model(self, path, content=True):
self.validate_notebook_model(model)
return model

def _normalize_path(self, path):
if path and self._fs.root_marker and not path.startswith(self._fs.root_marker):
path = f"{self._fs.root_marker}{path}"
if path and not path.startswith(self.root):
path = f"{self.root}/{path}"
path = path or self.root
return path

def get(self, path, content=True, type=None, format=None):
"""Takes a path for an entity and returns its model
Args:
Expand All @@ -215,11 +234,7 @@ def get(self, path, content=True, type=None, format=None):
Returns
model (dict): the contents model. If content=True, returns the contents of the file or directory as well.
"""
if path and self._fs.root_marker and not path.startswith(self._fs.root_marker):
path = f"{self._fs.root_marker}{path}"
if path and not path.startswith(self.root):
path = f"{self.root}/{path}"
path = path or self.root
path = self._normalize_path(path)

try:
if self._fs.isdir(path):
Expand Down Expand Up @@ -270,9 +285,7 @@ def _save_file(self, path, content, format):

def save(self, model, path=""):
"""Save the file model and return the model with no content."""
path = path.strip("/")
if path and self._fs.root_marker and not path.startswith(self._fs.root_marker):
path = self._fs.root_marker + path
path = self._normalize_path(path)

self.run_pre_save_hook(model=model, path=path)

Expand Down Expand Up @@ -309,15 +322,13 @@ def save(self, model, path=""):

def delete_file(self, path):
"""Delete file at path."""
path = path.strip("/")
if path and self._fs.root_marker and not path.startswith(self._fs.root_marker):
path = self._fs.root_marker + path
path = self._normalize_path(path)
self._fs.rm(path, recursive=True)

def rename_file(self, old_path, new_path):
"""Rename a file."""
old_path = old_path.strip("/")
new_path = new_path.strip("/")
old_path = self._normalize_path(old_path).strip("/")
new_path = self._normalize_path(new_path).strip("/")
if new_path == old_path:
return

Expand Down

0 comments on commit 219afaf

Please sign in to comment.