diff --git a/README.md b/README.md index c1aae95..0669fc9 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,14 @@ A dedicated right sidebar panel provides detailed project information: - **Contents** — View metadata, dependencies, environment specs, and more - **Artifacts** — See buildable outputs like wheels, conda packages, documentation +### 📁 jupyter-fs Integration + +If [jupyter-fs](https://github.com/jpmorganchase/jupyter-fs) is installed, projspec chips appear in each jupyter-fs sidebar automatically. No extra configuration is needed — the extension detects jupyter-fs at runtime and injects chips below the toolbar in every tree-finder sidebar. + +- **Automatic detection** — If jupyter-fs is not installed, this feature is silently disabled +- **Per-resource scanning** — Each sidebar scans its own fsspec URL via the `/scan-url` backend endpoint +- **Directory navigation** — Chips update as you browse subdirectories within a resource by observing the tree-finder breadcrumbs + ### 🎨 Supported Project Types jupyter-projspec recognizes many project types through projspec: @@ -46,6 +54,8 @@ jupyter-projspec recognizes many project types through projspec: - JupyterLab >= 4.0.0 - Python >= 3.10 - [projspec](https://github.com/fsspec/projspec) +- [jupyter-fs](https://github.com/jpmorganchase/jupyter-fs) (optional, for remote filesystem support) + ## Install @@ -162,17 +172,19 @@ See [ui-tests/README.md](./ui-tests/README.md) for details. ``` jupyter-projspec/ ├── src/ # TypeScript frontend -│ ├── index.ts # Extension entry point +│ ├── index.ts # Extension entry point (both plugins) +│ ├── api.ts # Backend API client functions │ ├── components/ # React components │ │ ├── ProjspecPanelComponent.tsx │ │ ├── ProjectView.tsx │ │ ├── SpecItem.tsx │ │ ├── ContentsView.tsx │ │ ├── ArtifactsView.tsx -│ │ └── ProjspecChips.tsx # File browser chips +│ │ └── ProjspecChips.tsx # Shared chips component │ └── widgets/ │ ├── ProjspecPanel.ts # Sidebar panel widget -│ └── ProjspecChipsWidget.ts +│ ├── ProjspecChipsWidget.ts # Chips in default file browser +│ └── JfsChipsWidget.ts # Chips in jupyter-fs sidebars ├── jupyter_projspec/ # Python backend │ ├── __init__.py # Server extension setup │ └── routes.py # API route handlers @@ -182,19 +194,22 @@ jupyter-projspec/ ### API Endpoints -| Endpoint | Method | Description | -| ------------------------ | ------ | ----------------------------------------- | -| `/jupyter-projspec/scan` | GET | Scan a directory and return projspec data | +| Endpoint | Method | Description | +| ---------------------------- | ------ | ------------------------------------------------------------ | +| `/jupyter-projspec/scan` | GET | Scan a local directory and return projspec data | +| `/jupyter-projspec/scan-url` | POST | Scan an fsspec URL (for jupyter-fs) and return projspec data | +| `/jupyter-projspec/make` | POST | Execute an artifact's build command via projspec | ## Roadmap Future enhancements being considered: -- [ ] **MAKE buttons** — Execute artifact builds directly from the UI -- [ ] **Build output display** — Show stdout/stderr from artifact builds +- [x] **MAKE buttons** — Execute artifact builds directly from the UI +- [x] **Build output display** — Show stdout/stderr from artifact builds +- [x] **jupyter-fs integration** — Projspec chips in jupyter-fs sidebars - [ ] **File browser navigation** — Click built artifacts to reveal them - [ ] **Real-time streaming** — Live output for long-running builds -- [ ] **jupyter-fsspec integration** — Support for remote filesystems +- [ ] **Jupyter Notebook 7 support** — Currently requires JupyterLab (`ILabShell`); Notebook 7 uses `INotebookShell` ## AI Coding Assistant Support diff --git a/jupyter_projspec/routes.py b/jupyter_projspec/routes.py index 2a50eb1..015146b 100644 --- a/jupyter_projspec/routes.py +++ b/jupyter_projspec/routes.py @@ -2,9 +2,12 @@ import json import logging import os +import posixpath +import re import shlex import subprocess import threading +import urllib.parse from concurrent.futures import ThreadPoolExecutor from jupyter_server.base.handlers import APIHandler @@ -508,15 +511,295 @@ def get(self): self.finish(json.dumps({"error": "Error scanning directory"})) +def _scan_url(fsspec_url): + """Run projspec.Project() in a worker thread (blocking I/O safe). + + Uses the shared _executor. This does not compete with make commands + because make is only available for local paths (the UI disables make + buttons for jfs sources), so there is no thread-pool starvation risk. + """ + project = projspec.Project(fsspec_url) + return project.to_dict() + + +class ScanUrlRouteHandler(APIHandler): + """Handler for scanning an fsspec URL with projspec. + + Used by the jupyter-fs integration to scan remote/virtual filesystems. + Validates that the requested URL matches a configured jupyter-fs resource + to prevent arbitrary URL scanning. + """ + + @tornado.web.authenticated + async def post(self): + """Scan an fsspec URL and return projspec project data as JSON. + + Request Body (JSON): + url: Base fsspec URL from jupyter-fs (e.g., "osfs:///tmp/demo") + subpath: Relative path within the resource (default: "") + + Returns: + JSON with "project" key containing the to_dict() output, + or "error" key if something went wrong. + """ + body = self.get_json_body() + if not body or not isinstance(body, dict): + self.set_status(400) + self.finish(json.dumps({"error": "Request body must be a JSON object"})) + return + + url = body.get("url", "") + subpath = body.get("subpath", "") + + if not isinstance(url, str) or not isinstance(subpath, (str, type(None))): + self.set_status(400) + self.finish(json.dumps({"error": "'url' must be a string and 'subpath' must be a string or null"})) + return + + subpath = subpath or "" + + if not url: + self.set_status(400) + self.finish(json.dumps({"error": "Missing required 'url' parameter"})) + return + + # Validate the URL against configured jupyter-fs resources + contents_manager = self.contents_manager + allowed_urls = _get_jfs_resource_urls(contents_manager) + + if allowed_urls is None: + self.set_status(404) + self.finish(json.dumps({ + "error": "jupyter-fs MetaManager not available" + })) + return + + if len(allowed_urls) == 0: + self.set_status(422) + self.finish(json.dumps({ + "error": "No jupyter-fs resources are configured. " + "Add a resource in the jupyter-fs settings panel and restart." + })) + return + + if not _is_url_allowed(url, allowed_urls): + self.set_status(403) + self.finish(json.dumps({ + "error": "URL does not match any configured jupyter-fs resource" + })) + return + + # Use the server-configured allowed URL (not the client-supplied one) as + # the base for path construction. This discards any query parameters or + # other components that a client might inject to manipulate filesystem + # behavior (e.g., fake AWS credentials via ?endpoint_url=...). + matched_url = next( + (a for a in allowed_urls if _normalize_url(url) == _normalize_url(a)), + None, + ) + # matched_url is guaranteed non-None because _is_url_allowed returned True, + # but guard defensively. + if matched_url is None: + self.set_status(500) + self.finish(json.dumps({"error": "Internal error resolving allowed URL"})) + return + + if subpath: + if "\x00" in subpath: + self.set_status(400) + self.finish(json.dumps({ + "error": "Invalid subpath: null bytes not allowed" + })) + return + subpath = subpath.replace("\\", "/") + + # Two-layer traversal check. + # + # Layer 1 — raw string: catches literal "../", double-encoded + # "%252E%252E" (normpath does not decode percent sequences, so + # "%252e%252e" stays as-is and does not resolve to ".."). + # + # Layer 2 — once-decoded string: catches single-encoded "%2e%2e" + # which would pass the raw check but is decoded by + # urllib.parse.unquote inside _pyfs_url_to_fsspec for osfs:// paths, + # yielding "../" after the fact. We do NOT apply the decoded value + # as the canonical subpath here (that would corrupt folder names + # that legitimately contain literal '%' characters); we only use it + # to gate the request. + # + # Note: double-encoded "%252e%252e" is NOT caught by layer 2 + # (one unquote gives "%2e%2e", not ".."), and _pyfs_url_to_fsspec + # also only unquotes once, so double-encoded sequences are safe. + def _has_traversal(s: str) -> bool: + n = posixpath.normpath(s.strip("/")) if s.strip("/") else "" + return n in ("..", ".") or n.startswith("../") or n.startswith("/") + + if _has_traversal(subpath) or _has_traversal(urllib.parse.unquote(subpath)): + self.set_status(400) + self.finish(json.dumps({ + "error": "Invalid subpath: traversal not allowed" + })) + return + + subpath = posixpath.normpath(subpath.strip("/")) + + parsed = urllib.parse.urlparse(matched_url) + base_path = parsed.path.rstrip("/") + new_path = f"{base_path}/{subpath}" if subpath else base_path + full_url = urllib.parse.urlunparse(parsed._replace(path=new_path, query="", fragment="")) + + try: + fsspec_url = _pyfs_url_to_fsspec(full_url) + except ValueError as e: + self.set_status(400) + self.finish(json.dumps({"error": str(e)})) + return + + try: + loop = tornado.ioloop.IOLoop.current() + project_dict = await loop.run_in_executor( + _executor, _scan_url, fsspec_url + ) + self.finish(json.dumps({"project": project_dict})) + except Exception as e: + logger.error( + "projspec error scanning URL %s: %s", + _redact_url_credentials(fsspec_url), + e, + exc_info=True, + ) + self.set_status(500) + self.finish(json.dumps({"error": "Error scanning URL"})) + + +def _get_jfs_resource_urls(contents_manager): + """Extract configured resource URLs from jupyter-fs MetaManager. + + Returns: + A list of URL strings if MetaManager with resources is found, + or None if jupyter-fs is not active. + """ + resources = getattr(contents_manager, "_resources", None) + if resources is None: + resources = getattr(contents_manager, "resources", None) + if resources is None: + return None + + urls = [] + for resource in resources: + resource_url = None + if isinstance(resource, dict): + resource_url = resource.get("url") + else: + resource_url = getattr(resource, "url", None) + if resource_url: + urls.append(resource_url) + return urls + + +def _redact_url_credentials(url): + """Return url with any embedded user:password replaced by user:*** for safe logging. + + Handles: + - scheme://user:password@host → scheme://user:***@host + - scheme://:password@host → scheme://:***@host (password-only) + - scheme://user:p:ass@host → scheme://user:***@host (password contains ':') + """ + # [^:@/]* allows zero-or-more chars before ':' (covers password-only URLs). + # [^@]+ greedily matches up to the last '@', handling ':' inside passwords. + return re.sub(r"(://[^:@/]*):[^@]+@", r"\1:***@", url) + + +def _normalize_url(url): + """Produce a canonical form for URL comparison. + + Lowercases the scheme, percent-decodes the path, resolves dot segments, + and strips trailing slashes. This prevents bypasses via encoding tricks + or case differences (e.g., OSFS vs osfs, %6D vs m). + + Query parameters and fragments are intentionally excluded so that + server-configured URLs (which never have query params) compare equal to + client-submitted base URLs regardless of any injected query parameters. + Actual URL construction uses the server-configured matched URL, not the + client-supplied one, so injected query params are never forwarded. + """ + parsed = urllib.parse.urlparse(url) + scheme = parsed.scheme.lower() + netloc = urllib.parse.unquote(parsed.netloc).lower() + raw_path = urllib.parse.unquote(parsed.path) + # posixpath.normpath('') returns '.' rather than ''; normalise to '' so + # that root-level cloud URLs (e.g. s3://bucket with no path) compare + # equal to s3://bucket/ (path='/'). + normed = posixpath.normpath(raw_path) if raw_path else "" + path = normed.rstrip("/") + return urllib.parse.urlunparse((scheme, netloc, path, "", "", "")) + + +def _is_url_allowed(url, allowed_urls): + """Check if a URL matches one of the allowed jupyter-fs resource URLs. + + Both the submitted URL and each allowed URL are normalized to a canonical + form (lowercase scheme/host, percent-decoded path, dot segments resolved) + before comparison, preventing bypasses via encoding or casing differences. + """ + normalized = _normalize_url(url) + for allowed in allowed_urls: + if normalized == _normalize_url(allowed): + return True + return False + + +_FSSPEC_NATIVE_SCHEMES = frozenset({ + "s3", "gcs", "gs", "az", "abfs", "hdfs", + "file", "http", "https", "ftp", "sftp", "smb", +}) + + +def _pyfs_url_to_fsspec(url): + """Convert a PyFilesystem2 URL to an fsspec-compatible URL. + + jupyter-fs uses PyFilesystem2 URL schemes (e.g., osfs://) + while projspec uses fsspec. This translates between them. + + Raises: + ValueError: If the URL scheme is not recognised as either a known + PyFilesystem2 scheme or an fsspec-native scheme. + """ + parsed = urllib.parse.urlparse(url) + scheme = parsed.scheme.lower() + + if scheme == "osfs": + netloc = parsed.netloc + # Python's urlparse puts a Windows drive letter in netloc: + # urlparse('osfs://C:/path') → netloc='C:', path='/path' + if netloc and len(netloc) == 2 and netloc[1] == ":": + path = netloc + urllib.parse.unquote(parsed.path) + return path # e.g. "C:/path" + if netloc: + raise ValueError( + f"osfs:// URLs with a host component are not supported: {url!r}. " + "Use osfs:///path (triple slash) for local paths." + ) + path = urllib.parse.unquote(parsed.path) + return path if path.startswith("/") else "/" + path + + if scheme in _FSSPEC_NATIVE_SCHEMES: + return url + + raise ValueError(f"Unsupported filesystem scheme: {scheme}") + + def setup_route_handlers(web_app): host_pattern = ".*$" base_url = web_app.settings["base_url"] scan_route_pattern = url_path_join(base_url, "jupyter-projspec", "scan") + scan_url_route_pattern = url_path_join(base_url, "jupyter-projspec", "scan-url") make_route_pattern = url_path_join(base_url, "jupyter-projspec", "make") handlers = [ (scan_route_pattern, ScanRouteHandler), + (scan_url_route_pattern, ScanUrlRouteHandler), (make_route_pattern, MakeRouteHandler), ] diff --git a/jupyter_projspec/tests/test_routes.py b/jupyter_projspec/tests/test_routes.py index 546df37..ba641d4 100644 --- a/jupyter_projspec/tests/test_routes.py +++ b/jupyter_projspec/tests/test_routes.py @@ -609,3 +609,598 @@ async def test_none_command_returns_400(self, mock_project_cls, jp_fetch): assert exc_info.value.response.code == 400 payload = json.loads(exc_info.value.response.body) assert "no command defined" in payload["error"] + + +# --------------------------------------------------------------------------- +# Unit tests for _normalize_url +# --------------------------------------------------------------------------- + +class TestNormalizeUrl: + """Unit tests for the _normalize_url helper.""" + + def test_lowercases_scheme(self): + from jupyter_projspec.routes import _normalize_url + assert _normalize_url("OSFS:///tmp/foo") == _normalize_url("osfs:///tmp/foo") + + def test_lowercases_netloc(self): + from jupyter_projspec.routes import _normalize_url + assert _normalize_url("s3://Bucket/path") == _normalize_url("s3://bucket/path") + + def test_strips_trailing_slash(self): + from jupyter_projspec.routes import _normalize_url + assert _normalize_url("osfs:///tmp/foo/") == _normalize_url("osfs:///tmp/foo") + + def test_resolves_dot_segments(self): + from jupyter_projspec.routes import _normalize_url + assert _normalize_url("osfs:///tmp/foo/../foo") == _normalize_url("osfs:///tmp/foo") + + def test_percent_decodes_path(self): + from jupyter_projspec.routes import _normalize_url + assert _normalize_url("osfs:///%74mp/foo") == _normalize_url("osfs:///tmp/foo") + + def test_empty_path_and_slash_are_equal(self): + """s3://bucket and s3://bucket/ must normalise to the same value.""" + from jupyter_projspec.routes import _normalize_url + assert _normalize_url("s3://bucket") == _normalize_url("s3://bucket/") + + def test_netloc_percent_decoded(self): + """Percent-encoded characters in netloc must be decoded for comparison.""" + from jupyter_projspec.routes import _normalize_url + assert _normalize_url("s3://My%42ucket/path") == _normalize_url("s3://mybucket/path") + + def test_query_params_stripped(self): + """Query parameters must be excluded from the canonical form.""" + from jupyter_projspec.routes import _normalize_url + assert _normalize_url("s3://bucket/path?secret=x") == _normalize_url("s3://bucket/path") + + def test_fragment_stripped(self): + from jupyter_projspec.routes import _normalize_url + assert _normalize_url("osfs:///tmp/foo#frag") == _normalize_url("osfs:///tmp/foo") + + +# --------------------------------------------------------------------------- +# Unit tests for _is_url_allowed +# --------------------------------------------------------------------------- + +class TestIsUrlAllowed: + """Unit tests for the _is_url_allowed allowlist checker.""" + + def test_exact_match(self): + from jupyter_projspec.routes import _is_url_allowed + assert _is_url_allowed("osfs:///tmp/demo", ["osfs:///tmp/demo"]) is True + + def test_trailing_slash_ignored(self): + from jupyter_projspec.routes import _is_url_allowed + assert _is_url_allowed("osfs:///tmp/demo/", ["osfs:///tmp/demo"]) is True + + def test_case_insensitive_scheme(self): + from jupyter_projspec.routes import _is_url_allowed + assert _is_url_allowed("OSFS:///tmp/demo", ["osfs:///tmp/demo"]) is True + + def test_encoded_path_matches(self): + from jupyter_projspec.routes import _is_url_allowed + assert _is_url_allowed("osfs:///tmp/%64emo", ["osfs:///tmp/demo"]) is True + + def test_disallowed_url(self): + from jupyter_projspec.routes import _is_url_allowed + assert _is_url_allowed("osfs:///etc/passwd", ["osfs:///tmp/demo"]) is False + + def test_empty_allowed_list(self): + from jupyter_projspec.routes import _is_url_allowed + assert _is_url_allowed("osfs:///tmp/demo", []) is False + + def test_query_param_injection_blocked(self): + """A URL with injected query params must still match the allowlist entry.""" + from jupyter_projspec.routes import _is_url_allowed + # The injected query param must not cause a false negative + assert _is_url_allowed( + "s3://bucket/path?evil=true", ["s3://bucket/path"] + ) is True + + def test_sibling_prefix_not_allowed(self): + """A URL that is a parent of an allowed path must not pass.""" + from jupyter_projspec.routes import _is_url_allowed + assert _is_url_allowed("osfs:///tmp", ["osfs:///tmp/demo"]) is False + + +# --------------------------------------------------------------------------- +# Unit tests for _pyfs_url_to_fsspec +# --------------------------------------------------------------------------- + +class TestPyfsUrlToFsspec: + """Unit tests for the PyFilesystem2 → fsspec URL translator.""" + + def test_osfs_triple_slash(self): + from jupyter_projspec.routes import _pyfs_url_to_fsspec + assert _pyfs_url_to_fsspec("osfs:///tmp/foo") == "/tmp/foo" + + def test_osfs_url_decoded(self): + """Percent-encoded characters in osfs:// paths must be decoded.""" + from jupyter_projspec.routes import _pyfs_url_to_fsspec + assert _pyfs_url_to_fsspec("osfs:///tmp/My%20Project") == "/tmp/My Project" + + def test_osfs_windows_drive_url_decoded(self): + """Percent-encoded Windows drive paths must be decoded.""" + from jupyter_projspec.routes import _pyfs_url_to_fsspec + assert _pyfs_url_to_fsspec("osfs://C:/My%20Docs") == "C:/My Docs" + + def test_osfs_no_leading_slash_normalised(self): + from jupyter_projspec.routes import _pyfs_url_to_fsspec + # osfs://tmp/foo has 'tmp' as netloc (a hostname), which is not supported + with pytest.raises(ValueError, match="host component"): + _pyfs_url_to_fsspec("osfs://tmp/foo") + + def test_osfs_windows_drive(self): + """osfs://C:/path — Python urlparse puts 'C:' in netloc, path is '/path'.""" + from jupyter_projspec.routes import _pyfs_url_to_fsspec + assert _pyfs_url_to_fsspec("osfs://C:/path") == "C:/path" + + def test_osfs_windows_lowercase_drive(self): + from jupyter_projspec.routes import _pyfs_url_to_fsspec + assert _pyfs_url_to_fsspec("osfs://d:/data") == "d:/data" + + def test_osfs_host_raises(self): + """osfs with a real hostname should raise ValueError.""" + from jupyter_projspec.routes import _pyfs_url_to_fsspec + with pytest.raises(ValueError, match="host component"): + _pyfs_url_to_fsspec("osfs://remotehost/path") + + def test_s3_passthrough(self): + from jupyter_projspec.routes import _pyfs_url_to_fsspec + url = "s3://my-bucket/prefix" + assert _pyfs_url_to_fsspec(url) == url + + def test_https_passthrough(self): + from jupyter_projspec.routes import _pyfs_url_to_fsspec + url = "https://example.com/data" + assert _pyfs_url_to_fsspec(url) == url + + def test_unsupported_scheme_raises(self): + from jupyter_projspec.routes import _pyfs_url_to_fsspec + with pytest.raises(ValueError, match="Unsupported filesystem scheme"): + _pyfs_url_to_fsspec("mem://") + + def test_unsupported_pyfs_scheme_raises(self): + from jupyter_projspec.routes import _pyfs_url_to_fsspec + with pytest.raises(ValueError, match="Unsupported filesystem scheme"): + _pyfs_url_to_fsspec("ftp2://host/path") + + +# --------------------------------------------------------------------------- +# Unit tests for _get_jfs_resource_urls +# --------------------------------------------------------------------------- + +class TestGetJfsResourceUrls: + """Unit tests for the jupyter-fs MetaManager resource URL extractor.""" + + def test_no_resources_attr_returns_none(self): + """Contents manager without _resources or resources → None (no jfs).""" + from jupyter_projspec.routes import _get_jfs_resource_urls + + class PlainCM: + pass + + assert _get_jfs_resource_urls(PlainCM()) is None + + def test_none_resources_attr_returns_none(self): + """resources=None explicitly → None.""" + from jupyter_projspec.routes import _get_jfs_resource_urls + + class CM: + resources = None + + assert _get_jfs_resource_urls(CM()) is None + + def test_dict_style_resources(self): + """Resources as a list of dicts with 'url' keys.""" + from jupyter_projspec.routes import _get_jfs_resource_urls + + class CM: + _resources = [{"url": "s3://bucket"}, {"url": "osfs:///tmp"}] + + assert _get_jfs_resource_urls(CM()) == ["s3://bucket", "osfs:///tmp"] + + def test_object_style_resources(self): + """Resources as objects with a .url attribute.""" + from jupyter_projspec.routes import _get_jfs_resource_urls + + class Res: + def __init__(self, url): + self.url = url + + class CM: + resources = [Res("s3://bucket"), Res("osfs:///tmp")] + + assert _get_jfs_resource_urls(CM()) == ["s3://bucket", "osfs:///tmp"] + + def test_private_attr_takes_precedence(self): + """_resources is checked before the public resources attribute.""" + from jupyter_projspec.routes import _get_jfs_resource_urls + + class CM: + _resources = [{"url": "s3://private"}] + resources = [{"url": "s3://public"}] + + assert _get_jfs_resource_urls(CM()) == ["s3://private"] + + def test_falls_back_to_public_attr(self): + """When _resources is absent, falls back to resources.""" + from jupyter_projspec.routes import _get_jfs_resource_urls + + class CM: + resources = [{"url": "s3://public"}] + + assert _get_jfs_resource_urls(CM()) == ["s3://public"] + + def test_missing_url_field_skipped(self): + """Resources without a 'url' field are silently skipped.""" + from jupyter_projspec.routes import _get_jfs_resource_urls + + class CM: + _resources = [{"url": "s3://ok"}, {"name": "no-url"}, {}] + + assert _get_jfs_resource_urls(CM()) == ["s3://ok"] + + def test_empty_resources_returns_empty_list(self): + """Empty resource list returns [] (not None — jfs is active but unconfigured).""" + from jupyter_projspec.routes import _get_jfs_resource_urls + + class CM: + _resources = [] + + result = _get_jfs_resource_urls(CM()) + assert result == [] + assert result is not None + + +# --------------------------------------------------------------------------- +# Unit tests for _redact_url_credentials +# --------------------------------------------------------------------------- + +class TestRedactUrlCredentials: + """Unit tests for the credential redaction helper.""" + + def test_redacts_password(self): + from jupyter_projspec.routes import _redact_url_credentials + result = _redact_url_credentials("s3://key:secret@bucket/path") + assert "secret" not in result + assert "key" in result + assert "***" in result + + def test_redacts_password_only_url(self): + """ftp://:secret@host — no username, only password.""" + from jupyter_projspec.routes import _redact_url_credentials + result = _redact_url_credentials("ftp://:secret@host/path") + assert "secret" not in result + assert "***" in result + + def test_redacts_password_containing_colon(self): + """s3://user:p:ass@host — password itself contains ':'.""" + from jupyter_projspec.routes import _redact_url_credentials + result = _redact_url_credentials("s3://user:p:ass@host/bucket") + assert "p:ass" not in result + assert "user" in result + assert "***" in result + + def test_no_credentials_unchanged(self): + from jupyter_projspec.routes import _redact_url_credentials + url = "s3://bucket/path" + assert _redact_url_credentials(url) == url + + def test_osfs_no_credentials_unchanged(self): + from jupyter_projspec.routes import _redact_url_credentials + url = "osfs:///tmp/demo" + assert _redact_url_credentials(url) == url + + +# --------------------------------------------------------------------------- +# ScanUrlRouteHandler integration tests +# --------------------------------------------------------------------------- + +def _make_mock_contents_manager(resource_urls): + """Return a mock contents_manager with jupyter-fs resources at given URLs.""" + class FakeResource: + def __init__(self, url): + self.url = url + + cm = MagicMock() + cm._resources = [FakeResource(u) for u in resource_urls] + return cm + + +class TestScanUrlValidation: + """Integration tests for ScanUrlRouteHandler input validation.""" + + async def test_missing_body_returns_400(self, jp_fetch): + """POST with empty body should return 400.""" + with pytest.raises(Exception) as exc_info: + await jp_fetch( + "jupyter-projspec", "scan-url", + method="POST", + body=b"", + headers={"Content-Type": "application/json"}, + ) + assert exc_info.value.response.code == 400 + + async def test_non_object_body_returns_400(self, jp_fetch): + """POST with a JSON array instead of object should return 400.""" + with pytest.raises(Exception) as exc_info: + await jp_fetch( + "jupyter-projspec", "scan-url", + method="POST", + body=json.dumps([1, 2, 3]).encode(), + ) + assert exc_info.value.response.code == 400 + + async def test_missing_url_returns_400(self, jp_fetch): + """POST without 'url' field should return 400.""" + with pytest.raises(Exception) as exc_info: + await jp_fetch( + "jupyter-projspec", "scan-url", + method="POST", + body=json.dumps({"subpath": "sub"}).encode(), + ) + assert exc_info.value.response.code == 400 + payload = json.loads(exc_info.value.response.body) + assert "url" in payload["error"] + + async def test_non_string_url_returns_400(self, jp_fetch): + """POST with a numeric 'url' should return 400 (not 500).""" + with pytest.raises(Exception) as exc_info: + await jp_fetch( + "jupyter-projspec", "scan-url", + method="POST", + body=json.dumps({"url": 42}).encode(), + ) + assert exc_info.value.response.code == 400 + + async def test_null_subpath_returns_400(self, jp_fetch): + """POST with subpath: null should be treated as empty (not crash).""" + # With null subpath the URL itself will fail allowlist (no jfs resources + # configured), so we expect 404 (no MetaManager) rather than 500. + with pytest.raises(Exception) as exc_info: + await jp_fetch( + "jupyter-projspec", "scan-url", + method="POST", + body=json.dumps({"url": "osfs:///tmp", "subpath": None}).encode(), + ) + # Must not be 500 — null subpath must not crash the handler + assert exc_info.value.response.code != 500 + + async def test_non_string_subpath_returns_400(self, jp_fetch): + """POST with subpath as a dict should return 400.""" + with pytest.raises(Exception) as exc_info: + await jp_fetch( + "jupyter-projspec", "scan-url", + method="POST", + body=json.dumps({"url": "osfs:///tmp", "subpath": {}}).encode(), + ) + assert exc_info.value.response.code == 400 + + async def test_no_jfs_returns_404(self, jp_fetch): + """When jupyter-fs MetaManager is not present, expect 404.""" + with pytest.raises(Exception) as exc_info: + await jp_fetch( + "jupyter-projspec", "scan-url", + method="POST", + body=json.dumps({"url": "osfs:///tmp"}).encode(), + ) + assert exc_info.value.response.code == 404 + payload = json.loads(exc_info.value.response.body) + assert "MetaManager" in payload["error"] + + @patch("jupyter_projspec.routes._get_jfs_resource_urls", return_value=[]) + async def test_empty_resources_returns_422(self, _mock_jfs, jp_fetch): + """MetaManager active but zero resources configured must return 422 + with a descriptive message, not a generic 403.""" + with pytest.raises(Exception) as exc_info: + await jp_fetch( + "jupyter-projspec", "scan-url", + method="POST", + body=json.dumps({"url": "osfs:///anything"}).encode(), + ) + assert exc_info.value.response.code == 422 + payload = json.loads(exc_info.value.response.body) + assert "No jupyter-fs resources" in payload["error"] + + @patch("jupyter_projspec.routes._get_jfs_resource_urls", + return_value=["osfs:///tmp"]) + @patch("jupyter_projspec.routes._scan_url") + async def test_successful_scan(self, mock_scan, _mock_jfs, jp_fetch): + """A valid URL matching the allowlist must return 200 with project data.""" + mock_scan.return_value = {"name": "demo", "specs": {}} + response = await jp_fetch( + "jupyter-projspec", "scan-url", + method="POST", + body=json.dumps({"url": "osfs:///tmp"}).encode(), + ) + assert response.code == 200 + payload = json.loads(response.body) + assert payload["project"] == {"name": "demo", "specs": {}} + # Confirm the scan was called with the fsspec URL (decoded osfs path) + mock_scan.assert_called_once_with("/tmp") + + @patch("jupyter_projspec.routes._get_jfs_resource_urls", + return_value=["osfs:///allowed"]) + async def test_disallowed_url_returns_403(self, _mock_jfs, jp_fetch): + """A URL not matching any configured resource must return 403.""" + with pytest.raises(Exception) as exc_info: + await jp_fetch( + "jupyter-projspec", "scan-url", + method="POST", + body=json.dumps({"url": "osfs:///not-allowed"}).encode(), + ) + assert exc_info.value.response.code == 403 + payload = json.loads(exc_info.value.response.body) + assert "does not match" in payload["error"] + + @patch("jupyter_projspec.routes._get_jfs_resource_urls", + return_value=["osfs:///allowed"]) + async def test_subpath_traversal_returns_400(self, _mock_jfs, jp_fetch): + """A subpath containing .. traversal should return 400.""" + with pytest.raises(Exception) as exc_info: + await jp_fetch( + "jupyter-projspec", "scan-url", + method="POST", + body=json.dumps({ + "url": "osfs:///allowed", + "subpath": "../../etc", + }).encode(), + ) + assert exc_info.value.response.code == 400 + payload = json.loads(exc_info.value.response.body) + assert "traversal" in payload["error"] + + @patch("jupyter_projspec.routes._get_jfs_resource_urls", + return_value=["osfs:///allowed"]) + async def test_double_encoded_traversal_blocked(self, _mock_jfs, jp_fetch): + """Double-encoded traversal (%252E%252E) must be caught. + + Without the fix (unquote before check), %252E%252E would decode to + %2E%2E on the first pass — bypassing the '../' check — then to '..' + on the second decode in _pyfs_url_to_fsspec. With the fix, no unquote + is applied before the check, so the raw %252E%252E string is normalised + to itself and rejected because normpath of a relative path containing + %2E-style segments does NOT produce '..'. + """ + with pytest.raises(Exception) as exc_info: + await jp_fetch( + "jupyter-projspec", "scan-url", + method="POST", + body=json.dumps({ + "url": "osfs:///allowed", + "subpath": "%252E%252E/%252E%252E/etc/passwd", + }).encode(), + ) + # Must not be 500 (no crash) and must not reach the scan step + # (would be caught earlier as a non-existent path or similar error). + # The critical assertion: a double-encoded payload must never produce + # a successful 200 response by escaping the configured resource root. + assert exc_info.value.response.code != 200 + + @patch("jupyter_projspec.routes._get_jfs_resource_urls", + return_value=["osfs:///allowed"]) + async def test_single_encoded_traversal_blocked(self, _mock_jfs, jp_fetch): + """Single-encoded dot segments (%2e%2e) must be caught. + + The raw traversal check does not decode percent sequences, so + '%2e%2e' looks like an innocuous literal. However _pyfs_url_to_fsspec + calls urllib.parse.unquote() on the path for osfs:// resources, turning + '%2e%2e/%2e%2e' into '../../' — a traversal — after the validation + step. The fix adds a second check on the once-decoded form of the + subpath so both raw '../' and encoded '%2e%2e' are caught. + """ + for payload_subpath in [ + "%2e%2e/%2e%2e/etc/passwd", # lowercase single-encoded + "%2E%2E/%2E%2E/etc/passwd", # uppercase single-encoded + "sub/%2e%2e/%2e%2e/etc/passwd", # encoded segments after a real dir + ]: + with pytest.raises(Exception) as exc_info: + await jp_fetch( + "jupyter-projspec", "scan-url", + method="POST", + body=json.dumps({ + "url": "osfs:///allowed", + "subpath": payload_subpath, + }).encode(), + ) + assert exc_info.value.response.code == 400, ( + f"Expected 400 for single-encoded traversal '{payload_subpath}', " + f"got {exc_info.value.response.code}" + ) + body = json.loads(exc_info.value.response.body) + assert "traversal" in body["error"] + + @patch("jupyter_projspec.routes._get_jfs_resource_urls", + return_value=["osfs:///allowed"]) + async def test_legitimate_percent_in_foldername_allowed( + self, _mock_jfs, jp_fetch + ): + """A folder name that literally contains '%' (e.g. 'foo%bar') must not + be mistakenly rejected as traversal. + + 'foo%bar' decoded is still 'foo%bar' (incomplete percent sequence, no + further decoding), so neither the raw nor the decoded traversal check + triggers. The request may fail because the directory doesn't exist, + but it must NOT be rejected as traversal (400 with 'traversal' text). + """ + try: + await jp_fetch( + "jupyter-projspec", "scan-url", + method="POST", + body=json.dumps({ + "url": "osfs:///allowed", + "subpath": "foo%25bar", # literal name with '%' (encoded as %25) + }).encode(), + ) + except Exception as exc: + if hasattr(exc, "response") and exc.response.code == 400: + body = json.loads(exc.response.body) + assert "traversal" not in body.get("error", ""), ( + "Folder name containing literal '%' must not be rejected as traversal" + ) + + @patch("jupyter_projspec.routes._get_jfs_resource_urls", + return_value=["osfs:///allowed"]) + async def test_absolute_subpath_is_safe(self, _mock_jfs, jp_fetch): + """A subpath with a leading slash is stripped before normalisation, + so '/sub/dir' becomes 'sub/dir' — a safe relative path, not a + traversal. It must NOT be rejected with a 400 traversal error.""" + try: + await jp_fetch( + "jupyter-projspec", "scan-url", + method="POST", + body=json.dumps({ + "url": "osfs:///allowed", + "subpath": "/sub/dir", + }).encode(), + ) + except Exception as exc_info: + # May fail (osfs:///allowed/sub/dir doesn't exist in CI), but + # must not be rejected as a traversal attempt. + if hasattr(exc_info, "response"): + code = exc_info.response.code + if code == 400: + payload = json.loads(exc_info.response.body) + assert "traversal" not in payload.get("error", ""), ( + "Leading-slash subpath must not be rejected as traversal" + ) + + @patch("jupyter_projspec.routes._get_jfs_resource_urls", + return_value=["osfs:///allowed"]) + async def test_null_byte_in_subpath_returns_400(self, _mock_jfs, jp_fetch): + """A subpath containing a null byte should return 400.""" + with pytest.raises(Exception) as exc_info: + await jp_fetch( + "jupyter-projspec", "scan-url", + method="POST", + body=json.dumps({ + "url": "osfs:///allowed", + "subpath": "sub\x00path", + }).encode(), + ) + assert exc_info.value.response.code == 400 + payload = json.loads(exc_info.value.response.body) + assert "null bytes" in payload["error"] + + @patch("jupyter_projspec.routes._get_jfs_resource_urls", + return_value=["s3://bucket/prefix"]) + async def test_query_param_injection_blocked(self, _mock_jfs, jp_fetch): + """A URL with injected query params matching an allowed URL must still pass + the allowlist (query params stripped) and must NOT forward those params.""" + # The handler finds the match and uses the clean server URL, so it won't + # 403. It will proceed to scan and fail (s3 needs real credentials), + # but the important assertion is no 403 and no 500 crash. + with pytest.raises(Exception) as exc_info: + await jp_fetch( + "jupyter-projspec", "scan-url", + method="POST", + body=json.dumps({ + "url": "s3://bucket/prefix?evil=creds", + }).encode(), + ) + code = exc_info.value.response.code + assert code != 403, "Should not 403 — URL matches the allowlist" + # 500 is acceptable here: the handler correctly passed the allowlist + # and attempted a real S3 scan (no credentials in test env), confirming + # injected query params were discarded and the clean URL was used. diff --git a/package.json b/package.json index dfbdf28..675db7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jupyter-projspec", - "version": "0.2.0", + "version": "0.3.0", "description": "A Jupyter interface for projspec", "keywords": [ "jupyter", diff --git a/src/api.ts b/src/api.ts index bddaa4a..24bcff4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,4 +1,5 @@ import { ServerConnection } from '@jupyterlab/services'; +import { URLExt } from '@jupyterlab/coreutils'; import { requestAPI } from './request'; /** @@ -65,3 +66,75 @@ export async function make(request: IMakeRequest): Promise { throw new Error(`Make request failed: ${msg}`); } } + +/** + * A single jupyter-fs resource entry returned by `GET /jupyterfs/resources`. + */ +export interface IJfsResource { + url: string; + drive: string; + name: string; + [key: string]: unknown; +} + +/** + * Compute a sidebar widget ID using the same formula as jupyter-fs's + * `idFromResource`: `_`. + */ +function jfsSidebarId(resource: IJfsResource): string { + return resource.name.split(' ').join('') + '_' + resource.drive; +} + +/** + * Fetch the list of configured jupyter-fs resources from the server. + * + * @returns A map of sidebar widget ID to resource URL, or null if + * jupyter-fs is not available. The sidebar ID is computed using the + * same formula as jupyter-fs (`_`). + */ +export async function fetchJfsResources(): Promise | null> { + const settings = ServerConnection.makeSettings(); + const requestUrl = URLExt.join(settings.baseUrl, 'jupyterfs', 'resources'); + + let response: Response; + try { + response = await ServerConnection.makeRequest(requestUrl, {}, settings); + } catch (err) { + console.error('jupyter-projspec: network error fetching jupyter-fs resources:', err); + return null; + } + + if (!response.ok) { + if (response.status !== 404) { + console.warn( + `jupyter-projspec: jupyter-fs /resources returned ${response.status}. ` + + 'jupyter-fs may be misconfigured.' + ); + } + return null; + } + + let resources: IJfsResource[]; + try { + resources = (await response.json()) as IJfsResource[]; + } catch (err) { + console.error('jupyter-projspec: failed to parse jupyter-fs /resources response:', err); + return null; + } + + if (!Array.isArray(resources)) { + console.error( + 'jupyter-projspec: expected array from jupyter-fs /resources, got:', + typeof resources + ); + return null; + } + + const idToUrl = new Map(); + for (const resource of resources) { + if (resource.drive && resource.url && resource.name) { + idToUrl.set(jfsSidebarId(resource), resource.url); + } + } + return idToUrl; +} diff --git a/src/components/ArtifactsView.tsx b/src/components/ArtifactsView.tsx index 1e5f897..2353230 100644 --- a/src/components/ArtifactsView.tsx +++ b/src/components/ArtifactsView.tsx @@ -12,8 +12,8 @@ const MAX_OUTPUT_LENGTH = 10000; */ interface IArtifactsViewProps { artifacts: Record; - /** Relative path from server root for the project. */ - path: string; + /** Relative path from server root, or null if make is unavailable (e.g. remote filesystem). */ + path: string | null; /** The projspec spec type (e.g., "python_library"). */ specType: string; } @@ -61,14 +61,16 @@ function truncateOutput(text: string): string { /** * Shared hook for running a make request and tracking state. + * When path is null, make is unavailable (remote filesystem) and handleMake is a no-op. */ function useMakeArtifact( - path: string, + path: string | null, specType: string, artifactName: string ): { isRunning: boolean; result: string | null; + canMake: boolean; handleMake: () => Promise; } { const [isRunning, setIsRunning] = useState(false); @@ -84,6 +86,9 @@ function useMakeArtifact( }, []); const handleMake = async () => { + if (path === null) { + return; + } // Use a ref for synchronous double-click prevention. // React state updates are async, so checking `isRunning` alone // cannot prevent rapid duplicate invocations. @@ -130,7 +135,7 @@ function useMakeArtifact( } }; - return { isRunning, result, handleMake }; + return { isRunning, result, canMake: path !== null, handleMake }; } /** @@ -191,8 +196,8 @@ function MakeButton({ interface IStringArtifactItemProps { name: string; artifact: string; - /** Relative path from server root for the project. */ - path: string; + /** Relative path from server root, or null if make is unavailable. */ + path: string | null; /** The projspec spec type (e.g., "python_library"). */ specType: string; } @@ -208,7 +213,7 @@ function StringArtifactItem({ specType }: IStringArtifactItemProps): React.ReactElement { const { cmd, status } = parseCompactArtifact(artifact); - const { isRunning, result, handleMake } = useMakeArtifact( + const { isRunning, result, canMake, handleMake } = useMakeArtifact( path, specType, name @@ -226,11 +231,13 @@ function StringArtifactItem({ {status} )} - + {canMake && ( + + )} 0); - const { isRunning, result, handleMake } = useMakeArtifact( + const { isRunning, result, canMake, handleMake } = useMakeArtifact( path, specType, name @@ -297,7 +304,7 @@ function ObjectArtifactItem({ {artifact.status} )} - {hasCmd && ( + {canMake && hasCmd && ( void; /** Callback when visibility should change (has specs vs no specs). */ onVisibilityChange?: (visible: boolean) => void; + /** + * Optional fsspec URL for jupyter-fs resources. + * When provided, fetches from `/scan-url` (POST) instead of `/scan?path=` (GET). + */ + scanUrl?: string; } /** @@ -27,7 +37,8 @@ interface IProjspecChipsProps { export function ProjspecChips({ path, onChipClick, - onVisibilityChange + onVisibilityChange, + scanUrl }: IProjspecChipsProps): React.ReactElement { const [specs, setSpecs] = useState([]); const [error, setError] = useState(false); @@ -35,42 +46,46 @@ export function ProjspecChips({ const debounceTimerRef = useRef | null>(null); const prevVisibleRef = useRef(null); - const fetchSpecs = useCallback(async (scanPath: string) => { - // Cancel any in-flight request - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } + const fetchSpecs = useCallback( + async (scanPath: string) => { + // Cancel any in-flight request + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const controller = new AbortController(); + abortControllerRef.current = controller; + + setError(false); - const controller = new AbortController(); - abortControllerRef.current = controller; + try { + const source: ScanSource = scanUrl + ? { type: 'jfs', url: scanUrl, subpath: scanPath } + : { type: 'local', path: scanPath }; - setError(false); + const response = await requestAPI( + buildScanEndpoint(source), + buildScanInit(source, { signal: controller.signal }) + ); - try { - const response = await requestAPI( - `scan?path=${encodeURIComponent(scanPath)}`, - { - method: 'GET', - signal: controller.signal + if (response?.project?.specs) { + setSpecs(Object.keys(response.project.specs)); + } else { + setSpecs([]); + } + } catch (err: unknown) { + // Ignore abort errors + if (err instanceof Error && err.name === 'AbortError') { + return; } - ); - if (response?.project?.specs) { - setSpecs(Object.keys(response.project.specs)); - } else { + console.warn('Projspec chips: failed to fetch specs', err); setSpecs([]); + setError(true); } - } catch (err: unknown) { - // Ignore abort errors - if (err instanceof Error && err.name === 'AbortError') { - return; - } - - console.warn('Projspec chips: failed to fetch specs', err); - setSpecs([]); - setError(true); - } - }, []); + }, + [scanUrl] + ); useEffect(() => { // Clear specs immediately on path change to avoid showing stale data diff --git a/src/components/ProjspecPanelComponent.tsx b/src/components/ProjspecPanelComponent.tsx index 4c4463c..6078f10 100644 --- a/src/components/ProjspecPanelComponent.tsx +++ b/src/components/ProjspecPanelComponent.tsx @@ -1,43 +1,30 @@ import React, { useEffect, useRef, useCallback } from 'react'; -import { IProject, IScanResponse } from '../types'; +import { + IProject, + IScanResponse, + ScanSource, + formatScanSource, + buildScanEndpoint, + buildScanInit, + scanSourceKey +} from '../types'; import { ProjectView } from './ProjectView'; import { requestAPI } from '../request'; -/** - * Debounce delay in milliseconds. - */ const DEBOUNCE_DELAY = 300; -/** - * State for the panel component. - */ interface IPanelState { loading: boolean; error: string | null; project: IProject | null; } -/** - * Props for the ProjspecPanelComponent. - */ interface IProjspecPanelComponentProps { - path: string; - /** Spec name to expand (e.g., 'python_library'). */ + scanSource: ScanSource | null; expandedSpecName?: string | null; - /** Unique ID that changes on each expand request, ensures expansion always triggers. */ expandRequestId?: number; } -/** - * Format the display path. - */ -function formatPath(path: string): string { - return path === '' || path === '/' ? '/ (root)' : path; -} - -/** - * Loading spinner component. - */ function LoadingSpinner(): React.ReactElement { return (
@@ -47,9 +34,6 @@ function LoadingSpinner(): React.ReactElement { ); } -/** - * Error display component. - */ function ErrorDisplay({ message }: { message: string }): React.ReactElement { return (
@@ -60,46 +44,50 @@ function ErrorDisplay({ message }: { message: string }): React.ReactElement { } /** - * Main panel component that renders projspec data using React. + * Make commands only work with local paths; for jfs sources we return + * null so the make buttons are hidden in the UI. + */ +function pathForMake(source: ScanSource): string | null { + return source.type === 'local' ? source.path : null; +} + +/** + * Main panel component that renders projspec data. + * Supports local paths and jupyter-fs URLs via the ScanSource prop. + * When scanSource is null the panel shows an empty state without issuing a scan. */ export function ProjspecPanelComponent({ - path, + scanSource, expandedSpecName, expandRequestId }: IProjspecPanelComponentProps): React.ReactElement { const [state, setState] = React.useState({ - loading: true, + loading: false, error: null, project: null }); - // Keep track of the current path for comparison - const currentPathRef = useRef(path); + const currentSourceKeyRef = useRef(null); const abortControllerRef = useRef(null); const debounceTimerRef = useRef | null>(null); - // Scan directory function - const scanDirectory = useCallback(async (scanPath: string) => { - // Cancel any in-flight request + const scanDirectory = useCallback(async (source: ScanSource) => { if (abortControllerRef.current) { abortControllerRef.current.abort(); } - // Create a new abort controller for this request const controller = new AbortController(); abortControllerRef.current = controller; + const key = scanSourceKey(source); + try { const response = await requestAPI( - `scan?path=${encodeURIComponent(scanPath)}`, - { - method: 'GET', - signal: controller.signal - } + buildScanEndpoint(source), + buildScanInit(source, { signal: controller.signal }) ); - // Check if this is still the current path - if (scanPath !== currentPathRef.current) { + if (key !== currentSourceKeyRef.current) { return; } @@ -129,22 +117,18 @@ export function ProjspecPanelComponent({ }); } } catch (err: unknown) { - // Check if this is still the current path - if (scanPath !== currentPathRef.current) { + if (key !== currentSourceKeyRef.current) { return; } - // Ignore abort errors if (err instanceof Error && err.name === 'AbortError') { return; } - // Extract error message let message = 'Unknown error occurred'; if (err instanceof Error) { message = err.message; - // Try to parse error details from the response const match = message.match(/API request failed \(\d+\): (.+)/); if (match) { try { @@ -166,24 +150,25 @@ export function ProjspecPanelComponent({ } }, []); - // Effect to handle path changes with debouncing + const sourceKey = scanSourceKey(scanSource); + useEffect(() => { - currentPathRef.current = path; + if (scanSource === null) { + return; + } - // Show loading state immediately + currentSourceKeyRef.current = sourceKey; setState(prev => ({ ...prev, loading: true })); - // Clear any existing debounce timer if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } - // Debounce the scan request + const source = scanSource; debounceTimerRef.current = setTimeout(() => { - scanDirectory(path); + scanDirectory(source); }, DEBOUNCE_DELAY); - // Cleanup on unmount or path change return () => { if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); @@ -192,12 +177,23 @@ export function ProjspecPanelComponent({ abortControllerRef.current.abort(); } }; - }, [path, scanDirectory]); + }, [sourceKey, scanDirectory]); + + if (scanSource === null) { + return ( +
+
Project Spec
+
+ Open a file browser to see project specs. +
+
+ ); + } return (
Project Spec
-
{formatPath(path)}
+
{formatScanSource(scanSource)}
{state.loading && } @@ -206,7 +202,7 @@ export function ProjspecPanelComponent({ {!state.loading && !state.error && state.project && ( diff --git a/src/components/SpecItem.tsx b/src/components/SpecItem.tsx index 66bfcf1..30ceaed 100644 --- a/src/components/SpecItem.tsx +++ b/src/components/SpecItem.tsx @@ -10,8 +10,8 @@ import { getSpecInfo, getTextColorForBackground } from '../specInfo'; interface ISpecItemProps { name: string; spec: ISpec; - /** Relative path from server root for the project. */ - path: string; + /** Relative path from server root for the project, or null if make is unavailable. */ + path: string | null; defaultExpanded?: boolean; /** When true, forces this spec to expand (triggered by chip click). */ forceExpanded?: boolean; diff --git a/src/index.ts b/src/index.ts index e2c1f26..7d3c2ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { + ILabShell, ILayoutRestorer, JupyterFrontEnd, JupyterFrontEndPlugin @@ -6,95 +7,134 @@ import { import { IDefaultFileBrowser } from '@jupyterlab/filebrowser'; import { PanelLayout, Widget } from '@lumino/widgets'; +import { fetchJfsResources } from './api'; import { projspecIcon } from './icon'; +import { IProjspecPanelProvider } from './tokens'; import { ProjspecPanel } from './widgets/ProjspecPanel'; import { ProjspecChipsWidget } from './widgets/ProjspecChipsWidget'; +import { + JfsChipsWidget, + JFS_CHIPS_CONTAINER_CLASS, + readBreadcrumbPath +} from './widgets/JfsChipsWidget'; -/** - * The plugin ID for jupyter-projspec. - */ const PLUGIN_ID = 'jupyter-projspec:plugin'; - -/** - * The ID for the projspec panel widget. - */ const PANEL_ID = 'projspec-panel'; - -/** - * CSS ID for the chips container element, used for idempotency. - */ const CHIPS_CONTAINER_ID = 'jp-projspec-chips-container'; /** - * Initialization data for the jupyter-projspec extension. + * Main plugin: projspec panel in the right sidebar + chips in the file browser. + * + * Provides `IProjspecPanelProvider` so the jupyter-fs plugin can depend on it + * with a guaranteed activation order. */ -const plugin: JupyterFrontEndPlugin = { +const plugin: JupyterFrontEndPlugin = { id: PLUGIN_ID, description: 'A Jupyter interface for projspec', autoStart: true, - requires: [IDefaultFileBrowser], + provides: IProjspecPanelProvider, + requires: [IDefaultFileBrowser, ILabShell], optional: [ILayoutRestorer], activate: ( app: JupyterFrontEnd, fileBrowser: IDefaultFileBrowser, + labShell: ILabShell, restorer: ILayoutRestorer | null - ) => { - // Create the projspec panel widget for the right sidebar + ): IProjspecPanelProvider => { const panel = new ProjspecPanel(); panel.id = PANEL_ID; panel.title.icon = projspecIcon; - // Function to update the panel with the current path - const updatePath = () => { - const path = fileBrowser.model.path; - panel.updatePath(path); - }; + const sidebarIdToUrl = new Map(); - // Set initial path - updatePath(); + function getActiveLeftWidgetId(): string | null { + for (const widget of labShell.widgets('left')) { + if (widget.isVisible) { + return widget.id; + } + } + return null; + } - // Subscribe to path changes in the file browser - fileBrowser.model.pathChanged.connect(updatePath); + fileBrowser.model.pathChanged.connect(() => { + const currentId = getActiveLeftWidgetId(); + if (currentId === null || currentId === fileBrowser.id) { + panel.updateSource({ + type: 'local', + path: fileBrowser.model.path + }); + } + }); - // Add the panel to the right sidebar app.shell.add(panel, 'right', { rank: 1000 }); - // Restore the widget state if a restorer is available if (restorer) { restorer.add(panel, PANEL_ID); } - // Create chips widget for the file browser - // Clicking a chip opens/focuses the sidebar panel and expands the spec const chipsWidget = new ProjspecChipsWidget(fileBrowser, specName => { - // Expand the clicked spec in the panel panel.expandSpec(specName); - // Open/focus the sidebar panel app.shell.activateById(PANEL_ID); }); - // Clean up the container div when the chips widget is disposed chipsWidget.disposed.connect(() => { - const existing = document.getElementById(CHIPS_CONTAINER_ID); - if (existing) { - existing.remove(); - } + document.getElementById(CHIPS_CONTAINER_ID)?.remove(); }); - // Defer DOM injection until after the app is fully restored - // This ensures the breadcrumbs element exists in the DOM - // Note: depends on JupyterLab internal class .jp-BreadCrumbs + // --- Sidebar-aware source switching -------------------------------- + // + // Tracks which left sidebar tab is active and points the panel at the + // matching source (local file browser or a jupyter-fs resource). + // + // `force` bypasses the dedup guard — needed when the jfs plugin + // populates sidebarIdToUrl *after* layoutModified already saw the tab + // but couldn't resolve its URL. + + let lastLeftWidgetId: string | null = null; + + const doSyncPanelToActiveTab = (force = false) => { + const currentId = getActiveLeftWidgetId(); + + if (!force && currentId === lastLeftWidgetId) { + return; + } + lastLeftWidgetId = currentId; + + if (!currentId) { + panel.updateSource({ type: 'local', path: fileBrowser.model.path }); + return; + } + + if (currentId === fileBrowser.id) { + panel.updateSource({ type: 'local', path: fileBrowser.model.path }); + return; + } + + const url = sidebarIdToUrl.get(currentId); + if (url) { + const sidebarEl = document.getElementById(currentId); + const subpath = sidebarEl ? readBreadcrumbPath(sidebarEl) : ''; + panel.updateSource({ type: 'jfs', url, subpath }); + } + // For unrecognised sidebars (TOC, git, extensions, etc.) we + // intentionally keep the panel showing the last file browser's + // specs — there is nothing to scan for those tabs. + }; + + labShell.layoutModified.connect(() => doSyncPanelToActiveTab()); + + // --- Post-restore initialisation ----------------------------------- + void app.restored.then(() => { - // Idempotency: skip if already attached (e.g., hot-reload) + doSyncPanelToActiveTab(true); + if (chipsWidget.isAttached) { return; } - // Find the breadcrumbs element inside the file browser const breadcrumbs = fileBrowser.node.querySelector('.jp-BreadCrumbs'); if (breadcrumbs && breadcrumbs.parentNode) { - // Reuse existing container or create a new one let container = document.getElementById(CHIPS_CONTAINER_ID); if (!container) { container = document.createElement('div'); @@ -104,18 +144,143 @@ const plugin: JupyterFrontEndPlugin = { breadcrumbs.nextSibling ); } - // Use Lumino's Widget.attach for proper lifecycle management Widget.attach(chipsWidget, container); } else { - // Fallback: insert at position 1 in the layout. - // This path is reached if JupyterLab's internal DOM structure changes - // and .jp-BreadCrumbs is no longer present. This is expected in some - // JupyterLab versions/configurations, so no warning is logged. const layout = fileBrowser.layout as PanelLayout; layout.insertWidget(1, chipsWidget); } }); + + return { + panel, + sidebarIdToUrl, + syncPanelToActiveTab: doSyncPanelToActiveTab + }; + } +}; + +/** + * Jupyter-fs integration plugin. + * + * Detects jupyter-fs sidebars via DOM (`.jp-tree-finder-sidebar`), maps each + * sidebar's widget ID to its resource URL, and injects projspec chips. + * Clicking a chip updates the shared panel to show that resource's specs. + * + * Depends on `IProjspecPanelProvider` to guarantee activation order and + * eliminate module-level mutable state. + */ +const jupyterFsPlugin: JupyterFrontEndPlugin = { + id: 'jupyter-projspec:jupyter-fs', + description: 'Projspec chips for jupyter-fs sidebars', + autoStart: true, + requires: [IProjspecPanelProvider], + activate: ( + app: JupyterFrontEnd, + provider: IProjspecPanelProvider + ) => { + const { panel, sidebarIdToUrl, syncPanelToActiveTab } = provider; + const injected = new Set(); + + function injectChips( + sidebar: Element, + idToUrl: Map + ): void { + const sidebarId = sidebar.id; + if (!sidebarId || injected.has(sidebarId)) { + return; + } + + const resourceUrl = idToUrl.get(sidebarId); + if (!resourceUrl) { + return; + } + + const toolbar = sidebar.querySelector('.jp-tree-finder-toolbar'); + if (!toolbar) { + return; + } + + injected.add(sidebarId); + sidebarIdToUrl.set(sidebarId, resourceUrl); + + const container = document.createElement('div'); + container.classList.add(JFS_CHIPS_CONTAINER_CLASS); + toolbar.insertAdjacentElement('afterend', container); + + const chipsWidget = new JfsChipsWidget( + resourceUrl, + sidebar, + (specName: string, subpath: string) => { + panel.updateSource({ + type: 'jfs', + url: resourceUrl, + subpath + }); + panel.expandSpec(specName); + app.shell.activateById(PANEL_ID); + }, + (subpath: string) => { + const current = panel.scanSource; + if (current?.type === 'jfs' && current.url === resourceUrl) { + panel.updateSource({ + type: 'jfs', + url: resourceUrl, + subpath + }); + } + } + ); + + Widget.attach(chipsWidget, container); + } + + void app.restored.then(async () => { + const idToUrl = await fetchJfsResources(); + if (!idToUrl || idToUrl.size === 0) { + return; + } + + document + .querySelectorAll('.jp-tree-finder-sidebar') + .forEach(el => injectChips(el, idToUrl)); + + syncPanelToActiveTab(true); + + const target = document.getElementById('jp-left-stack'); + if (!target) { + return; + } + + const observer = new MutationObserver(() => { + const prevSize = sidebarIdToUrl.size; + document + .querySelectorAll('.jp-tree-finder-sidebar') + .forEach(el => injectChips(el, idToUrl)); + if (sidebarIdToUrl.size > prevSize) { + syncPanelToActiveTab(true); + } + if (sidebarIdToUrl.size >= idToUrl.size) { + observer.disconnect(); + clearTimeout(observerTimeout); + } + }); + observer.observe(target, { childList: true, subtree: true }); + + const observerTimeout = setTimeout(() => { + observer.disconnect(); + if (sidebarIdToUrl.size < idToUrl.size) { + const unmatched = [...idToUrl.keys()].filter( + id => !sidebarIdToUrl.has(id) + ); + console.warn( + `jupyter-projspec: ${unmatched.length} jupyter-fs sidebar(s) not found in DOM. ` + + `Expected IDs: ${unmatched.join(', ')}. ` + + 'The sidebar ID formula may have changed in jupyter-fs.' + ); + } + }, 30_000); + }); } }; -export default plugin; +export default [plugin, jupyterFsPlugin]; diff --git a/src/tokens.ts b/src/tokens.ts new file mode 100644 index 0000000..aa3e0c3 --- /dev/null +++ b/src/tokens.ts @@ -0,0 +1,17 @@ +import { Token } from '@lumino/coreutils'; +import { ProjspecPanel } from './widgets/ProjspecPanel'; + +/** + * Shared state provided by the main plugin and consumed by the jupyter-fs + * integration plugin. Using a JupyterLab Token guarantees activation order + * and makes the cross-plugin dependency explicit and type-safe. + */ +export interface IProjspecPanelProvider { + panel: ProjspecPanel; + sidebarIdToUrl: Map; + syncPanelToActiveTab: (force?: boolean) => void; +} + +export const IProjspecPanelProvider = new Token( + 'jupyter-projspec:IProjspecPanelProvider' +); diff --git a/src/types.ts b/src/types.ts index 5e05881..0d3ac80 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,3 +72,114 @@ export interface IScanResponse { project?: IProject; error?: string; } + +/** + * Scan source for a local directory (default file browser). + */ +export interface ILocalScanSource { + type: 'local'; + path: string; +} + +/** + * Scan source for a jupyter-fs resource (fsspec URL). + */ +export interface IJfsScanSource { + type: 'jfs'; + url: string; + subpath: string; +} + +/** + * Discriminated union: local file browser path or jupyter-fs resource URL. + */ +export type ScanSource = ILocalScanSource | IJfsScanSource; + +/** + * Value-compare two scan sources, treating null as equal only to itself. + */ +export function scanSourcesEqual( + a: ScanSource | null, + b: ScanSource | null +): boolean { + if (a === null || b === null) { + return a === b; + } + if (a.type !== b.type) { + return false; + } + if (a.type === 'local' && b.type === 'local') { + return a.path === b.path; + } + if (a.type === 'jfs' && b.type === 'jfs') { + return a.url === b.url && a.subpath === b.subpath; + } + // Exhaustiveness check: TypeScript will error here at compile time if a new + // ScanSource variant is added without updating this function. + throw new Error(`Unhandled ScanSource type: ${(a as { type: string }).type}`); +} + +/** + * Human-readable label for a scan source (empty string for null). + */ +export function formatScanSource(source: ScanSource | null): string { + if (source === null) { + return ''; + } + if (source.type === 'local') { + return source.path === '' || source.path === '/' ? '/ (root)' : source.path; + } + const base = source.url; + if (!source.subpath) { + return base; + } + return `${base.replace(/\/+$/, '')}/${source.subpath}`; +} + +/** + * Build the REST endpoint path for a scan request. + */ +export function buildScanEndpoint(source: ScanSource): string { + if (source.type === 'local') { + return `scan?path=${encodeURIComponent(source.path)}`; + } + return 'scan-url'; +} + +/** + * Build the RequestInit options for a scan request. + * Local scans use GET; jfs scans use POST with URL in the body + * to avoid leaking credentials in query strings / server logs. + * + * Only `signal` is extracted from `extra` to prevent accidental + * overwrites of method, headers, or body. + */ +export function buildScanInit( + source: ScanSource, + extra?: { signal?: AbortSignal } +): RequestInit { + const signal = extra?.signal; + if (source.type === 'local') { + return { method: 'GET', signal }; + } + return { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: source.url, subpath: source.subpath }), + signal + }; +} + +/** + * Stable string key for a scan source (null when source is null). + * Used to detect stale responses and as a React effect dependency. + */ +export function scanSourceKey(source: ScanSource | null): string | null { + if (source === null) { + return null; + } + if (source.type === 'local') { + return `local:${source.path}`; + } + return `jfs:${source.url}:${source.subpath}`; +} diff --git a/src/widgets/JfsChipsWidget.ts b/src/widgets/JfsChipsWidget.ts new file mode 100644 index 0000000..468147f --- /dev/null +++ b/src/widgets/JfsChipsWidget.ts @@ -0,0 +1,196 @@ +import { ReactWidget } from '@jupyterlab/ui-components'; +import React from 'react'; +import { ProjspecChips } from '../components/ProjspecChips'; + +/** + * CSS class for the projspec chips widget in jupyter-fs sidebars. + */ +const JFS_WIDGET_CLASS = 'jp-projspec-JfsChipsWidget'; + +/** + * CSS class for the container injected into a jupyter-fs sidebar. + */ +export const JFS_CHIPS_CONTAINER_CLASS = 'jp-projspec-jfs-chips-container'; + +/** + * Read the current subpath from a tree-finder sidebar's breadcrumbs. + * The breadcrumb element has class `tf-panel-breadcrumbs` and contains + * the current path as its textContent (e.g., "/data-pipeline/src"). + */ +export function readBreadcrumbPath(sidebar: Element): string { + const crumbs = sidebar.querySelector('.tf-panel-breadcrumbs'); + if (!crumbs) { + return ''; + } + const raw = (crumbs.textContent ?? '').trim(); + // Normalize: strip leading slash, trim + return raw.replace(/^\/+/, '').replace(/\/+$/, ''); +} + +/** + * A widget that displays projspec type chips inside a jupyter-fs sidebar. + * Uses the `/scan-url` endpoint with the resource's fsspec URL. + * + * Observes the sidebar's breadcrumb path to update chips when the user + * navigates to a different directory within the resource. + */ +export class JfsChipsWidget extends ReactWidget { + private _scanUrl: string; + private _subpath: string; + private _onChipClick: (specName: string, subpath: string) => void; + private _onNavigate: ((subpath: string) => void) | null; + private _observer: MutationObserver | null = null; + private _sidebar: Element | null = null; + /** The specific breadcrumb element we are watching, if found. */ + private _crumbsEl: Element | null = null; + /** Pending re-read timer after a breadcrumb detachment. */ + private _rereadTimer: ReturnType | null = null; + + /** + * @param scanUrl - The fsspec URL for this jupyter-fs resource. + * @param sidebar - The sidebar DOM element (used to watch breadcrumb changes). + * @param onChipClick - Callback when a chip is clicked, receives the spec name and current subpath. + * @param onNavigate - Optional callback when the breadcrumb path changes (directory navigation). + */ + constructor( + scanUrl: string, + sidebar: Element, + onChipClick: (specName: string, subpath: string) => void, + onNavigate?: (subpath: string) => void + ) { + super(); + this._scanUrl = scanUrl; + this._onChipClick = onChipClick; + this._onNavigate = onNavigate ?? null; + this._sidebar = sidebar; + this._subpath = readBreadcrumbPath(sidebar); + this.addClass(JFS_WIDGET_CLASS); + + this._observer = new MutationObserver(() => { + if (!this._sidebar) { + return; + } + + if (this._crumbsEl && !this._crumbsEl.isConnected) { + this._crumbsEl = null; + this._observeSidebar(); + + // tree-finder replaces the breadcrumb element and populates its text + // asynchronously — schedule a re-read so we pick up the new path once + // the replacement element's content has been rendered. + this._scheduleReread(); + return; + } + + if (!this._crumbsEl) { + this._narrowToCrumbs(); + } + + this._syncBreadcrumb(); + }); + + this._observeSidebar(); + this._narrowToCrumbs(); + } + + dispose(): void { + if (this._rereadTimer !== null) { + clearTimeout(this._rereadTimer); + this._rereadTimer = null; + } + if (this._observer) { + this._observer.disconnect(); + this._observer = null; + } + this._sidebar = null; + this._onNavigate = null; + super.dispose(); + } + + /** + * Start observing the full sidebar for structural changes (childList). + * This ensures we detect when tree-finder replaces the breadcrumb element. + */ + private _observeSidebar(): void { + this._observer?.disconnect(); + if (this._sidebar) { + this._observer?.observe(this._sidebar, { + childList: true, + subtree: true + }); + } + } + + /** + * Additionally observe the breadcrumb element for text changes. + * MutationObserver.observe() *adds* targets, so the sidebar observation + * from _observeSidebar() remains active. + */ + private _narrowToCrumbs(): void { + if (!this._sidebar) { + return; + } + const crumbs = this._sidebar.querySelector('.tf-panel-breadcrumbs'); + if (crumbs) { + this._crumbsEl = crumbs; + this._observer?.observe(crumbs, { + childList: true, + subtree: true, + characterData: true + }); + } + } + + /** + * Schedule a deferred breadcrumb re-read. tree-finder populates the + * replacement breadcrumb element asynchronously, so we wait one task + * before reading. + */ + private _scheduleReread(): void { + if (this._rereadTimer !== null) { + clearTimeout(this._rereadTimer); + } + this._rereadTimer = setTimeout(() => { + this._rereadTimer = null; + this._narrowToCrumbs(); + this._syncBreadcrumb(); + }, 0); + } + + /** + * Read the current breadcrumb path and update state if it has changed. + */ + private _syncBreadcrumb(): void { + if (!this._sidebar) { + return; + } + + const newPath = readBreadcrumbPath(this._sidebar); + if (newPath !== this._subpath) { + this._subpath = newPath; + this._onNavigate?.(newPath); + this.update(); + } + } + + /** + * Hide/show the widget based on whether specs are found. + */ + private _handleVisibilityChange = (visible: boolean): void => { + if (visible) { + this.removeClass('jp-projspec-ChipsWidget-hidden'); + } else { + this.addClass('jp-projspec-ChipsWidget-hidden'); + } + }; + + render(): React.ReactElement { + const subpath = this._subpath; + return React.createElement(ProjspecChips, { + path: subpath, + scanUrl: this._scanUrl, + onChipClick: (specName: string) => this._onChipClick(specName, subpath), + onVisibilityChange: this._handleVisibilityChange + }); + } +} diff --git a/src/widgets/ProjspecPanel.ts b/src/widgets/ProjspecPanel.ts index ae4adbd..d4a9089 100644 --- a/src/widgets/ProjspecPanel.ts +++ b/src/widgets/ProjspecPanel.ts @@ -1,18 +1,19 @@ import React from 'react'; import { ReactWidget } from '@jupyterlab/ui-components'; import { ProjspecPanelComponent } from '../components'; +import { ScanSource, scanSourcesEqual } from '../types'; -/** - * CSS class for the projspec panel widget. - */ const PANEL_CLASS = 'jp-projspec-Panel'; /** - * A widget that displays projspec information for the current directory. - * Uses React for rendering via ReactWidget. + * Right-sidebar widget that displays projspec information for the currently + * active file browser directory (local or jupyter-fs). + * + * Starts with no scan source (null); the plugin sets the real source after + * the app layout is restored. */ export class ProjspecPanel extends ReactWidget { - private _currentPath: string; + private _scanSource: ScanSource | null; private _expandedSpecName: string | null; private _expandRequestId: number; @@ -22,48 +23,39 @@ export class ProjspecPanel extends ReactWidget { this.id = 'projspec-panel'; this.title.caption = 'Project Spec'; this.title.closable = true; - this._currentPath = ''; + this._scanSource = null; this._expandedSpecName = null; this._expandRequestId = 0; } - /** - * Get the current path being displayed. - */ - get currentPath(): string { - return this._currentPath; + get scanSource(): ScanSource | null { + return this._scanSource; } /** - * Update the displayed path and trigger a re-render. - * @param path - The new path to scan. + * Set the scan source and trigger a re-render. + * No-ops when the new source is value-equal to the current one. */ - updatePath(path: string): void { - if (this._currentPath !== path) { - this._currentPath = path; - // Clear expanded spec when path changes + updateSource(source: ScanSource): void { + if (!scanSourcesEqual(this._scanSource, source)) { + this._scanSource = source; this._expandedSpecName = null; this.update(); } } /** - * Expand a specific spec by name and trigger a re-render. - * @param specName - The spec name to expand (e.g., 'python_library'). + * Expand a specific spec by name (e.g. 'python_library'). */ expandSpec(specName: string): void { this._expandedSpecName = specName; - // Increment request ID to ensure expansion triggers even if same spec is clicked this._expandRequestId++; this.update(); } - /** - * Render the React component. - */ render(): React.ReactElement { return React.createElement(ProjspecPanelComponent, { - path: this._currentPath, + scanSource: this._scanSource, expandedSpecName: this._expandedSpecName, expandRequestId: this._expandRequestId }); diff --git a/style/base.css b/style/base.css index 57a1bd7..49a92fb 100644 --- a/style/base.css +++ b/style/base.css @@ -95,6 +95,16 @@ color: var(--jp-ui-font-color2); } +/* Empty state when no scan source is available */ + +.jp-projspec-empty-state { + padding: 24px 16px; + text-align: center; + color: var(--jp-ui-font-color2); + font-size: var(--jp-ui-font-size1); + line-height: 1.5; +} + .jp-projspec-no-specs-icon { font-size: 32px; margin-bottom: 12px; @@ -561,32 +571,24 @@ Projspec Chips Widget - File Browser Integration ========================================================================== */ -/** - * Container widget injected into the file browser. - * Positioned after breadcrumbs, before file listing. - */ +/* Container widget injected into the file browser. + * Positioned after breadcrumbs, before file listing. */ .jp-projspec-ChipsWidget { padding: 4px 12px 8px; overflow: visible; } -/** - * Hidden state - completely collapse when no specs detected. - */ +/* Hidden state - completely collapse when no specs detected. */ .jp-projspec-ChipsWidget-hidden { display: none !important; } -/** - * Hidden state for the inner content. - */ +/* Hidden state for the inner content. */ .jp-projspec-chips-hidden { display: none; } -/** - * Flex container for the chip buttons. - */ +/* Flex container for the chip buttons. */ .jp-projspec-chips { display: flex; flex-wrap: wrap; @@ -594,11 +596,9 @@ align-items: center; } -/** - * Individual chip button for a spec type. +/* Individual chip button for a spec type. * Pill-shaped with emoji icon and label. - * Colors, border-radius, and padding are set inline via React. - */ + * Colors, border-radius, and padding are set inline via React. */ .jp-projspec-chip { display: inline-flex; align-items: center; @@ -633,24 +633,18 @@ outline-offset: 1px; } -/** - * Chip icon (emoji). - */ +/* Chip icon (emoji). */ .jp-projspec-chip-icon { font-size: 12px; line-height: 1; } -/** - * Chip label text. - */ +/* Chip label text. */ .jp-projspec-chip-label { font-size: 11px; } -/** - * Loading indicator shown while fetching specs. - */ +/* Loading indicator shown while fetching specs. */ .jp-projspec-chips-loading { font-size: 11px; color: var(--jp-ui-font-color2); @@ -673,3 +667,18 @@ .jp-projspec-make-button:active { transform: translateY(1px); } + +/* ========================================================================== + jupyter-fs Sidebar Integration + ========================================================================== */ + +/* Container injected after .jp-tree-finder-toolbar in jupyter-fs sidebars. */ +.jp-projspec-jfs-chips-container { + padding: 4px 12px; +} + +/* JfsChipsWidget inside a jupyter-fs sidebar. + * Reuses the same chip styles as the default file browser widget. */ +.jp-projspec-JfsChipsWidget { + overflow: visible; +}