From 1c43b768a169a4d479043c3a1b0a4f445f5bbe16 Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Sat, 4 Jul 2026 18:33:33 -0400 Subject: [PATCH 01/13] Filebrowser --- PROMPT.text | 52 + src/projspec/__main__.py | 196 +++ src/projspec/filebrowser.py | 632 +++++++++ vsextension/package.json | 10 + vsextension/src/extension.ts | 27 + vsextension/src/fileBrowserPanel.ts | 1991 +++++++++++++++++++++++++++ vsextension/src/sidebarView.ts | 18 +- 7 files changed, 2922 insertions(+), 4 deletions(-) create mode 100644 PROMPT.text create mode 100644 src/projspec/filebrowser.py create mode 100644 vsextension/src/fileBrowserPanel.ts diff --git a/PROMPT.text b/PROMPT.text new file mode 100644 index 0000000..5858f5f --- /dev/null +++ b/PROMPT.text @@ -0,0 +1,52 @@ +*Preamble* + +This library, projspec, contains python code to scan directories for +project-like structure and metadata. It implements a library for storing +previously scanned locations, which can be searched to find he project a user needs +to do their current work. This library is exposed to the used via GUIs embedded into +their IDEs, e.g., in the directory vsextension for vscode. + +Of note, some directories are not "projects" in the sense of code+metadata, but +might be just data, or normal projects might contain datasets within them. Currently, +projspec supports these by depending on intake to guess the dataset types and +glean minimal summary information about them. + +Another important point, is that the project directories are not necessarily on +the local disk, but anything supported by fsspec can be scanned. Sometimes this +requires just a URL formed with the appropriate protocol (such as "s3://") or +extra arguments ("storage_options") may be needed - which can also be defined +via environment variable or fsspec config files. + +In IDEs such as vscode, the file browser is scoped to the current (local) directory +of the current local open project. This makes it hard to bootstrap projects or +datasets into the library. + +*The task* + +Without changing the existing project library UI; generate a new interface for +browsing files locally or remotely. It should be possible to bookmark/favourite +any location, so that later the user can select these and have the file tree +root at that location. It should be possible to set the file tree root with double +click or ascend to a parent, as in a local file browser. + +Any directory will be scanned by projspec, and can optionally be added to the +fsspec library. Any file when selected will be scanned by intake. The detilas +will be shown in an info panel next to the file tree; if it is a text file +(of no known data type), the first few rows will be shown. Any filetype that +vscode supports can be opened and edited (up to some configurable max +file size) - so we need a full model for reading and writing files. +The file browser should support normal filesystem operations like +create file, move, delete. Note that some filesystems (object stores) do +not support empty directories (mkdir is a no-op), the directories will +come into existence when the first contents are written. + +To be able to handle arbitrary remote filesystems (as well as local), +IO will go through python/fsspec. +The library at https://github.com/fsspec/fsspec-proxy/tree/main/fsspec-proxy +provides a model for this. In that model, the possible remote locations +are preconfigured, but in this case we want to be able to dynamically jump +to any supported URL and save bookmarks/favourites dynamically. + +Create the new UI for vscode, as well as any backend code needed for the IO, +which may involve copying code from fsspec-proxy or similar projects +(jupyter-fs). diff --git a/src/projspec/__main__.py b/src/projspec/__main__.py index 47141bc..1fdfb36 100755 --- a/src/projspec/__main__.py +++ b/src/projspec/__main__.py @@ -326,5 +326,201 @@ def set_(key, value): set_conf(key, value) +# --------------------------------------------------------------------------- +# filebrowser subcommand group +# --------------------------------------------------------------------------- + + +@main.group("filebrowser") +def filebrowser(): + """File browser operations via fsspec (local and remote filesystems).""" + + +@filebrowser.command("browse") +@click.argument("url") +@click.option( + "--storage-options", + default="", + help="fsspec storage options as JSON", +) +def fb_browse(url, storage_options): + """List the contents of a directory URL. + + Outputs JSON with keys: url, entries, parent, protocol, error. + """ + from projspec.filebrowser import browse + + so = json.loads(storage_options) if storage_options.strip() else None + print(json.dumps(browse(url, storage_options=so))) + + +@filebrowser.command("inspect") +@click.argument("url") +@click.option("--storage-options", default="", help="fsspec storage options as JSON") +@click.option( + "--max-text-bytes", + default=4096, + type=int, + help="Maximum bytes to include in text preview", +) +def fb_inspect(url, storage_options, max_text_bytes): + """Inspect a single file: metadata + text/intake preview. + + Outputs JSON with keys: url, name, size, last_modified, mime_type, + intake, text_preview, error. + """ + from projspec.filebrowser import inspect_file + + so = json.loads(storage_options) if storage_options.strip() else None + print( + json.dumps(inspect_file(url, storage_options=so, max_text_bytes=max_text_bytes)) + ) + + +@filebrowser.command("read-file") +@click.argument("url") +@click.option("--storage-options", default="", help="fsspec storage options as JSON") +@click.option( + "--max-bytes", + default=5 * 1024 * 1024, + type=int, + help="Maximum file size that may be read", +) +def fb_read_file(url, storage_options, max_bytes): + """Read a file and emit its contents as JSON. + + Outputs JSON with keys: url, content, size, error. + """ + from projspec.filebrowser import read_file + + so = json.loads(storage_options) if storage_options.strip() else None + print(json.dumps(read_file(url, storage_options=so, max_bytes=max_bytes))) + + +@filebrowser.command("write-file") +@click.argument("url") +@click.option("--storage-options", default="", help="fsspec storage options as JSON") +@click.option( + "--content-file", + default="-", + type=click.File("r", encoding="utf-8"), + help="File to read content from (default: stdin)", +) +def fb_write_file(url, storage_options, content_file): + """Write content to a file (create or overwrite). + + Content is read from --content-file or stdin. + Outputs JSON with keys: url, error. + """ + from projspec.filebrowser import write_file + + so = json.loads(storage_options) if storage_options.strip() else None + content = content_file.read() + print(json.dumps(write_file(url, content, storage_options=so))) + + +@filebrowser.command("delete") +@click.argument("url") +@click.option("--storage-options", default="", help="fsspec storage options as JSON") +@click.option( + "--recursive", is_flag=True, default=False, help="Delete directories recursively" +) +def fb_delete(url, storage_options, recursive): + """Delete a file or directory. + + Outputs JSON with keys: url, error. + """ + from projspec.filebrowser import delete + + so = json.loads(storage_options) if storage_options.strip() else None + print(json.dumps(delete(url, storage_options=so, recursive=recursive))) + + +@filebrowser.command("move") +@click.argument("src") +@click.argument("dst") +@click.option("--storage-options", default="", help="fsspec storage options as JSON") +def fb_move(src, dst, storage_options): + """Move / rename SRC to DST (same filesystem). + + Outputs JSON with keys: src, dst, error. + """ + from projspec.filebrowser import move + + so = json.loads(storage_options) if storage_options.strip() else None + print(json.dumps(move(src, dst, storage_options=so))) + + +@filebrowser.command("mkdir") +@click.argument("url") +@click.option("--storage-options", default="", help="fsspec storage options as JSON") +def fb_mkdir(url, storage_options): + """Create a directory. No-op on object stores. + + Outputs JSON with keys: url, error. + """ + from projspec.filebrowser import mkdir + + so = json.loads(storage_options) if storage_options.strip() else None + print(json.dumps(mkdir(url, storage_options=so))) + + +@filebrowser.command("add-to-library") +@click.argument("url") +@click.option("--storage-options", default="", help="fsspec storage options as JSON") +def fb_add_to_library(url, storage_options): + """Scan URL with projspec and add it to the project library. + + Outputs JSON with keys: url, project, error. + """ + from projspec.filebrowser import add_to_projspec_library + + so = json.loads(storage_options) if storage_options.strip() else None + print(json.dumps(add_to_projspec_library(url, storage_options=so))) + + +@filebrowser.command("protocols") +def fb_protocols(): + """List fsspec protocols available in this environment. + + Outputs JSON list of protocol strings. + """ + from projspec.filebrowser import supported_protocols + + print(json.dumps(supported_protocols())) + + +@filebrowser.group("bookmarks") +def fb_bookmarks(): + """Manage file browser bookmarks.""" + + +@fb_bookmarks.command("list") +def fb_bookmarks_list(): + """List saved bookmarks as JSON.""" + from projspec.filebrowser import bookmarks_list + + print(json.dumps(bookmarks_list())) + + +@fb_bookmarks.command("add") +@click.argument("url") +@click.option("--label", default="", help="Human-readable label for the bookmark") +def fb_bookmarks_add(url, label): + """Add or update a bookmark. Returns updated bookmarks list as JSON.""" + from projspec.filebrowser import bookmark_add + + print(json.dumps(bookmark_add(url, label=label))) + + +@fb_bookmarks.command("remove") +@click.argument("url") +def fb_bookmarks_remove(url): + """Remove a bookmark by URL. Returns updated bookmarks list as JSON.""" + from projspec.filebrowser import bookmark_remove + + print(json.dumps(bookmark_remove(url))) + + if __name__ == "__main__": main() diff --git a/src/projspec/filebrowser.py b/src/projspec/filebrowser.py new file mode 100644 index 0000000..f13b958 --- /dev/null +++ b/src/projspec/filebrowser.py @@ -0,0 +1,632 @@ +"""fsspec-backed file browser for projspec. + +This module provides the Python IO layer for the projspec file browser UI +(VS Code extension, Jupyter widget, etc.). All filesystem operations go +through :mod:`fsspec` so the same interface works for local paths, S3, GCS, +Azure, HTTP, SSH and any other fsspec-supported protocol. + +Public interface (called via ``projspec filebrowser ...`` CLI) +-------------------------------------------------------------- + +``browse(url, storage_options)`` + List directory contents, returning a JSON-serialisable dict. + +``inspect_file(url, storage_options, max_text_bytes)`` + Return file metadata + an optional text preview / intake summary. + +``read_file(url, storage_options, max_bytes)`` + Return file contents as a UTF-8 string (for opening in VS Code). + +``write_file(url, content, storage_options)`` + Write (create or overwrite) a file. + +``delete(url, storage_options, recursive)`` + Delete a file or directory. + +``move(src, dst, storage_options)`` + Move / rename a file or directory. + +``mkdir(url, storage_options)`` + Create a directory. No-op on object stores that don't support empty dirs. + +``bookmarks_list(bookmarks_path)`` + Return the saved bookmarks list. + +``bookmark_add(url, label, bookmarks_path)`` + Add or update a bookmark. + +``bookmark_remove(url, bookmarks_path)`` + Remove a bookmark by URL. +""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path +from typing import Any + +import fsspec + +# --------------------------------------------------------------------------- +# Bookmark persistence +# --------------------------------------------------------------------------- + +_DEFAULT_BOOKMARKS_FILE = os.path.join( + os.environ.get( + "PROJSPEC_CONFIG_DIR", + os.path.join(os.path.expanduser("~"), ".config", "projspec"), + ), + "filebrowser_bookmarks.json", +) + + +def _bookmarks_path(bookmarks_path: str | None = None) -> str: + return bookmarks_path or _DEFAULT_BOOKMARKS_FILE + + +def bookmarks_list(bookmarks_path: str | None = None) -> list[dict]: + """Return the list of saved bookmarks. + + Each bookmark is a dict with ``url``, ``label``, and optionally + ``storage_options`` keys. + """ + path = _bookmarks_path(bookmarks_path) + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return [] + + +def bookmark_add( + url: str, + label: str = "", + storage_options: dict | None = None, + bookmarks_path: str | None = None, +) -> list[dict]: + """Add or update a bookmark for *url*. + + ``storage_options`` are stored with the bookmark so that navigating + to it later automatically uses the correct credentials. They are + scoped to the URL — a bookmark for ``s3://bucket-a`` and one for + ``s3://bucket-b`` each carry their own independent options. + + Returns the updated bookmarks list. + """ + path = _bookmarks_path(bookmarks_path) + bms = bookmarks_list(path) + entry: dict = {"url": url, "label": label or _basename(url)} + if storage_options: + entry["storage_options"] = storage_options + # Update existing bookmark if URL already present. + for i, bm in enumerate(bms): + if bm.get("url") == url: + entry["label"] = label or bm.get("label", _basename(url)) + bms[i] = entry + _save_bookmarks(bms, path) + return bms + bms.append(entry) + _save_bookmarks(bms, path) + return bms + + +def bookmark_remove( + url: str, bookmarks_path: str | None = None +) -> list[dict[str, str]]: + """Remove the bookmark with the given *url*. Returns updated list.""" + path = _bookmarks_path(bookmarks_path) + bms = [b for b in bookmarks_list(path) if b.get("url") != url] + _save_bookmarks(bms, path) + return bms + + +def _save_bookmarks(bms: list[dict], path: str) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(bms, f, indent=2) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _basename(url: str) -> str: + url = url.rstrip("/") + return url.rsplit("/", 1)[-1] or url + + +def _parent(url: str) -> str: + """Return the parent directory URL, preserving the protocol.""" + url = url.rstrip("/") + # Find the last '/' that is not part of the protocol separator '://' + proto_end = url.find("://") + if proto_end >= 0: + # Don't go above the bucket/host root + path_part = url[proto_end + 3 :] + slash = path_part.rfind("/") + if slash < 0: + # Already at root; return as-is + return url + return url[: proto_end + 3 + slash] or url[: proto_end + 3] + slash = url.rfind("/") + if slash <= 0: + return "/" + return url[:slash] + + +def _get_fs(url: str, storage_options: dict | None = None) -> tuple[Any, str]: + """Return (fs, path) for the given URL and optional storage_options.""" + so = storage_options or {} + fs, path = fsspec.url_to_fs(url, **so) + return fs, path + + +def _entry_info(fs: Any, info: dict) -> dict: + """Normalise an fsspec info dict into a stable shape for the UI.""" + out: dict[str, Any] = {} + out["name"] = fs.unstrip_protocol(info.get("name", "")) + out["type"] = info.get("type", "file") # "directory" or "file" + out["size"] = info.get("size") # may be None for directories + out["last_modified"] = _coerce_mtime( + info.get("LastModified") or info.get("last_modified") or info.get("mtime") + ) + out["basename"] = _basename(out["name"]) + return out + + +def _coerce_mtime(v: Any) -> float | None: + if v is None: + return None + if isinstance(v, (int, float)): + return float(v) + # datetime / Timestamp objects + try: + return float(v.timestamp()) + except Exception: + pass + try: + import datetime + + if isinstance(v, str): + dt = datetime.datetime.fromisoformat(v.replace("Z", "+00:00")) + return dt.timestamp() + except Exception: + pass + return None + + +# --------------------------------------------------------------------------- +# Core operations +# --------------------------------------------------------------------------- + + +def browse(url: str, storage_options: dict | None = None) -> dict: + """List the contents of *url* (must be a directory). + + Returns:: + + { + "url": , + "entries": [ + { + "name": , + "basename": , + "type": "directory"|"file", + "size": , + "last_modified": , + }, + ... + ], + "parent": , + "protocol": , + "error": null or , + } + """ + try: + fs, path = _get_fs(url, storage_options) + try: + raw = fs.ls(path, detail=True) + except NotADirectoryError: + # Treat as a single-file listing + info = fs.info(path) + raw = [info] + entries = [_entry_info(fs, e) for e in raw] + # Sort: directories first, then files, both alphabetically + entries.sort( + key=lambda e: (0 if e["type"] == "directory" else 1, e["basename"].lower()) + ) + canonical = fs.unstrip_protocol(path) + parent_path = _parent(canonical) if canonical != url or path != "/" else None + protocol = ( + fs.protocol + if isinstance(fs.protocol, str) + else (fs.protocol[0] if fs.protocol else "file") + ) + return { + "url": canonical, + "entries": entries, + "parent": parent_path, + "protocol": protocol, + "error": None, + } + except Exception as exc: + return { + "url": url, + "entries": [], + "parent": None, + "protocol": "", + "error": str(exc), + } + + +def inspect_file( + url: str, + storage_options: dict | None = None, + max_text_bytes: int = 4096, +) -> dict: + """Return metadata and a text/data preview for a single file. + + Returns:: + + { + "url": , + "name": , + "size": , + "last_modified": , + "mime_type": , + "intake": , + "text_preview": , + "error": null or , + } + """ + try: + fs, path = _get_fs(url, storage_options) + info = fs.info(path) + size = info.get("size") + mtime = _coerce_mtime( + info.get("LastModified") or info.get("last_modified") or info.get("mtime") + ) + mime = _guess_mime(path) + intake_summary = _intake_inspect(url, path, storage_options) + text_preview = None + + # Always show a text preview for text-like files. + is_text = mime and (mime.startswith("text/") or mime in _TEXT_MIMES) + if is_text: + try: + with fs.open(path, "rb") as f: + raw_bytes = f.read(max_text_bytes) + text_preview = raw_bytes.decode("utf-8", errors="replace") + except Exception: + pass + + return { + "url": url, + "name": _basename(path), + "size": size, + "last_modified": mtime, + "mime_type": mime, + "intake": intake_summary, + "text_preview": text_preview, + "error": None, + } + except Exception as exc: + return { + "url": url, + "name": _basename(url), + "size": None, + "last_modified": None, + "mime_type": None, + "intake": None, + "text_preview": None, + "error": str(exc), + } + + +def _intake_inspect( + url: str, local_path: str, storage_options: dict | None +) -> dict | None: + """Run intake inspection on a file and return a serialisable summary. + + Strategy: + 1. ``intake.recommend()`` — identifies the data type from the path/extension. + Works without pandas or any heavy dep. + 2. ``source.discover()`` — tries to get schema (columns, dtype, shape). + Requires the relevant reader (e.g. pandas for CSV). Skipped on error. + 3. ``source.read()`` head — reads a small sample to get shape. + Only attempted if discover succeeds and data is small enough. + + Returns a dict suitable for display, or None if intake is not installed + or the file is not a recognised data type. + """ + try: + import intake as _intake + except ImportError: + return None + + so = storage_options or {} + result: dict[str, Any] = {} + + # 1. Type recognition — works for any installed intake + try: + recs = ( + _intake.recommend(url, storage_options=so) if so else _intake.recommend(url) + ) + except TypeError: + # Older intake versions don't accept storage_options kwarg + try: + recs = _intake.recommend(url) + except Exception: + recs = [] + except Exception: + recs = [] + + if not recs: + return None # Intake doesn't know this file type + + type_names = [getattr(r, "__name__", str(r)) for r in recs] + result["types"] = type_names + result["primary_type"] = type_names[0] + + # 2. Try to open with the best available reader and discover schema + reader = None + open_fn_name = "open_" + type_names[0].lower() + open_fn = getattr(_intake, open_fn_name, None) + + # Fall back to direct instantiation of the datatype reader + if open_fn is None: + try: + dt = recs[0](url, storage_options=so or None) + importable = [ + r + for r in dt.possible_readers + if getattr(r, "__name__", "") == "importable" + ] + if importable: + reader = importable[0](dt).read + except Exception: + pass + else: + try: + reader_src = open_fn(url) + except Exception: + reader_src = None + + if reader_src is not None: + try: + discovered = reader_src.discover() + # discover() returns a Schema-like object or dict + if hasattr(discovered, "__dict__"): + disc_dict = { + k: v + for k, v in vars(discovered).items() + if isinstance( + v, (str, int, float, bool, list, dict, type(None)) + ) + } + elif isinstance(discovered, dict): + disc_dict = { + k: v + for k, v in discovered.items() + if isinstance( + v, (str, int, float, bool, list, dict, type(None)) + ) + } + else: + disc_dict = {} + if "dtype" in disc_dict and hasattr(disc_dict["dtype"], "items"): + # pandas dtype dict → convert to {col: dtype_str} + disc_dict["dtype"] = { + k: str(v) for k, v in disc_dict["dtype"].items() + } + result.update({k: v for k, v in disc_dict.items() if v is not None}) + except Exception: + pass # discover may need pandas or other optional dep + + return result if result else None + + +_TEXT_MIMES = { + "application/json", + "application/x-yaml", + "application/toml", + "application/javascript", + "application/xml", +} + + +def _guess_mime(path: str) -> str | None: + import mimetypes + + mt, _ = mimetypes.guess_type(path) + return mt + + +def read_file( + url: str, + storage_options: dict | None = None, + max_bytes: int = 5 * 1024 * 1024, +) -> dict: + """Read a file and return its contents as a UTF-8 string. + + Files larger than *max_bytes* are rejected. + + Returns:: + + {"url": ..., "content": , "size": , "error": null or } + """ + try: + fs, path = _get_fs(url, storage_options) + info = fs.info(path) + size = info.get("size", 0) or 0 + if size > max_bytes: + return { + "url": url, + "content": None, + "size": size, + "error": f"File too large to open ({size} bytes > {max_bytes} limit)", + } + with fs.open(path, "rb") as f: + raw = f.read() + content = raw.decode("utf-8", errors="replace") + return {"url": url, "content": content, "size": len(raw), "error": None} + except Exception as exc: + return {"url": url, "content": None, "size": None, "error": str(exc)} + + +def write_file( + url: str, + content: str, + storage_options: dict | None = None, +) -> dict: + """Write *content* (UTF-8 string) to *url*. + + Returns:: + + {"url": ..., "error": null or } + """ + try: + fs, path = _get_fs(url, storage_options) + with fs.open(path, "w", encoding="utf-8") as f: + f.write(content) + return {"url": url, "error": None} + except Exception as exc: + return {"url": url, "error": str(exc)} + + +def delete( + url: str, + storage_options: dict | None = None, + recursive: bool = False, +) -> dict: + """Delete a file or directory at *url*. + + Returns:: + + {"url": ..., "error": null or } + """ + try: + fs, path = _get_fs(url, storage_options) + fs.rm(path, recursive=recursive) + return {"url": url, "error": None} + except Exception as exc: + return {"url": url, "error": str(exc)} + + +def move( + src: str, + dst: str, + storage_options: dict | None = None, +) -> dict: + """Move / rename *src* to *dst* (same filesystem). + + Returns:: + + {"src": ..., "dst": ..., "error": null or } + """ + try: + fs, src_path = _get_fs(src, storage_options) + _, dst_path = _get_fs(dst, storage_options) + fs.mv(src_path, dst_path) + return {"src": src, "dst": dst, "error": None} + except Exception as exc: + return {"src": src, "dst": dst, "error": str(exc)} + + +def mkdir( + url: str, + storage_options: dict | None = None, +) -> dict: + """Create a directory at *url*. + + On object stores (S3, GCS, …) that don't support empty directories this + is a no-op; the directory will come into existence when the first file + is written inside it. + + Returns:: + + {"url": ..., "error": null or } + """ + try: + fs, path = _get_fs(url, storage_options) + try: + fs.mkdir(path, create_parents=True) + except (NotImplementedError, AttributeError): + # Object stores: silently ignore + pass + return {"url": url, "error": None} + except Exception as exc: + return {"url": url, "error": str(exc)} + + +def add_to_projspec_library( + url: str, + storage_options: dict | None = None, +) -> dict: + """Scan *url* with projspec and add it to the project library. + + Returns:: + + {"url": ..., "project": , "error": null or } + """ + try: + from projspec.utils import scan_glob + + projects = [] + for proj in scan_glob( + url, + storage_options=json.dumps(storage_options or {}), + walk=False, + add_to_library=True, + ): + projects.append(proj.to_dict(compact=False)) + if projects: + return {"url": url, "project": projects[0], "error": None} + return { + "url": url, + "project": None, + "error": "No project found at this location", + } + except Exception as exc: + return {"url": url, "project": None, "error": str(exc)} + + +def supported_protocols() -> list[str]: + """Return a list of protocols available in the current fsspec installation.""" + try: + from fsspec.registry import known_implementations + + return sorted(known_implementations.keys()) + except Exception: + return ["file", "s3", "gcs", "az", "abfs", "http", "https", "ftp", "memory"] + + +def scan_directory( + url: str, + storage_options: dict | None = None, +) -> dict: + """Scan *url* with projspec and return the project dict for display. + + Does NOT add to the library. Returns a JSON-serialisable dict:: + + { + "url": ..., + "project": , + "error": null or , + } + """ + try: + from projspec.proj import Project + + so = storage_options or {} + proj = Project(url, storage_options=so, walk=False) + return { + "url": url, + "project": proj.to_dict(compact=False), + "error": None, + } + except Exception as exc: + return {"url": url, "project": None, "error": str(exc)} diff --git a/vsextension/package.json b/vsextension/package.json index be8c2d2..9bd3481 100644 --- a/vsextension/package.json +++ b/vsextension/package.json @@ -22,6 +22,16 @@ { "command": "projspec.showTree", "title": "Project Library" + }, + { + "command": "projspec.openFileBrowser", + "title": "Open File Browser", + "category": "projspec" + }, + { + "command": "projspec.openFileBrowserHere", + "title": "Open File Browser at Current Folder", + "category": "projspec" } ], "viewsContainers": { diff --git a/vsextension/src/extension.ts b/vsextension/src/extension.ts index ecc6202..e7bdbc7 100644 --- a/vsextension/src/extension.ts +++ b/vsextension/src/extension.ts @@ -1,8 +1,10 @@ import * as vscode from 'vscode'; import { ProjspecPanel } from './panel'; import { SidebarViewProvider } from './sidebarView'; +import { FileBrowserPanel } from './fileBrowserPanel'; export function activate(context: vscode.ExtensionContext): void { + console.log('[projspec] activate called'); const sidebarProvider = new SidebarViewProvider(context.extensionUri); context.subscriptions.push( vscode.window.registerWebviewViewProvider('projspec.view', sidebarProvider) @@ -13,6 +15,31 @@ export function activate(context: vscode.ExtensionContext): void { ProjspecPanel.createOrShow(context.extensionUri); }) ); + + context.subscriptions.push( + vscode.commands.registerCommand('projspec.openFileBrowser', (initialUrl?: string) => { + console.log('[projspec] openFileBrowser fired, FileBrowserPanel=', typeof FileBrowserPanel); + try { + FileBrowserPanel.createOrShow(context.extensionUri, initialUrl); + } catch (e) { + console.error('[projspec] createOrShow threw:', e); + vscode.window.showErrorMessage('FileBrowser error: ' + String(e)); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('projspec.openFileBrowserHere', () => { + const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + console.log('[projspec] openFileBrowserHere fired, folder=', folder); + try { + FileBrowserPanel.createOrShow(context.extensionUri, folder); + } catch (e) { + console.error('[projspec] createOrShow threw:', e); + vscode.window.showErrorMessage('FileBrowser error: ' + String(e)); + } + }) + ); } export function deactivate(): void { diff --git a/vsextension/src/fileBrowserPanel.ts b/vsextension/src/fileBrowserPanel.ts new file mode 100644 index 0000000..05a9262 --- /dev/null +++ b/vsextension/src/fileBrowserPanel.ts @@ -0,0 +1,1991 @@ +/** + * FileBrowserPanel — VS Code webview panel for browsing local and remote + * filesystems via fsspec / projspec filebrowser subcommands. + * + * Features + * -------- + * - Browse any fsspec-supported URL (local, S3, GCS, HTTP, FTP, …) + * - Bookmarks / favourites with custom labels + * - Double-click a directory to descend; "Up" button or breadcrumb to ascend + * - Info panel alongside the file tree showing file metadata, text preview + * or intake summary for the selected file + * - Normal filesystem operations: create file, rename/move, delete + * - Open any text-type file in VS Code for editing (up to configurable limit) + * - Add any directory to the projspec project library + * - Storage-options (credentials) per protocol / session + */ + +import * as vscode from 'vscode'; +import * as childProcess from 'child_process'; +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; +import { parseJsonOutput } from './projspec'; + +// --------------------------------------------------------------------------- +// Output channel for host-side logging (visible in Output > projspec-filebrowser) +// --------------------------------------------------------------------------- +let _log: vscode.OutputChannel | undefined; +function log(msg: string): void { + if (!_log) { _log = vscode.window.createOutputChannel('projspec-filebrowser'); } + _log.appendLine('[' + new Date().toISOString() + '] ' + msg); +} + +// --------------------------------------------------------------------------- +// Python runner — calls projspec.filebrowser functions directly via python3 +// without depending on the `projspec` binary being installed or up-to-date. +// --------------------------------------------------------------------------- + +/** Path to python3 (override with PROJSPEC_PYTHON env var). */ +function python3(): string { + return process.env['PROJSPEC_PYTHON'] || 'python3'; +} + +/** + * Run a projspec.filebrowser function by spawning python3 -c. + * The script is self-contained: it adds the projspec src path, imports the + * module, calls fn(**kwargs), then prints JSON. + */ +function runFbPython(fn: string, kwargs: Record): Promise<{ data: unknown; stderr: string; code: number | null }> { + // Load filebrowser.py directly via importlib so we don't trigger + // projspec/__init__.py (which pulls in yaml, toml, etc. that may not be + // installed in every environment). + const fbPath = path.resolve(__dirname, '..', '..', 'src', 'projspec', 'filebrowser.py'); + const script = [ + 'import sys, json, importlib.util', + `spec = importlib.util.spec_from_file_location("projspec_filebrowser", ${JSON.stringify(fbPath)})`, + 'mod = importlib.util.module_from_spec(spec)', + 'spec.loader.exec_module(mod)', + 'kwargs = json.loads(sys.argv[1])', + `result = getattr(mod, ${JSON.stringify(fn)})(**kwargs)`, + 'print(json.dumps(result))', + ].join('\n'); + const kwargsStr = JSON.stringify(kwargs); + log(`runFbPython ${fn} ${kwargsStr.slice(0, 100)}`); + return new Promise((resolve) => { + const proc = childProcess.spawn(python3(), ['-c', script, kwargsStr], { env: process.env }); + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + proc.on('error', (err: Error) => { + log(`spawn error: ${err}`); + resolve({ data: null, stderr: String(err), code: -1 }); + }); + proc.on('close', (code: number | null) => { + log(`exit ${code} stdout=${stdout.slice(0, 200)} stderr=${stderr.slice(0, 200)}`); + let data: unknown = null; + try { data = parseJsonOutput(stdout); } catch { /* ok */ } + resolve({ data, stderr, code }); + }); + }); +} + +/** Convenience: call fn and return parsed data, or throw with stderr on failure. */ +async function fbCall(fn: string, kwargs: Record): Promise { + const res = await runFbPython(fn, kwargs); + if (res.code !== 0 || res.data === null) { + throw new Error(`${fn} failed (exit ${res.code}): ${res.stderr.trim() || 'no output'}`); + } + return res.data; +} + +// --------------------------------------------------------------------------- +// FileBrowserPanel +// --------------------------------------------------------------------------- + +export class FileBrowserPanel { + public static current: FileBrowserPanel | undefined; + private readonly panel: vscode.WebviewPanel; + private readonly extensionUri: vscode.Uri; + private disposables: vscode.Disposable[] = []; + private busyCount = 0; + /** Pending initial data to send once the webview posts 'ready'. */ + private pendingInit: { bookmarks: unknown[]; protocols: string[]; startUrl: string } | null = null; + + // --------------------------------------------------------------------------- + // Factory + // --------------------------------------------------------------------------- + public static createOrShow(extensionUri: vscode.Uri, initialUrl?: string): void { + const col = vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One; + if (FileBrowserPanel.current) { + FileBrowserPanel.current.panel.reveal(col); + if (initialUrl) { + FileBrowserPanel.current.navigateTo(initialUrl); + } + return; + } + const panel = vscode.window.createWebviewPanel( + 'projspec.filebrowser', + 'File Browser', + col, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.joinPath(extensionUri, 'media')], + }, + ); + FileBrowserPanel.current = new FileBrowserPanel(panel, extensionUri, initialUrl); + } + + // --------------------------------------------------------------------------- + // Constructor + // --------------------------------------------------------------------------- + private constructor( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + initialUrl?: string, + ) { + this.panel = panel; + this.extensionUri = extensionUri; + + this.panel.webview.html = this.getHtml(); + this.panel.onDidDispose(() => this.dispose(), null, this.disposables); + this.panel.webview.onDidReceiveMessage( + (msg) => void this.onMessage(msg), + null, + this.disposables, + ); + + // Pre-fetch bookmarks + protocols in the background so we are ready + // when the webview fires 'ready'. We do NOT post any messages until + // then – the webview's message listener isn't registered yet. + void this.prefetchInit(initialUrl); + } + + // --------------------------------------------------------------------------- + // Busy counter + // --------------------------------------------------------------------------- + private withBusy(fn: () => Promise): Promise { + this.busyCount += 1; + if (this.busyCount === 1) { + this.panel.webview.postMessage({ type: 'loading', loading: true }); + } + return fn().finally(() => { + this.busyCount -= 1; + if (this.busyCount === 0) { + this.panel.webview.postMessage({ type: 'loading', loading: false }); + } + }); + } + + // --------------------------------------------------------------------------- + // Initialisation + // --------------------------------------------------------------------------- + + /** Run the cheap subprocess calls while the webview is still rendering. */ + private async prefetchInit(startUrl?: string): Promise { + log('prefetchInit start'); + let bookmarks: unknown[] = []; + let protocols: string[] = []; + try { + const bmsRes = await runFbPython('bookmarks_list', {}); + if (Array.isArray(bmsRes.data)) { bookmarks = bmsRes.data; } + } catch (e) { log('bookmarks_list error: ' + e); } + try { + const protoRes = await runFbPython('supported_protocols', {}); + if (Array.isArray(protoRes.data)) { protocols = protoRes.data as string[]; } + } catch (e) { log('supported_protocols error: ' + e); } + log(`prefetchInit done: ${bookmarks.length} bookmarks, ${protocols.length} protocols`); + this.pendingInit = { bookmarks, protocols, startUrl: startUrl || os.homedir() }; + // If 'ready' already arrived before we finished, flush now. + if (this.readyReceived) { + void this.sendInitialData(); + } + } + + private readyReceived = false; + + private async sendInitialData(): Promise { + const init = this.pendingInit; + if (!init) { return; } + this.pendingInit = null; + log('sendInitialData: posting init + browse ' + init.startUrl); + await this.withBusy(async () => { + this.panel.webview.postMessage({ + type: 'init', + bookmarks: init.bookmarks, + protocols: init.protocols, + }); + await this.browse(init.startUrl, undefined, false); + }); + } + + private navigateTo(url: string): void { + void this.withBusy(() => this.browse(url, undefined, false)); + } + + // --------------------------------------------------------------------------- + // Message handling + // --------------------------------------------------------------------------- + private async onMessage(msg: Record): Promise { + const cmd = msg.cmd as string; + log('onMessage cmd=' + cmd + (msg.url ? ' url=' + msg.url : '')); + try { + switch (cmd) { + case 'ready': + // The webview's message listener is now live. Send initial data. + log('ready received, readyReceived was ' + this.readyReceived + ', pendingInit=' + !!this.pendingInit); + this.readyReceived = true; + if (this.pendingInit) { + void this.sendInitialData(); + } + break; + + case 'browse': + // msg.push === false means a refresh (no history entry); + // anything else (including undefined) pushes history. + await this.withBusy(() => + this.browse( + msg.url as string, + msg.storageOptions as string | undefined, + msg.push !== false, + ), + ); + break; + + case 'inspect': + await this.withBusy(() => + this.inspect(msg.url as string, msg.storageOptions as string | undefined), + ); + break; + + case 'openFile': + await this.openFileInEditor( + msg.url as string, + msg.storageOptions as string | undefined, + msg.maxBytes as number | undefined, + ); + break; + + case 'writeFile': + await this.writeFile( + msg.url as string, + msg.content as string, + msg.storageOptions as string | undefined, + ); + break; + + case 'createFile': + await this.createFile( + msg.parentUrl as string, + msg.name as string, + msg.storageOptions as string | undefined, + ); + break; + + case 'deleteEntry': + await this.deleteEntry( + msg.url as string, + msg.isDir as boolean, + msg.storageOptions as string | undefined, + ); + break; + + case 'renameEntry': + await this.renameEntry( + msg.url as string, + msg.newName as string, + msg.storageOptions as string | undefined, + ); + break; + + case 'mkdir': + await this.mkdirEntry( + msg.parentUrl as string, + msg.name as string, + msg.storageOptions as string | undefined, + ); + break; + + case 'addBookmark': + await this.bookmarkAdd( + msg.url as string, + msg.label as string | undefined, + msg.storageOptions as string | undefined, + ); + break; + + case 'removeBookmark': + await this.bookmarkRemove(msg.url as string); + break; + + case 'addToLibrary': + await this.addToLibrary( + msg.url as string, + msg.storageOptions as string | undefined, + ); + break; + + case 'scanDir': + await this.withBusy(() => + this.scanDir(msg.url as string, msg.storageOptions as string | undefined), + ); + break; + + case 'goToUrl': + await this.withBusy(() => + this.goToUrl(msg.url as string, msg.storageOptions as string | undefined), + ); + break; + + default: + log('unknown cmd: ' + cmd); + console.warn('[FileBrowser] Unknown cmd:', cmd); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log('onMessage error: ' + message); + vscode.window.showErrorMessage(`File Browser: ${message}`); + this.panel.webview.postMessage({ type: 'error', message }); + } + } + + // --------------------------------------------------------------------------- + // Backend operations + // --------------------------------------------------------------------------- + + private async browse( + url: string, + storageOptions: string | undefined, + pushHistory: boolean, + ): Promise { + log('browse ' + url); + const so = storageOptions ? JSON.parse(storageOptions) : null; + const res = await runFbPython('browse', so ? { url, storage_options: so } : { url }); + const data = (res.data as Record) || { + url, entries: [], parent: null, protocol: '', + error: res.stderr || `exit ${res.code}`, + }; + log('browse result: ' + (data.error || `${(data.entries as unknown[])?.length} entries`)); + this.panel.webview.postMessage({ + type: 'browseResult', + pushHistory, + storageOptions: storageOptions || '', + ...data, + }); + } + + private async inspect(url: string, storageOptions: string | undefined): Promise { + log('inspect ' + url); + const so = storageOptions ? JSON.parse(storageOptions) : null; + const res = await runFbPython('inspect_file', so ? { url, storage_options: so } : { url }); + const data = (res.data as Record) || { url, error: res.stderr || `exit ${res.code}` }; + log('inspect result: ' + (data.error || data.mime_type)); + this.panel.webview.postMessage({ type: 'inspectResult', ...data }); + } + + private async openFileInEditor( + url: string, + storageOptions: string | undefined, + maxBytes?: number, + ): Promise { + await this.withBusy(async () => { + // For local file:// URLs, just open directly + if (url.startsWith('file://') || !url.includes('://')) { + const localPath = url.startsWith('file://') ? url.slice('file://'.length) : url; + const doc = await vscode.workspace.openTextDocument(localPath); + await vscode.window.showTextDocument(doc); + return; + } + // For remote files, fetch via Python then open a temp file + const so = storageOptions ? JSON.parse(storageOptions) : null; + const kwargs: Record = { url }; + if (so) { kwargs['storage_options'] = so; } + if (maxBytes) { kwargs['max_bytes'] = maxBytes; } + const res = await runFbPython('read_file', kwargs); + const data = res.data as Record; + if (!data || data['error']) { throw new Error((data?.['error'] as string) || res.stderr || 'Failed to read file'); } + const ext = path.extname(url) || '.txt'; + const tmpFile = path.join(os.tmpdir(), `projspec_fb_${Date.now()}${ext}`); + fs.writeFileSync(tmpFile, (data['content'] as string) || '', 'utf-8'); + const doc = await vscode.workspace.openTextDocument(tmpFile); + await vscode.window.showTextDocument(doc); + this._openedRemoteFiles.set(tmpFile, { url, storageOptions: storageOptions || '' }); + const sub = vscode.workspace.onDidSaveTextDocument(async (saved) => { + if (saved.fileName === tmpFile) { + await this.pushRemoteFile(tmpFile, url, storageOptions); + } + }); + this.disposables.push(sub); + }); + } + + private _openedRemoteFiles = new Map(); + + private async pushRemoteFile(tmpFile: string, remoteUrl: string, storageOptions: string | undefined): Promise { + const content = fs.readFileSync(tmpFile, 'utf-8'); + const so = storageOptions ? JSON.parse(storageOptions) : null; + const kwargs: Record = { url: remoteUrl, content }; + if (so) { kwargs['storage_options'] = so; } + const res = await runFbPython('write_file', kwargs); + const data = res.data as Record; + if (data?.['error']) { vscode.window.showErrorMessage(`Save failed: ${data['error']}`); } + else { vscode.window.showInformationMessage(`Saved to ${remoteUrl}`); } + } + + private async writeFile(url: string, content: string, storageOptions: string | undefined): Promise { + await this.withBusy(async () => { + const so = storageOptions ? JSON.parse(storageOptions) : null; + const kwargs: Record = { url, content }; + if (so) { kwargs['storage_options'] = so; } + const res = await runFbPython('write_file', kwargs); + const data = res.data as Record; + if (data?.['error']) { throw new Error(data['error'] as string); } + const parent = url.replace(/\/?[^/]+$/, '') || '/'; + await this.browse(parent, storageOptions, false); + }); + } + + private async createFile(parentUrl: string, name: string, storageOptions: string | undefined): Promise { + await this.withBusy(async () => { + const newUrl = parentUrl.replace(/\/$/, '') + '/' + name; + const so = storageOptions ? JSON.parse(storageOptions) : null; + const kwargs: Record = { url: newUrl, content: '' }; + if (so) { kwargs['storage_options'] = so; } + const res = await runFbPython('write_file', kwargs); + const data = res.data as Record; + if (data?.['error']) { throw new Error(data['error'] as string); } + await this.browse(parentUrl, storageOptions, false); + }); + } + + private async deleteEntry(url: string, isDir: boolean, storageOptions: string | undefined): Promise { + const label = url.split('/').pop() || url; + const confirm = await vscode.window.showWarningMessage(`Delete "${label}"?`, { modal: true }, 'Delete'); + if (confirm !== 'Delete') { return; } + await this.withBusy(async () => { + const so = storageOptions ? JSON.parse(storageOptions) : null; + const kwargs: Record = { url, recursive: isDir }; + if (so) { kwargs['storage_options'] = so; } + const res = await runFbPython('delete', kwargs); + const data = res.data as Record; + if (data?.['error']) { throw new Error(data['error'] as string); } + const parent = url.replace(/\/?[^/]+$/, '') || '/'; + await this.browse(parent, storageOptions, false); + }); + } + + private async renameEntry(url: string, newName: string, storageOptions: string | undefined): Promise { + await this.withBusy(async () => { + const parent = url.replace(/\/?[^/]+$/, '') || '/'; + const dst = parent.replace(/\/$/, '') + '/' + newName; + const so = storageOptions ? JSON.parse(storageOptions) : null; + const kwargs: Record = { src: url, dst }; + if (so) { kwargs['storage_options'] = so; } + const res = await runFbPython('move', kwargs); + const data = res.data as Record; + if (data?.['error']) { throw new Error(data['error'] as string); } + await this.browse(parent, storageOptions, false); + }); + } + + private async mkdirEntry(parentUrl: string, name: string, storageOptions: string | undefined): Promise { + await this.withBusy(async () => { + const newUrl = parentUrl.replace(/\/$/, '') + '/' + name; + const so = storageOptions ? JSON.parse(storageOptions) : null; + const kwargs: Record = { url: newUrl }; + if (so) { kwargs['storage_options'] = so; } + const res = await runFbPython('mkdir', kwargs); + const data = res.data as Record; + if (data?.['error']) { throw new Error(data['error'] as string); } + await this.browse(parentUrl, storageOptions, false); + }); + } + + private async bookmarkAdd(url: string, label?: string, storageOptions?: string): Promise { + const kwargs: Record = { url }; + if (label) { kwargs['label'] = label; } + if (storageOptions) { + try { kwargs['storage_options'] = JSON.parse(storageOptions); } catch { /* ignore invalid SO */ } + } + const res = await runFbPython('bookmark_add', kwargs); + const bms = Array.isArray(res.data) ? res.data : []; + this.panel.webview.postMessage({ type: 'bookmarksUpdated', bookmarks: bms }); + } + + private async bookmarkRemove(url: string): Promise { + const res = await runFbPython('bookmark_remove', { url }); + const bms = Array.isArray(res.data) ? res.data : []; + this.panel.webview.postMessage({ type: 'bookmarksUpdated', bookmarks: bms }); + } + + private async addToLibrary(url: string, storageOptions: string | undefined): Promise { + await this.withBusy(async () => { + const so = storageOptions ? JSON.parse(storageOptions) : null; + const kwargs: Record = { url }; + if (so) { kwargs['storage_options'] = so; } + const res = await runFbPython('add_to_projspec_library', kwargs); + const data = res.data as Record; + if (data?.['error']) { + vscode.window.showWarningMessage(`Add to library: ${data['error']}`); + } else { + vscode.window.showInformationMessage(`Added to projspec library: ${url}`); + } + }); + } + + private async goToUrl(url: string, storageOptions: string | undefined): Promise { + await this.browse(url, storageOptions, true); + } + + private async scanDir(url: string, storageOptions: string | undefined): Promise { + log('scanDir ' + url); + const so = storageOptions ? JSON.parse(storageOptions) : null; + const srcPath = path.resolve(__dirname, '..', '..', 'src'); + const soArg = so ? JSON.stringify(so) : ''; + // Try projspec CLI first (fastest when projspec is installed in the env). + // Fall back to importing projspec.filebrowser.scan_directory directly. + const result = await new Promise<{data: unknown; stderr: string; code: number|null}>((resolve) => { + const script = [ + 'import sys, json, subprocess', + `sys.path.insert(0, ${JSON.stringify(srcPath)})`, + `url = ${JSON.stringify(url)}`, + `so_str = ${JSON.stringify(soArg)}`, + 'args = ["projspec", "scan", "--json-out", url]', + 'if so_str: args = ["projspec", "scan", "--json-out", "--storage_options", so_str, url]', + 'r = subprocess.run(args, capture_output=True, text=True)', + 'if r.returncode == 0 and r.stdout.strip():', + ' raw = r.stdout.strip()', + ' idx = raw.find("{")', + ' if idx >= 0: raw = raw[idx:]', + ' proj = json.loads(raw)', + ' print(json.dumps({"url": url, "project": proj, "error": None}))', + ' sys.exit(0)', + // Fall back to direct import + 'try:', + ' from projspec.filebrowser import scan_directory', + ' so = json.loads(so_str) if so_str else None', + ' result = scan_directory(url, storage_options=so)', + 'except Exception as e:', + ' result = {"url": url, "project": None, "error": str(e)}', + 'print(json.dumps(result))', + ].join('\n'); + const proc = childProcess.spawn(python3(), ['-c', script], { env: process.env }); + let stdout = '', stderr = ''; + proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + proc.on('error', (err: Error) => resolve({ data: null, stderr: String(err), code: -1 })); + proc.on('close', (code: number | null) => { + log(`scanDir exit=${code} out=${stdout.slice(0,200)} err=${stderr.slice(0,200)}`); + let data: unknown = null; + try { data = JSON.parse(stdout.trim()); } catch { /* ok */ } + resolve({ data, stderr, code }); + }); + }); + const data = (result.data as Record) || { url, error: result.stderr || `exit ${result.code}` }; + this.panel.webview.postMessage({ type: 'projectScanned', ...data }); + } + + // --------------------------------------------------------------------------- + // HTML + // --------------------------------------------------------------------------- + private getHtml(): string { + const webview = this.panel.webview; + const nonce = getNonce(); + const csp = [ + `default-src 'none'`, + `style-src ${webview.cspSource} 'unsafe-inline'`, + `img-src ${webview.cspSource} data:`, + `script-src 'nonce-${nonce}'`, + ].join('; '); + + const css = getFileBrowserCss(); + const js = getFileBrowserJs(); + + return /* html */ ` + + + + + +File Browser + + +${FB_HTML_BODY} + + +`; + } + + // --------------------------------------------------------------------------- + // Dispose + // --------------------------------------------------------------------------- + private dispose(): void { + FileBrowserPanel.current = undefined; + this.panel.dispose(); + while (this.disposables.length) { + const d = this.disposables.pop(); + if (d) { d.dispose(); } + } + } +} + +// --------------------------------------------------------------------------- +// Nonce helper +// --------------------------------------------------------------------------- +function getNonce(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + return Array.from({ length: 32 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); +} + +// --------------------------------------------------------------------------- +// HTML body +// --------------------------------------------------------------------------- +const FB_HTML_BODY = ` +
+ + +
+
+ + + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ +
+ + +
+ + +
+
+
No file selected
+ +
+
+
+
+
+ +
+ +
+ + + + + + + + + + + + +`; + +// --------------------------------------------------------------------------- +// CSS +// --------------------------------------------------------------------------- +function getFileBrowserCss(): string { + return ` +/* reset / base */ +*, *::before, *::after { box-sizing: border-box; } +body { margin: 0; padding: 0; + font-family: var(--vscode-font-family); + color: var(--vscode-foreground); + background: var(--vscode-editor-background); + font-size: 13px; +} + +/* layout */ +#fb-app { + display: flex; + height: 100vh; + overflow: hidden; +} +#fb-tree-pane { + width: 45%; + min-width: 260px; + max-width: 600px; + border-right: 1px solid var(--vscode-panel-border); + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; +} +#fb-info-pane { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} +#fb-info-top { + display: flex; + flex-direction: column; + overflow: hidden; + flex: 0 0 auto; + max-height: 45%; +} +#fb-scan-pane { + flex: 1; + display: flex; + flex-direction: column; + border-top: 2px solid var(--vscode-panel-border); + overflow: hidden; + min-height: 0; +} +#fb-scan-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-bottom: 1px solid var(--vscode-panel-border); + font-weight: 600; + font-size: 12px; + flex-shrink: 0; +} +#fb-scan-status { font-weight: normal; color: var(--vscode-descriptionForeground); font-size: 11px; } +#fb-scan-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 6px 12px; + border-bottom: 1px solid var(--vscode-panel-border); + flex-shrink: 0; +} +#fb-scan-details { flex: 1; overflow-y: auto; padding: 8px 12px; } +.fb-chip { + display: inline-flex; align-items: center; padding: 2px 8px; + border-radius: 10px; font-size: 11px; cursor: pointer; + border: 1px solid transparent; user-select: none; color: #222; +} +.fb-chip:hover { filter: brightness(0.92); } +.fb-chip.active { border-color: var(--vscode-focusBorder); } +.scan-item { + border: 1px solid var(--vscode-panel-border); border-radius: 3px; + margin-bottom: 4px; padding: 4px 8px; font-size: 11px; +} +.scan-item-title { font-weight: 600; margin-bottom: 2px; } +.scan-kv { display: flex; gap: 6px; flex-wrap: wrap; } +.scan-k { color: var(--vscode-descriptionForeground); min-width: 80px; } +.scan-v { word-break: break-all; } + +/* toolbar */ +#fb-toolbar { + display: flex; + align-items: center; + gap: 2px; + padding: 5px 6px; + border-bottom: 1px solid var(--vscode-panel-border); +} +.fb-icon-btn { + background: transparent; + border: none; + color: var(--vscode-foreground); + cursor: pointer; + padding: 3px 7px; + border-radius: 3px; + font-size: 13px; + line-height: 1.2; +} +.fb-icon-btn:hover { background: var(--vscode-toolbar-hoverBackground); } +.fb-icon-btn:disabled { opacity: 0.4; cursor: default; } +.fb-spacer { flex: 1; } +.fb-go-btn { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + cursor: pointer; + padding: 4px 10px; + border-radius: 3px; + font-size: 12px; +} +.fb-go-btn:hover { background: var(--vscode-button-hoverBackground); } + +/* URL bar */ +#fb-url-bar { + display: flex; + padding: 4px 6px; + gap: 4px; + border-bottom: 1px solid var(--vscode-panel-border); +} +#fb-url-input { + flex: 1; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border, transparent); + padding: 4px 8px; + font-size: 12px; + border-radius: 2px; + outline: none; + font-family: var(--vscode-editor-font-family, monospace); +} +#fb-url-input:focus { border-color: var(--vscode-focusBorder); } + +/* breadcrumb */ +#fb-breadcrumb { + padding: 3px 8px; + font-size: 11px; + color: var(--vscode-descriptionForeground); + border-bottom: 1px solid var(--vscode-panel-border); + display: flex; + flex-wrap: wrap; + gap: 2px; + min-height: 22px; + align-items: center; +} +.bc-seg { + cursor: pointer; + color: var(--vscode-textLink-foreground); + text-decoration: none; +} +.bc-seg:hover { text-decoration: underline; } +.bc-sep { color: var(--vscode-descriptionForeground); } + +/* file list */ +#fb-file-list { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} +.fb-entry { + display: flex; + align-items: center; + padding: 3px 10px; + cursor: pointer; + gap: 6px; + border: 1px solid transparent; + border-radius: 2px; + position: relative; +} +.fb-entry:hover { background: var(--vscode-list-hoverBackground); } +.fb-entry.active { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} +.fb-entry-icon { width: 16px; text-align: center; flex-shrink: 0; font-size: 14px; } +.fb-entry-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.fb-entry-meta { font-size: 11px; color: var(--vscode-descriptionForeground); white-space: nowrap; flex-shrink: 0; } +.fb-entry.is-dir .fb-entry-name { font-weight: 500; } + +.fb-status { padding: 20px; color: var(--vscode-descriptionForeground); text-align: center; } +.fb-error { padding: 12px; color: var(--vscode-errorForeground); } + +/* spinner */ +#fb-spinner { + position: absolute; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 12px; + padding: 4px 12px; + font-size: 12px; + z-index: 20; +} +.spin { display: inline-block; animation: spin 1.2s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* info pane */ +#fb-info-header { + padding: 8px 12px; + border-bottom: 1px solid var(--vscode-panel-border); + display: flex; + align-items: flex-start; + flex-wrap: wrap; + gap: 6px; +} +#fb-info-title { + font-weight: bold; + font-size: 13px; + flex: 1; + min-width: 0; + word-break: break-all; +} +#fb-info-actions { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +#fb-info-actions button { + background: var(--vscode-button-secondaryBackground, var(--vscode-button-background)); + color: var(--vscode-button-secondaryForeground, var(--vscode-button-foreground)); + border: none; + cursor: pointer; + padding: 3px 9px; + border-radius: 3px; + font-size: 11px; +} +#fb-info-actions button:hover { background: var(--vscode-button-hoverBackground); } +#fb-info-actions button.primary { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} +#fb-info-actions button.danger { + background: var(--vscode-errorForeground, #f44); + color: #fff; +} +#fb-info-meta { + padding: 10px 12px; + font-size: 12px; + color: var(--vscode-descriptionForeground); + border-bottom: 1px solid var(--vscode-panel-border); +} +.info-row { display: flex; gap: 6px; margin-bottom: 4px; } +.info-key { font-weight: 600; color: var(--vscode-foreground); min-width: 100px; } +#fb-info-preview { + flex: 1; + overflow-y: auto; + padding: 8px 12px; +} +.preview-label { + font-size: 11px; + font-weight: 600; + color: var(--vscode-descriptionForeground); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.preview-text { + font-family: var(--vscode-editor-font-family, monospace); + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; + background: var(--vscode-textBlockQuote-background, rgba(128,128,128,0.1)); + padding: 8px; + border-radius: 3px; + max-height: 280px; + overflow-y: auto; +} +.intake-card { + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + padding: 8px 10px; + font-size: 12px; +} +.intake-type-badge { + display: inline-block; + background: var(--vscode-badge-background, #4d4d4d); + color: var(--vscode-badge-foreground, #fff); + border-radius: 4px; + padding: 2px 8px; + font-weight: 600; + font-size: 12px; + margin-bottom: 6px; +} +.intake-schema-hdr { + font-weight: 600; + font-size: 11px; + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 6px 0 3px; +} +.intake-col-row { + display: flex; + gap: 8px; + padding: 1px 0; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 11px; +} +.intake-col-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.intake-col-dtype { color: var(--vscode-descriptionForeground); flex-shrink: 0; } +.intake-row { display: flex; gap: 6px; margin-bottom: 3px; flex-wrap: wrap; } +.intake-key { font-weight: 600; min-width: 80px; } +.intake-val { word-break: break-all; } + +/* Bookmarks panel */ +#bm-panel { + position: absolute; + top: 38px; + left: 6px; + z-index: 50; + background: var(--vscode-menu-background, var(--vscode-editorWidget-background)); + color: var(--vscode-menu-foreground, var(--vscode-foreground)); + border: 1px solid var(--vscode-menu-border, var(--vscode-panel-border)); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0,0,0,0.35); + min-width: 260px; + max-width: 340px; + max-height: 360px; + display: flex; + flex-direction: column; +} +.bm-header { + display: flex; + align-items: center; + padding: 7px 10px; + font-weight: bold; + font-size: 12px; + border-bottom: 1px solid var(--vscode-panel-border); + gap: 6px; +} +.bm-header .fb-icon-btn { margin-left: auto; } +#bm-list { + flex: 1; + overflow-y: auto; +} +.bm-item { + display: flex; + align-items: center; + padding: 5px 10px; + gap: 6px; + cursor: pointer; + font-size: 12px; +} +.bm-item:hover { background: var(--vscode-list-hoverBackground); } +.bm-item-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.bm-item-url { font-size: 10px; color: var(--vscode-descriptionForeground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.bm-item-so { font-size: 10px; color: var(--vscode-descriptionForeground); font-style: italic; margin-top: 1px; } +.bm-remove { + background: transparent; border: none; cursor: pointer; + color: var(--vscode-descriptionForeground); padding: 1px 4px; + border-radius: 2px; font-size: 11px; +} +.bm-remove:hover { color: var(--vscode-errorForeground); } +.bm-empty { padding: 12px 10px; color: var(--vscode-descriptionForeground); font-size: 12px; } +.bm-footer { + border-top: 1px solid var(--vscode-panel-border); + padding: 6px 10px; +} +.bm-footer button { + background: transparent; + border: none; + color: var(--vscode-textLink-foreground); + cursor: pointer; + font-size: 11px; + padding: 0; +} +.bm-footer button:hover { text-decoration: underline; } + +/* Modals / overlays */ +.overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} +.fb-modal { + background: var(--vscode-editorWidget-background); + color: var(--vscode-editorWidget-foreground, var(--vscode-foreground)); + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-panel-border)); + border-radius: 6px; + min-width: 340px; + max-width: 85%; + box-shadow: 0 8px 24px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; +} +.fb-modal-title { + padding: 10px 14px; + font-weight: bold; + font-size: 14px; + border-bottom: 1px solid var(--vscode-panel-border); +} +.fb-modal-body { padding: 12px 14px; } +.fb-modal-body label { + display: block; + font-size: 12px; + margin-bottom: 4px; + color: var(--vscode-descriptionForeground); +} +.fb-modal-body input, .fb-modal-body textarea { + width: 100%; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border, var(--vscode-focusBorder, transparent)); + padding: 6px 8px; + font-size: 13px; + border-radius: 3px; + outline: none; + font-family: var(--vscode-editor-font-family, monospace); + resize: vertical; +} +.fb-modal-body input:focus, .fb-modal-body textarea:focus { border-color: var(--vscode-focusBorder); } +.hint { font-size: 11px; color: var(--vscode-descriptionForeground); margin: 0 0 10px; line-height: 1.5; } +.fb-modal-footer { + display: flex; + justify-content: flex-end; + gap: 6px; + padding: 10px 14px; + border-top: 1px solid var(--vscode-panel-border); +} +.fb-modal-footer button { + border: none; + cursor: pointer; + padding: 6px 14px; + font-size: 12px; + border-radius: 3px; +} +.fb-modal-footer button.primary { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} +.fb-modal-footer button.primary:hover { background: var(--vscode-button-hoverBackground); } +.fb-modal-footer button.secondary { + background: var(--vscode-button-secondaryBackground, transparent); + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); + border: 1px solid var(--vscode-panel-border); +} +.fb-modal-footer button.secondary:hover { background: var(--vscode-toolbar-hoverBackground); } + +.hidden { display: none !important; } +`; +} + +// --------------------------------------------------------------------------- +// JavaScript +// --------------------------------------------------------------------------- +function getFileBrowserJs(): string { + return String.raw` +(function() { + // Proof-of-life: visible immediately if the script block executes at all + document.title = 'File Browser [script running]'; + + // Show any uncaught JS errors as a red banner + window.onerror = function(msg, src, line, col, err) { + var d = document.createElement('div'); + d.style.cssText = 'position:fixed;top:0;left:0;right:0;padding:10px;background:#c00;color:#fff;font-family:monospace;font-size:12px;z-index:9999;white-space:pre-wrap;'; + d.textContent = 'FB error: ' + msg + ' (' + src + ':' + line + ')'; + document.body.appendChild(d); + }; + + const vscode = acquireVsCodeApi(); + + // ── debug log ───────────────────────────────────────────────────────── + const debugEl = document.getElementById('fb-debug'); + function dbg(msg) { + if (debugEl) { + const line = document.createElement('div'); + line.textContent = '[' + new Date().toISOString().slice(11,23) + '] ' + msg; + debugEl.appendChild(line); + debugEl.scrollTop = debugEl.scrollHeight; + } + console.log('[fb] ' + msg); + } + dbg('script started'); + + // ── state ────────────────────────────────────────────────────────────── + let bookmarks = []; + let protocols = []; + let currentUrl = ''; + let currentSo = ''; + let history = []; + let histIdx = -1; + let selected = null; + let newentryMode = 'file'; + + // ── DOM refs ─────────────────────────────────────────────────────────── + // NOTE: #fb-entries is the scrollable entry list. #fb-empty and + // #fb-error are siblings of #fb-entries inside #fb-file-list and must + // NEVER be cleared by setting innerHTML on their parent. + const entriesEl = document.getElementById('fb-entries'); + const emptyEl = document.getElementById('fb-empty'); + const errorEl = document.getElementById('fb-error'); + const urlInput = document.getElementById('fb-url-input'); + const breadcrumb = document.getElementById('fb-breadcrumb'); + const spinner = document.getElementById('fb-spinner'); + const infoTitle = document.getElementById('fb-info-title'); + const infoActions = document.getElementById('fb-info-actions'); + const infoMeta = document.getElementById('fb-info-meta'); + const infoPreview = document.getElementById('fb-info-preview'); + const scanPane = document.getElementById('fb-scan-pane'); + const scanStatus = document.getElementById('fb-scan-status'); + const scanChips = document.getElementById('fb-scan-chips'); + const scanDetails = document.getElementById('fb-scan-details'); + const bmPanel = document.getElementById('bm-panel'); + const bmList = document.getElementById('bm-list'); + const soOverlay = document.getElementById('so-overlay'); + const soInput = document.getElementById('so-input'); + const neOverlay = document.getElementById('newentry-overlay'); + const neTitle = document.getElementById('newentry-title'); + const neInput = document.getElementById('newentry-name'); + const renOverlay = document.getElementById('rename-overlay'); + const renInput = document.getElementById('rename-input'); + + // Verify critical elements exist + const missing = ['fb-entries','fb-empty','fb-error','fb-url-input','fb-breadcrumb', + 'fb-spinner','fb-info-title','fb-info-actions'].filter(id => !document.getElementById(id)); + if (missing.length) { dbg('ERROR: missing elements: ' + missing.join(', ')); } + else { dbg('all DOM elements found'); } + + // ── utilities ────────────────────────────────────────────────────────── + function basename(url) { + const s = (url || '').replace(/\/+$/, ''); + const i = s.lastIndexOf('/'); + return i >= 0 ? s.slice(i + 1) : s; + } + function parentUrl(url) { + const s = (url || '').replace(/\/+$/, ''); + const protoEnd = s.indexOf('://'); + if (protoEnd >= 0) { + const pathPart = s.slice(protoEnd + 3); + const slash = pathPart.lastIndexOf('/'); + if (slash <= 0) return s.slice(0, protoEnd + 3) || s; + return s.slice(0, protoEnd + 3 + slash); + } + const slash = s.lastIndexOf('/'); + if (slash <= 0) return '/'; + return s.slice(0, slash); + } + function fmtSize(bytes) { + if (bytes == null) return ''; + const u = ['B','KB','MB','GB','TB']; + let n = parseFloat(bytes); + for (let i = 0; i < u.length; i++) { + if (n < 1024 || i === u.length - 1) return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + u[i]; + n /= 1024; + } + } + function fmtDate(ts) { + if (!ts) return ''; + const d = new Date(parseFloat(ts) * 1000); + return d.toLocaleString(); + } + function escHtml(s) { + return String(s || '').replace(/[&<>"']/g, + c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + } + function fileIcon(entry) { + if (entry.type === 'directory') return '\uD83D\uDCC1'; // folder + const name = (entry.basename || entry.name || '').toLowerCase(); + if (/\.(py|pyx|pyi)$/.test(name)) return '\uD83D\uDC0D'; // snake + if (/\.(js|ts|jsx|tsx)$/.test(name)) return '\uD83D\uDCDC'; // scroll + if (/\.(json|yaml|yml|toml|ini|cfg)$/.test(name)) return '\u2699\uFE0F'; // gear + if (/\.(md|rst|txt|org)$/.test(name)) return '\uD83D\uDCC4'; // page + if (/\.(csv|tsv|parquet|hdf5?|nc|zarr|feather)$/.test(name)) return '\uD83D\uDCCA'; // chart + if (/\.(png|jpg|jpeg|gif|svg|webp|bmp|tiff?)$/.test(name)) return '\uD83D\uDDBC\uFE0F'; // picture + if (/\.(zip|tar|gz|bz2|xz|7z|rar)$/.test(name)) return '\uD83D\uDCE6'; // package + if (/\.(sh|bash|zsh|fish|ps1|bat|cmd)$/.test(name)) return '\uD83D\uDCBB'; // computer + return '\uD83D\uDCC4'; // page + } + + // ── browse result ────────────────────────────────────────────────────── + function renderBrowse(data) { + dbg('renderBrowse url=' + data.url + ' entries=' + (data.entries ? data.entries.length : 'none') + ' error=' + data.error); + currentUrl = data.url || ''; + urlInput.value = currentUrl; + renderBreadcrumb(currentUrl); + + // Clear only the entries container — never touch fb-empty/fb-error + entriesEl.innerHTML = ''; + emptyEl.classList.add('hidden'); + errorEl.classList.add('hidden'); + + if (data.error) { + errorEl.textContent = 'Error: ' + data.error; + errorEl.classList.remove('hidden'); + return; + } + + const entries = data.entries || []; + if (entries.length === 0) { + emptyEl.classList.remove('hidden'); + return; + } + + for (const entry of entries) { + const row = document.createElement('div'); + row.className = 'fb-entry' + (entry.type === 'directory' ? ' is-dir' : ''); + row.dataset.url = entry.name; + row.dataset.type = entry.type || 'file'; + + const icon = document.createElement('span'); + icon.className = 'fb-entry-icon'; + icon.textContent = fileIcon(entry); + + const name = document.createElement('span'); + name.className = 'fb-entry-name'; + name.textContent = entry.basename || basename(entry.name); + name.title = entry.name; + + const meta = document.createElement('span'); + meta.className = 'fb-entry-meta'; + if (entry.type !== 'directory' && entry.size != null) { + meta.textContent = fmtSize(entry.size); + } + + row.appendChild(icon); + row.appendChild(name); + row.appendChild(meta); + + row.addEventListener('click', function(e) { + e.stopPropagation(); + selectEntry(entry.name, entry.type, row); + }); + + if (entry.type === 'directory') { + row.addEventListener('dblclick', function(e) { + e.stopPropagation(); + navigateTo(entry.name, currentSo); + }); + } + + entriesEl.appendChild(row); + } + dbg('rendered ' + entries.length + ' entries'); + } + + function selectEntry(url, type, rowEl) { + document.querySelectorAll('.fb-entry.active').forEach(function(el) { el.classList.remove('active'); }); + if (rowEl) rowEl.classList.add('active'); + selected = { url: url, type: type, so: currentSo }; + dbg('selected ' + type + ': ' + url); + + infoTitle.textContent = basename(url); + infoActions.classList.remove('hidden'); + + const isFile = type !== 'directory'; + document.getElementById('btn-open-editor').style.display = isFile ? '' : 'none'; + document.getElementById('btn-add-to-lib').style.display = type === 'directory' ? '' : 'none'; + + infoMeta.innerHTML = ''; + infoPreview.innerHTML = ''; + + // Always hide and reset the scan pane when selection changes + if (scanPane) { + scanPane.classList.add('hidden'); + if (scanChips) scanChips.innerHTML = ''; + if (scanDetails) scanDetails.innerHTML = ''; + if (scanStatus) scanStatus.textContent = ''; + } + + if (isFile) { + dbg('posting inspect for ' + url); + vscode.postMessage({ cmd: 'inspect', url: url, storageOptions: currentSo || undefined }); + } else { + renderMeta({ type: 'directory', url: url }); + // Trigger projspec scan on directory selection + if (scanPane) { + scanPane.classList.remove('hidden'); + if (scanStatus) scanStatus.textContent = 'scanning...'; + } + dbg('posting scanDir for ' + url); + vscode.postMessage({ cmd: 'scanDir', url: url, storageOptions: currentSo || undefined }); + } + } + + function renderBreadcrumb(url) { + breadcrumb.innerHTML = ''; + if (!url) return; + const protoMatch = url.match(/^([a-z][a-z0-9+.\-]*):\/\//i); + let proto = ''; + let rest = url; + if (protoMatch) { + proto = protoMatch[0]; + rest = url.slice(proto.length); + } + const parts = rest.replace(/\/+$/, '').split('/').filter(Boolean); + if (proto) { + const link = document.createElement('span'); + link.className = 'bc-seg'; + link.textContent = proto; + link.title = proto; + link.addEventListener('click', function() { navigateTo(proto, currentSo); }); + breadcrumb.appendChild(link); + } + let accumulated = proto; + for (let i = 0; i < parts.length; i++) { + accumulated += (accumulated.slice(-1) === '/' ? '' : '/') + parts[i]; + const sep = document.createElement('span'); + sep.className = 'bc-sep'; + sep.textContent = ' / '; + breadcrumb.appendChild(sep); + const link = document.createElement('span'); + link.className = 'bc-seg'; + link.textContent = parts[i]; + const target = accumulated; + link.title = target; + link.addEventListener('click', function() { navigateTo(target, currentSo); }); + breadcrumb.appendChild(link); + } + } + + function renderMeta(data) { + infoMeta.innerHTML = ''; + var rows = []; + if (data.type) rows.push(['Type', data.type]); + if (data.size != null) rows.push(['Size', fmtSize(data.size)]); + if (data.last_modified) rows.push(['Modified', fmtDate(data.last_modified)]); + if (data.mime_type) rows.push(['MIME', data.mime_type]); + for (var i = 0; i < rows.length; i++) { + const row = document.createElement('div'); + row.className = 'info-row'; + row.innerHTML = '' + escHtml(rows[i][0]) + '' + escHtml(String(rows[i][1])) + ''; + infoMeta.appendChild(row); + } + } + + function renderInspect(data) { + dbg('renderInspect name=' + data.name + ' error=' + data.error); + renderMeta(data); + infoPreview.innerHTML = ''; + + if (data.intake) { + const intake = data.intake; + + // Header: primary type badge + const hdr = document.createElement('div'); + hdr.className = 'preview-label'; + hdr.textContent = 'Data type (intake)'; + infoPreview.appendChild(hdr); + + const card = document.createElement('div'); + card.className = 'intake-card'; + + // Primary type — shown prominently + if (intake.primary_type) { + const typeRow = document.createElement('div'); + typeRow.className = 'intake-type-badge'; + typeRow.textContent = intake.primary_type; + card.appendChild(typeRow); + } + + // Additional recognised types (if more than one) + if (intake.types && intake.types.length > 1) { + const also = document.createElement('div'); + also.className = 'intake-row'; + also.innerHTML = 'also' + + '' + escHtml(intake.types.slice(1).join(', ')) + ''; + card.appendChild(also); + } + + // Schema: dtype dict → column table + if (intake.dtype && typeof intake.dtype === 'object' && !Array.isArray(intake.dtype)) { + const schemaHdr = document.createElement('div'); + schemaHdr.className = 'intake-schema-hdr'; + schemaHdr.textContent = 'Columns'; + card.appendChild(schemaHdr); + const cols = Object.keys(intake.dtype); + for (var ci = 0; ci < cols.length; ci++) { + const colRow = document.createElement('div'); + colRow.className = 'intake-col-row'; + colRow.innerHTML = '' + escHtml(cols[ci]) + '' + + '' + escHtml(String(intake.dtype[cols[ci]])) + ''; + card.appendChild(colRow); + } + } + + // Other schema fields (shape, npartitions, etc.) — skip types/dtype/primary_type + const SKIP = new Set(['types', 'dtype', 'primary_type', 'name']); + const entries = Object.entries(intake); + for (var i = 0; i < entries.length; i++) { + const k = entries[i][0], v = entries[i][1]; + if (SKIP.has(k) || v == null) continue; + const row = document.createElement('div'); + row.className = 'intake-row'; + const vStr = typeof v === 'object' ? JSON.stringify(v) : String(v); + row.innerHTML = '' + escHtml(k) + '' + + '' + escHtml(vStr) + ''; + card.appendChild(row); + } + + infoPreview.appendChild(card); + } + + // Text preview — always shown for text files + if (data.text_preview) { + const label = document.createElement('div'); + label.className = 'preview-label'; + label.style.marginTop = '10px'; + label.textContent = 'Preview'; + infoPreview.appendChild(label); + const pre = document.createElement('pre'); + pre.className = 'preview-text'; + pre.textContent = data.text_preview; + infoPreview.appendChild(pre); + } + } + + // ── navigation ───────────────────────────────────────────────────────── + function navigateTo(url, so, push) { + const shouldPush = push !== false; + dbg('navigateTo push=' + shouldPush + ' url=' + url); + vscode.postMessage({ cmd: 'browse', url: url, storageOptions: so || undefined, push: shouldPush }); + selected = null; + infoTitle.textContent = 'Loading...'; + infoActions.classList.add('hidden'); + infoMeta.innerHTML = ''; + infoPreview.innerHTML = ''; + } + + // ── bookmarks ────────────────────────────────────────────────────────── + function renderBookmarks() { + bmList.innerHTML = ''; + if (!bookmarks.length) { + const e = document.createElement('div'); + e.className = 'bm-empty'; + e.textContent = 'No bookmarks yet.'; + bmList.appendChild(e); + return; + } + for (var i = 0; i < bookmarks.length; i++) { + const bm = bookmarks[i]; + const row = document.createElement('div'); + row.className = 'bm-item'; + const info = document.createElement('div'); + info.style.flex = '1'; + info.style.overflow = 'hidden'; + const lbl = document.createElement('div'); + lbl.className = 'bm-item-label'; + lbl.textContent = bm.label || bm.url; + const urlDiv = document.createElement('div'); + urlDiv.className = 'bm-item-url'; + urlDiv.textContent = bm.url; + info.appendChild(lbl); + info.appendChild(urlDiv); + // Show a key icon if the bookmark has stored storage_options + if (bm.storage_options && Object.keys(bm.storage_options).length > 0) { + const soTag = document.createElement('div'); + soTag.className = 'bm-item-so'; + soTag.title = 'Saved storage options: ' + JSON.stringify(bm.storage_options); + soTag.textContent = '\uD83D\uDD11 credentials stored'; + info.appendChild(soTag); + } + const rm = document.createElement('button'); + rm.className = 'bm-remove'; + rm.title = 'Remove bookmark'; + rm.textContent = 'X'; + const bmUrl = bm.url; + // Serialise the bookmark's own storage_options as a JSON string (or ''). + // These are scoped to this URL only — they replace currentSo entirely + // when navigating to a different protocol/host. + const bmSo = (bm.storage_options && Object.keys(bm.storage_options).length > 0) + ? JSON.stringify(bm.storage_options) : ''; + rm.addEventListener('click', function(e) { + e.stopPropagation(); + vscode.postMessage({ cmd: 'removeBookmark', url: bmUrl }); + }); + row.appendChild(info); + row.appendChild(rm); + row.addEventListener('click', function(ev) { + if (ev.target === rm) return; + bmPanel.classList.add('hidden'); + // Switch currentSo to the bookmark's own options (may be empty) + currentSo = bmSo; + navigateTo(bmUrl, bmSo); + }); + bmList.appendChild(row); + } + } + + // ── toolbar ──────────────────────────────────────────────────────────── + document.getElementById('btn-back').addEventListener('click', function() { + if (histIdx > 0) { + histIdx--; + const h = history[histIdx]; + currentSo = h.so; + dbg('back to ' + h.url); + vscode.postMessage({ cmd: 'browse', url: h.url, storageOptions: h.so || undefined, push: false }); + selected = null; + infoTitle.textContent = 'Loading...'; + infoActions.classList.add('hidden'); + infoMeta.innerHTML = ''; + infoPreview.innerHTML = ''; + } + }); + document.getElementById('btn-up').addEventListener('click', function() { + const p = parentUrl(currentUrl); + if (p && p !== currentUrl) { navigateTo(p, currentSo); } + }); + document.getElementById('btn-refresh').addEventListener('click', function() { + navigateTo(currentUrl, currentSo, false); + }); + document.getElementById('btn-bm-dropdown').addEventListener('click', function(e) { + e.stopPropagation(); + bmPanel.classList.toggle('hidden'); + if (!bmPanel.classList.contains('hidden')) renderBookmarks(); + }); + document.getElementById('bm-close').addEventListener('click', function() { + bmPanel.classList.add('hidden'); + }); + document.getElementById('btn-bm-add-current').addEventListener('click', function() { + bmPanel.classList.add('hidden'); + vscode.postMessage({ cmd: 'addBookmark', url: currentUrl, storageOptions: currentSo || undefined }); + }); + document.addEventListener('click', function(e) { + if (!bmPanel.contains(e.target) && e.target !== document.getElementById('btn-bm-dropdown')) { + bmPanel.classList.add('hidden'); + } + }); + + // Storage options + document.getElementById('btn-so').addEventListener('click', function(e) { + e.stopPropagation(); + soInput.value = currentSo; + soOverlay.classList.remove('hidden'); + setTimeout(function() { soInput.focus(); }, 0); + }); + document.getElementById('so-cancel').addEventListener('click', function() { + soOverlay.classList.add('hidden'); + }); + document.getElementById('so-ok').addEventListener('click', function() { + const val = soInput.value.trim(); + try { + if (val) JSON.parse(val); + currentSo = val; + } catch(ex) { + alert('Storage options must be valid JSON: ' + ex.message); + return; + } + soOverlay.classList.add('hidden'); + dbg('storage options set: ' + currentSo); + }); + soOverlay.addEventListener('click', function(e) { + if (e.target === soOverlay) soOverlay.classList.add('hidden'); + }); + + // Go / URL bar + document.getElementById('btn-go').addEventListener('click', function() { + const url = urlInput.value.trim(); + if (url) { navigateTo(url, currentSo); } + }); + urlInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + const url = urlInput.value.trim(); + if (url) { navigateTo(url, currentSo); } + } + }); + + // New file / folder + document.getElementById('btn-new-file').addEventListener('click', function() { + newentryMode = 'file'; + neTitle.textContent = 'New file'; + neInput.value = ''; + neOverlay.classList.remove('hidden'); + setTimeout(function() { neInput.focus(); }, 0); + }); + document.getElementById('btn-new-dir').addEventListener('click', function() { + newentryMode = 'dir'; + neTitle.textContent = 'New folder'; + neInput.value = ''; + neOverlay.classList.remove('hidden'); + setTimeout(function() { neInput.focus(); }, 0); + }); + document.getElementById('newentry-cancel').addEventListener('click', function() { + neOverlay.classList.add('hidden'); + }); + document.getElementById('newentry-ok').addEventListener('click', function() { + const name = neInput.value.trim(); + if (!name) return; + neOverlay.classList.add('hidden'); + if (newentryMode === 'file') { + dbg('createFile ' + name); + vscode.postMessage({ cmd: 'createFile', parentUrl: currentUrl, name: name, storageOptions: currentSo || undefined }); + } else { + dbg('mkdir ' + name); + vscode.postMessage({ cmd: 'mkdir', parentUrl: currentUrl, name: name, storageOptions: currentSo || undefined }); + } + }); + neInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') document.getElementById('newentry-ok').click(); + if (e.key === 'Escape') neOverlay.classList.add('hidden'); + }); + neOverlay.addEventListener('click', function(e) { + if (e.target === neOverlay) neOverlay.classList.add('hidden'); + }); + + // Info panel actions + document.getElementById('btn-open-editor').addEventListener('click', function() { + if (!selected || selected.type === 'directory') return; + dbg('openFile ' + selected.url); + vscode.postMessage({ cmd: 'openFile', url: selected.url, storageOptions: selected.so || undefined }); + }); + document.getElementById('btn-add-to-lib').addEventListener('click', function() { + if (!selected) return; + dbg('addToLibrary ' + selected.url); + vscode.postMessage({ cmd: 'addToLibrary', url: selected.url, storageOptions: selected.so || undefined }); + }); + document.getElementById('btn-bookmark').addEventListener('click', function() { + const url = selected ? selected.url : currentUrl; + dbg('addBookmark ' + url); + vscode.postMessage({ cmd: 'addBookmark', url: url, storageOptions: currentSo || undefined }); + }); + document.getElementById('btn-delete-sel').addEventListener('click', function() { + if (!selected) return; + dbg('deleteEntry ' + selected.url); + vscode.postMessage({ + cmd: 'deleteEntry', + url: selected.url, + isDir: selected.type === 'directory', + storageOptions: selected.so || undefined, + }); + }); + document.getElementById('btn-rename-sel').addEventListener('click', function() { + if (!selected) return; + renInput.value = basename(selected.url); + renOverlay.classList.remove('hidden'); + setTimeout(function() { renInput.focus(); }, 0); + }); + + // Rename modal + document.getElementById('rename-cancel').addEventListener('click', function() { + renOverlay.classList.add('hidden'); + }); + document.getElementById('rename-ok').addEventListener('click', function() { + const newName = renInput.value.trim(); + if (!newName || !selected) return; + renOverlay.classList.add('hidden'); + dbg('rename ' + selected.url + ' -> ' + newName); + vscode.postMessage({ cmd: 'renameEntry', url: selected.url, newName: newName, storageOptions: selected.so || undefined }); + }); + renInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') document.getElementById('rename-ok').click(); + if (e.key === 'Escape') renOverlay.classList.add('hidden'); + }); + renOverlay.addEventListener('click', function(e) { + if (e.target === renOverlay) renOverlay.classList.add('hidden'); + }); + + // ── projspec scan rendering ──────────────────────────────────────────── + const PALETTE = [ + '#f9c0c0','#f9dcc0','#f9f0c0','#d9f9c0','#c0f9d2', + '#c0f9f0','#c0e3f9','#c0ccf9','#d6c0f9','#efc0f9', + '#f9c0e3','#d9c9b5','#b5d9c9','#b5c9d9','#c9b5d9', + ]; + function hashStr(s) { + let h = 0; + for (let i = 0; i < s.length; i++) { h = ((h << 5) - h + s.charCodeAt(i)) | 0; } + return Math.abs(h); + } + function chipColour(label) { return PALETTE[hashStr(label) % PALETTE.length]; } + + function renderProjectScan(data) { + if (!scanChips || !scanDetails || !scanStatus) return; + scanChips.innerHTML = ''; + scanDetails.innerHTML = ''; + if (data.error) { + scanStatus.textContent = data.error; + return; + } + const proj = data.project; + if (!proj) { scanStatus.textContent = 'no project data'; return; } + + const specs = proj.specs || {}; + const specNames = Object.keys(specs); + if (specNames.length === 0) { + scanStatus.textContent = 'no specs found'; + const msg = document.createElement('div'); + msg.style.cssText = 'color:var(--vscode-descriptionForeground);font-size:12px;font-style:italic;padding:4px 0;'; + msg.textContent = 'No project specs detected in this directory.'; + scanDetails.appendChild(msg); + return; + } + + scanStatus.textContent = specNames.length + ' spec' + (specNames.length !== 1 ? 's' : ''); + + let activeChip = null; + function showSpec(name) { + scanDetails.innerHTML = ''; + if (activeChip) activeChip.classList.remove('active'); + const spec = specs[name]; + if (!spec) return; + renderSpecItems(scanDetails, spec._contents || {}, 'Content'); + renderSpecItems(scanDetails, spec._artifacts || {}, 'Artifact'); + } + + for (var i = 0; i < specNames.length; i++) { + const name = specNames[i]; + const chip = document.createElement('span'); + chip.className = 'fb-chip'; + chip.style.background = chipColour(name); + chip.textContent = name; + (function(n, c) { + c.addEventListener('click', function() { + activeChip = c; c.classList.add('active'); showSpec(n); + }); + })(name, chip); + scanChips.appendChild(chip); + } + // Auto-show first + const firstChip = scanChips.querySelector('.fb-chip'); + if (firstChip) { activeChip = firstChip; firstChip.classList.add('active'); } + if (specNames.length > 0) showSpec(specNames[0]); + } + + function renderSpecItems(container, items, kindLabel) { + const keys = Object.keys(items); + for (var i = 0; i < keys.length; i++) { + renderOneItem(container, keys[i], items[keys[i]], kindLabel); + } + } + + function renderOneItem(container, typeName, val, kindLabel) { + if (!val) return; + if (Array.isArray(val)) { + for (var i = 0; i < val.length; i++) renderOneItem(container, typeName, val[i], kindLabel); + return; + } + if (typeof val === 'object') { + if ('klass' in val) { + appendItemCard(container, typeName, null, val, kindLabel); + } else { + const names = Object.keys(val); + for (var j = 0; j < names.length; j++) { + appendItemCard(container, typeName, names[j], val[names[j]], kindLabel); + } + } + } + } + + function appendItemCard(container, typeName, itemName, data, kindLabel) { + const card = document.createElement('div'); + card.className = 'scan-item'; + const title = document.createElement('div'); + title.className = 'scan-item-title'; + title.textContent = kindLabel + ': ' + typeName + (itemName ? ' \u2014 ' + itemName : ''); + card.appendChild(title); + if (data && typeof data === 'object') { + const kv = document.createElement('div'); + kv.className = 'scan-kv'; + const entries = Object.entries(data); + for (var i = 0; i < entries.length; i++) { + const k = entries[i][0], v = entries[i][1]; + if (k === 'klass') continue; + const kEl = document.createElement('span'); kEl.className = 'scan-k'; kEl.textContent = k + ':'; + const vEl = document.createElement('span'); vEl.className = 'scan-v'; + vEl.textContent = typeof v === 'object' ? JSON.stringify(v) : String(v == null ? '' : v); + kv.appendChild(kEl); kv.appendChild(vEl); + } + card.appendChild(kv); + } + container.appendChild(card); + } + + // ── message bus ──────────────────────────────────────────────────────── + window.addEventListener('message', function(ev) { + const msg = ev.data; + dbg('recv type=' + msg.type); + switch (msg.type) { + case 'loading': + spinner.classList.toggle('hidden', !msg.loading); + break; + + case 'init': + bookmarks = msg.bookmarks || []; + protocols = msg.protocols || []; + dbg('init: ' + bookmarks.length + ' bookmarks, ' + protocols.length + ' protocols'); + break; + + case 'browseResult': + // Keep currentSo in sync with the storage options that were + // used for this browse (the host echoes them back). + if (typeof msg.storageOptions === 'string') { + currentSo = msg.storageOptions; + } + renderBrowse(msg); + if (msg.pushHistory) { + history = history.slice(0, histIdx + 1); + history.push({ url: msg.url, so: currentSo }); + histIdx = history.length - 1; + dbg('history push, depth=' + history.length); + } + break; + + case 'inspectResult': + renderInspect(msg); + break; + + case 'projectScanned': + dbg('projectScanned url=' + msg.url + ' error=' + msg.error); + if (scanStatus) scanStatus.textContent = ''; + renderProjectScan(msg); + break; + + case 'bookmarksUpdated': + bookmarks = msg.bookmarks || []; + dbg('bookmarks updated: ' + bookmarks.length); + if (!bmPanel.classList.contains('hidden')) renderBookmarks(); + break; + + case 'error': + errorEl.textContent = 'Error: ' + (msg.message || 'unknown'); + errorEl.classList.remove('hidden'); + dbg('error: ' + msg.message); + break; + } + }); + + dbg('sending ready'); + vscode.postMessage({ cmd: 'ready' }); +})(); +`; + +} diff --git a/vsextension/src/sidebarView.ts b/vsextension/src/sidebarView.ts index 9401de9..42c1800 100644 --- a/vsextension/src/sidebarView.ts +++ b/vsextension/src/sidebarView.ts @@ -1,10 +1,10 @@ import * as vscode from 'vscode'; /** - * Simple sidebar view that provides a button to launch the main Project - * Library webview panel. Keeping this view lightweight (just a command - * trigger) leaves the full two-panel UI to be rendered in a roomy editor-area - * WebviewPanel, as described in ACTIONS.md. + * Simple sidebar view that provides buttons to launch the main Project + * Library webview panel and the File Browser. Keeping this view lightweight + * (just command triggers) leaves the full UIs to be rendered in roomy + * editor-area WebviewPanels, as described in ACTIONS.md. */ export class SidebarViewProvider implements vscode.WebviewViewProvider { constructor(private readonly _extensionUri: vscode.Uri) {} @@ -15,6 +15,8 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { webviewView.webview.onDidReceiveMessage((msg) => { if (msg.cmd === 'open') { vscode.commands.executeCommand('projspec.showTree'); + } else if (msg.cmd === 'openFileBrowser') { + vscode.commands.executeCommand('projspec.openFileBrowserHere'); } }); } @@ -36,8 +38,15 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { border-radius: 3px; font-size: 13px; width: 100%; + margin-bottom: 8px; } button:hover { background: var(--vscode-button-hoverBackground); } + button.secondary { + background: var(--vscode-button-secondaryBackground, transparent); + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); + border: 1px solid var(--vscode-panel-border); + } + button.secondary:hover { background: var(--vscode-toolbar-hoverBackground); } p { color: var(--vscode-descriptionForeground); font-size: 12px; } @@ -45,6 +54,7 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider {

projspec manages a library of projects, scans directories for known project types, and can build/run their artifacts.

+ `; } From aa3027cc5fbeb8e5ae73b6b38dfaf746e4659b5f Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Sun, 5 Jul 2026 08:25:11 -0400 Subject: [PATCH 02/13] project scan --- vsextension/src/fileBrowserPanel.ts | 268 ++++++++++++---------------- 1 file changed, 110 insertions(+), 158 deletions(-) diff --git a/vsextension/src/fileBrowserPanel.ts b/vsextension/src/fileBrowserPanel.ts index 05a9262..ae17305 100644 --- a/vsextension/src/fileBrowserPanel.ts +++ b/vsextension/src/fileBrowserPanel.ts @@ -188,7 +188,6 @@ export class FileBrowserPanel { } catch (e) { log('supported_protocols error: ' + e); } log(`prefetchInit done: ${bookmarks.length} bookmarks, ${protocols.length} protocols`); this.pendingInit = { bookmarks, protocols, startUrl: startUrl || os.homedir() }; - // If 'ready' already arrived before we finished, flush now. if (this.readyReceived) { void this.sendInitialData(); } @@ -593,16 +592,72 @@ export class FileBrowserPanel { const css = getFileBrowserCss(); const js = getFileBrowserJs(); + // Read shared panel assets from the projspec webui package on disk. + // Done synchronously here so the assets are in the original HTML + // response — all three ', ''); + html = html.replace('', ''); + const bodyStart = html.indexOf('') + ''.length; + const bodyEnd = html.lastIndexOf(''); + panelBodyHtml = html.slice(bodyStart, bodyEnd).trim(); + log(`panel assets: css=${panelCss.length} js=${panelJs.length} html=${panelBodyHtml.length}`); + } catch (e) { + log(`panel asset read error: ${e}`); + } + + // Transport bootstrap: runs BEFORE panel.js so projspecTransport is + // set when panel.js starts. Written as a plain string so it does not + // contain any template-literal backticks that could confuse editors. + const bootstrap = '(function(){' + + 'var root=document.getElementById("fb-scan-panel-root");' + + 'if(!root)return;' + + 'var pending=[];' + + 'window.__fbPanelDeliver=function(msg){' + + 'if(window.__fbPanelDispatch){window.__fbPanelDispatch(msg);}' + + 'else{pending.push(msg);}};' + + 'window.projspecRoot=root;' + + 'window.projspecTransport={' + + 'send:function(){},' + + 'onReady:function(d){' + + 'window.__fbPanelDispatch=d;' + + 'pending.forEach(function(m){d(m);});pending=[];' + + 'delete window.projspecRoot;delete window.projspecTransport;' + + '}};' + + '})()'; + + const pageBody = FB_HTML_BODY.replace( + '
', + `
${panelBodyHtml}
` + ); + return /* html */ ` - + File Browser -${FB_HTML_BODY} +${pageBody} + + `; @@ -685,15 +740,10 @@ const FB_HTML_BODY = `
-
- + + @@ -806,41 +856,35 @@ body { margin: 0; padding: 0; overflow: hidden; min-height: 0; } -#fb-scan-header { +#fb-scan-panel-root { + flex: 1; + overflow: hidden; display: flex; - align-items: center; - gap: 8px; - padding: 6px 12px; - border-bottom: 1px solid var(--vscode-panel-border); - font-weight: 600; - font-size: 12px; - flex-shrink: 0; + flex-direction: column; } -#fb-scan-status { font-weight: normal; color: var(--vscode-descriptionForeground); font-size: 11px; } -#fb-scan-chips { - display: flex; - flex-wrap: wrap; - gap: 4px; - padding: 6px 12px; - border-bottom: 1px solid var(--vscode-panel-border); - flex-shrink: 0; +#fb-scan-panel-root #app { + flex-direction: column; + height: 100%; + overflow: hidden; } -#fb-scan-details { flex: 1; overflow-y: auto; padding: 8px 12px; } -.fb-chip { - display: inline-flex; align-items: center; padding: 2px 8px; - border-radius: 10px; font-size: 11px; cursor: pointer; - border: 1px solid transparent; user-select: none; color: #222; +#fb-scan-panel-root #library { + width: 100% !important; + max-width: 100% !important; + border-right: none !important; + flex: 0 0 auto; + max-height: 50%; + overflow: hidden; } -.fb-chip:hover { filter: brightness(0.92); } -.fb-chip.active { border-color: var(--vscode-focusBorder); } -.scan-item { - border: 1px solid var(--vscode-panel-border); border-radius: 3px; - margin-bottom: 4px; padding: 4px 8px; font-size: 11px; +#fb-scan-panel-root .toolbar, +#fb-scan-panel-root .search, +#fb-scan-panel-root #spinner { display: none !important; } +#fb-scan-panel-root #projects { flex: 1; overflow-y: auto; padding: 4px; } +#fb-scan-panel-root #details { + flex: 1; + border-top: 1px solid var(--vscode-panel-border); + overflow: hidden; + min-height: 0; } -.scan-item-title { font-weight: 600; margin-bottom: 2px; } -.scan-kv { display: flex; gap: 6px; flex-wrap: wrap; } -.scan-k { color: var(--vscode-descriptionForeground); min-width: 80px; } -.scan-v { word-break: break-all; } /* toolbar */ #fb-toolbar { @@ -1268,8 +1312,7 @@ function getFileBrowserJs(): string { const infoPreview = document.getElementById('fb-info-preview'); const scanPane = document.getElementById('fb-scan-pane'); const scanStatus = document.getElementById('fb-scan-status'); - const scanChips = document.getElementById('fb-scan-chips'); - const scanDetails = document.getElementById('fb-scan-details'); + const scanPanelRoot = document.getElementById('fb-scan-panel-root'); const bmPanel = document.getElementById('bm-panel'); const bmList = document.getElementById('bm-list'); const soOverlay = document.getElementById('so-overlay'); @@ -1422,9 +1465,7 @@ function getFileBrowserJs(): string { // Always hide and reset the scan pane when selection changes if (scanPane) { scanPane.classList.add('hidden'); - if (scanChips) scanChips.innerHTML = ''; - if (scanDetails) scanDetails.innerHTML = ''; - if (scanStatus) scanStatus.textContent = ''; + if (scanStatus) scanStatus.textContent = ''; } if (isFile) { @@ -1814,119 +1855,33 @@ function getFileBrowserJs(): string { if (e.target === renOverlay) renOverlay.classList.add('hidden'); }); - // ── projspec scan rendering ──────────────────────────────────────────── - const PALETTE = [ - '#f9c0c0','#f9dcc0','#f9f0c0','#d9f9c0','#c0f9d2', - '#c0f9f0','#c0e3f9','#c0ccf9','#d6c0f9','#efc0f9', - '#f9c0e3','#d9c9b5','#b5d9c9','#b5c9d9','#c9b5d9', - ]; - function hashStr(s) { - let h = 0; - for (let i = 0; i < s.length; i++) { h = ((h << 5) - h + s.charCodeAt(i)) | 0; } - return Math.abs(h); - } - function chipColour(label) { return PALETTE[hashStr(label) % PALETTE.length]; } - - function renderProjectScan(data) { - if (!scanChips || !scanDetails || !scanStatus) return; - scanChips.innerHTML = ''; - scanDetails.innerHTML = ''; - if (data.error) { - scanStatus.textContent = data.error; - return; - } - const proj = data.project; - if (!proj) { scanStatus.textContent = 'no project data'; return; } - - const specs = proj.specs || {}; - const specNames = Object.keys(specs); - if (specNames.length === 0) { - scanStatus.textContent = 'no specs found'; - const msg = document.createElement('div'); - msg.style.cssText = 'color:var(--vscode-descriptionForeground);font-size:12px;font-style:italic;padding:4px 0;'; - msg.textContent = 'No project specs detected in this directory.'; - scanDetails.appendChild(msg); - return; - } - - scanStatus.textContent = specNames.length + ' spec' + (specNames.length !== 1 ? 's' : ''); - - let activeChip = null; - function showSpec(name) { - scanDetails.innerHTML = ''; - if (activeChip) activeChip.classList.remove('active'); - const spec = specs[name]; - if (!spec) return; - renderSpecItems(scanDetails, spec._contents || {}, 'Content'); - renderSpecItems(scanDetails, spec._artifacts || {}, 'Artifact'); - } + // ── embedded projspec panel ──────────────────────────────────────────── + // The shared panel JS was already run by the inline bootstrap script in + // getHtml() before this IIFE executed. The bootstrap installed + // window.__fbPanelDeliver(msg) — call it to push a data message into + // the embedded panel. - for (var i = 0; i < specNames.length; i++) { - const name = specNames[i]; - const chip = document.createElement('span'); - chip.className = 'fb-chip'; - chip.style.background = chipColour(name); - chip.textContent = name; - (function(n, c) { - c.addEventListener('click', function() { - activeChip = c; c.classList.add('active'); showSpec(n); - }); - })(name, chip); - scanChips.appendChild(chip); - } - // Auto-show first - const firstChip = scanChips.querySelector('.fb-chip'); - if (firstChip) { activeChip = firstChip; firstChip.classList.add('active'); } - if (specNames.length > 0) showSpec(specNames[0]); - } + function showProjectInPanel(data) { + if (!scanPanelRoot) return; + if (scanStatus) scanStatus.textContent = ''; - function renderSpecItems(container, items, kindLabel) { - const keys = Object.keys(items); - for (var i = 0; i < keys.length; i++) { - renderOneItem(container, keys[i], items[keys[i]], kindLabel); - } - } - - function renderOneItem(container, typeName, val, kindLabel) { - if (!val) return; - if (Array.isArray(val)) { - for (var i = 0; i < val.length; i++) renderOneItem(container, typeName, val[i], kindLabel); + var proj = data.project; + var url = data.url || ''; + if (!proj) { + if (scanStatus) scanStatus.textContent = data.error || 'no project data'; return; } - if (typeof val === 'object') { - if ('klass' in val) { - appendItemCard(container, typeName, null, val, kindLabel); - } else { - const names = Object.keys(val); - for (var j = 0; j < names.length; j++) { - appendItemCard(container, typeName, names[j], val[names[j]], kindLabel); - } - } - } - } - function appendItemCard(container, typeName, itemName, data, kindLabel) { - const card = document.createElement('div'); - card.className = 'scan-item'; - const title = document.createElement('div'); - title.className = 'scan-item-title'; - title.textContent = kindLabel + ': ' + typeName + (itemName ? ' \u2014 ' + itemName : ''); - card.appendChild(title); - if (data && typeof data === 'object') { - const kv = document.createElement('div'); - kv.className = 'scan-kv'; - const entries = Object.entries(data); - for (var i = 0; i < entries.length; i++) { - const k = entries[i][0], v = entries[i][1]; - if (k === 'klass') continue; - const kEl = document.createElement('span'); kEl.className = 'scan-k'; kEl.textContent = k + ':'; - const vEl = document.createElement('span'); vEl.className = 'scan-v'; - vEl.textContent = typeof v === 'object' ? JSON.stringify(v) : String(v == null ? '' : v); - kv.appendChild(kEl); kv.appendChild(vEl); - } - card.appendChild(kv); + var lib = {}; + lib[url] = proj; + var dataMsg = { type: 'data', library: lib, info: {}, enums: {} }; + + if (typeof window.__fbPanelDeliver === 'function') { + dbg('delivering to embedded panel: ' + url); + window.__fbPanelDeliver(dataMsg); + } else { + dbg('ERROR: __fbPanelDeliver not available'); } - container.appendChild(card); } // ── message bus ──────────────────────────────────────────────────────── @@ -1945,8 +1900,6 @@ function getFileBrowserJs(): string { break; case 'browseResult': - // Keep currentSo in sync with the storage options that were - // used for this browse (the host echoes them back). if (typeof msg.storageOptions === 'string') { currentSo = msg.storageOptions; } @@ -1965,8 +1918,7 @@ function getFileBrowserJs(): string { case 'projectScanned': dbg('projectScanned url=' + msg.url + ' error=' + msg.error); - if (scanStatus) scanStatus.textContent = ''; - renderProjectScan(msg); + showProjectInPanel(msg); break; case 'bookmarksUpdated': From d102ebc42f16f4ecf56948e50d6f3d762e085ade Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Sun, 5 Jul 2026 14:15:20 -0400 Subject: [PATCH 03/13] tree --- vsextension/src/fileBrowserPanel.ts | 578 +++++++++++++++++++++++----- 1 file changed, 482 insertions(+), 96 deletions(-) diff --git a/vsextension/src/fileBrowserPanel.ts b/vsextension/src/fileBrowserPanel.ts index ae17305..535de19 100644 --- a/vsextension/src/fileBrowserPanel.ts +++ b/vsextension/src/fileBrowserPanel.ts @@ -20,7 +20,7 @@ import * as childProcess from 'child_process'; import * as os from 'os'; import * as path from 'path'; import * as fs from 'fs'; -import { parseJsonOutput } from './projspec'; +import { parseJsonOutput, runProjspec } from './projspec'; // --------------------------------------------------------------------------- // Output channel for host-side logging (visible in Output > projspec-filebrowser) @@ -101,7 +101,7 @@ export class FileBrowserPanel { private disposables: vscode.Disposable[] = []; private busyCount = 0; /** Pending initial data to send once the webview posts 'ready'. */ - private pendingInit: { bookmarks: unknown[]; protocols: string[]; startUrl: string } | null = null; + private pendingInit: { bookmarks: unknown[]; protocols: string[]; libraryUrls: string[]; startUrl: string } | null = null; // --------------------------------------------------------------------------- // Factory @@ -178,6 +178,7 @@ export class FileBrowserPanel { log('prefetchInit start'); let bookmarks: unknown[] = []; let protocols: string[] = []; + let libraryUrls: string[] = []; try { const bmsRes = await runFbPython('bookmarks_list', {}); if (Array.isArray(bmsRes.data)) { bookmarks = bmsRes.data; } @@ -186,8 +187,15 @@ export class FileBrowserPanel { const protoRes = await runFbPython('supported_protocols', {}); if (Array.isArray(protoRes.data)) { protocols = protoRes.data as string[]; } } catch (e) { log('supported_protocols error: ' + e); } - log(`prefetchInit done: ${bookmarks.length} bookmarks, ${protocols.length} protocols`); - this.pendingInit = { bookmarks, protocols, startUrl: startUrl || os.homedir() }; + try { + const libRes = await runProjspec(['library', 'list', '--json-out']); + if (libRes.code === 0) { + const libData = parseJsonOutput(libRes.stdout) as Record; + libraryUrls = Object.keys(libData); + } + } catch (e) { log('library list error: ' + e); } + log(`prefetchInit done: ${bookmarks.length} bookmarks, ${protocols.length} protocols, ${libraryUrls.length} library entries`); + this.pendingInit = { bookmarks, protocols, libraryUrls, startUrl: startUrl || os.homedir() }; if (this.readyReceived) { void this.sendInitialData(); } @@ -205,6 +213,7 @@ export class FileBrowserPanel { type: 'init', bookmarks: init.bookmarks, protocols: init.protocols, + libraryUrls: init.libraryUrls, }); await this.browse(init.startUrl, undefined, false); }); @@ -322,6 +331,11 @@ export class FileBrowserPanel { ); break; + case 'expandDir': + // Lazy-load children of a tree node without navigating + void this.expandDir(msg.url as string, msg.storageOptions as string | undefined); + break; + case 'goToUrl': await this.withBusy(() => this.goToUrl(msg.url as string, msg.storageOptions as string | undefined), @@ -520,6 +534,14 @@ export class FileBrowserPanel { vscode.window.showWarningMessage(`Add to library: ${data['error']}`); } else { vscode.window.showInformationMessage(`Added to projspec library: ${url}`); + // Refresh library URL set so the indicator updates immediately + try { + const libRes = await runProjspec(['library', 'list', '--json-out']); + if (libRes.code === 0) { + const libData = parseJsonOutput(libRes.stdout) as Record; + this.panel.webview.postMessage({ type: 'libraryUrlsUpdated', libraryUrls: Object.keys(libData) }); + } + } catch { /* ok */ } } }); } @@ -528,52 +550,111 @@ export class FileBrowserPanel { await this.browse(url, storageOptions, true); } + private async expandDir(url: string, storageOptions: string | undefined): Promise { + log('expandDir ' + url); + const so = storageOptions ? JSON.parse(storageOptions) : null; + const kwargs: Record = { url }; + if (so) { kwargs['storage_options'] = so; } + const res = await runFbPython('browse', kwargs); + const data = (res.data as Record) || { + url, entries: [], parent: null, protocol: '', + error: res.stderr || `exit ${res.code}`, + }; + this.panel.webview.postMessage({ type: 'expandResult', parentUrl: url, ...data }); + } + private async scanDir(url: string, storageOptions: string | undefined): Promise { log('scanDir ' + url); const so = storageOptions ? JSON.parse(storageOptions) : null; const srcPath = path.resolve(__dirname, '..', '..', 'src'); const soArg = so ? JSON.stringify(so) : ''; - // Try projspec CLI first (fastest when projspec is installed in the env). - // Fall back to importing projspec.filebrowser.scan_directory directly. - const result = await new Promise<{data: unknown; stderr: string; code: number|null}>((resolve) => { - const script = [ - 'import sys, json, subprocess', - `sys.path.insert(0, ${JSON.stringify(srcPath)})`, - `url = ${JSON.stringify(url)}`, - `so_str = ${JSON.stringify(soArg)}`, - 'args = ["projspec", "scan", "--json-out", url]', - 'if so_str: args = ["projspec", "scan", "--json-out", "--storage_options", so_str, url]', - 'r = subprocess.run(args, capture_output=True, text=True)', - 'if r.returncode == 0 and r.stdout.strip():', - ' raw = r.stdout.strip()', - ' idx = raw.find("{")', - ' if idx >= 0: raw = raw[idx:]', - ' proj = json.loads(raw)', - ' print(json.dumps({"url": url, "project": proj, "error": None}))', - ' sys.exit(0)', - // Fall back to direct import - 'try:', - ' from projspec.filebrowser import scan_directory', - ' so = json.loads(so_str) if so_str else None', - ' result = scan_directory(url, storage_options=so)', - 'except Exception as e:', - ' result = {"url": url, "project": None, "error": str(e)}', - 'print(json.dumps(result))', - ].join('\n'); - const proc = childProcess.spawn(python3(), ['-c', script], { env: process.env }); - let stdout = '', stderr = ''; - proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); - proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); - proc.on('error', (err: Error) => resolve({ data: null, stderr: String(err), code: -1 })); - proc.on('close', (code: number | null) => { - log(`scanDir exit=${code} out=${stdout.slice(0,200)} err=${stderr.slice(0,200)}`); - let data: unknown = null; - try { data = JSON.parse(stdout.trim()); } catch { /* ok */ } - resolve({ data, stderr, code }); - }); + + // Run projspec scan, info, and enum introspection in parallel + const [scanResult, infoResult, enumsResult] = await Promise.all([ + // 1. Scan the directory + new Promise<{data: unknown; stderr: string; code: number|null}>((resolve) => { + const script = [ + 'import sys, json, subprocess', + `sys.path.insert(0, ${JSON.stringify(srcPath)})`, + `url = ${JSON.stringify(url)}`, + `so_str = ${JSON.stringify(soArg)}`, + 'args = ["projspec", "scan", "--json-out", url]', + 'if so_str: args = ["projspec", "scan", "--json-out", "--storage_options", so_str, url]', + 'r = subprocess.run(args, capture_output=True, text=True)', + 'if r.returncode == 0 and r.stdout.strip():', + ' raw = r.stdout.strip()', + ' idx = raw.find("{")', + ' if idx >= 0: raw = raw[idx:]', + ' proj = json.loads(raw)', + ' print(json.dumps({"url": url, "project": proj, "error": None}))', + ' sys.exit(0)', + // Fall back to direct import + 'try:', + ' from projspec.filebrowser import scan_directory', + ' so = json.loads(so_str) if so_str else None', + ' result = scan_directory(url, storage_options=so)', + 'except Exception as e:', + ' result = {"url": url, "project": None, "error": str(e)}', + 'print(json.dumps(result))', + ].join('\n'); + const proc = childProcess.spawn(python3(), ['-c', script], { env: process.env }); + let stdout = '', stderr = ''; + proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + proc.on('error', (err: Error) => resolve({ data: null, stderr: String(err), code: -1 })); + proc.on('close', (code: number | null) => { + log(`scanDir exit=${code} out=${stdout.slice(0,200)} err=${stderr.slice(0,200)}`); + let data: unknown = null; + try { data = JSON.parse(stdout.trim()); } catch { /* ok */ } + resolve({ data, stderr, code }); + }); + }), + // 2. Fetch class info (for documentation popups) + new Promise((resolve) => { + const proc = childProcess.spawn('projspec', ['info'], { env: process.env }); + let stdout = ''; + proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.on('error', () => resolve({})); + proc.on('close', () => { + try { resolve(JSON.parse(stdout.trim())); } catch { resolve({}); } + }); + }), + // 3. Fetch enum members (for human-readable enum values in YAML tree) + new Promise((resolve) => { + const script = [ + 'import json, importlib, pkgutil', + 'import projspec.utils as pu', + 'import projspec.content, projspec.artifact', + "for pkg in (projspec.content, projspec.artifact):", + " for m in pkgutil.iter_modules(pkg.__path__, pkg.__name__ + '.'):", + ' importlib.import_module(m.name)', + 'from projspec.utils import camel_to_snake', + 'out, seen = {}, set()', + 'def walk(cls):', + ' for sub in cls.__subclasses__():', + ' if sub in seen: continue', + ' seen.add(sub); walk(sub)', + ' out[camel_to_snake(sub.__name__)] = {m.name: m.value for m in sub}', + 'walk(pu.Enum)', + 'print(json.dumps(out))', + ].join('\n'); + const proc = childProcess.spawn('python3', ['-c', script], { env: process.env }); + let stdout = ''; + proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.on('error', () => resolve({})); + proc.on('close', () => { + try { resolve(JSON.parse(stdout.trim())); } catch { resolve({}); } + }); + }), + ]); + + const data = (scanResult.data as Record) || { url, error: scanResult.stderr || `exit ${scanResult.code}` }; + this.panel.webview.postMessage({ + type: 'projectScanned', + info: infoResult || {}, + enums: enumsResult || {}, + ...data, }); - const data = (result.data as Record) || { url, error: result.stderr || `exit ${result.code}` }; - this.panel.webview.postMessage({ type: 'projectScanned', ...data }); } // --------------------------------------------------------------------------- @@ -713,6 +794,11 @@ const FB_HTML_BODY = `
+
+ Name + Size + Modified +
@@ -962,27 +1048,107 @@ body { margin: 0; padding: 0; #fb-file-list { flex: 1; overflow-y: auto; - padding: 4px 0; + display: flex; + flex-direction: column; +} +#fb-col-headers { + display: grid; + grid-template-columns: 1fr 72px 112px; + padding: 2px 10px 2px 26px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--vscode-descriptionForeground); + border-bottom: 1px solid var(--vscode-panel-border); + flex-shrink: 0; +} +.fb-col-hdr { + cursor: pointer; + user-select: none; + display: inline-flex; + align-items: center; + gap: 2px; + border-radius: 2px; + padding: 1px 2px; +} +.fb-col-hdr:hover { color: var(--vscode-foreground); } +.fb-col-hdr.active { color: var(--vscode-foreground); } +.fb-sort-arrow { font-size: 9px; opacity: 0.6; min-width: 8px; } +.fb-col-size, .fb-col-mtime { + text-align: right; + justify-content: flex-end; } +#fb-entries { flex: 1; overflow-y: auto; } + +/* Tree entry row */ .fb-entry { - display: flex; + display: grid; + grid-template-columns: 1fr 72px 112px; align-items: center; - padding: 3px 10px; + padding: 2px 10px; cursor: pointer; - gap: 6px; + gap: 0; border: 1px solid transparent; border-radius: 2px; position: relative; + min-width: 0; } .fb-entry:hover { background: var(--vscode-list-hoverBackground); } .fb-entry.active { background: var(--vscode-list-activeSelectionBackground); color: var(--vscode-list-activeSelectionForeground); } -.fb-entry-icon { width: 16px; text-align: center; flex-shrink: 0; font-size: 14px; } -.fb-entry-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.fb-entry-meta { font-size: 11px; color: var(--vscode-descriptionForeground); white-space: nowrap; flex-shrink: 0; } +/* name cell: indent + toggle + icon + text */ +.fb-entry-name-cell { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + overflow: hidden; +} +.fb-entry-indent { display: inline-block; flex-shrink: 0; } +.fb-entry-toggle { + width: 14px; + flex-shrink: 0; + text-align: center; + font-size: 9px; + color: var(--vscode-descriptionForeground); + cursor: pointer; + user-select: none; +} +.fb-entry-toggle:hover { color: var(--vscode-foreground); } +.fb-entry-icon { width: 16px; text-align: center; flex-shrink: 0; font-size: 13px; } +.fb-entry-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; } .fb-entry.is-dir .fb-entry-name { font-weight: 500; } +.fb-entry-size { + font-size: 11px; + color: var(--vscode-descriptionForeground); + text-align: right; + white-space: nowrap; +} +.fb-entry-mtime { + font-size: 11px; + color: var(--vscode-descriptionForeground); + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.fb-entry.active .fb-entry-size, +.fb-entry.active .fb-entry-mtime { + color: inherit; + opacity: 0.8; +} +/* Children container — indented child rows */ +.fb-children { display: none; } +.fb-children.expanded { display: block; } +.fb-child-loading { + padding: 3px 10px 3px 36px; + font-size: 11px; + color: var(--vscode-descriptionForeground); + font-style: italic; +} .fb-status { padding: 20px; color: var(--vscode-descriptionForeground); text-align: center; } .fb-error { padding: 12px; color: var(--vscode-errorForeground); } @@ -1289,6 +1455,7 @@ function getFileBrowserJs(): string { // ── state ────────────────────────────────────────────────────────────── let bookmarks = []; let protocols = []; + var libraryUrls = new Set(); // canonical URLs of entries in the project library let currentUrl = ''; let currentSo = ''; let history = []; @@ -1366,8 +1533,8 @@ function getFileBrowserJs(): string { return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } - function fileIcon(entry) { - if (entry.type === 'directory') return '\uD83D\uDCC1'; // folder + function fileIcon(entry, inLibrary) { + if (entry.type === 'directory') return inLibrary ? '\uD83D\uDDC2\uFE0F' : '\uD83D\uDCC1'; // 🗂️ or 📁 const name = (entry.basename || entry.name || '').toLowerCase(); if (/\.(py|pyx|pyi)$/.test(name)) return '\uD83D\uDC0D'; // snake if (/\.(js|ts|jsx|tsx)$/.test(name)) return '\uD83D\uDCDC'; // scroll @@ -1381,13 +1548,240 @@ function getFileBrowserJs(): string { } // ── browse result ────────────────────────────────────────────────────── + // ── tree rendering ───────────────────────────────────────────────────── + + // Sort state + var sortCol = 'name'; // 'name' | 'size' | 'mtime' + var sortAsc = true; + // Last browse entries (root level) — kept for re-sort without re-fetch + var lastBrowseEntries = []; + + function sortEntries(entries) { + // Stable sort: directories always before files, then by chosen column + function key(e) { + if (sortCol === 'size') return e.size == null ? -1 : e.size; + if (sortCol === 'mtime') return e.last_modified == null ? 0 : parseFloat(e.last_modified); + // name: case-insensitive + return (e.basename || basename(e.name || '')).toLowerCase(); + } + return entries.slice().sort(function(a, b) { + var aDir = a.type === 'directory' ? 0 : 1; + var bDir = b.type === 'directory' ? 0 : 1; + if (aDir !== bDir) return aDir - bDir; // dirs before files always + var ak = key(a), bk = key(b); + var cmp = ak < bk ? -1 : ak > bk ? 1 : 0; + return sortAsc ? cmp : -cmp; + }); + } + + function updateSortHeaders() { + document.querySelectorAll('.fb-col-hdr').forEach(function(el) { + var col = el.dataset.col; + var arrow = el.querySelector('.fb-sort-arrow'); + if (col === sortCol) { + el.classList.add('active'); + if (arrow) arrow.textContent = sortAsc ? '\u25B4' : '\u25BE'; // ▴ ▾ + } else { + el.classList.remove('active'); + if (arrow) arrow.textContent = ''; + } + }); + } + + // Wire up column header clicks + document.querySelectorAll('.fb-col-hdr').forEach(function(el) { + el.addEventListener('click', function() { + var col = el.dataset.col; + if (col === sortCol) { + sortAsc = !sortAsc; + } else { + sortCol = col; + sortAsc = col === 'name'; // name defaults asc, size/mtime default desc + } + updateSortHeaders(); + // Re-render root entries with new sort (no network call) + if (lastBrowseEntries.length > 0) { + treeNodes = {}; + entriesEl.innerHTML = ''; + var sorted = sortEntries(lastBrowseEntries); + for (var i = 0; i < sorted.length; i++) { + entriesEl.appendChild(makeEntryRow(sorted[i], 0)); + } + } + }); + }); + updateSortHeaders(); + + // Map from directory URL -> child container element (for expand/collapse) + var treeNodes = {}; + + function makeEntryRow(entry, depth) { + var isDir = entry.type === 'directory'; + var url = entry.name; + + // Outer wrapper: row + (for dirs) a children container + var wrapper = document.createElement('div'); + wrapper.className = 'fb-entry-wrapper'; + + var row = document.createElement('div'); + row.className = 'fb-entry' + (isDir ? ' is-dir' : ''); + row.dataset.url = url; + row.dataset.type = entry.type || 'file'; + + // Name cell: indent + toggle + icon + name + var nameCell = document.createElement('span'); + nameCell.className = 'fb-entry-name-cell'; + + var indent = document.createElement('span'); + indent.className = 'fb-entry-indent'; + indent.style.width = (depth * 12) + 'px'; + nameCell.appendChild(indent); + + var toggle = document.createElement('span'); + toggle.className = 'fb-entry-toggle'; + toggle.textContent = isDir ? '\u25B6' : ''; // ▶ for dirs, blank for files + nameCell.appendChild(toggle); + + var icon = document.createElement('span'); + icon.className = 'fb-entry-icon'; + icon.textContent = fileIcon(entry, isDir && libraryUrls.has(url)); + nameCell.appendChild(icon); + + var nameEl = document.createElement('span'); + nameEl.className = 'fb-entry-name'; + nameEl.textContent = entry.basename || basename(url); + nameEl.title = url; + nameCell.appendChild(nameEl); + + // Size cell + var sizeEl = document.createElement('span'); + sizeEl.className = 'fb-entry-size'; + if (entry.size != null) sizeEl.textContent = fmtSize(entry.size); + + // Modified cell + var mtimeEl = document.createElement('span'); + mtimeEl.className = 'fb-entry-mtime'; + if (entry.last_modified) mtimeEl.textContent = fmtMtime(entry.last_modified); + + row.appendChild(nameCell); + row.appendChild(sizeEl); + row.appendChild(mtimeEl); + + // Children container (lazy-populated) + var childrenEl = null; + if (isDir) { + childrenEl = document.createElement('div'); + childrenEl.className = 'fb-children'; + treeNodes[url] = childrenEl; + } + + // Click: select + row.addEventListener('click', function(e) { + e.stopPropagation(); + selectEntry(url, entry.type, row); + }); + + // Toggle click: expand/collapse (stop propagation so row click doesn't fire) + if (isDir) { + toggle.addEventListener('click', function(e) { + e.stopPropagation(); + toggleDir(url, toggle, childrenEl); + }); + // Double-click on row: navigate to dir as new root + row.addEventListener('dblclick', function(e) { + e.stopPropagation(); + navigateTo(url, currentSo); + }); + } + + wrapper.appendChild(row); + if (childrenEl) wrapper.appendChild(childrenEl); + return wrapper; + } + + function fmtMtime(ts) { + if (!ts) return ''; + var d = new Date(parseFloat(ts) * 1000); + var now = new Date(); + var diffDays = (now - d) / 86400000; + if (diffDays < 1) { + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + if (diffDays < 180) { + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); + } + return d.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }); + } + + function toggleDir(url, toggle, childrenEl) { + var expanded = childrenEl.classList.contains('expanded'); + if (expanded) { + // Collapse + childrenEl.classList.remove('expanded'); + toggle.textContent = '\u25B6'; // ▶ + } else { + // Expand: lazy-load if not yet populated + childrenEl.classList.add('expanded'); + toggle.textContent = '\u25BC'; // ▼ + if (!childrenEl.dataset.loaded) { + // Show loading indicator + var loader = document.createElement('div'); + loader.className = 'fb-child-loading'; + loader.textContent = 'Loading...'; + childrenEl.appendChild(loader); + // Request children from host + vscode.postMessage({ cmd: 'expandDir', url: url, storageOptions: currentSo || undefined }); + } + } + } + + function handleExpandResult(data) { + var parentUrl = data.parentUrl || data.url; + var childrenEl = treeNodes[parentUrl]; + if (!childrenEl) { dbg('no treeNode for ' + parentUrl); return; } + + childrenEl.innerHTML = ''; + childrenEl.dataset.loaded = '1'; + + if (data.error) { + var errEl = document.createElement('div'); + errEl.className = 'fb-child-loading'; + errEl.textContent = 'Error: ' + data.error; + childrenEl.appendChild(errEl); + return; + } + + var entries = data.entries || []; + if (entries.length === 0) { + var emptyMsg = document.createElement('div'); + emptyMsg.className = 'fb-child-loading'; + emptyMsg.textContent = 'Empty'; + childrenEl.appendChild(emptyMsg); + return; + } + + // Find the depth of the parent by looking at the DOM + var parentRow = childrenEl.previousSibling; + var parentIndent = parentRow ? (parseInt(parentRow.querySelector('.fb-entry-indent').style.width) || 0) : 0; + var childDepth = Math.round(parentIndent / 12) + 1; + + var sorted = sortEntries(entries); + for (var i = 0; i < sorted.length; i++) { + childrenEl.appendChild(makeEntryRow(sorted[i], childDepth)); + } + dbg('expanded ' + parentUrl + ': ' + sorted.length + ' children'); + } + function renderBrowse(data) { dbg('renderBrowse url=' + data.url + ' entries=' + (data.entries ? data.entries.length : 'none') + ' error=' + data.error); currentUrl = data.url || ''; urlInput.value = currentUrl; renderBreadcrumb(currentUrl); - // Clear only the entries container — never touch fb-empty/fb-error + // Reset tree node map and stored entries + treeNodes = {}; + lastBrowseEntries = []; + entriesEl.innerHTML = ''; emptyEl.classList.add('hidden'); errorEl.classList.add('hidden'); @@ -1398,52 +1792,18 @@ function getFileBrowserJs(): string { return; } - const entries = data.entries || []; + var entries = data.entries || []; if (entries.length === 0) { emptyEl.classList.remove('hidden'); return; } - for (const entry of entries) { - const row = document.createElement('div'); - row.className = 'fb-entry' + (entry.type === 'directory' ? ' is-dir' : ''); - row.dataset.url = entry.name; - row.dataset.type = entry.type || 'file'; - - const icon = document.createElement('span'); - icon.className = 'fb-entry-icon'; - icon.textContent = fileIcon(entry); - - const name = document.createElement('span'); - name.className = 'fb-entry-name'; - name.textContent = entry.basename || basename(entry.name); - name.title = entry.name; - - const meta = document.createElement('span'); - meta.className = 'fb-entry-meta'; - if (entry.type !== 'directory' && entry.size != null) { - meta.textContent = fmtSize(entry.size); - } - - row.appendChild(icon); - row.appendChild(name); - row.appendChild(meta); - - row.addEventListener('click', function(e) { - e.stopPropagation(); - selectEntry(entry.name, entry.type, row); - }); - - if (entry.type === 'directory') { - row.addEventListener('dblclick', function(e) { - e.stopPropagation(); - navigateTo(entry.name, currentSo); - }); - } - - entriesEl.appendChild(row); + lastBrowseEntries = entries; + var sorted = sortEntries(entries); + for (var i = 0; i < sorted.length; i++) { + entriesEl.appendChild(makeEntryRow(sorted[i], 0)); } - dbg('rendered ' + entries.length + ' entries'); + dbg('rendered ' + sorted.length + ' entries'); } function selectEntry(url, type, rowEl) { @@ -1628,6 +1988,16 @@ function getFileBrowserJs(): string { } // ── bookmarks ────────────────────────────────────────────────────────── + function refreshLibraryBadges() { + // Walk every directory row in the DOM and swap the folder icon + document.querySelectorAll('.fb-entry[data-type="directory"]').forEach(function(row) { + var url = row.dataset.url || ''; + var iconEl = row.querySelector('.fb-entry-icon'); + if (!iconEl) return; + iconEl.textContent = libraryUrls.has(url) ? '\uD83D\uDDC2\uFE0F' : '\uD83D\uDCC1'; // 🗂️ or 📁 + }); + } + function renderBookmarks() { bmList.innerHTML = ''; if (!bookmarks.length) { @@ -1874,7 +2244,8 @@ function getFileBrowserJs(): string { var lib = {}; lib[url] = proj; - var dataMsg = { type: 'data', library: lib, info: {}, enums: {} }; + // Pass info and enums so spec documentation popups and enum labels work + var dataMsg = { type: 'data', library: lib, info: data.info || {}, enums: data.enums || {} }; if (typeof window.__fbPanelDeliver === 'function') { dbg('delivering to embedded panel: ' + url); @@ -1896,7 +2267,12 @@ function getFileBrowserJs(): string { case 'init': bookmarks = msg.bookmarks || []; protocols = msg.protocols || []; - dbg('init: ' + bookmarks.length + ' bookmarks, ' + protocols.length + ' protocols'); + if (msg.libraryUrls) { + libraryUrls = new Set(msg.libraryUrls); + dbg('init: ' + bookmarks.length + ' bookmarks, ' + libraryUrls.size + ' library entries'); + } else { + dbg('init: ' + bookmarks.length + ' bookmarks, ' + protocols.length + ' protocols'); + } break; case 'browseResult': @@ -1916,6 +2292,10 @@ function getFileBrowserJs(): string { renderInspect(msg); break; + case 'expandResult': + handleExpandResult(msg); + break; + case 'projectScanned': dbg('projectScanned url=' + msg.url + ' error=' + msg.error); showProjectInPanel(msg); @@ -1927,6 +2307,12 @@ function getFileBrowserJs(): string { if (!bmPanel.classList.contains('hidden')) renderBookmarks(); break; + case 'libraryUrlsUpdated': + libraryUrls = new Set(msg.libraryUrls || []); + dbg('library URLs updated: ' + libraryUrls.size); + refreshLibraryBadges(); + break; + case 'error': errorEl.textContent = 'Error: ' + (msg.message || 'unknown'); errorEl.classList.remove('hidden'); From 27cacec6253200a17f4be23de2d85e5531805346 Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Sun, 5 Jul 2026 16:57:04 -0400 Subject: [PATCH 04/13] data display --- src/projspec/filebrowser.py | 385 ++++++++++++++++++++++++- vsextension/src/fileBrowserPanel.ts | 433 ++++++++++++++++++++++------ 2 files changed, 720 insertions(+), 98 deletions(-) diff --git a/src/projspec/filebrowser.py b/src/projspec/filebrowser.py index f13b958..f3e3123 100644 --- a/src/projspec/filebrowser.py +++ b/src/projspec/filebrowser.py @@ -293,15 +293,22 @@ def inspect_file( intake_summary = _intake_inspect(url, path, storage_options) text_preview = None - # Always show a text preview for text-like files. - is_text = mime and (mime.startswith("text/") or mime in _TEXT_MIMES) - if is_text: + # Always show a text preview when the file looks like text. + # We check the extension exhaustively rather than relying on MIME alone + # (many text formats have no registered MIME type or get None from + # mimetypes.guess_type). We then attempt strict UTF-8 decoding and + # silently skip the preview if the bytes aren't valid UTF-8 — this + # naturally rejects binary files with innocent-looking extensions. + if _looks_like_text(path): try: with fs.open(path, "rb") as f: raw_bytes = f.read(max_text_bytes) - text_preview = raw_bytes.decode("utf-8", errors="replace") + # strict=True: raises UnicodeDecodeError on invalid UTF-8 + text_preview = raw_bytes.decode("utf-8") + except UnicodeDecodeError: + text_preview = None # binary content — skip preview except Exception: - pass + text_preview = None # IO or other error return { "url": url, @@ -429,13 +436,240 @@ def _intake_inspect( return result if result else None -_TEXT_MIMES = { - "application/json", - "application/x-yaml", - "application/toml", - "application/javascript", - "application/xml", -} +# Extensions we treat as text for the purposes of showing a preview. +# Organised by category; err on the side of inclusion — the UTF-8 +# strict-decode in inspect_file will silently reject anything that +# turns out to be binary. +_TEXT_EXTENSIONS: frozenset[str] = frozenset( + { + # ── source code ────────────────────────────────────────────────────── + "py", + "pyi", + "pyx", + "pxd", # Python + "js", + "mjs", + "cjs", + "jsx", # JavaScript + "ts", + "tsx", + "d.ts", # TypeScript + "rb", + "rake", + "gemspec", # Ruby + "java", + "kt", + "kts", + "groovy", # JVM + "scala", + "clj", + "cljs", # Scala / Clojure + "c", + "h", + "cc", + "cpp", + "cxx", # C / C++ + "hh", + "hpp", + "hxx", + "cs", + "fs", + "fsx", + "fsi", # C# / F# + "go", # Go + "rs", # Rust + "swift", # Swift + "m", + "mm", # Objective-C + "r", + "rmd", # R + "jl", # Julia + "lua", # Lua + "pl", + "pm", + "t", # Perl + "php", # PHP + "ex", + "exs", # Elixir + "erl", + "hrl", # Erlang + "hs", + "lhs", # Haskell + "ml", + "mli", # OCaml + "elm", # Elm + "dart", # Dart + "v", + "vhd", + "vhdl", # Verilog / VHDL + "zig", # Zig + "nim", # Nim + # ── shell / scripting ──────────────────────────────────────────────── + "sh", + "bash", + "zsh", + "fish", + "ps1", + "psm1", + "psd1", # PowerShell + "bat", + "cmd", # Windows batch + "awk", + "sed", + # ── data / config ──────────────────────────────────────────────────── + "json", + "jsonc", + "json5", + "yaml", + "yml", + "toml", + "ini", + "cfg", + "conf", + "config", + "properties", + "env", + "xml", + "xsd", + "xsl", + "xslt", + "html", + "htm", + "xhtml", + "css", + "scss", + "sass", + "less", + "svg", + "csv", + "tsv", + "psv", + "ndjson", + "jsonl", + "graphql", + "gql", + "proto", # Protocol Buffers + "thrift", + "avsc", # Avro schema + # ── documentation / markup ─────────────────────────────────────────── + "md", + "markdown", + "rst", + "txt", + "text", + "adoc", + "asciidoc", + "org", + "tex", + "sty", + "cls", + "bib", # LaTeX + "pod", # Perl docs + "rdoc", + # ── build / CI / infra ─────────────────────────────────────────────── + "makefile", + "mk", + "mak", + "dockerfile", + "cmake", + "bazel", + "bzl", + "build", + "gradle", + "tf", + "tfvars", # Terraform + "hcl", + "nix", + "cabal", + "spec", # RPM spec / test spec + # ── notebooks / interactive ────────────────────────────────────────── + "ipynb", # JSON-based, readable + # ── lock files / manifests ─────────────────────────────────────────── + "lock", # various lockfiles (text) + "sum", # go.sum + "mod", # go.mod + # ── misc plain-text formats ────────────────────────────────────────── + "log", + "diff", + "patch", + "sql", + "pgsql", + "graphml", + "dot", + "gv", # Graphviz + "puml", + "pu", # PlantUML + "mmd", # Mermaid + "vim", + "vimrc", + "emacs", + "el", + "editorconfig", + "gitignore", + "gitattributes", + "gitmodules", + "hgignore", + "dockerignore", + "npmignore", + "license", + "licence", + "authors", + "contributors", + "readme", + "changelog", + "news", + "todo", + "fixme", + } +) + + +def _looks_like_text(path: str) -> bool: + """Return True when *path*'s extension (or full basename for dotfiles / + extensionless names) suggests the file is human-readable text.""" + import os + + name = os.path.basename(path).lower() + # Dotfiles with no extension: .gitignore, .env, .editorconfig, … + if name.startswith(".") and "." not in name[1:]: + return name[1:] in _TEXT_EXTENSIONS or True # dotfiles are usually text + # Strip leading dot for the extension comparison + if "." in name: + ext = name.rsplit(".", 1)[-1] + if ext in _TEXT_EXTENSIONS: + return True + # Extensionless or unrecognised — also accept files whose MIME is text/* + mime = _guess_mime(path) + if mime: + if mime.startswith("text/"): + return True + if mime in { + "application/json", + "application/x-yaml", + "application/toml", + "application/javascript", + "application/xml", + "application/x-sh", + "application/x-shellscript", + }: + return True + # Bare filenames like "Makefile", "Dockerfile", "Rakefile", "Gemfile" … + if name in { + "makefile", + "dockerfile", + "rakefile", + "gemfile", + "podfile", + "fastfile", + "appfile", + "vagrantfile", + "berksfile", + "guardfile", + "capfile", + "brewfile", + }: + return True + return False def _guess_mime(path: str) -> str | None: @@ -594,6 +828,133 @@ def add_to_projspec_library( return {"url": url, "project": None, "error": str(exc)} +def inspect_as_project( + url: str, + storage_options: dict | None = None, +) -> dict: + """Inspect a single file and return a project-shaped dict for the UI. + + Produces the same ``to_dict(compact=False)`` structure as a real + ``DataProject`` scan so the embedded library panel renders it + identically — a single ``data_project`` spec chip whose contents + are ``Dataset``-shaped dicts, exactly as produced by + :class:`projspec.proj.data_project.DataProject`. + + Returns the same keys as ``scan_directory`` plus the raw inspect + fields for the top-half metadata strip. + """ + result = inspect_file(url, storage_options=storage_options) + if result.get("error"): + return { + "url": url, + "project": None, + "intake": None, + "text_preview": None, + "error": result["error"], + } + + intake = result.get("intake") # {"primary_type": "Parquet", "types": [...], ...} + text_preview = result.get("text_preview") + mime = result.get("mime_type") or "" + size = result.get("size") + mtime = result.get("last_modified") + name = result.get("name", _basename(url)) + + # Try to get richer intake info using intake.readers.inspect.inspect_dataset + # (same call DataProject uses). Falls back gracefully if unavailable. + intake_inspect: dict | None = None + try: + from intake.readers.inspect import inspect_dataset # type: ignore + + intake_inspect = inspect_dataset(url, storage_options=storage_options or None) + except Exception: + pass + + # Build a Dataset-shaped content item (klass ["content", "dataset"]) + # matching the exact structure produced by DataProject._describe(). + dataset_content: dict[str, Any] = { + "klass": ["content", "dataset"], + "url": url, + "datatype": (intake or {}).get("primary_type") or None, + "structure": list((intake_inspect or {}).get("structure", [])), + "schema": {}, + "n_files": 1, + "total_size": size, + "metadata": {}, + } + + # Populate schema from intake discover() output if available + if intake_inspect: + schema = intake_inspect.get("schema") or {} + if schema: + dataset_content["schema"] = schema + meta = { + k: v + for k, v in intake_inspect.items() + if k not in ("schema", "structure", "datatype", "url") + and isinstance(v, (str, int, float, bool, list, dict, type(None))) + } + if meta: + dataset_content["metadata"] = meta + elif intake: + # Fallback: populate schema from intake._intake_inspect output + dtype = intake.get("dtype") + if dtype and isinstance(dtype, dict): + dataset_content["schema"] = dtype + # surface npartitions, shape etc. in metadata + meta = { + k: v + for k, v in intake.items() + if k not in ("primary_type", "types", "dtype") and v is not None + } + if meta: + dataset_content["metadata"] = meta + + # Contents dict keyed by the short file name (mimicking DataProject) + contents: dict[str, Any] = {name: dataset_content} + + # Text-preview as a separate content item when available + if text_preview: + contents["text_preview"] = { + "klass": ["content", "text_preview"], + "preview": text_preview, + } + + # Single spec: "data_project", just like DataProject scans + project: dict[str, Any] = { + "url": url, + "specs": { + "data_project": { + "klass": ["spec", "data_project"], + "_contents": contents, + "_artifacts": {}, + } + }, + # No top-level contents/artifacts — avoids the duplicate "Global" chip + "contents": {}, + "artifacts": {}, + "klass": ["project", "data_project"], + "file_count": 1, + "total_size": size, + "last_modified": mtime, + "storage_options": storage_options or {}, + "scanned_at": None, + } + + return { + "url": url, + "project": project, + # Top-level fields for the inspectResult / renderMeta strip + "name": name, + "size": size, + "last_modified": mtime, + "mime_type": mime or None, + "intake": intake, + "text_preview": text_preview, + "error": None, + } + + def supported_protocols() -> list[str]: """Return a list of protocols available in the current fsspec installation.""" try: diff --git a/vsextension/src/fileBrowserPanel.ts b/vsextension/src/fileBrowserPanel.ts index 535de19..6273b4a 100644 --- a/vsextension/src/fileBrowserPanel.ts +++ b/vsextension/src/fileBrowserPanel.ts @@ -382,10 +382,60 @@ export class FileBrowserPanel { private async inspect(url: string, storageOptions: string | undefined): Promise { log('inspect ' + url); const so = storageOptions ? JSON.parse(storageOptions) : null; - const res = await runFbPython('inspect_file', so ? { url, storage_options: so } : { url }); + // inspect_as_project returns both the raw inspect fields (size, mtime, + // mime_type, text_preview) AND a project-shaped dict for the scan pane. + const res = await runFbPython('inspect_as_project', so ? { url, storage_options: so } : { url }); const data = (res.data as Record) || { url, error: res.stderr || `exit ${res.code}` }; - log('inspect result: ' + (data.error || data.mime_type)); + log('inspect result: ' + (data['error'] || data['mime_type'] || (data['project'] ? 'project' : 'no project'))); + + // Top half: lightweight metadata (name, size, mtime, mime_type, intake, text_preview) this.panel.webview.postMessage({ type: 'inspectResult', ...data }); + + // Bottom half (scan pane): project-shaped view — same as directory scan + // Fetch info/enums in parallel so the panel has full documentation + const [infoResult, enumsResult] = await Promise.all([ + new Promise((resolve) => { + const proc = childProcess.spawn('projspec', ['info'], { env: process.env }); + let stdout = ''; + proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.on('error', () => resolve({})); + proc.on('close', () => { try { resolve(JSON.parse(stdout.trim())); } catch { resolve({}); } }); + }), + new Promise((resolve) => { + const script = [ + 'import json, importlib, pkgutil', + 'import projspec.utils as pu', + 'import projspec.content, projspec.artifact', + "for pkg in (projspec.content, projspec.artifact):", + " for m in pkgutil.iter_modules(pkg.__path__, pkg.__name__ + '.'):", + ' importlib.import_module(m.name)', + 'from projspec.utils import camel_to_snake', + 'out, seen = {}, set()', + 'def walk(cls):', + ' for sub in cls.__subclasses__():', + ' if sub in seen: continue', + ' seen.add(sub); walk(sub)', + ' out[camel_to_snake(sub.__name__)] = {m.name: m.value for m in sub}', + 'walk(pu.Enum)', + 'print(json.dumps(out))', + ].join('\n'); + const proc = childProcess.spawn('python3', ['-c', script], { env: process.env }); + let stdout = ''; + proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.on('error', () => resolve({})); + proc.on('close', () => { try { resolve(JSON.parse(stdout.trim())); } catch { resolve({}); } }); + }), + ]); + + this.panel.webview.postMessage({ + type: 'projectScanned', + url, + project: data['project'] || null, + error: data['error'] || null, + text_preview: data['text_preview'] || null, + info: infoResult || {}, + enums: enumsResult || {}, + }); } private async openFileInEditor( @@ -828,7 +878,10 @@ const FB_HTML_BODY = `
@@ -948,6 +1001,83 @@ body { margin: 0; padding: 0; display: flex; flex-direction: column; } +/* Inline file content display — used instead of the embedded panel for files */ +#fb-file-content { + flex: 1; + overflow-y: auto; + padding: 10px 14px; + font-size: 12px; +} +.fc-section-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--vscode-descriptionForeground); + margin-bottom: 6px; +} +.fc-dataset { + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + padding: 8px 10px; + margin-bottom: 8px; +} +.fc-datatype { + font-weight: 600; + font-size: 13px; + margin-bottom: 6px; + color: var(--vscode-foreground); +} +.fc-schema-hdr { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--vscode-descriptionForeground); + margin: 6px 0 3px; +} +.fc-col-row { + display: flex; + gap: 8px; + padding: 1px 0; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 11px; +} +.fc-col-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.fc-col-dtype { color: var(--vscode-descriptionForeground); flex-shrink: 0; } +.fc-kv { display: flex; gap: 8px; margin-bottom: 2px; flex-wrap: wrap; } +.fc-k { color: var(--vscode-descriptionForeground); min-width: 80px; flex-shrink: 0; } +.fc-v { word-break: break-all; } +.fc-text-preview { + font-family: var(--vscode-editor-font-family, monospace); + font-size: 11px; + white-space: pre-wrap; + word-break: break-all; + background: var(--vscode-textBlockQuote-background, rgba(128,128,128,0.08)); + padding: 8px; + border-radius: 3px; + max-height: 300px; + overflow-y: auto; + border: 1px solid var(--vscode-panel-border); +} +.fc-html-repr { + font-size: 11px; + overflow-x: auto; + margin-bottom: 4px; +} +.fc-html-repr table { border-collapse: collapse; font-size: 11px; } +.fc-html-repr th, .fc-html-repr td { + border: 1px solid var(--vscode-panel-border); + padding: 2px 6px; +} +.fc-html-repr th { background: var(--vscode-list-hoverBackground); } +.fc-thumbnail { + max-width: 100%; + height: auto; + margin: 4px 0; + border-radius: 3px; +} #fb-scan-panel-root #app { flex-direction: column; height: 100%; @@ -1456,6 +1586,7 @@ function getFileBrowserJs(): string { let bookmarks = []; let protocols = []; var libraryUrls = new Set(); // canonical URLs of entries in the project library + var selectedIsFile = false; // true when the current selection is a file (not a dir) let currentUrl = ''; let currentSo = ''; let history = []; @@ -1477,9 +1608,10 @@ function getFileBrowserJs(): string { const infoActions = document.getElementById('fb-info-actions'); const infoMeta = document.getElementById('fb-info-meta'); const infoPreview = document.getElementById('fb-info-preview'); - const scanPane = document.getElementById('fb-scan-pane'); - const scanStatus = document.getElementById('fb-scan-status'); + const scanPane = document.getElementById('fb-scan-pane'); + const scanStatus = document.getElementById('fb-scan-status'); const scanPanelRoot = document.getElementById('fb-scan-panel-root'); + const fileContent = document.getElementById('fb-file-content'); const bmPanel = document.getElementById('bm-panel'); const bmList = document.getElementById('bm-list'); const soOverlay = document.getElementById('so-overlay'); @@ -1816,19 +1948,33 @@ function getFileBrowserJs(): string { infoActions.classList.remove('hidden'); const isFile = type !== 'directory'; + selectedIsFile = isFile; document.getElementById('btn-open-editor').style.display = isFile ? '' : 'none'; document.getElementById('btn-add-to-lib').style.display = type === 'directory' ? '' : 'none'; infoMeta.innerHTML = ''; infoPreview.innerHTML = ''; - // Always hide and reset the scan pane when selection changes + // Reset both scan pane variants on every selection change. + // Also send an empty library to the embedded panel so the previous + // directory's project widget and details are cleared immediately. if (scanPane) { scanPane.classList.add('hidden'); if (scanStatus) scanStatus.textContent = ''; } + if (scanPanelRoot) scanPanelRoot.classList.remove('hidden'); + if (fileContent) { fileContent.classList.add('hidden'); fileContent.innerHTML = ''; } + if (typeof window.__fbPanelDeliver === 'function') { + window.__fbPanelDeliver({ type: 'data', library: {}, info: {}, enums: {} }); + } if (isFile) { + // Show scan pane immediately with loading indicator — it gets populated + // when both inspectResult and projectScanned arrive + if (scanPane) { + scanPane.classList.remove('hidden'); + if (scanStatus) scanStatus.textContent = 'inspecting...'; + } dbg('posting inspect for ' + url); vscode.postMessage({ cmd: 'inspect', url: url, storageOptions: currentSo || undefined }); } else { @@ -1879,10 +2025,10 @@ function getFileBrowserJs(): string { } } - function renderMeta(data) { + function renderMeta(data) { infoMeta.innerHTML = ''; var rows = []; - if (data.type) rows.push(['Type', data.type]); + // Don't show data.type — it's the JS message type, not a file type if (data.size != null) rows.push(['Size', fmtSize(data.size)]); if (data.last_modified) rows.push(['Modified', fmtDate(data.last_modified)]); if (data.mime_type) rows.push(['MIME', data.mime_type]); @@ -1896,83 +2042,18 @@ function getFileBrowserJs(): string { function renderInspect(data) { dbg('renderInspect name=' + data.name + ' error=' + data.error); - renderMeta(data); - infoPreview.innerHTML = ''; - - if (data.intake) { - const intake = data.intake; - - // Header: primary type badge - const hdr = document.createElement('div'); - hdr.className = 'preview-label'; - hdr.textContent = 'Data type (intake)'; - infoPreview.appendChild(hdr); - - const card = document.createElement('div'); - card.className = 'intake-card'; - - // Primary type — shown prominently - if (intake.primary_type) { - const typeRow = document.createElement('div'); - typeRow.className = 'intake-type-badge'; - typeRow.textContent = intake.primary_type; - card.appendChild(typeRow); - } - - // Additional recognised types (if more than one) - if (intake.types && intake.types.length > 1) { - const also = document.createElement('div'); - also.className = 'intake-row'; - also.innerHTML = 'also' - + '' + escHtml(intake.types.slice(1).join(', ')) + ''; - card.appendChild(also); - } - - // Schema: dtype dict → column table - if (intake.dtype && typeof intake.dtype === 'object' && !Array.isArray(intake.dtype)) { - const schemaHdr = document.createElement('div'); - schemaHdr.className = 'intake-schema-hdr'; - schemaHdr.textContent = 'Columns'; - card.appendChild(schemaHdr); - const cols = Object.keys(intake.dtype); - for (var ci = 0; ci < cols.length; ci++) { - const colRow = document.createElement('div'); - colRow.className = 'intake-col-row'; - colRow.innerHTML = '' + escHtml(cols[ci]) + '' - + '' + escHtml(String(intake.dtype[cols[ci]])) + ''; - card.appendChild(colRow); - } - } - - // Other schema fields (shape, npartitions, etc.) — skip types/dtype/primary_type - const SKIP = new Set(['types', 'dtype', 'primary_type', 'name']); - const entries = Object.entries(intake); - for (var i = 0; i < entries.length; i++) { - const k = entries[i][0], v = entries[i][1]; - if (SKIP.has(k) || v == null) continue; - const row = document.createElement('div'); - row.className = 'intake-row'; - const vStr = typeof v === 'object' ? JSON.stringify(v) : String(v); - row.innerHTML = '' + escHtml(k) + '' - + '' + escHtml(vStr) + ''; - card.appendChild(row); - } - - infoPreview.appendChild(card); - } - - // Text preview — always shown for text files - if (data.text_preview) { - const label = document.createElement('div'); - label.className = 'preview-label'; - label.style.marginTop = '10px'; - label.textContent = 'Preview'; - infoPreview.appendChild(label); - const pre = document.createElement('pre'); - pre.className = 'preview-text'; - pre.textContent = data.text_preview; - infoPreview.appendChild(pre); + // For files: only show MIME in the meta strip — size/modified are already + // visible in the file tree columns and would be redundant here. + // The intake type and text preview now live in the scan pane below. + infoMeta.innerHTML = ''; + if (data.mime_type) { + const row = document.createElement('div'); + row.className = 'info-row'; + row.innerHTML = 'MIME' + escHtml(data.mime_type) + ''; + infoMeta.appendChild(row); } + // Clear preview — content is in the scan pane + infoPreview.innerHTML = ''; } // ── navigation ───────────────────────────────────────────────────────── @@ -2231,6 +2312,180 @@ function getFileBrowserJs(): string { // window.__fbPanelDeliver(msg) — call it to push a data message into // the embedded panel. + // ── file content display (inline, no project widget) ───────────────── + // For single-file selections we render content directly rather than + // routing through the embedded library panel. The rule: + // - If the file has meaningful data info (datatype + schema/metadata), + // show that. Text preview is suppressed — the data description is + // the useful thing. + // - Otherwise show the text preview (first N lines). + // - If neither is available, hide the scan pane. + function showFileInScanPane(data) { + if (!scanPane || !fileContent) return; + + // Switch: hide the embedded panel root, show the file content div + if (scanPanelRoot) scanPanelRoot.classList.add('hidden'); + fileContent.classList.remove('hidden'); + fileContent.innerHTML = ''; + + var proj = data.project; + var textPreview = data.text_preview || ''; + + // Extract the dataset content from the data_project spec + var dataset = null; + if (proj && proj.specs && proj.specs.data_project) { + var cont = proj.specs.data_project._contents || {}; + var keys = Object.keys(cont); + for (var i = 0; i < keys.length; i++) { + var item = cont[keys[i]]; + if (item && item.klass && item.klass[1] === 'dataset') { + dataset = item; + break; + } + } + } + + var hasData = dataset && ( + dataset.datatype || + (dataset.schema && Object.keys(dataset.schema).length > 0) || + (dataset.metadata && Object.keys(dataset.metadata).length > 0) + ); + + if (hasData) { + var card = document.createElement('div'); + card.className = 'fc-dataset'; + + // Datatype heading + if (dataset.datatype) { + var dtEl = document.createElement('div'); + dtEl.className = 'fc-datatype'; + dtEl.textContent = dataset.datatype; + card.appendChild(dtEl); + } + + var meta = dataset.metadata || {}; + + // HTML repr — render inline (sanitised: strip scripts/iframes) + var htmlRepr = typeof meta.html_repr === 'string' ? meta.html_repr : null; + if (htmlRepr) { + var reprDiv = document.createElement('div'); + reprDiv.className = 'fc-html-repr'; + reprDiv.innerHTML = sanitizeHtmlRepr(htmlRepr); + card.appendChild(reprDiv); + } + + // Thumbnail image (data: URI only) + var thumb = typeof meta.thumbnail === 'string' ? meta.thumbnail : null; + if (thumb && /^data:image\//i.test(thumb)) { + var img = document.createElement('img'); + img.src = thumb; + img.className = 'fc-thumbnail'; + img.alt = 'thumbnail'; + card.appendChild(img); + } + + // Schema / columns — only if no richer HTML repr + if (!htmlRepr) { + var schema = dataset.schema; + if (schema && typeof schema === 'object' && !Array.isArray(schema)) { + var cols = Object.keys(schema); + if (cols.length > 0) { + var schemaHdr = document.createElement('div'); + schemaHdr.className = 'fc-schema-hdr'; + schemaHdr.textContent = 'Columns'; + card.appendChild(schemaHdr); + for (var ci = 0; ci < cols.length; ci++) { + var colRow = document.createElement('div'); + colRow.className = 'fc-col-row'; + colRow.innerHTML = '' + escHtml(cols[ci]) + '' + + '' + escHtml(String(schema[cols[ci]])) + ''; + card.appendChild(colRow); + } + } + } + } + + // Metadata key-values — skip html_repr/thumbnail (already rendered above) + var SKIP_META = new Set(['html_repr', 'thumbnail']); + var mkeys = Object.keys(meta); + for (var mi = 0; mi < mkeys.length; mi++) { + var mk = mkeys[mi], mv = meta[mk]; + if (SKIP_META.has(mk) || mv == null) continue; + var mvStr = typeof mv === 'object' ? JSON.stringify(mv) : String(mv); + if (!mvStr || mvStr === '{}' || mvStr === '[]') continue; + var kvEl = document.createElement('div'); + kvEl.className = 'fc-kv'; + kvEl.innerHTML = '' + escHtml(mk) + ':' + + '' + escHtml(mvStr) + ''; + card.appendChild(kvEl); + } + + // Structure tags (e.g. ["table"]) + var structure = dataset.structure; + if (Array.isArray(structure) && structure.length > 0) { + var stEl = document.createElement('div'); + stEl.className = 'fc-kv'; + stEl.innerHTML = 'structure:' + + '' + escHtml(structure.join(', ')) + ''; + card.appendChild(stEl); + } + + fileContent.appendChild(card); + + // For data files, also show text preview below if there's no html_repr + // (e.g. CSV — the first few rows are more useful than just column names) + if (!htmlRepr && textPreview) { + var prevHdr = document.createElement('div'); + prevHdr.className = 'fc-schema-hdr'; + prevHdr.style.marginTop = '10px'; + prevHdr.textContent = 'Preview'; + fileContent.appendChild(prevHdr); + var pre2 = document.createElement('pre'); + pre2.className = 'fc-text-preview'; + pre2.textContent = textPreview; + fileContent.appendChild(pre2); + } + + } else if (textPreview) { + // Pure text file — just show the preview + var pre = document.createElement('pre'); + pre.className = 'fc-text-preview'; + pre.textContent = textPreview; + fileContent.appendChild(pre); + + } else { + // Nothing to show — hide the scan pane + scanPane.classList.add('hidden'); + } + } + + // Minimal sanitiser for html_repr content (strips scripts, iframes, on* handlers) + function sanitizeHtmlRepr(html) { + var tpl = document.createElement('template'); + tpl.innerHTML = String(html); + var walker = document.createTreeWalker(tpl.content, NodeFilter.SHOW_ELEMENT); + var toRemove = []; + var n = walker.nextNode(); + while (n) { + var tag = n.tagName.toLowerCase(); + if (tag === 'script' || tag === 'iframe' || tag === 'object' || tag === 'embed') { + toRemove.push(n); + } else { + var attrs = Array.from(n.attributes); + for (var ai = 0; ai < attrs.length; ai++) { + var an = attrs[ai].name.toLowerCase(); + if (an.startsWith('on')) { n.removeAttribute(attrs[ai].name); continue; } + if ((an === 'href' || an === 'src') && /^\s*javascript:/i.test(attrs[ai].value)) { + n.removeAttribute(attrs[ai].name); + } + } + } + n = walker.nextNode(); + } + for (var ri = 0; ri < toRemove.length; ri++) toRemove[ri].remove(); + return tpl.innerHTML; + } + function showProjectInPanel(data) { if (!scanPanelRoot) return; if (scanStatus) scanStatus.textContent = ''; @@ -2258,7 +2513,7 @@ function getFileBrowserJs(): string { // ── message bus ──────────────────────────────────────────────────────── window.addEventListener('message', function(ev) { const msg = ev.data; - dbg('recv type=' + msg.type); + // (type logged selectively in each case handler) switch (msg.type) { case 'loading': spinner.classList.toggle('hidden', !msg.loading); @@ -2297,8 +2552,14 @@ function getFileBrowserJs(): string { break; case 'projectScanned': - dbg('projectScanned url=' + msg.url + ' error=' + msg.error); - showProjectInPanel(msg); + dbg('projectScanned url=' + msg.url); + if (selectedIsFile) { + // Single file: inline display, no project widget + showFileInScanPane(msg); + } else { + // Directory: full embedded library panel + showProjectInPanel(msg); + } break; case 'bookmarksUpdated': From 34b46ab94c2b2cee6785dcfc78e237427add211f Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Sun, 5 Jul 2026 20:58:47 -0400 Subject: [PATCH 05/13] remove prompt --- PROMPT.text | 52 ---------------------------------------------------- 1 file changed, 52 deletions(-) delete mode 100644 PROMPT.text diff --git a/PROMPT.text b/PROMPT.text deleted file mode 100644 index 5858f5f..0000000 --- a/PROMPT.text +++ /dev/null @@ -1,52 +0,0 @@ -*Preamble* - -This library, projspec, contains python code to scan directories for -project-like structure and metadata. It implements a library for storing -previously scanned locations, which can be searched to find he project a user needs -to do their current work. This library is exposed to the used via GUIs embedded into -their IDEs, e.g., in the directory vsextension for vscode. - -Of note, some directories are not "projects" in the sense of code+metadata, but -might be just data, or normal projects might contain datasets within them. Currently, -projspec supports these by depending on intake to guess the dataset types and -glean minimal summary information about them. - -Another important point, is that the project directories are not necessarily on -the local disk, but anything supported by fsspec can be scanned. Sometimes this -requires just a URL formed with the appropriate protocol (such as "s3://") or -extra arguments ("storage_options") may be needed - which can also be defined -via environment variable or fsspec config files. - -In IDEs such as vscode, the file browser is scoped to the current (local) directory -of the current local open project. This makes it hard to bootstrap projects or -datasets into the library. - -*The task* - -Without changing the existing project library UI; generate a new interface for -browsing files locally or remotely. It should be possible to bookmark/favourite -any location, so that later the user can select these and have the file tree -root at that location. It should be possible to set the file tree root with double -click or ascend to a parent, as in a local file browser. - -Any directory will be scanned by projspec, and can optionally be added to the -fsspec library. Any file when selected will be scanned by intake. The detilas -will be shown in an info panel next to the file tree; if it is a text file -(of no known data type), the first few rows will be shown. Any filetype that -vscode supports can be opened and edited (up to some configurable max -file size) - so we need a full model for reading and writing files. -The file browser should support normal filesystem operations like -create file, move, delete. Note that some filesystems (object stores) do -not support empty directories (mkdir is a no-op), the directories will -come into existence when the first contents are written. - -To be able to handle arbitrary remote filesystems (as well as local), -IO will go through python/fsspec. -The library at https://github.com/fsspec/fsspec-proxy/tree/main/fsspec-proxy -provides a model for this. In that model, the possible remote locations -are preconfigured, but in this case we want to be able to dynamically jump -to any supported URL and save bookmarks/favourites dynamically. - -Create the new UI for vscode, as well as any backend code needed for the IO, -which may involve copying code from fsspec-proxy or similar projects -(jupyter-fs). From fb2171ae0c5651a1b1d59fcd19c0260adf8eb24a Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Mon, 6 Jul 2026 15:32:39 -0400 Subject: [PATCH 06/13] One panel --- src/projspec/webui/panel.js | 2 +- vsextension/package.json | 3 +- vsextension/src/extension.ts | 16 +++++---- vsextension/src/fileBrowserPanel.ts | 56 +++++++++++++++++++++++++++-- vsextension/src/sidebarView.ts | 2 ++ 5 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/projspec/webui/panel.js b/src/projspec/webui/panel.js index 418fdaa..b634ef6 100644 --- a/src/projspec/webui/panel.js +++ b/src/projspec/webui/panel.js @@ -283,7 +283,7 @@ const isLocal = url.startsWith('file://'); if (isLocal) { addItem(menu, 'Open with VSCode', () => postMessage({ cmd: 'openWith', tool: 'vscode', url })); - addItem(menu, 'Open with system filebrowser', () => postMessage({ cmd: 'openWith', tool: 'filebrowser', url })); + addItem(menu, 'Show in file browser', () => postMessage({ cmd: 'openWith', tool: 'filebrowser', url })); addItem(menu, 'Open with PyCharm', () => postMessage({ cmd: 'openWith', tool: 'pycharm', url })); addItem(menu, 'Open with jupyter', () => postMessage({ cmd: 'openWith', tool: 'jupyter', url })); addSeparator(menu); diff --git a/vsextension/package.json b/vsextension/package.json index 9bd3481..845507c 100644 --- a/vsextension/package.json +++ b/vsextension/package.json @@ -21,7 +21,8 @@ "commands": [ { "command": "projspec.showTree", - "title": "Project Library" + "title": "Open projspec Panel", + "category": "projspec" }, { "command": "projspec.openFileBrowser", diff --git a/vsextension/src/extension.ts b/vsextension/src/extension.ts index e7bdbc7..3900b9b 100644 --- a/vsextension/src/extension.ts +++ b/vsextension/src/extension.ts @@ -1,26 +1,29 @@ import * as vscode from 'vscode'; -import { ProjspecPanel } from './panel'; +import { CombinedPanel } from './combinedPanel'; import { SidebarViewProvider } from './sidebarView'; -import { FileBrowserPanel } from './fileBrowserPanel'; export function activate(context: vscode.ExtensionContext): void { console.log('[projspec] activate called'); + + // Sidebar view (activity bar launcher) const sidebarProvider = new SidebarViewProvider(context.extensionUri); context.subscriptions.push( vscode.window.registerWebviewViewProvider('projspec.view', sidebarProvider) ); + // Primary command: open the combined panel (defaults to Library tab) context.subscriptions.push( vscode.commands.registerCommand('projspec.showTree', () => { - ProjspecPanel.createOrShow(context.extensionUri); + CombinedPanel.createOrShow(context.extensionUri, 'library'); }) ); + // Open directly to the File Browser tab (optionally at a specific URL) context.subscriptions.push( vscode.commands.registerCommand('projspec.openFileBrowser', (initialUrl?: string) => { - console.log('[projspec] openFileBrowser fired, FileBrowserPanel=', typeof FileBrowserPanel); + console.log('[projspec] openFileBrowser fired, initialUrl=', initialUrl); try { - FileBrowserPanel.createOrShow(context.extensionUri, initialUrl); + CombinedPanel.createOrShow(context.extensionUri, 'filebrowser', initialUrl); } catch (e) { console.error('[projspec] createOrShow threw:', e); vscode.window.showErrorMessage('FileBrowser error: ' + String(e)); @@ -28,12 +31,13 @@ export function activate(context: vscode.ExtensionContext): void { }) ); + // Open File Browser at the current workspace folder context.subscriptions.push( vscode.commands.registerCommand('projspec.openFileBrowserHere', () => { const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; console.log('[projspec] openFileBrowserHere fired, folder=', folder); try { - FileBrowserPanel.createOrShow(context.extensionUri, folder); + CombinedPanel.createOrShow(context.extensionUri, 'filebrowser', folder); } catch (e) { console.error('[projspec] createOrShow threw:', e); vscode.window.showErrorMessage('FileBrowser error: ' + String(e)); diff --git a/vsextension/src/fileBrowserPanel.ts b/vsextension/src/fileBrowserPanel.ts index 6273b4a..5690e33 100644 --- a/vsextension/src/fileBrowserPanel.ts +++ b/vsextension/src/fileBrowserPanel.ts @@ -946,7 +946,7 @@ const FB_HTML_BODY = ` // --------------------------------------------------------------------------- // CSS // --------------------------------------------------------------------------- -function getFileBrowserCss(): string { +export function getFileBrowserCss(): string { return ` /* reset / base */ *, *::before, *::after { box-sizing: border-box; } @@ -1408,6 +1408,34 @@ body { margin: 0; padding: 0; .intake-key { font-weight: 600; min-width: 80px; } .intake-val { word-break: break-all; } +.fc-reader-details { + margin-top: 6px; + border-top: 1px solid var(--vscode-panel-border); + padding-top: 4px; +} +.fc-reader-summary { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--vscode-descriptionForeground); + cursor: pointer; + user-select: none; + list-style: none; + display: flex; + align-items: center; + gap: 4px; + padding: 2px 0; +} +.fc-reader-summary::-webkit-details-marker { display: none; } +.fc-reader-summary::before { + content: '\\25B6'; + font-size: 8px; + transition: transform 0.15s; +} +details[open] > .fc-reader-summary::before { transform: rotate(90deg); } +.fc-reader-details .fc-kv { margin-top: 3px; } + /* Bookmarks panel */ #bm-panel { position: absolute; @@ -1553,7 +1581,7 @@ body { margin: 0; padding: 0; // --------------------------------------------------------------------------- // JavaScript // --------------------------------------------------------------------------- -function getFileBrowserJs(): string { +export function getFileBrowserJs(): string { return String.raw` (function() { // Proof-of-life: visible immediately if the script block executes at all @@ -2405,20 +2433,42 @@ function getFileBrowserJs(): string { } } - // Metadata key-values — skip html_repr/thumbnail (already rendered above) + // Metadata key-values — skip html_repr/thumbnail (already rendered above). + // reader_* / readers* fields are collected into a collapsible box at the end. var SKIP_META = new Set(['html_repr', 'thumbnail']); var mkeys = Object.keys(meta); + var readerRows = []; for (var mi = 0; mi < mkeys.length; mi++) { var mk = mkeys[mi], mv = meta[mk]; if (SKIP_META.has(mk) || mv == null) continue; var mvStr = typeof mv === 'object' ? JSON.stringify(mv) : String(mv); if (!mvStr || mvStr === '{}' || mvStr === '[]') continue; + if (/^readers?(_|$)/i.test(mk)) { + readerRows.push([mk, mvStr]); + continue; + } var kvEl = document.createElement('div'); kvEl.className = 'fc-kv'; kvEl.innerHTML = '' + escHtml(mk) + ':' + '' + escHtml(mvStr) + ''; card.appendChild(kvEl); } + if (readerRows.length > 0) { + var det = document.createElement('details'); + det.className = 'fc-reader-details'; + var sum = document.createElement('summary'); + sum.className = 'fc-reader-summary'; + sum.textContent = 'Reader info'; + det.appendChild(sum); + for (var ri = 0; ri < readerRows.length; ri++) { + var rkvEl = document.createElement('div'); + rkvEl.className = 'fc-kv'; + rkvEl.innerHTML = '' + escHtml(readerRows[ri][0]) + ':' + + '' + escHtml(readerRows[ri][1]) + ''; + det.appendChild(rkvEl); + } + card.appendChild(det); + } // Structure tags (e.g. ["table"]) var structure = dataset.structure; diff --git a/vsextension/src/sidebarView.ts b/vsextension/src/sidebarView.ts index 42c1800..915c891 100644 --- a/vsextension/src/sidebarView.ts +++ b/vsextension/src/sidebarView.ts @@ -14,8 +14,10 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { webviewView.webview.html = this.getHtml(); webviewView.webview.onDidReceiveMessage((msg) => { if (msg.cmd === 'open') { + // Opens combined panel on the Library tab vscode.commands.executeCommand('projspec.showTree'); } else if (msg.cmd === 'openFileBrowser') { + // Opens combined panel on the File Browser tab vscode.commands.executeCommand('projspec.openFileBrowserHere'); } }); From ad6c3f53ec0e08feb9dad4c4ea7f7324e4f0bc2a Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Mon, 6 Jul 2026 15:49:19 -0400 Subject: [PATCH 07/13] Add server --- pyproject.toml | 2 + src/projspec/__main__.py | 40 ++++ src/projspec/server.py | 444 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 486 insertions(+) create mode 100644 src/projspec/server.py diff --git a/pyproject.toml b/pyproject.toml index 9fab6b9..388c4d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,11 +37,13 @@ test = ["pytest", "pytest-cov", "django", "streamlit", "copier", "jinja2-time", qt = ["pyqt>5,<6", "pyqtwebengin>5,<6"] textual = ["textual>=0.80"] ipywidget = ["anywidget>=0.9"] +serve = ["fastapi>=0.100", "uvicorn[standard]>=0.23"] [project.scripts] projspec = "projspec.__main__:main" projspec-qt = "projspec.qtapp.main:main" projspec-tui = "projspec.textapp.main:main" +projspec-server = "projspec.server:main" [tool.poetry.extras] po_test = ["pytest"] diff --git a/src/projspec/__main__.py b/src/projspec/__main__.py index 1fdfb36..981ae25 100755 --- a/src/projspec/__main__.py +++ b/src/projspec/__main__.py @@ -522,5 +522,45 @@ def fb_bookmarks_remove(url): print(json.dumps(bookmark_remove(url))) +@main.command("serve") +@click.option( + "--host", + default="127.0.0.1", + show_default=True, + help="Interface to bind the server to.", +) +@click.option( + "--port", + default=0, + show_default=True, + type=int, + help="TCP port (0 = pick a free port automatically).", +) +@click.option( + "--port-file", + default=None, + type=click.Path(dir_okay=False, writable=True), + help=( + "If given, write the chosen port number to this file once the server " + "is ready. Useful for callers that need to discover a dynamically " + "assigned port." + ), +) +def serve(host, port, port_file): + """Start the projspec HTTP server (requires fastapi + uvicorn). + + The server exposes all projspec and filebrowser operations as JSON + endpoints so that callers (e.g. the VS Code extension) can avoid the + overhead of spawning a new Python process for each operation. + + Install the required extras with: + + pip install 'projspec[serve]' + """ + from projspec.server import run + + run(host=host, port=port, port_file=port_file) + + if __name__ == "__main__": main() diff --git a/src/projspec/server.py b/src/projspec/server.py new file mode 100644 index 0000000..6e63653 --- /dev/null +++ b/src/projspec/server.py @@ -0,0 +1,444 @@ +""" +projspec HTTP server +==================== +A lightweight FastAPI server that exposes all projspec and filebrowser +operations as JSON endpoints. It is intended to run as a long-lived +background process owned by the VS Code extension so that repeated +subprocess-spawn overhead is eliminated. + +Start with:: + + projspec serve [--port PORT] [--host HOST] + +The server binds to localhost only by default so it is not exposed on +the network. The extension discovers the port via the ``--port-file`` +option, which writes the chosen port number to a file that the TypeScript +client can read. + +Endpoints +--------- +GET /ping → {"ok": true} +GET /info → class_infos() JSON +GET /enum_members → {snake_name: {MEMBER: value}} JSON +GET /library → {url: project_dict, ...} +POST /library/delete → {"url": url} +POST /scan → {"path": str, "add_to_library": bool, "storage_options": str|null} +POST /create → {"spec": str, "path": str} + +POST /filebrowser/browse → {"url": str, "storage_options": obj|null} +POST /filebrowser/inspect → {"url": str, "storage_options": obj|null} +POST /filebrowser/inspect_as_project → {"url": str, "storage_options": obj|null} +POST /filebrowser/scan_directory → {"url": str, "storage_options": obj|null} +POST /filebrowser/read_file → {"url": str, "storage_options": obj|null, "max_bytes": int|null} +POST /filebrowser/write_file → {"url": str, "content": str, "storage_options": obj|null} +POST /filebrowser/delete → {"url": str, "storage_options": obj|null, "recursive": bool} +POST /filebrowser/move → {"src": str, "dst": str, "storage_options": obj|null} +POST /filebrowser/mkdir → {"url": str, "storage_options": obj|null} +POST /filebrowser/add_to_library → {"url": str, "storage_options": obj|null} +GET /filebrowser/protocols → [str, ...] +GET /filebrowser/bookmarks → [{url, label, storage_options?}, ...] +POST /filebrowser/bookmarks/add → {"url": str, "label": str, "storage_options": obj|null} +POST /filebrowser/bookmarks/remove → {"url": str} +""" + +from __future__ import annotations + +import json +import threading +from typing import Any + +try: + from fastapi import FastAPI + from fastapi.responses import JSONResponse + from pydantic import BaseModel +except ImportError as _e: # pragma: no cover + raise ImportError( + "projspec server requires 'fastapi' and 'uvicorn'. " + "Install them with: pip install 'projspec[serve]'" + ) from _e + +# --------------------------------------------------------------------------- +# App +# --------------------------------------------------------------------------- + +app = FastAPI(title="projspec", docs_url=None, redoc_url=None) + + +# Serialise to JSON ourselves so NaN/Infinity in Python floats become null +# (the default FastAPI JSONResponse would raise on those). +def _json(obj: Any, **kw) -> JSONResponse: + return JSONResponse(content=json.loads(json.dumps(obj, default=str))) + + +# --------------------------------------------------------------------------- +# Shared state — loaded lazily and cached for the lifetime of the server +# --------------------------------------------------------------------------- + +_info_cache: dict | None = None +_info_lock = threading.Lock() + +_enum_cache: dict | None = None +_enum_lock = threading.Lock() + + +def _get_info() -> dict: + global _info_cache + with _info_lock: + if _info_cache is None: + from projspec.utils import class_infos + + _info_cache = class_infos() + return _info_cache + + +def _get_enum_members() -> dict: + global _enum_cache + with _enum_lock: + if _enum_cache is None: + import importlib + import pkgutil + import projspec.content + import projspec.artifact + import projspec.utils as pu + from projspec.utils import camel_to_snake + + for pkg in (projspec.content, projspec.artifact): + for m in pkgutil.iter_modules(pkg.__path__, pkg.__name__ + "."): + importlib.import_module(m.name) + + out: dict = {} + seen: set = set() + + def walk(cls): + for sub in cls.__subclasses__(): + if sub in seen: + continue + seen.add(sub) + walk(sub) + try: + out[camel_to_snake(sub.__name__)] = { + m.name: m.value for m in sub + } + except TypeError: + pass # not an enum + + walk(pu.Enum) + _enum_cache = out + return _enum_cache + + +# --------------------------------------------------------------------------- +# Health +# --------------------------------------------------------------------------- + + +@app.get("/ping") +def ping(): + return {"ok": True} + + +# --------------------------------------------------------------------------- +# projspec core +# --------------------------------------------------------------------------- + + +@app.get("/info") +def get_info(): + return _json(_get_info()) + + +@app.get("/enum_members") +def get_enum_members(): + return _json(_get_enum_members()) + + +@app.get("/library") +def get_library(): + from projspec.library import ProjectLibrary + + lib = ProjectLibrary() + return _json({k: v.to_dict(compact=False) for k, v in lib.entries.items()}) + + +class LibraryDeleteRequest(BaseModel): + url: str + + +@app.post("/library/delete") +def library_delete(req: LibraryDeleteRequest): + from projspec.library import ProjectLibrary + + lib = ProjectLibrary() + lib.entries.pop(req.url, None) + lib.save() + return {"ok": True} + + +class ScanRequest(BaseModel): + path: str + add_to_library: bool = False + storage_options: str | None = None + + +@app.post("/scan") +def do_scan(req: ScanRequest): + from projspec.utils import scan_glob + + so = req.storage_options or "" + results = [] + for proj in scan_glob( + req.path, + storage_options=so, + add_to_library=req.add_to_library, + ): + try: + results.append(proj.to_dict(compact=False)) + except Exception as exc: + results.append({"error": str(exc)}) + return _json({"results": results, "code": 0}) + + +class CreateRequest(BaseModel): + spec: str + path: str + + +@app.post("/create") +def do_create(req: CreateRequest): + from projspec.proj import Project + from projspec.proj.base import registry + + if req.spec not in registry: + return _json({"error": f"Unknown spec type: {req.spec}", "code": 1}) + proj = Project(req.path) + if req.spec in proj: + return _json({"error": f"Project already has a {req.spec} spec", "code": 1}) + try: + files = proj.create(req.spec) + return _json({"files": list(files), "code": 0}) + except Exception as exc: + return _json({"error": str(exc), "code": 1}) + + +# --------------------------------------------------------------------------- +# Filebrowser — request models +# --------------------------------------------------------------------------- + + +class UrlSoRequest(BaseModel): + url: str + storage_options: dict | None = None + + +class ReadFileRequest(BaseModel): + url: str + storage_options: dict | None = None + max_bytes: int | None = None + + +class WriteFileRequest(BaseModel): + url: str + content: str + storage_options: dict | None = None + + +class DeleteRequest(BaseModel): + url: str + storage_options: dict | None = None + recursive: bool = False + + +class MoveRequest(BaseModel): + src: str + dst: str + storage_options: dict | None = None + + +class BookmarkAddRequest(BaseModel): + url: str + label: str = "" + storage_options: dict | None = None + + +class BookmarkRemoveRequest(BaseModel): + url: str + + +# --------------------------------------------------------------------------- +# Filebrowser — endpoints +# --------------------------------------------------------------------------- + + +@app.post("/filebrowser/browse") +def fb_browse(req: UrlSoRequest): + from projspec.filebrowser import browse + + return _json(browse(req.url, storage_options=req.storage_options)) + + +@app.post("/filebrowser/inspect") +def fb_inspect(req: UrlSoRequest): + from projspec.filebrowser import inspect_file + + return _json(inspect_file(req.url, storage_options=req.storage_options)) + + +@app.post("/filebrowser/inspect_as_project") +def fb_inspect_as_project(req: UrlSoRequest): + from projspec.filebrowser import inspect_as_project + + return _json(inspect_as_project(req.url, storage_options=req.storage_options)) + + +@app.post("/filebrowser/scan_directory") +def fb_scan_directory(req: UrlSoRequest): + from projspec.filebrowser import scan_directory + + return _json(scan_directory(req.url, storage_options=req.storage_options)) + + +@app.post("/filebrowser/read_file") +def fb_read_file(req: ReadFileRequest): + from projspec.filebrowser import read_file + + kwargs: dict = {"storage_options": req.storage_options} + if req.max_bytes is not None: + kwargs["max_bytes"] = req.max_bytes + return _json(read_file(req.url, **kwargs)) + + +@app.post("/filebrowser/write_file") +def fb_write_file(req: WriteFileRequest): + from projspec.filebrowser import write_file + + return _json(write_file(req.url, req.content, storage_options=req.storage_options)) + + +@app.post("/filebrowser/delete") +def fb_delete(req: DeleteRequest): + from projspec.filebrowser import delete + + return _json( + delete(req.url, storage_options=req.storage_options, recursive=req.recursive) + ) + + +@app.post("/filebrowser/move") +def fb_move(req: MoveRequest): + from projspec.filebrowser import move + + return _json(move(req.src, req.dst, storage_options=req.storage_options)) + + +@app.post("/filebrowser/mkdir") +def fb_mkdir(req: UrlSoRequest): + from projspec.filebrowser import mkdir + + return _json(mkdir(req.url, storage_options=req.storage_options)) + + +@app.post("/filebrowser/add_to_library") +def fb_add_to_library(req: UrlSoRequest): + from projspec.filebrowser import add_to_projspec_library + + return _json(add_to_projspec_library(req.url, storage_options=req.storage_options)) + + +@app.get("/filebrowser/protocols") +def fb_protocols(): + from projspec.filebrowser import supported_protocols + + return _json(supported_protocols()) + + +@app.get("/filebrowser/bookmarks") +def fb_bookmarks(): + from projspec.filebrowser import bookmarks_list + + return _json(bookmarks_list()) + + +@app.post("/filebrowser/bookmarks/add") +def fb_bookmark_add(req: BookmarkAddRequest): + from projspec.filebrowser import bookmark_add + + return _json( + bookmark_add(req.url, label=req.label, storage_options=req.storage_options) + ) + + +@app.post("/filebrowser/bookmarks/remove") +def fb_bookmark_remove(req: BookmarkRemoveRequest): + from projspec.filebrowser import bookmark_remove + + return _json(bookmark_remove(req.url)) + + +# --------------------------------------------------------------------------- +# Entrypoint (used by `projspec serve`) +# --------------------------------------------------------------------------- + + +def run(host: str = "127.0.0.1", port: int = 0, port_file: str | None = None) -> None: + """Start the uvicorn server. + + If *port* is 0 a free port is chosen automatically. When *port_file* is + given the chosen port number is written to that path as a plain integer + string so the caller can discover it. + """ + import socket + import uvicorn + + if port == 0: + with socket.socket() as s: + s.bind((host, 0)) + port = s.getsockname()[1] + + if port_file: + import os + + os.makedirs(os.path.dirname(os.path.abspath(port_file)), exist_ok=True) + with open(port_file, "w") as fh: + fh.write(str(port)) + + uvicorn.run(app, host=host, port=port, log_level="warning") + + +def main() -> None: + """Console-script entry point for the ``projspec-server`` command. + + Accepts the same arguments as :func:`run` via the command line:: + + projspec-server [--host HOST] [--port PORT] [--port-file PATH] + + Defaults: host=127.0.0.1, port=0 (free port chosen automatically). + """ + import argparse + + parser = argparse.ArgumentParser( + prog="projspec-server", + description="Start the projspec HTTP server (requires fastapi + uvicorn).", + ) + parser.add_argument( + "--host", + default="127.0.0.1", + help="Interface to bind to (default: 127.0.0.1)", + ) + parser.add_argument( + "--port", + type=int, + default=0, + help="TCP port; 0 picks a free port automatically (default: 0)", + ) + parser.add_argument( + "--port-file", + default=None, + metavar="PATH", + help="Write the chosen port number to this file once the server is ready", + ) + args = parser.parse_args() + run(host=args.host, port=args.port, port_file=args.port_file) + + +if __name__ == "__main__": + main() From 2ef3a298112e5c8450957f274a42aa63533e19cc Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Mon, 6 Jul 2026 15:58:07 -0400 Subject: [PATCH 08/13] details --- src/projspec/webui/panel.js | 2 +- vsextension/src/fileBrowserPanel.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/projspec/webui/panel.js b/src/projspec/webui/panel.js index b634ef6..f63458c 100644 --- a/src/projspec/webui/panel.js +++ b/src/projspec/webui/panel.js @@ -175,7 +175,7 @@ : 'No projects match the filter.'; projectsEl.appendChild(e); } - if (selection) renderDetails(); + renderDetails(); } function renderProject(url, project) { diff --git a/vsextension/src/fileBrowserPanel.ts b/vsextension/src/fileBrowserPanel.ts index 5690e33..97bd046 100644 --- a/vsextension/src/fileBrowserPanel.ts +++ b/vsextension/src/fileBrowserPanel.ts @@ -2443,7 +2443,7 @@ export function getFileBrowserJs(): string { if (SKIP_META.has(mk) || mv == null) continue; var mvStr = typeof mv === 'object' ? JSON.stringify(mv) : String(mv); if (!mvStr || mvStr === '{}' || mvStr === '[]') continue; - if (/^readers?(_|$)/i.test(mk)) { + if (/^readers?(_|$)/i.test(mk) || mk === 'errors') { readerRows.push([mk, mvStr]); continue; } From 852240ce95750066d3b4e1bfb535be0932275c5a Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Tue, 7 Jul 2026 16:39:39 -0400 Subject: [PATCH 09/13] Fix TUI, qt, notebook-ui Plus logging, and almost fixes up pycharm --- docs/source/ui.rst | 336 +++- .../com/projspec/toolwindow/HtmlContent.kt | 1481 ++--------------- .../toolwindow/ProjspecToolWindowPanel.kt | 427 ++++- .../com/projspec/util/ProjspecRunner.kt | 138 +- src/projspec/__main__.py | 17 + src/projspec/filebrowser.py | 87 +- src/projspec/qtapp/main.py | 392 ++++- src/projspec/qtapp/views.py | 227 ++- src/projspec/server.py | 84 +- src/projspec/textapp/main.py | 554 +++++- src/projspec/webui/__init__.py | 246 ++- src/projspec/webui/ipywidget.py | 875 ++++++---- 12 files changed, 2873 insertions(+), 1991 deletions(-) diff --git a/docs/source/ui.rst b/docs/source/ui.rst index 1888256..576d2a4 100644 --- a/docs/source/ui.rst +++ b/docs/source/ui.rst @@ -1,156 +1,334 @@ UI Support ========== -`projspec` is designed to be useful in whichever interface you are already using - -it comes to you, rather than the other way around. As well as the python -library interface (which provides full power and flexibility), -the following interactivity is supported. Of these, all but -the CLI look very similar and have the same capabilities. +`projspec` is designed to be useful in whichever interface you are already using — +it comes to you rather than the other way around. As well as the Python library +interface (which provides full power and flexibility), the following interactive +surfaces are supported. All graphical interfaces share the same HTML/CSS/JavaScript +panel rendered through their respective browser embedding, so they look and behave +identically wherever possible. CLI --- -Installing `projspec` makes the command `projspec` available. This is the same command which is called -as a subprocess by the `vscode`, `pycharm` and `anaconda-desktop` plugins. Unlike the rest of the UIs here, -this is already covered in the :ref:`quickstart`. Text feedback is given for every command and parameter +Installing `projspec` makes the ``projspec`` command available. The same binary is +called as a subprocess by the PyCharm and Anaconda-Desktop plugins. Unlike the rest +of the UIs described here, the CLI is already covered in the :ref:`quickstart`. Text +feedback is given for every command and parameter. -Here is the full tree of possible commands +Here is the full tree of possible commands: .. code-block:: text projspec - config Interact with the projspec config. - defaults Show default config settings for all available values and short descriptions. + config Interact with the projspec config. + defaults Show default config settings and short descriptions. get Get a value from the config. set Set a config value. show Show all contents of the config. unset Remove a value from the config (returns to default). - create Create a new project of the given type in the given path. - info Documentation about all the classes within projspec. - library Interact with the project library. + create Create a new project of the given type in the given path. + filebrowser File browser operations via fsspec (local and remote). + browse List the contents of a directory URL. + inspect File metadata and text/intake preview. + read-file Read a file and emit its contents as JSON. + write-file Write content to a file (create or overwrite). + delete Delete a file or directory. + move Move / rename a file or directory. + mkdir Create a directory. + add-to-library Scan a URL and add it to the project library. + protocols List available fsspec protocols. + bookmarks + list List saved bookmarks. + add Add or update a bookmark. + remove Remove a bookmark by URL. + info Documentation about all the classes within projspec. + library Interact with the project library. clear Clear all contents of the library. - delete Delete the project at the given URL from the library. + delete Delete a project at the given URL from the library. list Show contents of the library. - make Make the given artifact in the project at the given path. - scan Scan the given path for projects, and display. - version Show version and quit. + make Make the given artifact in the project at the given path. + scan Scan the given path for projects, and display. + serve Start the projspec HTTP server (see below). + version Show version and quit. + +A dedicated ``projspec-server`` console script is also installed; it is equivalent +to ``projspec serve`` and is the command started automatically by the VS Code +extension (see *HTTP server* below). + + +HTTP server +----------- + +Repeated subprocess spawning for every operation is slow, especially on cold-start. +To eliminate this overhead the VS Code extension (and any other UI that chooses to) +can run a persistent HTTP server: + +.. code-block:: bash + + $ projspec-server # picks a free port automatically + $ projspec-server --port 8765 # fixed port + $ projspec-server --port 0 --port-file /tmp/projspec.port # write chosen port to file + +Install the required extras first: + +.. code-block:: bash + + $ pip install 'projspec[serve]' + +The server exposes every ``projspec`` and ``filebrowser`` operation as a JSON +endpoint (FastAPI/uvicorn). The VS Code extension starts it automatically on launch +using ``projspec-server --port 0 --port-file ``, reads the port from the file, +and falls back transparently to direct subprocess calls if the server is not +available (i.e. if ``fastapi``/``uvicorn`` are not installed). + GUIs ---- -Each UI looks something like the following (which is for `vscode`): +Each GUI consists of two tabs described below. The VSCode extension is the +most fully featured; all others use the same shared HTML/CSS/JS for the +webview-based hosts (Qt, PyCharm/IDEA, ipywidget) and a native Textual +equivalent for the terminal UI. + +Project Library tab +~~~~~~~~~~~~~~~~~~~ + +The searchable list on the left shows every project that has been scanned, +either via the GUI or the CLI. For each project it displays: + +* **Title** (directory basename) and full URL. +* Any stored ``storage_options`` (credentials / fsspec kwargs for remote filesystems). +* File count, total size, writable flag and last-modified / last-scanned timestamps. +* **Coloured chips** — one labelled *Global* (for project-level contents and artifacts) + and one per matched spec type (e.g. *git_repo*, *pixi*, *python_library*). Clicking + a chip populates the Details pane with information for that spec. + +A **⋮ kebab menu** on each project offers: + +* *Open with VSCode / PyCharm / Jupyter* — launches the project in that tool. +* *Show in file browser* — (VSCode only) switches to the File Browser tab and + navigates to the project root, including any stored ``storage_options``. +* *Rescan* — re-runs ``projspec scan`` on the project and refreshes the library entry. +* *Create spec* — opens a typeahead picker to scaffold a new spec type inside the + project directory. +* *Remove from library* — deletes the entry from the library. + +The toolbar above the list provides **Add**, **Reload**, and **Configure** buttons. +"Add" opens the system file picker (local) or an Advanced dialog that accepts any +fsspec URL or glob pattern plus optional JSON ``storage_options``, making it possible +to add remote projects (S3, GCS, HTTP, …) without using the CLI. + +Details tab / pane +~~~~~~~~~~~~~~~~~~ + +Clicking a chip switches the right-hand Details pane to show the contents and +artifacts for that spec (or the project-level global items). The pane is fully +cleared whenever the selection changes or the library is reloaded. + +Each **Content widget** (blue border, *CONTENT* badge) displays the structured +information extracted by that spec — environments, metadata, dataset schema, +``html_repr`` thumbnails, etc. For dataset content produced by ``intake``, technical +reader fields (``reader_used``, ``reader_tier``, ``readers_attempted``, ``readers``, +``errors``) are folded into a collapsed **Reader info** disclosure at the bottom of +the widget rather than shown inline. + +Each **Artifact widget** (amber border, *ARTIFACT* badge) displays the artifact +description. A **▶ Make** button runs the artifact's action (build, install, start +service, …) in a terminal. A **➡ Reveal** button is shown for local file artifacts; +it highlights the file in the IDE or OS file manager. An **ℹ Info** button shows the +class docstring in a small popup. + + +File Browser tab +~~~~~~~~~~~~~~~~ -.. image:: ./img/vscode-extension.png - :alt: The VSCode UI snapshot +All graphical UIs now include a **File Browser** tab alongside the Project Library: -Ths UI consists of two main areas: the library (left) and project details (right). +* Browse any fsspec-supported URL (local, S3, GCS, HTTP, FTP, …). +* Bookmarks with custom labels and stored ``storage_options``. +* Breadcrumb navigation, back/up buttons, sortable columns. +* Per-file info panel: MIME type, size, date; text preview; dataset summaries + (schema, column types, HTML repr, thumbnail) for files recognised by + ``intake``. Reader-specific fields are folded into a collapsed "Reader info" + section. +* Per-directory embedded projspec scan panel showing matched specs and + contents/artifacts inline. +* **+ Library** button — adds the selected directory to the Project Library and + switches to the Library tab with the new entry selected. +* File operations: create, rename, delete, create folder, open in editor + (remote files are fetched to a temp file; saves are written back). +* Storage-options (🔑) dialog for per-session credentials. +* Persistent bookmarks stored in ``~/.config/projspec/filebrowser_bookmarks.json``. -*Project Library*: this lists projects that have been scanned, either via this UI or the CLI. The searchable list -of projects shows the title and location of each project, and the list of project specs it conforms to. Clicking on -any of the coloured chips will populate that information into the details pane. Each project also has a set of -commands in a menu under the "⋮" button, which allow you to rescan, remove or open the project. The open action will -reuse the current application, if appropriate and implemented. Control buttons above the project list allow you -to add new projects and configure `projspec` (i.e., open the config file for editing). Note that most UIs will -present a system file picker for scanning new projects, so you need to use the CLI for remote ones - w may change this -in the future. Choosing "create" for a project will present a picker for you to choose the project type you -would like to add to the project, and (if accepted) can open the created file(s), if the application -supports this. +In the TUI the File Browser is implemented as a native Textual pane (no +webview); it lists directory entries, shows file metadata and projspec scan +results inline, and supports "Add to Library" with automatic tab-switch. + +Cross-tab interactions (all UIs): + +* Kebab → *Show in file browser* on a library project switches to the File + Browser tab and navigates to that URL (including stored ``storage_options``). +* *+ Library* in the File Browser reloads the Library tab and selects the new + entry. + + +VSCode +~~~~~~ + +The extension is available in the VS Code marketplace; you will also need +``projspec`` (the CLI) available on your ``PATH``. + +The combined panel hosts two **tabs** in a single editor pane: + +**Project Library tab** + The standard two-pane library and details view described above. Launch with the + *"Open projspec Panel"* command (⇧⌘P → ``projspec``), or click the projspec + icon in the Activity Bar sidebar. + +**File Browser tab** + A full fsspec-backed file browser. Features include: + + * Browse any fsspec-supported URL (local, S3, GCS, HTTP, FTP, …). + * Bookmarks / favourites with custom labels and stored ``storage_options``. + * Breadcrumb navigation; double-click a directory to descend; Back / Up buttons. + * Column headers for Name / Size / Modified with sortable click-to-sort. + * Lazy-expanding tree (click the ▶ arrow to expand a sub-directory in-place). + * Per-file info panel: MIME type, size, modified date; text preview for text files; + dataset summary (schema, column types, HTML repr, thumbnail) for data files + recognised by ``intake``. + * Per-directory projspec scan panel embedded below the file info. + * **+ Library** button — adds the selected directory to the Project Library and + immediately switches to the Library tab with the new entry selected and + highlighted. + * File operations: create, rename, delete, create folder. + * Open any text file in the VS Code editor (remote files are fetched to a temp + file; saves are pushed back automatically). + * Storage-options dialog (🔑) for supplying credentials per session. + * Persistent bookmarks stored in ``~/.config/projspec/filebrowser_bookmarks.json``. + + Launch with *"Open File Browser"* or *"Open File Browser at Current Folder"* from + the command palette, or by clicking the *File Browser* tab in the panel. + +Cross-tab interactions: + +* Kebab → *Show in file browser* on a library project switches to the File Browser + tab and navigates to that project's URL root (with its stored ``storage_options``). +* *+ Library* in the File Browser reloads the Library tab and selects the new entry. + +For fast operation the extension starts a persistent ``projspec-server`` process in +the background (requires ``pip install 'projspec[serve]'``). All requests are served +over HTTP instead of spawning a new Python process each time. If the server is +unavailable the extension falls back automatically to subprocess calls. -*Details*: the full information for the selected project spec. This is a list of Content objects (green, information -only) and Artifact objects (red). As well as displaying the scanned information, each -widget contains an (i) information button, which gives a brief description of that type. Artifacts also have a -Make button to perform the artifact's action. This runs as a subprocess, and where you can see feedback -depends on the application; we prefer an in-app terminal, if available. Notebooks ~~~~~~~~~ -Requires the optional ``anywidget`` and ``ipywidgets`` packages; -install them via +Requires the optional ``anywidget`` package; install via: .. code-block:: bash $ pip install projspec[ipywidget] -Instances of ProjectLibrary will render using the same HTML view as the standalone apps and plugins. +Instances of ``ProjectLibrary`` render automatically using the combined panel +with both tabs — Project Library and File Browser: .. code-block:: python from projspec.library import ProjectLibrary lib = ProjectLibrary() - lib + lib # displays the two-tab panel in a Jupyter cell -Of course, because this is a real python session, you can also call any methods on the library -instance as you might in normal python code. +The File Browser tab uses the kernel's filesystem (via ``filebrowser.py`` +in-process), so it can browse any path or remote URL that the kernel can +reach. All filebrowser operations (browse, inspect, create/delete/rename, +bookmarks, "Add to Library") work in the notebook exactly as they do in +the other GUIs. -Jupyter -~~~~~~~ +The widget works in JupyterLab, Jupyter Notebook classic, VS Code notebooks, +Google Colab, and marimo. -The separate repo `jupyter-projspec`_ implements an extension for jupyterlab/jupyter-classic, which shows -the `projspec` summary for any directory you navigate to in the file browser. It also works for -directories selected in `jupyter-fs`_ panels, so that you can scan remote directories too. -Currently, there is no integration with the project library (unlike the other UIs here), but we may -well clone the HTML look into a jupyter-lab side-panel in the future. For now, you can use the Notebook -interface, above. +Jupyter extension +~~~~~~~~~~~~~~~~~ + +The separate ``jupyter-projspec`` repository implements a JupyterLab / Jupyter +classic extension that shows the projspec summary for any directory you navigate to +in the file browser. It also works for directories opened via ``jupyter-fs`` panels, +making remote directory scanning possible. There is currently no integration with the +project library (unlike the other UIs); use the Notebook interface above for that. .. _jupyter-projspec: https://github.com/fsspec/jupyter-projspec .. _jupyter-fs: https://github.com/jpmorganchase/jupyter-fs -Note that, by its nature, the kernel running any given notebook is not necessarily in the same directory -or even the same machine as the server providing the interface, and neither of these needs to be your -local system. Running "Make" on artifacts will only work for projects local to the server process. +Note that the kernel running a given notebook is not necessarily on the same machine +as the server providing the interface. "Make" on artifacts will only work for +projects local to the server process. -VSCode -~~~~~~ -This extension will be made available via the extension store right within `vscode`, but you will also need -to separately make `projspec` (the CLI) available on your PATH. +PyCharm / IDEA +~~~~~~~~~~~~~~ + +The plugin is available via the JetBrains Marketplace; you will also need +``projspec`` (the CLI) available on your ``PATH``. The same plugin works in any +JetBrains IDE (IntelliJ IDEA, PyCharm Professional, DataSpell, etc.). -To launch, select "Project Library" from the command palette (super-shift-P to search). +The tool window (*View → Tool Windows → Project Library*) now hosts both +tabs. It uses the shared HTML/CSS/JS rendered inside JCEF (the Chromium engine +bundled with JetBrains IDEs); the HTML is generated by calling +``projspec.webui.get_combined_html()`` at startup so it is always in sync with +the other UIs. The File Browser tab calls ``projspec filebrowser`` subcommands +via the CLI, exactly as the library tab calls ``projspec library``, ``projspec +scan``, etc. All actions work identically to the VS Code extension. -Pycharm/IDEA -~~~~~~~~~~~~ +A ``CLI path`` setting in *Settings → Tools → Projspec* lets you point to a +non-PATH ``projspec`` binary. -This extension will be made available via the extension store right within `PyCharm`, but you will also need -to separately make `projspec` (the CLI) available on your PATH. It should also work exactly the same within any -IDEA frontend. Note that the project context menu has the item "Open with pycharm", which expects to run the -command `pycharm` as a subprocess. This will be updated to open a new window within the current application -using an internal API call instead. TUI ~~~ -Bundled with the `projspec` package as command `projspec-tui`, but requires `textual` to be installed, e.g. by +Bundled with the ``projspec`` package as the command ``projspec-tui``; requires +``textual``: .. code-block:: bash $ pip install projspec[textual] -This version does not integrate with any file picker for adding a new project to the library - you have to enter -a whole path into the text-box (but the path _can_ be remote). Also, when you run any background task such as -"Make" on an artifact, the output will appear pasted over the UI until it finishes. +Key bindings: ``a`` Add, ``r`` Reload, ``/`` focus search, ``q`` Quit. + +The TUI now has two tabs (``TabbedContent``): **Project Library** and **File +Browser**. The File Browser tab is implemented with native Textual widgets (not +a webview) — it lists directory entries, shows file metadata and an inline +projspec scan result, and supports "Add to Library" with an automatic switch to +the Library tab. There is no native system file picker; paths and remote URLs +must be typed into the text-box. When a *Make* artifact action runs, its output +appears in the terminal until the subprocess finishes. This interface is +considered more experimental than the rest. -This interface should be considered more experimental than the rest, due to the complexity of so many widgets -and the difficulty of text alligment. Qt app ~~~~~~ -Bundled with the `projspec` package as command `projspec-qt`, but requires Qt to be installed, e.g. by +Bundled with the ``projspec`` package as the command ``projspec-qt``; requires Qt: .. code-block:: bash $ pip install projspec[qt] -This interface would be easy to embedd in any other Qt app, and we include it as the simplest example of -getting a front-end up and running, principally for those wishing to build their own apps. As with the -``notebook`` UI, this one uses in-pocess python calls, and should be more responsive than the others. +The Qt app now renders the combined two-tab panel using ``QWebEngineView``. A +second ``QWebChannel`` bridge (``fb_bridge``) handles filebrowser messages, with +all file operations performed in-process via ``projspec.filebrowser``. Like the +notebook widget, the Qt app makes all calls in-process (no subprocess spawning), +making it the most responsive host. It is included primarily as the simplest +example of embedding the full projspec UI into a custom Qt application. + -Anaconda-Desktop +Anaconda Desktop ~~~~~~~~~~~~~~~~ -This only exists in an internal, experimental form right now (desktop is very new!), but expect it to -emerge eventually, and perhaps to have some functionality connected with Anaconda-specific functionality. +An integration with Anaconda Desktop exists in an internal, experimental form. +Expect it to become public eventually, with Anaconda-specific functionality. diff --git a/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/HtmlContent.kt b/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/HtmlContent.kt index 232018f..30ba64c 100644 --- a/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/HtmlContent.kt +++ b/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/HtmlContent.kt @@ -1,118 +1,161 @@ package com.projspec.toolwindow +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.CapturingProcessHandler + /** - * Generates the HTML/CSS/JS page for the "Project Library" tool window. - * - * Port of the VSCode extension's two-pane UI (vsextension/src/panel.ts). The - * HTML/CSS/JS is reused verbatim wherever possible. Only two things change: - * - * 1. `acquireVsCodeApi().postMessage(msg)` → - * `window.__javaBridge.query(JSON.stringify(msg))` - * (the JBCefJSQuery is injected from Kotlin after every page load; - * see [ProjspecToolWindowPanel.injectBridge]). - * - * 2. VSCode `--vscode-*` CSS variables are not resolved by JCEF, so the - * stylesheet is prefixed with a small `:root{}` block that binds them - * to the Darcula theme colours IntelliJ uses. The rest of the CSS is - * untouched. + * Generates the combined HTML/CSS/JS page (Project Library + File Browser tabs). * - * The inbound command vocabulary is identical to the VSCode panel: ready, - * reload, add, configure, openWith, rescan, createSpec, createSpecConfirmed, - * removeFromLibrary, make, copyToLocal, revealFile. + * The canonical HTML is produced by `projspec.webui.get_combined_html()` in Python. + * We invoke this at startup via a short python3 subprocess and cache the result. * - * The outbound messages from Kotlin are delivered to the page by calling - * `window.__projspecDeliver(msgObj)` via `executeJavaScript`, which - * re-dispatches to the same handlers that `window.addEventListener('message')` - * would receive in a real VSCode webview (types: `data`, `loading`, - * `openCreateSpecModal`). + * The bootstrap JS strings are built here in Kotlin and passed to the Python + * script as JSON-encoded command-line arguments — this avoids embedding Kotlin + * string interpolations inside Python triple-quoted strings inside JS, which + * causes escaping failures. */ object HtmlContent { - fun buildHtml(): String = """ - - - -Project Library - - - -
-
-
-
- - -
- - -
- -
- -
-
-
-
Details
- -
-
-
-
-
- - - - - -""" + fun buildHtml(): String { + return _cachedHtml ?: buildHtmlFresh().also { _cachedHtml = it } + } + + @Volatile private var _cachedHtml: String? = null + + private fun buildHtmlFresh(): String { + // Build the bootstrap strings in Kotlin and pass them as argv[1..3] + // so the Python script stays trivially simple and escaping-safe. + val extraHead = "" + val libBootstrap = LIB_BRIDGE_BOOTSTRAP + val fbBootstrap = FB_BRIDGE_BOOTSTRAP + + // Minimal Python script — just imports and prints. All variable + // content comes in through sys.argv to avoid any quoting issues. + val script = """ +import sys, json +extra_head = sys.argv[1] +lib_bootstrap = sys.argv[2] +fb_bootstrap = sys.argv[3] +try: + from projspec.webui import get_combined_html + html = get_combined_html( + extra_head=extra_head, + lib_bootstrap_js=lib_bootstrap, + fb_bootstrap_js=fb_bootstrap, + ) + sys.stdout.buffer.write(html.encode('utf-8')) +except Exception as e: + import traceback + print('ERROR: ' + str(e), file=sys.stderr) + traceback.print_exc(file=sys.stderr) + sys.exit(1) +""".trimIndent() + + return try { + val cmd = GeneralCommandLine( + listOf("python3", "-c", script, extraHead, libBootstrap, fbBootstrap) + ) + cmd.charset = Charsets.UTF_8 + val output = CapturingProcessHandler(cmd).runProcess(30_000) + val stderr = output.stderr.trim() + val stdout = output.stdout.trim() + when { + output.isTimeout -> + fallbackHtml("python3 timed out after 30 s") + output.exitCode != 0 -> + fallbackHtml(stderr.ifBlank { stdout.ifBlank { "exit code ${output.exitCode}" } }) + stdout.isBlank() -> + fallbackHtml("python3 produced no output\n$stderr") + else -> output.stdout + } + } catch (e: Exception) { + fallbackHtml("${e.javaClass.simpleName}: ${e.message}") + } + } + + private fun fallbackHtml(error: String): String = """ + + + +

projspec not available

+

Could not load the projspec UI. Make sure projspec is installed +and on PATH, then restart the IDE.

+
${error.take(2000).replace("<","<")}
+""" // ------------------------------------------------------------------------- - // CSS + // Bridge bootstrap JS — injected by injectBridge() after every page load. + // Kept here so the JS is co-located with its Kotlin counterpart and there + // is no escaping interaction with the Python script. // ------------------------------------------------------------------------- /** - * Bind the `--vscode-*` CSS variables referenced by the reused stylesheet - * to Darcula-ish colours so the styling looks right in JCEF. + * Library-panel bridge bootstrap. + * Installs window.projspecTransport backed by window.__javaBridge (set by + * [ProjspecToolWindowPanel.injectBridge]). + */ + val LIB_BRIDGE_BOOTSTRAP = """""" + + /** + * File-browser bridge bootstrap. + * Installs window.projspecFbTransport backed by window.__javaFbBridge. */ - private val THEME_FALLBACKS = """ + val FB_BRIDGE_BOOTSTRAP = """""" + + // ------------------------------------------------------------------------- + // Theme fallback CSS + // Binds --vscode-* variables to Darcula colours so the shared CSS works. + // ------------------------------------------------------------------------- + + val THEME_FALLBACKS_CSS = """ :root { --vscode-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; --vscode-editor-font-family: "JetBrains Mono", Consolas, Menlo, monospace; @@ -144,1233 +187,21 @@ object HtmlContent { --vscode-toolbar-hoverBackground: #4c5052; --vscode-disabledForeground: #707070; --vscode-textLink-foreground: #589df6; + --vscode-textBlockQuote-background: rgba(255,255,255,0.06); --vscode-symbolIcon-propertyForeground: #9876aa; --vscode-symbolIcon-stringForeground: #6a8759; --vscode-symbolIcon-numberForeground: #6897bb; --vscode-symbolIcon-keywordForeground: #cc7832; --vscode-symbolIcon-enumeratorMemberForeground: #4ec9b0; -} -html, body { height: 100%; } -""" - - /** The VSCode panel stylesheet verbatim. */ - private val PANEL_CSS = """ -body { margin: 0; padding: 0; font-family: var(--vscode-font-family); color: var(--vscode-foreground); - background: var(--vscode-editor-background); } -#app { display: flex; height: 100vh; overflow: hidden; } -#library { width: 40%; min-width: 320px; max-width: 520px; border-right: 1px solid var(--vscode-panel-border); - display: flex; flex-direction: column; overflow: hidden; } -#details { flex: 1; display: flex; flex-direction: column; overflow: hidden; } - -.toolbar { display: flex; gap: 6px; padding: 8px; border-bottom: 1px solid var(--vscode-panel-border); } -.toolbar button, .kebab-menu button { - background: var(--vscode-button-secondaryBackground, var(--vscode-button-background)); - color: var(--vscode-button-secondaryForeground, var(--vscode-button-foreground)); - border: none; padding: 4px 10px; cursor: pointer; font-size: 12px; border-radius: 3px; -} -.toolbar button:hover { background: var(--vscode-button-hoverBackground); } -.add-group { position: relative; display: flex; } -.add-group #btn-add { border-radius: 3px 0 0 3px; } -.add-group #btn-add-chevron { border-radius: 0 3px 3px 0; padding: 4px 6px; border-left: 1px solid var(--vscode-panel-border); } -.add-dropdown { - position: absolute; top: 100%; left: 0; z-index: 100; min-width: 160px; - background: var(--vscode-menu-background, var(--vscode-editorWidget-background)); - color: var(--vscode-menu-foreground, var(--vscode-foreground)); - border: 1px solid var(--vscode-menu-border, var(--vscode-panel-border)); - border-radius: 3px; padding: 4px 0; - box-shadow: 0 2px 6px rgba(0,0,0,0.4); -} -.add-dropdown .menu-item { padding: 5px 12px; cursor: pointer; font-size: 12px; white-space: nowrap; } -.add-dropdown .menu-item:hover { - background: var(--vscode-list-activeSelectionBackground, var(--vscode-list-hoverBackground)); - color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); -} -#add-advanced-overlay { - position: fixed; inset: 0; background: rgba(0,0,0,0.4); - display: flex; align-items: center; justify-content: center; - z-index: 2000; -} -#add-advanced-modal { - background: var(--vscode-editorWidget-background); - color: var(--vscode-editorWidget-foreground, var(--vscode-foreground)); - border: 1px solid var(--vscode-editorWidget-border, var(--vscode-panel-border)); - border-radius: 6px; min-width: 420px; max-width: 85%; - box-shadow: 0 8px 24px rgba(0,0,0,0.5); - display: flex; flex-direction: column; -} -#add-advanced-title { - padding: 10px 14px; font-weight: bold; font-size: 14px; - border-bottom: 1px solid var(--vscode-panel-border); -} -#add-advanced-body { padding: 12px 14px; } -.add-advanced-hint { - font-size: 11px; color: var(--vscode-descriptionForeground); - margin: 0 0 10px 0; line-height: 1.5; -} -.add-advanced-hint code { - font-family: monospace; background: rgba(255,255,255,0.05); - padding: 0 3px; border-radius: 2px; -} -#add-advanced-body label { - display: block; font-size: 12px; margin-bottom: 4px; - color: var(--vscode-descriptionForeground); -} -#add-advanced-path, #add-advanced-so { - width: 100%; box-sizing: border-box; - background: var(--vscode-input-background); color: var(--vscode-input-foreground); - border: 1px solid var(--vscode-input-border, transparent); - padding: 6px 8px; font-size: 13px; border-radius: 3px; outline: none; -} -#add-advanced-path:focus, #add-advanced-so:focus { border-color: var(--vscode-focusBorder); } -#add-advanced-actions { - display: flex; justify-content: flex-end; gap: 6px; - padding: 10px 14px; border-top: 1px solid var(--vscode-panel-border); -} - -.search { padding: 6px 8px; border-bottom: 1px solid var(--vscode-panel-border); } -.search-wrap { position: relative; display: flex; align-items: center; } -.search input { flex: 1; width: 100%; box-sizing: border-box; background: var(--vscode-input-background); color: var(--vscode-input-foreground); - border: 1px solid var(--vscode-input-border, transparent); padding: 4px 26px 4px 8px; font-size: 12px; - border-radius: 2px; outline: none; } -.search input:focus { border-color: var(--vscode-focusBorder); } -.search button { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); - background: transparent; color: var(--vscode-input-foreground); - border: none; cursor: pointer; padding: 0 2px; line-height: 1; font-size: 11px; - opacity: 0.6; } -.search button:hover { color: var(--vscode-input-foreground); opacity: 1; } - -#projects { flex: 1; overflow-y: auto; padding: 6px; } -#spinner { text-align: center; padding: 16px; color: var(--vscode-descriptionForeground); } -#spinner .spin { display: inline-block; animation: spin 1.2s linear infinite; } -@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } -.hidden { display: none !important; } - -.project { - border: 1px solid var(--vscode-panel-border); - border-radius: 4px; - padding: 8px 10px; - margin-bottom: 6px; - cursor: pointer; - position: relative; - background: var(--vscode-editorWidget-background); -} -.project:hover { background: var(--vscode-list-hoverBackground); } -.project.active { - border-color: var(--vscode-focusBorder); - background: var(--vscode-list-activeSelectionBackground, var(--vscode-list-hoverBackground)); -} -.project .title { font-weight: bold; margin-right: 24px; } -.project .url { font-size: 11px; color: var(--vscode-descriptionForeground); word-break: break-all; margin-top: 2px; } -.project .storage-opts { font-size: 11px; color: var(--vscode-descriptionForeground); margin-top: 2px; font-style: italic; } -.project .meta { font-size: 11px; color: var(--vscode-descriptionForeground); margin-top: 2px; } -.project .chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } - -.chip { - display: inline-flex; align-items: center; gap: 4px; - background: #c5e0c1; - color: #222; - padding: 2px 8px; - border-radius: 10px; - font-size: 11px; - cursor: pointer; - border: 1px solid transparent; - user-select: none; -} -.chip:hover { filter: brightness(0.95); } -.chip.active { border-color: var(--vscode-focusBorder); box-shadow: 0 0 0 1px var(--vscode-focusBorder); } - -.kebab { - position: absolute; top: 6px; right: 6px; - background: transparent; border: none; color: var(--vscode-foreground); - cursor: pointer; padding: 2px 6px; border-radius: 3px; -} -.kebab:hover { background: var(--vscode-toolbar-hoverBackground); } -.kebab-menu { - position: absolute; right: 6px; top: 28px; - background: var(--vscode-menu-background, var(--vscode-editorWidget-background)); - color: var(--vscode-menu-foreground, var(--vscode-foreground)); - border: 1px solid var(--vscode-menu-border, var(--vscode-panel-border)); - border-radius: 3px; padding: 4px 0; z-index: 10; - box-shadow: 0 2px 6px rgba(0,0,0,0.3); min-width: 180px; -} -.kebab-menu .menu-item { padding: 4px 12px; cursor: pointer; font-size: 12px; white-space: nowrap; } -.kebab-menu .menu-item:hover { background: var(--vscode-menu-selectionBackground, var(--vscode-list-hoverBackground)); - color: var(--vscode-menu-selectionForeground, var(--vscode-foreground)); } -.kebab-menu .menu-item.disabled { color: var(--vscode-disabledForeground); cursor: default; } -.kebab-menu .menu-item.disabled:hover { background: transparent; } -.kebab-menu .menu-sep { - height: 1px; margin: 4px 0; - background: var(--vscode-menu-separatorBackground, var(--vscode-panel-border)); -} - -/* Details panel */ -#details-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; - border-bottom: 1px solid var(--vscode-panel-border); } -#details-title { font-weight: bold; font-size: 14px; flex: 1; } -#details-toggle { background: transparent; border: none; color: var(--vscode-foreground); cursor: pointer; - padding: 2px 6px; } -#details-info { padding: 8px 12px; border-bottom: 1px solid var(--vscode-panel-border); - color: var(--vscode-descriptionForeground); font-size: 12px; } -#details-info a { color: var(--vscode-textLink-foreground); } -#details-info.hidden { display: none; } -#details-info.collapsed { display: none; } -#details-list { flex: 1; overflow-y: auto; padding: 8px 12px; } - -.item-widget { - position: relative; - border: 1px solid var(--vscode-panel-border); - border-radius: 4px; - margin-bottom: 8px; - padding: 8px 10px; - background: var(--vscode-editorWidget-background); -} -.item-widget.kind-content { - border-color: var(--vscode-editorInfo-border, var(--vscode-editorInfo-foreground, #3794ff)); - box-shadow: 0 0 0 1px rgba(55,148,255,0.12); -} -.item-widget.kind-artifact { - border-color: var(--vscode-editorWarning-border, var(--vscode-editorWarning-foreground, #cca700)); - box-shadow: 0 0 0 1px rgba(204,167,0,0.12); -} -.widget-kind-badge { - display: inline-block; font-size: 9px; font-weight: 600; - text-transform: uppercase; letter-spacing: 0.04em; - padding: 1px 5px; border-radius: 3px; margin-left: 6px; vertical-align: middle; opacity: 0.75; -} -.kind-content .widget-kind-badge { background: var(--vscode-editorInfo-border, var(--vscode-editorInfo-foreground, #3794ff)); color: var(--vscode-editor-background, #2b2b2b); } -.kind-artifact .widget-kind-badge { background: var(--vscode-editorWarning-border, var(--vscode-editorWarning-foreground, #cca700)); color: var(--vscode-editor-background, #2b2b2b); } -.item-widget .widget-html { margin-top: 6px; font-size: 12px; line-height: 1.4; } -.item-widget .widget-html img { max-width: 100%; height: auto; } -.item-widget .widget-html a { color: var(--vscode-textLink-foreground); } -.item-widget .widget-html table { border-collapse: collapse; } -.item-widget .widget-html th, .item-widget .widget-html td { - border: 1px solid var(--vscode-panel-border); padding: 2px 6px; -} -.item-widget .widget-title { font-weight: bold; font-size: 13px; } -.item-widget .widget-subtitle { font-size: 11px; color: var(--vscode-descriptionForeground); } -.item-widget .widget-actions { position: absolute; top: 6px; right: 6px; display: flex; gap: 4px; - opacity: 0; transition: opacity 0.1s; } -.item-widget:hover .widget-actions { opacity: 1; } -.item-widget .widget-actions button { - background: transparent; border: none; color: var(--vscode-foreground); - cursor: pointer; padding: 2px 6px; border-radius: 3px; -} -.item-widget .widget-actions button:hover { background: var(--vscode-toolbar-hoverBackground); } - -.tree { font-family: var(--vscode-editor-font-family, monospace); font-size: 12px; margin-top: 6px; } -.tree .node { padding-left: 14px; position: relative; } -.tree .node.collapsible > .label { cursor: pointer; } -.tree .node.collapsible > .label::before { - content: '\25BE'; position: absolute; left: 0; font-size: 10px; top: 2px; -} -.tree .node.collapsible.collapsed > .label::before { content: '\25B8'; } -.tree .node.collapsible.collapsed > .children { display: none; } -.tree .key { color: var(--vscode-symbolIcon-propertyForeground, var(--vscode-foreground)); } -.tree .value.str { color: var(--vscode-symbolIcon-stringForeground, #ce9178); } -.tree .value.num { color: var(--vscode-symbolIcon-numberForeground, #b5cea8); } -.tree .value.bool, .tree .value.null { color: var(--vscode-symbolIcon-keywordForeground, #569cd6); } -.tree .value.enum { color: var(--vscode-symbolIcon-enumeratorMemberForeground, #4ec9b0); font-weight: 600; } -.tree .value.empty { color: var(--vscode-descriptionForeground); font-style: italic; } - -/* YAML-style list/map items */ -.tree.yaml .yaml-item { position: relative; padding-left: 0; } -.tree.yaml .yaml-item.list-item { display: flex; align-items: flex-start; flex-wrap: wrap; } -.tree.yaml .yaml-item .marker { color: var(--vscode-descriptionForeground); user-select: none; - padding-right: 2px; } -.tree.yaml .yaml-item.collapsible > .marker, -.tree.yaml .yaml-item.collapsible > .label { cursor: pointer; } -.tree.yaml .yaml-item.collapsible > .marker::after, -.tree.yaml .yaml-item.collapsible > .label::after { - content: ' \25BE'; font-size: 9px; color: var(--vscode-descriptionForeground); -} -.tree.yaml .yaml-item.collapsible.collapsed > .marker::after, -.tree.yaml .yaml-item.collapsible.collapsed > .label::after { content: ' \25B8'; } -.tree.yaml .yaml-item.collapsible.collapsed > .children { display: none; } -.tree.yaml .yaml-item .body.empty { display: none; } -.tree.yaml .yaml-item > .children { padding-left: 16px; width: 100%; } -.tree.yaml .yaml-item.list-item > .children { margin-top: 0; } - -#popup { - position: fixed; background: var(--vscode-editorWidget-background); - color: var(--vscode-editorWidget-foreground, var(--vscode-foreground)); - border: 1px solid var(--vscode-editorWidget-border, var(--vscode-panel-border)); - padding: 8px 10px; border-radius: 4px; max-width: 360px; z-index: 1000; - box-shadow: 0 4px 10px rgba(0,0,0,0.4); font-size: 12px; -} -#popup a { color: var(--vscode-textLink-foreground); } - -/* Create-spec modal */ -#modal-overlay { - position: fixed; inset: 0; background: rgba(0,0,0,0.4); - display: flex; align-items: center; justify-content: center; - z-index: 2000; -} -#modal { - background: var(--vscode-editorWidget-background); - color: var(--vscode-editorWidget-foreground, var(--vscode-foreground)); - border: 1px solid var(--vscode-editorWidget-border, var(--vscode-panel-border)); - border-radius: 6px; - min-width: 360px; max-width: 80%; - box-shadow: 0 8px 24px rgba(0,0,0,0.5); - display: flex; flex-direction: column; -} -#modal-title { - padding: 10px 14px; font-weight: bold; font-size: 14px; - border-bottom: 1px solid var(--vscode-panel-border); -} -#modal-body { padding: 12px 14px; } -#modal-body label { display: block; font-size: 12px; margin-bottom: 4px; - color: var(--vscode-descriptionForeground); } -#modal-input { - width: 100%; box-sizing: border-box; - background: var(--vscode-input-background); color: var(--vscode-input-foreground); - border: 1px solid var(--vscode-input-border, var(--vscode-focusBorder, transparent)); - padding: 6px 8px; font-size: 13px; border-radius: 3px; - outline: none; -} -#modal-input:focus { border-color: var(--vscode-focusBorder); } -#modal-suggestions { - margin-top: 6px; max-height: 220px; overflow-y: auto; - border: 1px solid var(--vscode-panel-border); border-radius: 3px; - font-size: 12px; background: var(--vscode-input-background); -} -#modal-suggestions .suggestion { padding: 4px 8px; cursor: pointer; } -#modal-suggestions .suggestion:hover, -#modal-suggestions .suggestion.active { - background: var(--vscode-list-activeSelectionBackground, var(--vscode-list-hoverBackground)); - color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); -} -#modal-suggestions .empty { - padding: 4px 8px; color: var(--vscode-descriptionForeground); font-style: italic; -} -#modal-actions { - display: flex; justify-content: flex-end; gap: 6px; - padding: 10px 14px; border-top: 1px solid var(--vscode-panel-border); -} -#modal-actions button { - border: none; cursor: pointer; padding: 6px 14px; font-size: 12px; - border-radius: 3px; -} -#modal-actions button.primary { - background: var(--vscode-button-background); color: var(--vscode-button-foreground); -} -#modal-actions button.primary:hover:not(:disabled) { background: var(--vscode-button-hoverBackground); } -#modal-actions button.primary:disabled { opacity: 0.5; cursor: default; } -#modal-actions button.secondary { - background: var(--vscode-button-secondaryBackground, transparent); - color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); - border: 1px solid var(--vscode-panel-border); -} -#modal-actions button.secondary:hover { background: var(--vscode-toolbar-hoverBackground); } -""" - - // ------------------------------------------------------------------------- - // JS - // ------------------------------------------------------------------------- - - /** - * Panel JS reused verbatim from vsextension/src/panel.ts (PANEL_JS). - * - * The only changes: - * - the `vscode` shim at the top calls `window.__javaBridge.query(...)` - * (set up from Kotlin) instead of `acquireVsCodeApi()`. - * - outbound messages from Kotlin call `window.__projspecDeliver(msg)` - * which simply calls the same handler that `message` events did, - * because JCEF doesn't deliver browser `postMessage` events to an - * iframe-less page the way VSCode does. - * - * Uses a Kotlin raw string `""" ... """` so JS escapes survive unaltered; - * the sentinel `${'$'}` is used wherever a `$` must remain literal in the - * output (template-literal placeholders inside the JS). - */ - private val PANEL_JS = """ -(function() { - // ------------------------------------------------------------------ - // Bridge: Java ↔ JS. The Kotlin side injects a `window.__javaBridge` - // object via JBCefJSQuery.inject(), but that only runs after the page - // has finished loading — whereas this IIFE runs DURING load. So we - // expose a `vscode.postMessage()` that queues outbound messages while - // the bridge is absent, then flushes them as soon as Kotlin calls - // `window.__projspecBridgeReady()`. - // ------------------------------------------------------------------ - const outboundQueue = []; - function sendNow(msg) { - try { window.__javaBridge.query(JSON.stringify(msg)); } - catch (e) { /* swallow: bridge broken */ } - } - const vscode = { - postMessage: function(msg) { - if (window.__javaBridge && window.__javaBridge.query) { - sendNow(msg); - } else { - outboundQueue.push(msg); - } - } - }; - // Kotlin calls this once `window.__javaBridge` has been installed. - window.__projspecBridgeReady = function() { - while (outboundQueue.length > 0) { sendNow(outboundQueue.shift()); } - }; - // Kotlin delivers host→webview messages by calling this function. - window.__projspecDeliver = function(msg) { handleHostMessage(msg); }; - // If the bridge was injected before this IIFE ran (e.g. because - // onLoadEnd fired very quickly), flush immediately. - if (window.__javaBridge && window.__javaBridge.query) { - window.__projspecBridgeReady(); - } - - let info = null; - let enums = {}; - let library = {}; - // selection state: { url: string, kind: 'contents'|'artifacts'|'spec', specName?: string } - let selection = null; - - // ----- pastel palette for chip colours ----- - const PALETTE = [ - '#f9c0c0', '#f9dcc0', '#f9f0c0', '#d9f9c0', '#c0f9d2', - '#c0f9f0', '#c0e3f9', '#c0ccf9', '#d6c0f9', '#efc0f9', - '#f9c0e3', '#d9c9b5', '#b5d9c9', '#b5c9d9', '#c9b5d9', - ]; - function hashStr(s) { - let h = 0; - for (let i = 0; i < s.length; i++) { h = ((h << 5) - h + s.charCodeAt(i)) | 0; } - return Math.abs(h); - } - function chipColour(label) { return PALETTE[hashStr(label) % PALETTE.length]; } - - // ----- subroutines ----- - function basename(url) { - try { - const stripped = url.replace(/\/+${'$'}/, ''); - const idx = stripped.lastIndexOf('/'); - return idx >= 0 ? stripped.slice(idx + 1) : stripped; - } catch (_) { return url; } - } - function fmtSize(bytes) { - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let n = parseFloat(bytes); - for (let i = 0; i < units.length; i++) { - if (n < 1024 || i === units.length - 1) - return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + units[i]; - n /= 1024; - } - } - function fmtAge(ts) { - const secs = Math.floor(Date.now() / 1000 - parseFloat(ts)); - if (secs < 0) return 'just now'; - const days = Math.floor(secs / 86400); - if (days === 0) { - if (secs < 60) return 'just now'; - if (secs < 3600) { - const m = Math.floor(secs / 60); - return m + ' minute' + (m !== 1 ? 's' : '') + ' ago'; - } - const h = Math.floor(secs / 3600); - return h + ' hour' + (h !== 1 ? 's' : '') + ' ago'; - } - if (days === 1) return 'yesterday'; - if (days < 30) return days + ' days ago'; - if (days < 365) return Math.floor(days / 30) + ' months ago'; - const yrs = Math.floor(days / 365); - return yrs + ' year' + (yrs > 1 ? 's' : '') + ' ago'; - } - function specDisplayName(snake) { return snake; } - function iconForSpec(snake) { - const entry = info && info.specs && info.specs[snake]; - return (entry && entry.icon) || '\u{1F9E9}'; // puzzle piece - } - function iconForContent(snake) { - const entry = info && info.content && info.content[snake]; - return (entry && entry.icon) || '\u{1F4C4}'; // page - } - function iconForArtifact(snake) { - const entry = info && info.artifact && info.artifact[snake]; - return (entry && entry.icon) || '\u{1F4E6}'; // box - } - - // ----- rendering ----- - const projectsEl = document.getElementById('projects'); - const spinnerEl = document.getElementById('spinner'); - const searchEl = document.getElementById('search'); - const searchClear = document.getElementById('search-clear'); - - function render() { - const filter = (searchEl.value || '').trim().toLowerCase(); - projectsEl.innerHTML = ''; - const urls = Object.keys(library).sort(); - let any = false; - for (const url of urls) { - const project = library[url]; - if (filter) { - const hay = (url + ' ' + basename(url) + ' ' + Object.keys(project.specs || {}).join(' ')).toLowerCase(); - if (!hay.includes(filter)) continue; - } - any = true; - projectsEl.appendChild(renderProject(url, project)); - } - if (!any) { - const e = document.createElement('div'); - e.style.padding = '20px'; - e.style.color = 'var(--vscode-descriptionForeground)'; - e.style.textAlign = 'center'; - e.textContent = urls.length === 0 ? 'Library is empty. Click "Add" to scan a directory.' : 'No projects match the filter.'; - projectsEl.appendChild(e); - } - if (selection) renderDetails(); - } - - function renderProject(url, project) { - const wrap = document.createElement('div'); - wrap.className = 'project'; - wrap.dataset.url = url; - - const title = document.createElement('div'); - title.className = 'title'; - title.textContent = basename(url); - wrap.appendChild(title); - - const urlLine = document.createElement('div'); - urlLine.className = 'url'; - urlLine.textContent = url; - wrap.appendChild(urlLine); - - if (project.storage_options && Object.keys(project.storage_options).length > 0) { - const so = document.createElement('div'); - so.className = 'storage-opts'; - so.textContent = 'storage_options: ' + JSON.stringify(project.storage_options); - wrap.appendChild(so); - } - - const metaParts = []; - if (project.file_count != null && project.total_size != null) - metaParts.push(parseInt(project.file_count).toLocaleString() + ' files, ' + fmtSize(project.total_size)); - if (project.is_writable != null) - metaParts.push((project.is_writable === true || project.is_writable === 'True') ? 'writable' : 'read-only'); - if (project.last_modified != null) { - const age = fmtAge(project.last_modified); - const by = project.last_modified_by != null ? project.last_modified_by : null; - metaParts.push('last modified ' + age + (by ? ' by ' + by : '')); - } - if (project.scanned_at != null) - metaParts.push('scanned ' + fmtAge(project.scanned_at)); - if (metaParts.length > 0) { - const meta = document.createElement('div'); - meta.className = 'meta'; - meta.textContent = metaParts.join(' · '); - wrap.appendChild(meta); - } - - const chips = document.createElement('div'); - chips.className = 'chips'; - const contents = project.contents || {}; - const artifacts = project.artifacts || {}; - const globalCount = Object.keys(contents).length + Object.keys(artifacts).length; - if (globalCount > 0) { - const globalChip = makeChip('Global', url, 'global', null, null); - globalChip.style.background = '#d0d0d0'; - chips.appendChild(globalChip); - } - for (const specName of Object.keys(project.specs || {})) { - chips.appendChild(makeChip(specDisplayName(specName), url, 'spec', specName, iconForSpec(specName))); - } - wrap.appendChild(chips); - - // Kebab menu - const kebabBtn = document.createElement('button'); - kebabBtn.className = 'kebab'; - kebabBtn.title = 'More actions'; - kebabBtn.textContent = '\u22EE'; - kebabBtn.addEventListener('click', (ev) => { - ev.stopPropagation(); - toggleKebab(wrap, url); - }); - wrap.appendChild(kebabBtn); - - if (selection && selection.url === url) wrap.classList.add('active'); - return wrap; - } - - function makeChip(label, url, kind, specName, icon) { - const chip = document.createElement('span'); - chip.className = 'chip'; - chip.style.background = chipColour(label); - if (icon) { - chip.innerHTML = '' + escapeHtml(icon) + '' + escapeHtml(label) + ''; - } else { - chip.textContent = label; - } - chip.addEventListener('click', (ev) => { - ev.stopPropagation(); - selection = { url: url, kind: kind, specName: specName }; - document.querySelectorAll('.project.active').forEach(el => el.classList.remove('active')); - document.querySelectorAll('.chip.active').forEach(el => el.classList.remove('active')); - const projEl = ev.target.closest('.project'); - if (projEl) projEl.classList.add('active'); - chip.classList.add('active'); - renderDetails(); - }); - if (selection && selection.url === url && - ((selection.kind === kind && kind !== 'spec') || - (selection.kind === 'spec' && kind === 'spec' && selection.specName === specName))) { - chip.classList.add('active'); - } - return chip; - } - - // Kebab handling - let openKebab = null; - function toggleKebab(projectEl, url) { - if (openKebab && openKebab.el === projectEl) { closeKebab(); return; } - closeKebab(); - const menu = document.createElement('div'); - menu.className = 'kebab-menu'; - const isLocal = url.startsWith('file://'); - if (isLocal) { - addItem(menu, 'Open with VSCode', () => vscode.postMessage({ cmd: 'openWith', tool: 'vscode', url: url })); - addItem(menu, 'Open with system filebrowser', () => vscode.postMessage({ cmd: 'openWith', tool: 'filebrowser', url: url })); - addItem(menu, 'Open with PyCharm', () => vscode.postMessage({ cmd: 'openWith', tool: 'pycharm', url: url })); - addItem(menu, 'Open with jupyter', () => vscode.postMessage({ cmd: 'openWith', tool: 'jupyter', url: url })); - addSeparator(menu); - addItem(menu, 'Rescan', () => vscode.postMessage({ cmd: 'rescan', url: url })); - addItem(menu, 'Create spec', () => vscode.postMessage({ cmd: 'createSpec', url: url })); - addItem(menu, 'Remove from library', () => vscode.postMessage({ cmd: 'removeFromLibrary', url: url })); - } else { - const ctl = addItem(menu, 'Copy to local', () => vscode.postMessage({ cmd: 'copyToLocal', url: url })); - ctl.classList.add('disabled'); - addItem(menu, 'Rescan', () => vscode.postMessage({ cmd: 'rescan', url: url })); - addItem(menu, 'Remove from library', () => vscode.postMessage({ cmd: 'removeFromLibrary', url: url })); - } - projectEl.appendChild(menu); - openKebab = { el: projectEl, menu: menu }; - setTimeout(() => document.addEventListener('click', onDocClick, { once: true }), 0); - } - function addItem(menu, label, onClick) { - const mi = document.createElement('div'); - mi.className = 'menu-item'; - mi.textContent = label; - mi.addEventListener('click', (e) => { e.stopPropagation(); if (!mi.classList.contains('disabled')) { onClick(); closeKebab(); } }); - menu.appendChild(mi); - return mi; - } - function addSeparator(menu) { - const sep = document.createElement('div'); - sep.className = 'menu-sep'; - menu.appendChild(sep); - } - function closeKebab() { - if (openKebab) { - openKebab.menu.remove(); - openKebab = null; - } - } - function onDocClick() { closeKebab(); } - - // ----- details panel ----- - const detailsTitle = document.getElementById('details-title'); - const detailsInfo = document.getElementById('details-info'); - const detailsList = document.getElementById('details-list'); - const detailsToggle = document.getElementById('details-toggle'); - - detailsToggle.addEventListener('click', () => { - detailsInfo.classList.toggle('collapsed'); - const collapsed = detailsInfo.classList.contains('collapsed'); - detailsToggle.textContent = collapsed ? '\u25BE' : '\u25B4'; - detailsToggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); - }); - - function renderDetails() { - detailsList.innerHTML = ''; - detailsInfo.innerHTML = ''; - if (!selection) { detailsTitle.textContent = 'Details'; return; } - const project = library[selection.url]; - if (!project) { detailsTitle.textContent = 'Details'; return; } - - if (selection.kind === 'spec') { - detailsTitle.textContent = selection.specName; - const entry = info && info.specs && info.specs[selection.specName]; - if (entry) { - const doc = document.createElement('div'); - doc.textContent = entry.doc || ''; - detailsInfo.appendChild(doc); - if (entry.link) { - const a = document.createElement('a'); - a.href = entry.link; - a.textContent = entry.link; - a.target = '_blank'; - detailsInfo.appendChild(document.createElement('br')); - detailsInfo.appendChild(a); - } - } - const spec = project.specs[selection.specName]; - if (spec) { - renderItemGroup(spec._contents || {}, 'content', false, selection.specName); - renderItemGroup(spec._artifacts || {}, 'artifact', true, selection.specName); - } - } else if (selection.kind === 'global') { - detailsTitle.textContent = 'Global'; - renderItemGroup(project.contents || {}, 'content', false, undefined); - renderItemGroup(project.artifacts || {}, 'artifact', true, undefined); - } - } - - function renderItemGroup(items, kind, showMake, specName) { - if (!items || typeof items !== 'object') return; - const keys = Object.keys(items); - if (keys.length === 0) return; - - if (!hasKlass(items)) { - detailsList.appendChild(makePlainWidget(items)); - return; - } - - for (const typeName of keys) { - const entry = items[typeName]; - if (!entry) continue; - if (Array.isArray(entry)) { - for (const e of entry) { - detailsList.appendChild(makeItemWidget(typeName, null, e, kind, showMake, specName)); - } - } else if (entry && typeof entry === 'object' && 'klass' in entry) { - detailsList.appendChild(makeItemWidget(typeName, null, entry, kind, showMake, specName)); - } else if (entry && typeof entry === 'object') { - for (const name of Object.keys(entry)) { - detailsList.appendChild(makeItemWidget(typeName, name, entry[name], kind, showMake, specName)); - } - } else { - const obj = {}; obj[typeName] = entry; - detailsList.appendChild(makePlainWidget(obj)); - } - } - } - - function hasKlass(v) { - if (!v || typeof v !== 'object') return false; - if (!Array.isArray(v) && 'klass' in v) return true; - if (Array.isArray(v)) { - for (const e of v) if (hasKlass(e)) return true; - return false; - } - for (const k of Object.keys(v)) if (hasKlass(v[k])) return true; - return false; - } - - function makePlainWidget(data) { - const w = document.createElement('div'); - w.className = 'item-widget'; - const tree = document.createElement('div'); - tree.className = 'tree yaml'; - tree.appendChild(renderYaml(data)); - w.appendChild(tree); - return w; - } - - function makeItemWidget(typeName, name, data, kind, showMake, specName) { - const w = document.createElement('div'); - w.className = 'item-widget kind-' + kind; - - const title = document.createElement('div'); - title.className = 'widget-title'; - const klass = (data && data.klass && Array.isArray(data.klass)) ? data.klass[1] : typeName; - const iconName = kind === 'content' ? iconForContent(klass) : iconForArtifact(klass); - const badgeLabel = kind === 'content' ? 'Content' : 'Artifact'; - title.innerHTML = '' + escapeHtml(iconName) + ' ' + escapeHtml(klass) - + (name ? ' - ' + escapeHtml(name) + '' : '') - + ' ' + escapeHtml(badgeLabel) + ''; - w.appendChild(title); - - const actions = document.createElement('div'); - actions.className = 'widget-actions'; - - const fn = (data && typeof data === 'object') ? data.fn : undefined; - if (kind === 'artifact' && typeof fn === 'string' && fn.length > 0 && isLocalPath(fn)) { - const rv = document.createElement('button'); - rv.title = 'Reveal ' + fn + ' in project view'; - rv.textContent = '\u27A1\uFE0F'; - rv.addEventListener('click', (e) => { - e.stopPropagation(); - vscode.postMessage({ cmd: 'revealFile', fn: fn }); - }); - actions.appendChild(rv); - } - - if (showMake) { - const mk = document.createElement('button'); - mk.title = 'Make artifact'; - mk.textContent = '\u25B6\uFE0F'; - mk.addEventListener('click', (e) => { - e.stopPropagation(); - vscode.postMessage({ - cmd: 'make', - url: selection.url, - spec: specName, - artifactType: typeName, - name: name || undefined, - }); - }); - actions.appendChild(mk); - } - const ib = document.createElement('button'); - ib.title = 'Show documentation'; - ib.textContent = '\u2139\uFE0F'; - ib.addEventListener('click', (e) => { - e.stopPropagation(); - showInfoPopup(klass, kind, e.clientX, e.clientY); - }); - actions.appendChild(ib); - w.appendChild(actions); - - const html = (kind === 'content' && data && typeof data === 'object') ? data._html : undefined; - if (typeof html === 'string') { - const body = document.createElement('div'); - body.className = 'widget-html'; - body.innerHTML = sanitizeHtml(html); - w.appendChild(body); - } else { - // Datasets (and other content) may carry rich previews in - // metadata.html_repr (an HTML fragment) and metadata.thumbnail - // (a data: image URL). Embed those rather than dumping their - // (often huge) raw strings into the YAML tree. - const meta = (kind === 'content' && data && typeof data === 'object' - && data.metadata && typeof data.metadata === 'object') ? data.metadata : null; - const htmlRepr = meta && typeof meta.html_repr === 'string' ? meta.html_repr : null; - const thumb = meta && typeof meta.thumbnail === 'string' ? meta.thumbnail : null; - - const tree = document.createElement('div'); - tree.className = 'tree yaml'; - tree.appendChild(renderYaml(stripPreview(stripKlass(data)))); - w.appendChild(tree); - - if (thumb) w.appendChild(thumbnailImg(thumb)); - if (htmlRepr) { - const body = document.createElement('div'); - body.className = 'widget-html'; - body.innerHTML = sanitizeHtml(htmlRepr); - w.appendChild(body); - } - } - - return w; - } - - function stripPreview(obj) { - if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj; - if (!obj.metadata || typeof obj.metadata !== 'object' || Array.isArray(obj.metadata)) return obj; - const meta = {}; - let changed = false; - for (const k of Object.keys(obj.metadata)) { - if (k === 'html_repr' || k === 'thumbnail') { changed = true; continue; } - meta[k] = obj.metadata[k]; - } - if (!changed) return obj; - const out = {}; - for (const k of Object.keys(obj)) out[k] = obj[k]; - out.metadata = meta; - return out; - } - - function thumbnailImg(src) { - const wrap = document.createElement('div'); - wrap.className = 'widget-html'; - if (/^data:image\//i.test(src)) { - const img = document.createElement('img'); - img.src = src; - img.alt = 'thumbnail'; - wrap.appendChild(img); - } - return wrap; - } - - function sanitizeHtml(html) { - const tpl = document.createElement('template'); - tpl.innerHTML = String(html); - const walker = document.createTreeWalker(tpl.content, NodeFilter.SHOW_ELEMENT); - const toRemove = []; - let n = walker.nextNode(); - while (n) { - const tag = n.tagName.toLowerCase(); - if (tag === 'script' || tag === 'iframe' || tag === 'object' || tag === 'embed') { - toRemove.push(n); - } else { - for (const attr of Array.from(n.attributes)) { - const an = attr.name.toLowerCase(); - if (an.startsWith('on')) { n.removeAttribute(attr.name); continue; } - if ((an === 'href' || an === 'src') && /^\s*javascript:/i.test(attr.value)) { - n.removeAttribute(attr.name); - } - } - } - n = walker.nextNode(); - } - for (const el of toRemove) el.remove(); - return tpl.innerHTML; - } - - function stripKlass(obj) { - if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj; - const out = {}; - for (const k of Object.keys(obj)) { - if (k === 'klass') continue; - out[k] = obj[k]; - } - return out; - } - - // ----- YAML-style rendering ----- - function renderYaml(data) { - const frag = document.createDocumentFragment(); - if (Array.isArray(data)) { - if (data.length === 0) { - frag.appendChild(textNode('[]', 'value empty')); - return frag; - } - for (const item of data) { - frag.appendChild(yamlListItem(item)); - } - return frag; - } - if (data && typeof data === 'object') { - const keys = Object.keys(data); - if (keys.length === 0) { - frag.appendChild(textNode('{}', 'value empty')); - return frag; - } - for (const k of keys) { - frag.appendChild(yamlMapItem(k, data[k])); - } - return frag; - } - frag.appendChild(primitiveSpan(data)); - return frag; - } - - function yamlListItem(value) { - const node = document.createElement('div'); - node.className = 'yaml-item list-item'; - const marker = document.createElement('span'); - marker.className = 'marker'; - marker.textContent = '- '; - const body = document.createElement('span'); - body.className = 'body'; - - if (isEnum(value)) { - body.appendChild(enumSpan(value)); - node.appendChild(marker); - node.appendChild(body); - return node; - } - if (value && typeof value === 'object') { - node.classList.add('collapsible'); - node.appendChild(marker); - body.classList.add('empty'); - node.appendChild(body); - const children = document.createElement('div'); - children.className = 'children'; - children.appendChild(renderYaml(value)); - node.appendChild(children); - marker.addEventListener('click', (e) => { e.stopPropagation(); node.classList.toggle('collapsed'); }); - return node; - } - body.appendChild(primitiveSpan(value)); - node.appendChild(marker); - node.appendChild(body); - return node; - } - - function yamlMapItem(key, value) { - const node = document.createElement('div'); - node.className = 'yaml-item map-item'; - const label = document.createElement('div'); - label.className = 'label'; - - if (isEnum(value)) { - label.innerHTML = '' + escapeHtml(key) + ': '; - label.appendChild(enumSpan(value)); - node.appendChild(label); - return node; - } - - if (value && typeof value === 'object') { - node.classList.add('collapsible'); - label.innerHTML = '' + escapeHtml(key) + ':'; - node.appendChild(label); - const children = document.createElement('div'); - children.className = 'children'; - children.appendChild(renderYaml(value)); - node.appendChild(children); - label.addEventListener('click', (e) => { e.stopPropagation(); node.classList.toggle('collapsed'); }); - return node; - } - label.innerHTML = '' + escapeHtml(key) + ': '; - label.appendChild(primitiveSpan(value)); - node.appendChild(label); - return node; - } - - function isEnum(v) { - return v && typeof v === 'object' && !Array.isArray(v) - && Array.isArray(v.klass) && v.klass[0] === 'enum' - && ('value' in v); - } - - function enumSpan(v) { - const name = v.klass[1]; - const raw = v.value; - let label = null; - const members = enums && enums[name]; - if (members) { - for (const mname of Object.keys(members)) { - if (members[mname] === raw) { label = mname; break; } - } - } - const span = document.createElement('span'); - span.className = 'value enum'; - span.title = name + ' = ' + String(raw); - span.textContent = label !== null ? label : String(raw); - return span; - } - - function primitiveSpan(v) { - const s = document.createElement('span'); - s.className = 'value'; - if (typeof v === 'string') { - s.classList.add('str'); - s.textContent = v; - } else if (typeof v === 'number') { - s.classList.add('num'); - s.textContent = String(v); - } else if (typeof v === 'boolean') { - s.classList.add('bool'); - s.textContent = String(v); - } else if (v === null || v === undefined) { - s.classList.add('null'); - s.textContent = 'null'; - } else { - s.textContent = String(v); - } - return s; - } - - function textNode(text, cls) { - const s = document.createElement('span'); - if (cls) s.className = cls; - s.textContent = text; - return s; - } - - function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); - } - - function isLocalPath(p) { - if (p.startsWith('file://')) return true; - return !/^[a-z][a-z0-9+.-]*:\/\//i.test(p); - } - - // ----- info popup ----- - const popup = document.getElementById('popup'); - function showInfoPopup(klass, kind, mx, my) { - const map = kind === 'content' ? (info && info.content) : (info && info.artifact); - const entry = map && map[klass]; - if (!entry) { - popup.innerHTML = 'No info for ' + escapeHtml(klass) + ''; - } else { - popup.innerHTML = '
' + escapeHtml(klass) + '
' + - '
' + escapeHtml(entry.doc || '') + '
'; - } - popup.classList.remove('hidden'); - popup.style.left = '-9999px'; - popup.style.top = '-9999px'; - requestAnimationFrame(() => { - const w = popup.offsetWidth; - const h = popup.offsetHeight; - let x = mx - w - 8; - let y = my - 8; - if (x < 4) x = 4; - if (y + h > window.innerHeight - 4) y = window.innerHeight - h - 4; - if (y < 4) y = 4; - popup.style.left = x + 'px'; - popup.style.top = y + 'px'; - }); - setTimeout(() => { - document.addEventListener('click', hideInfoPopup, { once: true }); - }, 0); - } - function hideInfoPopup() { popup.classList.add('hidden'); } - - // ----- create-spec modal ----- - const modalOverlay = document.getElementById('modal-overlay'); - const modalInput = document.getElementById('modal-input'); - const modalSuggestions = document.getElementById('modal-suggestions'); - const modalOk = document.getElementById('modal-ok'); - const modalCancel = document.getElementById('modal-cancel'); - const modalEl = document.getElementById('modal'); - let modalState = null; // { url, specs, filtered, active } - - function openCreateSpecModal(url, specs) { - modalState = { url: url, specs: specs.slice(), filtered: specs.slice(), active: 0 }; - modalInput.value = ''; - modalOk.disabled = true; - renderSuggestions(); - modalOverlay.classList.remove('hidden'); - setTimeout(() => modalInput.focus(), 0); - } - function closeCreateSpecModal() { - modalOverlay.classList.add('hidden'); - modalState = null; - } - function renderSuggestions() { - modalSuggestions.innerHTML = ''; - if (!modalState) return; - if (modalState.filtered.length === 0) { - const e = document.createElement('div'); - e.className = 'empty'; - e.textContent = modalState.specs.length === 0 - ? 'No spec types available' - : 'No matches'; - modalSuggestions.appendChild(e); - return; - } - modalState.filtered.forEach((s, i) => { - const row = document.createElement('div'); - row.className = 'suggestion' + (i === modalState.active ? ' active' : ''); - row.textContent = s; - row.addEventListener('mousedown', (e) => { - e.preventDefault(); - modalState.active = i; - modalInput.value = s; - modalOk.disabled = false; - submitCreateSpec(); - }); - modalSuggestions.appendChild(row); - }); - } - function filterSuggestions() { - if (!modalState) return; - const q = modalInput.value.trim().toLowerCase(); - modalState.filtered = q - ? modalState.specs.filter((s) => s.toLowerCase().includes(q)) - : modalState.specs.slice(); - modalState.active = 0; - modalOk.disabled = !modalState.specs.includes(modalInput.value.trim()); - renderSuggestions(); - } - function submitCreateSpec() { - if (!modalState) return; - let pick = modalInput.value.trim(); - if (!modalState.specs.includes(pick) && modalState.filtered.length === 1) { - pick = modalState.filtered[0]; - } - if (!modalState.specs.includes(pick)) return; - const url = modalState.url; - closeCreateSpecModal(); - vscode.postMessage({ cmd: 'createSpecConfirmed', url: url, spec: pick }); - } - modalInput.addEventListener('input', filterSuggestions); - modalInput.addEventListener('keydown', (e) => { - if (!modalState) return; - if (e.key === 'Escape') { closeCreateSpecModal(); e.preventDefault(); return; } - if (e.key === 'Enter') { submitCreateSpec(); e.preventDefault(); return; } - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - const dir = e.key === 'ArrowDown' ? 1 : -1; - const n = modalState.filtered.length; - if (n === 0) return; - modalState.active = (modalState.active + dir + n) % n; - modalInput.value = modalState.filtered[modalState.active]; - modalOk.disabled = false; - renderSuggestions(); - e.preventDefault(); - } - if (e.key === 'Tab' && modalState.filtered.length > 0) { - modalInput.value = modalState.filtered[modalState.active]; - modalOk.disabled = false; - filterSuggestions(); - e.preventDefault(); - } - }); - modalOk.addEventListener('click', () => submitCreateSpec()); - modalCancel.addEventListener('click', () => closeCreateSpecModal()); - modalOverlay.addEventListener('click', (e) => { - if (e.target === modalOverlay) closeCreateSpecModal(); - }); - - // ----- toolbar ----- - const addDropdown = document.getElementById('add-dropdown'); - document.getElementById('btn-add').addEventListener('click', () => { - addDropdown.classList.add('hidden'); - vscode.postMessage({ cmd: 'add' }); - }); - document.getElementById('btn-add-chevron').addEventListener('click', (e) => { - e.stopPropagation(); - addDropdown.classList.toggle('hidden'); - }); - document.getElementById('add-local').addEventListener('click', () => { - addDropdown.classList.add('hidden'); - vscode.postMessage({ cmd: 'add' }); - }); - document.getElementById('add-advanced').addEventListener('click', () => { - addDropdown.classList.add('hidden'); - openAddAdvancedModal(); - }); - document.addEventListener('click', () => addDropdown.classList.add('hidden')); - - // ----- advanced-add modal ----- - const addAdvancedOverlay = document.getElementById('add-advanced-overlay'); - const addAdvancedPath = document.getElementById('add-advanced-path'); - const addAdvancedSo = document.getElementById('add-advanced-so'); - const addAdvancedOk = document.getElementById('add-advanced-ok'); - const addAdvancedCancel = document.getElementById('add-advanced-cancel'); - - function openAddAdvancedModal() { - addAdvancedPath.value = ''; - addAdvancedSo.value = ''; - addAdvancedOverlay.classList.remove('hidden'); - setTimeout(() => addAdvancedPath.focus(), 0); - } - function closeAddAdvancedModal() { - addAdvancedOverlay.classList.add('hidden'); - } - function submitAddAdvanced() { - const p = addAdvancedPath.value.trim(); - if (!p) return; - const so = addAdvancedSo.value.trim(); - closeAddAdvancedModal(); - vscode.postMessage({ cmd: 'addConfirmed', path: p, storageOptions: so }); - } - addAdvancedOk.addEventListener('click', submitAddAdvanced); - addAdvancedCancel.addEventListener('click', closeAddAdvancedModal); - addAdvancedOverlay.addEventListener('click', (e) => { - if (e.target === addAdvancedOverlay) closeAddAdvancedModal(); - }); - addAdvancedPath.addEventListener('keydown', (e) => { - if (e.key === 'Enter') submitAddAdvanced(); - if (e.key === 'Escape') closeAddAdvancedModal(); - }); - addAdvancedSo.addEventListener('keydown', (e) => { - if (e.key === 'Enter') submitAddAdvanced(); - if (e.key === 'Escape') closeAddAdvancedModal(); - }); - document.getElementById('btn-reload').addEventListener('click', () => vscode.postMessage({ cmd: 'reload' })); - document.getElementById('btn-configure').addEventListener('click', () => vscode.postMessage({ cmd: 'configure' })); - searchEl.addEventListener('input', () => render()); - searchClear.addEventListener('click', () => { searchEl.value = ''; render(); }); - - // ----- host → webview dispatch ----- - function handleHostMessage(msg) { - if (!msg) return; - if (msg.type === 'loading') { - spinnerEl.classList.toggle('hidden', !msg.loading); - } else if (msg.type === 'data') { - info = msg.info; - enums = msg.enums || {}; - library = msg.library || {}; - if (selection && !library[selection.url]) selection = null; - render(); - } else if (msg.type === 'openCreateSpecModal') { - openCreateSpecModal(msg.url, msg.specs || []); - } - } - - vscode.postMessage({ cmd: 'ready' }); -})(); -""" + --vscode-editorInfo-foreground: #589df6; + --vscode-editorInfo-border: #589df6; + --vscode-editorWarning-foreground: #bbb529; + --vscode-editorWarning-border: #bbb529; + --vscode-errorForeground: #ff5555; + --vscode-badge-background: #4d4d4d; + --vscode-badge-foreground: #ffffff; + --vscode-editorGroupHeader-tabsBackground: #252526; +} +html, body { height: 100%; margin: 0; padding: 0; overflow: hidden; } +""".trimIndent() } diff --git a/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/ProjspecToolWindowPanel.kt b/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/ProjspecToolWindowPanel.kt index 7a3fefa..edd6226 100644 --- a/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/ProjspecToolWindowPanel.kt +++ b/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/ProjspecToolWindowPanel.kt @@ -19,7 +19,9 @@ import com.projspec.settings.ProjspecSettings import com.projspec.util.CliResult import com.projspec.util.Notifier import com.projspec.util.OpenWithHelper +import com.projspec.util.PluginLogger import com.projspec.util.ProjspecRunner +import com.projspec.util.ProjspecServer import com.projspec.util.TerminalRunner import org.cef.browser.CefBrowser import org.cef.browser.CefFrame @@ -55,34 +57,26 @@ class ProjspecToolWindowPanel( private val gson = Gson() private val browser: JBCefBrowser = JBCefBrowser() + /** Library-panel bridge (window.__javaBridge) */ private val jsQuery: JBCefJSQuery = JBCefJSQuery.create(browser) + /** File-browser bridge (window.__javaFbBridge) */ + private val fbJsQuery: JBCefJSQuery = JBCefJSQuery.create(browser) + + /** HTTP server — started off-EDT alongside the HTML build. */ + private val server = ProjspecServer() // Cached data matching panel.ts's `info`, `enums` and `library` fields. - @Volatile private var info: String = "null" // raw JSON - @Volatile private var enums: String = "{}" // raw JSON - @Volatile private var library: String = "{}" // raw JSON + @Volatile private var info: String = "null" + @Volatile private var enums: String = "{}" + @Volatile private var library: String = "{}" @Volatile private var specNames: List = emptyList() @Volatile private var libraryMap: Map = emptyMap() - /** - * Reference count for in-flight operations. The spinner is displayed - * whenever this is > 0 — see panel.ts's `busyCount`. Uses atomics so - * the counter can be incremented from any background thread. - */ private val busyCount = AtomicInteger(0) + private val fbBusyCount = AtomicInteger(0) - /** - * True once the webview has posted its initial `ready` message. Until - * then, calls to [deliverToWebview] are deferred because executeJavaScript - * would simply drop the payload. - */ @Volatile private var webviewReady = false - - /** Any messages posted before the webview was ready. */ private val pending = ArrayDeque() - - /** Guards against starting the initial data load twice (onLoadEnd + - * the JS `ready` message both race to trigger it). */ private val initialLoadStarted = java.util.concurrent.atomic.AtomicBoolean(false) init { @@ -90,6 +84,10 @@ class ProjspecToolWindowPanel( handleJsMessage(json) null } + fbJsQuery.addHandler { json -> + handleFbJsMessage(json) + null + } browser.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() { override fun onLoadEnd(b: CefBrowser?, frame: CefFrame?, httpStatusCode: Int) { @@ -99,9 +97,22 @@ class ProjspecToolWindowPanel( add(browser.component, BorderLayout.CENTER) - browser.loadHTML(HtmlContent.buildHtml()) + // Load a lightweight placeholder immediately (EDT-safe — no subprocess). + // Start the server and build the real HTML in parallel on background threads. + browser.loadHTML(LOADING_HTML) + pool { + // Start server first so it's ready when the UI starts making requests. + server.start() + val html = HtmlContent.buildHtml() + ApplicationManager.getApplication().invokeLater({ + browser.loadHTML(html) + }, ModalityState.defaultModalityState()) + } + } - // Kick off initial data load once the JS posts `ready`. + override fun removeNotify() { + super.removeNotify() + server.dispose() } // ------------------------------------------------------------------------ @@ -124,25 +135,32 @@ class ProjspecToolWindowPanel( * never manages to send `ready`. */ private fun injectBridge() { - val inject = jsQuery.inject( + // Library bridge + val libInject = jsQuery.inject( + "msgJson", + "function(response) {}", + "function(error_code, error_message) {}", + ) + // Filebrowser bridge + val fbInject = fbJsQuery.inject( "msgJson", "function(response) {}", "function(error_code, error_message) {}", ) browser.cefBrowser.executeJavaScript( """ - window.__javaBridge = { - query: function(msgJson) { $inject } - }; - if (typeof window.__projspecBridgeReady === 'function') { - window.__projspecBridgeReady(); + var __javaLibBridge = { query: function(msgJson) { $libInject } }; + var __javaFbBridge = { query: function(msgJson) { $fbInject } }; + if (typeof window.__projspecLibBridgeConnect === 'function') { + window.__projspecLibBridgeConnect(__javaLibBridge); + } + if (typeof window.__projspecFbBridgeConnect === 'function') { + window.__projspecFbBridgeConnect(__javaFbBridge); } """.trimIndent(), "", 0, ) - // Start the initial load exactly once, regardless of whether the - // JS handshake arrives. if (!initialLoadStarted.getAndSet(true)) { webviewReady = true synchronized(pending) { @@ -154,19 +172,16 @@ class ProjspecToolWindowPanel( } } pool { reload(initial = true) } + pool { fbInit() } } } - /** - * Deliver a message to the webview. Mirrors `panel.webview.postMessage`. - * - * JCEF does not expose an inbound message channel the way VSCode does, so - * we call a globally-installed handler (`window.__projspecDeliver`) via - * `executeJavaScript`. See the JS at the top of [HtmlContent.PANEL_JS]. - */ + /** Deliver a message to the *library* panel (via window.__libDispatch). */ private fun deliverToWebview(msg: Any) { val json = gson.toJson(msg) - val script = "window.__projspecDeliver && window.__projspecDeliver($json);" + val msgMap = msg as? Map<*, *> + PluginLogger.info("LIB deliver type=${msgMap?.get("type")} size=${json.length}b") + val script = "window.__libDispatch && window.__libDispatch($json);" if (!webviewReady) { synchronized(pending) { pending.addLast(script) } return @@ -176,6 +191,25 @@ class ProjspecToolWindowPanel( }, ModalityState.defaultModalityState()) } + /** Deliver a message to the *filebrowser* panel (via window.__projspecFbDeliver). */ + private fun deliverToFbWebview(msg: Any) { + val json = gson.toJson(msg) + val msgMap = msg as? Map<*, *> + val type = msgMap?.get("type") + val preview = when (type) { + "browseResult" -> "entries=${((msgMap["entries"] as? List<*>)?.size ?: 0)}" + "projectScanned" -> "project=${msgMap["project"] != null} error=${msgMap["error"]}" + "inspectResult" -> "error=${msgMap["error"]}" + "error" -> "message=${msgMap["message"]}" + else -> json.take(100) + } + PluginLogger.info("FB deliver type=$type $preview") + val script = "window.__projspecFbDeliver && window.__projspecFbDeliver($json);" + ApplicationManager.getApplication().invokeLater({ + browser.cefBrowser.executeJavaScript(script, "", 0) + }, ModalityState.defaultModalityState()) + } + // ------------------------------------------------------------------------ // Busy indicator — counted so nested operations don't flicker the spinner // ------------------------------------------------------------------------ @@ -203,11 +237,11 @@ class ProjspecToolWindowPanel( @Suppress("UNCHECKED_CAST") private fun handleJsMessage(rawJson: String) { - LOG.debug { "JS→Kotlin: $rawJson" } + PluginLogger.info("LIB JS cmd=" + (try { com.google.gson.JsonParser.parseString(rawJson).asJsonObject.get("cmd")?.asString } catch (_: Exception) { "?" }) + " raw=" + rawJson.take(120)) val msg: Map = try { gson.fromJson(rawJson, Map::class.java) as Map } catch (e: Exception) { - LOG.warn("Failed to parse JS message: $rawJson", e) + PluginLogger.warn("Failed to parse JS message: ${rawJson.take(200)}") return } when (msg["cmd"] as? String) { @@ -235,6 +269,49 @@ class ProjspecToolWindowPanel( } } + // ------------------------------------------------------------------------ + // Filebrowser inbound dispatch (JS → Kotlin, via fbJsQuery) + // ------------------------------------------------------------------------ + + @Suppress("UNCHECKED_CAST") + private fun handleFbJsMessage(rawJson: String) { + val msg: Map = try { + gson.fromJson(rawJson, Map::class.java) as Map + } catch (e: Exception) { + PluginLogger.warn("Failed to parse FB JS message: ${rawJson.take(200)}") + return + } + val cmd = msg["cmd"] as? String + val url = msg["url"] as? String ?: msg["parentUrl"] as? String ?: "" + val so = msg["storageOptions"] as? String + PluginLogger.info("FB cmd=$cmd url=$url so=${so?.take(80) ?: "null"}") + when (cmd) { + "ready" -> pool { fbInit() } + "browse" -> pool { fbBrowse(msg["url"] as? String ?: "", so, + msg["push"] != false) } + "inspect" -> pool { fbInspect(msg["url"] as? String ?: "", so) } + "scanDir" -> pool { fbScanDir(msg["url"] as? String ?: "", so) } + "expandDir" -> pool { fbExpandDir(msg["url"] as? String ?: "", so) } + "openFile" -> fbOpenFile(msg["url"] as? String ?: "") + "writeFile" -> pool { fbWriteFile(msg["url"] as? String ?: "", + msg["content"] as? String ?: "", so) } + "createFile" -> pool { fbCreateFile(msg["parentUrl"] as? String ?: "", + msg["name"] as? String ?: "", so) } + "deleteEntry" -> pool { fbDeleteEntry(msg["url"] as? String ?: "", + msg["isDir"] == true, so) } + "renameEntry" -> pool { fbRenameEntry(msg["url"] as? String ?: "", + msg["newName"] as? String ?: "", so) } + "mkdir" -> pool { fbMkdir(msg["parentUrl"] as? String ?: "", + msg["name"] as? String ?: "", so) } + "addBookmark" -> pool { fbBookmarkAdd(msg["url"] as? String ?: "", + msg["label"] as? String, so) } + "removeBookmark" -> pool { fbBookmarkRemove(msg["url"] as? String ?: "") } + "addToLibrary" -> pool { fbAddToLibrary(msg["url"] as? String ?: "", so) } + "goToUrl" -> pool { fbBrowse(msg["url"] as? String ?: "", so, true) } + else -> { /* ignore */ } + } + } + private fun pool(fn: () -> Unit) { // ApplicationManager.executeOnPooledThread was deprecated in 2024.1. // AppExecutorUtil.getAppExecutorService() is the current replacement. @@ -271,35 +348,47 @@ class ProjspecToolWindowPanel( * enum members (panel.ts refreshes them only on `initial`). */ private fun reload(initial: Boolean) { - LOG.info("reload(initial=$initial) starting") + PluginLogger.info("reload(initial=$initial) starting") withBusy { if (initial || info == "null") { - LOG.info("running: projspec info") - when (val res = ProjspecRunner.runInfo()) { - is CliResult.Success -> info = ProjspecRunner.extractJson(res.stdout).ifEmpty { "null" } - is CliResult.Failure -> { - Notifier.error("projspec info: ${res.message}", project) - info = "null" + // info — try server, fall back to CLI + val infoMap = server.info() + if (infoMap != null) { + info = gson.toJson(infoMap) + } else { + when (val res = ProjspecRunner.runInfo()) { + is CliResult.Success -> info = ProjspecRunner.extractJson(res.stdout).ifEmpty { "null" } + is CliResult.Failure -> { Notifier.error("projspec info: ${res.message}", project); info = "null" } } } - enums = ProjspecRunner.runEnumMembers().ifBlank { "{}" } + // enum members — try server, fall back to python3 introspection + val enumMap = server.enumMembers() + enums = if (enumMap != null) gson.toJson(enumMap) + else ProjspecRunner.runEnumMembers().ifBlank { "{}" } specNames = extractCreatableSpecs(info) } - LOG.info("running: projspec library list --json-out") - when (val res = ProjspecRunner.runLibraryList()) { - is CliResult.Success -> { - val extracted = ProjspecRunner.extractJson(res.stdout) - library = extracted.ifEmpty { "{}" } - libraryMap = parseMap(library) - } - is CliResult.Failure -> { - Notifier.error("projspec library list: ${res.message}", project) - library = "{}" - libraryMap = emptyMap() + // library — try server, fall back to CLI + val libMap = server.libraryList() + if (libMap != null) { + library = gson.toJson(libMap) + @Suppress("UNCHECKED_CAST") + libraryMap = libMap as Map + } else { + when (val res = ProjspecRunner.runLibraryList()) { + is CliResult.Success -> { + val extracted = ProjspecRunner.extractJson(res.stdout) + library = extracted.ifEmpty { "{}" } + libraryMap = parseMap(library) + } + is CliResult.Failure -> { + Notifier.error("projspec library list: ${res.message}", project) + library = "{}" + libraryMap = emptyMap() + } } } postData() - LOG.info("reload complete; library has ${libraryMap.size} entries") + PluginLogger.info("reload complete; library has ${libraryMap.size} entries") } } @@ -367,9 +456,9 @@ class ProjspecToolWindowPanel( val target = chosen.path pool { withBusy { - val res = ProjspecRunner.runScan(target) - if (res is CliResult.Failure) { - Notifier.warning("projspec scan: ${res.message}", project) + if (server.scan(target, addToLibrary = true) == null) { + val res = ProjspecRunner.runScan(target) + if (res is CliResult.Failure) Notifier.warning("projspec scan: ${res.message}", project) } reload(initial = false) } @@ -424,7 +513,15 @@ class ProjspecToolWindowPanel( private fun openWith(tool: String, url: String) { when (tool) { "vscode" -> OpenWithHelper.openWithVSCode(project, url) - "filebrowser" -> OpenWithHelper.openWithFileBrowser(project, url) + "filebrowser" -> { + // Switch to the File Browser tab and navigate there + ApplicationManager.getApplication().invokeLater({ + browser.cefBrowser.executeJavaScript( + "window.__projspecShowTab && window.__projspecShowTab('filebrowser');", + "", 0) + }, ModalityState.defaultModalityState()) + pool { fbBrowse(url, null, false) } + } "pycharm" -> OpenWithHelper.openWithPyCharm(project, url) "jupyter" -> OpenWithHelper.openWithJupyter(project, url) } @@ -433,9 +530,10 @@ class ProjspecToolWindowPanel( private fun rescan(url: String) { withBusy { val path = OpenWithHelper.urlToPath(url) - val res = ProjspecRunner.runScan(path, entryStorageOptions(url)) - if (res is CliResult.Failure) { - Notifier.warning("projspec scan: ${res.message}", project) + val soJson = entryStorageOptions(url) + if (server.scan(path, addToLibrary = true, storageOptions = soJson) == null) { + val res = ProjspecRunner.runScan(path, soJson) + if (res is CliResult.Failure) Notifier.warning("projspec scan: ${res.message}", project) } reload(initial = false) } @@ -485,20 +583,25 @@ class ProjspecToolWindowPanel( private fun createSpecConfirmed(url: String, spec: String) { withBusy { val path = OpenWithHelper.urlToPath(url) - val createRes = ProjspecRunner.runCreate(spec, path) - if (createRes is CliResult.Failure) { - Notifier.warning("projspec create: ${createRes.message}", project) + val soJson = entryStorageOptions(url) + // create — server or CLI + if (server.create(spec, path) == null) { + val res = ProjspecRunner.runCreate(spec, path) + if (res is CliResult.Failure) Notifier.warning("projspec create: ${res.message}", project) + } + // rescan — server or CLI + if (server.scan(path, addToLibrary = true, storageOptions = soJson) == null) { + ProjspecRunner.runScan(path, soJson) } - ProjspecRunner.runScan(path, entryStorageOptions(url)) reload(initial = false) } } private fun removeFromLibrary(url: String) { withBusy { - val res = ProjspecRunner.runLibraryDelete(url) - if (res is CliResult.Failure) { - Notifier.warning("projspec library delete: ${res.message}", project) + if (!server.libraryDelete(url)) { + val res = ProjspecRunner.runLibraryDelete(url) + if (res is CliResult.Failure) Notifier.warning("projspec library delete: ${res.message}", project) } reload(initial = false) } @@ -598,9 +701,189 @@ class ProjspecToolWindowPanel( return Regex(sb.toString()) } + // ------------------------------------------------------------------------ + // Filebrowser actions — server first, CLI fallback + // ------------------------------------------------------------------------ + + private fun fbBeginBusy() { + if (fbBusyCount.incrementAndGet() == 1) deliverToFbWebview(mapOf("type" to "loading", "loading" to true)) + } + private fun fbEndBusy() { + if (fbBusyCount.decrementAndGet() == 0) deliverToFbWebview(mapOf("type" to "loading", "loading" to false)) + } + private fun fbWithBusy(work: () -> Unit) { fbBeginBusy(); try { work() } finally { fbEndBusy() } } + + private fun fbInit() { + fbWithBusy { + val bms = server.bookmarksList() + ?: gson.fromJson(ProjspecRunner.runFbCall("filebrowser", "bookmarks", "list"), List::class.java) + ?: emptyList() + val protos = server.protocols() + ?: gson.fromJson(ProjspecRunner.runFbCall("filebrowser", "protocols"), List::class.java) + ?: emptyList() + val libUrls: List = (server.libraryList() + ?: run { + val res = ProjspecRunner.runLibraryList() + if (res is CliResult.Success) parseMap(ProjspecRunner.extractJson(res.stdout)) else null + })?.keys?.toList() ?: emptyList() + deliverToFbWebview(mapOf( + "type" to "init", + "bookmarks" to bms, + "protocols" to protos, + "libraryUrls" to libUrls, + )) + fbBrowse(System.getProperty("user.home") ?: "/", null, false) + } + } + + private fun fbBrowse(url: String, storageOptions: String?, pushHistory: Boolean) { + val so = server.parseSo(storageOptions) + val data: Map = server.browse(url, so) + ?: run { + val raw = ProjspecRunner.runFbBrowse(url, storageOptions) + try { @Suppress("UNCHECKED_CAST") gson.fromJson(raw, Map::class.java) as Map } + catch (_: Exception) { mapOf("url" to url, "entries" to emptyList(), "error" to raw) } + } + deliverToFbWebview(data + mapOf("type" to "browseResult", + "pushHistory" to pushHistory, + "storageOptions" to (storageOptions ?: ""))) + } + + private fun fbInspect(url: String, storageOptions: String?) { + val so = server.parseSo(storageOptions) + val data: Map = server.inspectAsProject(url, so) + ?: run { + val raw = ProjspecRunner.runFbInspectAsProject(url, storageOptions) + try { @Suppress("UNCHECKED_CAST") gson.fromJson(raw, Map::class.java) as Map } + catch (_: Exception) { mapOf("url" to url, "error" to raw) } + } + deliverToFbWebview(data + mapOf("type" to "inspectResult")) + deliverToFbWebview(mapOf( + "type" to "projectScanned", + "url" to url, + "project" to data["project"], + "error" to data["error"], + "text_preview" to data["text_preview"], + "info" to (gson.fromJson(info, Map::class.java) ?: emptyMap()), + "enums" to (gson.fromJson(enums, Map::class.java) ?: emptyMap()), + )) + } + + private fun fbScanDir(url: String, storageOptions: String?) { + val so = server.parseSo(storageOptions) + val data: Map = server.scanDirectory(url, so) + ?: run { + // CLI fallback: projspec scan --json-out + val res = ProjspecRunner.runScan(url, storageOptions) + val rawJson = if (res is CliResult.Success) ProjspecRunner.extractJson(res.stdout) else "" + val proj = if (rawJson.isNotBlank()) { + try { gson.fromJson(rawJson, Map::class.java) } catch (_: Exception) { null } + } else null + mapOf("url" to url, "project" to proj, + "error" to if (rawJson.isBlank()) (res as? CliResult.Failure)?.message else null) + } + deliverToFbWebview(mapOf( + "type" to "projectScanned", + "info" to (gson.fromJson(info, Map::class.java) ?: emptyMap()), + "enums" to (gson.fromJson(enums, Map::class.java) ?: emptyMap()), + ) + data) + } + + private fun fbExpandDir(url: String, storageOptions: String?) { + val so = server.parseSo(storageOptions) + val data: Map = server.browse(url, so) + ?: run { + val raw = ProjspecRunner.runFbBrowse(url, storageOptions) + try { @Suppress("UNCHECKED_CAST") gson.fromJson(raw, Map::class.java) as Map } + catch (_: Exception) { mapOf("url" to url, "entries" to emptyList()) } + } + deliverToFbWebview(data + mapOf("type" to "expandResult", "parentUrl" to url)) + } + + private fun fbOpenFile(url: String) { + OpenWithHelper.openWithFileBrowser(project, url) + } + + private fun fbWriteFile(url: String, content: String, storageOptions: String?) { + val so = server.parseSo(storageOptions) + if (server.writeFile(url, content, so) == null) ProjspecRunner.runFbWriteFile(url, content, storageOptions) + fbBrowse(url.trimEnd('/').substringBeforeLast('/').ifBlank { "/" }, storageOptions, false) + } + + private fun fbCreateFile(parentUrl: String, name: String, storageOptions: String?) { + val newUrl = parentUrl.trimEnd('/') + "/" + name + val so = server.parseSo(storageOptions) + if (server.writeFile(newUrl, "", so) == null) ProjspecRunner.runFbWriteFile(newUrl, "", storageOptions) + fbBrowse(parentUrl, storageOptions, false) + } + + private fun fbDeleteEntry(url: String, isDir: Boolean, storageOptions: String?) { + val so = server.parseSo(storageOptions) + if (server.delete(url, isDir, so) == null) ProjspecRunner.runFbDelete(url, isDir, storageOptions) + fbBrowse(url.trimEnd('/').substringBeforeLast('/').ifBlank { "/" }, storageOptions, false) + } + + private fun fbRenameEntry(url: String, newName: String, storageOptions: String?) { + val parent = url.trimEnd('/').substringBeforeLast('/').ifBlank { "/" } + val dst = parent.trimEnd('/') + "/" + newName + val so = server.parseSo(storageOptions) + if (server.move(url, dst, so) == null) ProjspecRunner.runFbMove(url, dst, storageOptions) + fbBrowse(parent, storageOptions, false) + } + + private fun fbMkdir(parentUrl: String, name: String, storageOptions: String?) { + val newUrl = parentUrl.trimEnd('/') + "/" + name + val so = server.parseSo(storageOptions) + if (server.mkdir(newUrl, so) == null) ProjspecRunner.runFbMkdir(newUrl, storageOptions) + fbBrowse(parentUrl, storageOptions, false) + } + + private fun fbBookmarkAdd(url: String, label: String?, storageOptions: String?) { + val so = server.parseSo(storageOptions) + val bmsList: List<*> = server.bookmarkAdd(url, label, so) + ?: (gson.fromJson(ProjspecRunner.runFbBookmarkAdd(url, label, storageOptions), List::class.java) ?: emptyList()) + deliverToFbWebview(mapOf("type" to "bookmarksUpdated", "bookmarks" to bmsList)) + } + + private fun fbBookmarkRemove(url: String) { + val bmsList: List<*> = server.bookmarkRemove(url) + ?: (gson.fromJson(ProjspecRunner.runFbBookmarkRemove(url), List::class.java) ?: emptyList()) + deliverToFbWebview(mapOf("type" to "bookmarksUpdated", "bookmarks" to bmsList)) + } + + private fun fbAddToLibrary(url: String, storageOptions: String?) { + fbWithBusy { + val so = server.parseSo(storageOptions) + if (server.addToLibrary(url, so) == null) { + val args = mutableListOf("filebrowser", "add-to-library", url) + if (storageOptions != null) { args.add("--storage-options"); args.add(storageOptions) } + ProjspecRunner.runFbCall(*args.toTypedArray()) + } + // Refresh library URL badges + val libUrls = (server.libraryList() + ?: run { + val res = ProjspecRunner.runLibraryList() + if (res is CliResult.Success) parseMap(ProjspecRunner.extractJson(res.stdout)) else null + })?.keys?.toList() ?: emptyList() + deliverToFbWebview(mapOf("type" to "libraryUrlsUpdated", "libraryUrls" to libUrls)) + // Reload library tab and switch to it + pool { reload(initial = false) } + ApplicationManager.getApplication().invokeLater({ + browser.cefBrowser.executeJavaScript( + "window.__projspecShowTab && window.__projspecShowTab('library');", "", 0) + }, ModalityState.defaultModalityState()) + } + } + private companion object { private val LOG = Logger.getInstance(ProjspecToolWindowPanel::class.java) + /** Shown immediately while the real HTML is being generated off-EDT. */ + private val LOADING_HTML = """ + + +Loading projspec…""" + /** Written by the "Configure" button when the file does not exist. */ private val DEFAULT_CONFIG = """ { diff --git a/pycharm_plugin/src/main/kotlin/com/projspec/util/ProjspecRunner.kt b/pycharm_plugin/src/main/kotlin/com/projspec/util/ProjspecRunner.kt index 2238b20..923e668 100644 --- a/pycharm_plugin/src/main/kotlin/com/projspec/util/ProjspecRunner.kt +++ b/pycharm_plugin/src/main/kotlin/com/projspec/util/ProjspecRunner.kt @@ -89,11 +89,99 @@ object ProjspecRunner { walk(pu.Enum) print(json.dumps(out)) """.trimIndent() - return try { - val cmd = GeneralCommandLine(listOf("python3", "-c", script)) - val output = CapturingProcessHandler(cmd).runProcess(30_000) - if (output.isTimeout || output.exitCode != 0) "{}" else output.stdout.trim().ifBlank { "{}" } - } catch (_: Exception) { "{}" } + return when (val r = run(listOf("python", "-c", script))) { + is CliResult.Success -> r.stdout.trim().ifBlank { "{}" } + is CliResult.Failure -> "{}" + } + } + + // ------------------------------------------------------------------------- + // Filebrowser CLI wrappers + // ------------------------------------------------------------------------- + + /** + * Generic filebrowser subcommand call. Returns the raw stdout string + * (JSON) or an empty JSON object on failure. + */ + fun runFbCall(vararg args: String): String { + val fullArgs = listOf(cli) + args.toList() + return when (val r = run(fullArgs)) { + is CliResult.Success -> extractJson(r.stdout).ifBlank { "{}" } + is CliResult.Failure -> "{}" + } + } + + /** `projspec filebrowser inspect-as-project ` — file metadata + project-shaped dict. */ + fun runFbInspectAsProject(url: String, storageOptions: String?): String { + val args = mutableListOf(cli, "filebrowser", "inspect-as-project", url) + if (!storageOptions.isNullOrBlank()) { args.add("--storage-options"); args.add(storageOptions) } + return when (val r = run(args)) { + is CliResult.Success -> extractJson(r.stdout).ifBlank { "{}" } + is CliResult.Failure -> """{"url":"$url","project":null,"error":"${r.message.replace("\"","'")}"}""" + } + } + + /** `projspec filebrowser browse [--storage-options JSON]` */ + fun runFbBrowse(url: String, storageOptions: String?): String { + val args = mutableListOf(cli, "filebrowser", "browse", url) + if (!storageOptions.isNullOrBlank()) { args.add("--storage-options"); args.add(storageOptions) } + return when (val r = run(args)) { + is CliResult.Success -> extractJson(r.stdout).ifBlank { "{}" } + is CliResult.Failure -> """{"url":"$url","entries":[],"error":"${r.message.replace("\"","'")}"}""" + } + } + + /** `projspec filebrowser write-file ` (content via stdin/tmp file) */ + fun runFbWriteFile(url: String, content: String, storageOptions: String?) { + // Write content to a temp file then pass via --content-file + val tmp = java.io.File.createTempFile("projspec_fb_", ".txt") + try { + tmp.writeText(content, Charsets.UTF_8) + val args = mutableListOf(cli, "filebrowser", "write-file", url, + "--content-file", tmp.absolutePath) + if (!storageOptions.isNullOrBlank()) { args.add("--storage-options"); args.add(storageOptions) } + run(args) + } finally { tmp.delete() } + } + + /** `projspec filebrowser delete [--recursive] [--storage-options JSON]` */ + fun runFbDelete(url: String, recursive: Boolean, storageOptions: String?) { + val args = mutableListOf(cli, "filebrowser", "delete", url) + if (recursive) args.add("--recursive") + if (!storageOptions.isNullOrBlank()) { args.add("--storage-options"); args.add(storageOptions) } + run(args) + } + + /** `projspec filebrowser move [--storage-options JSON]` */ + fun runFbMove(src: String, dst: String, storageOptions: String?) { + val args = mutableListOf(cli, "filebrowser", "move", src, dst) + if (!storageOptions.isNullOrBlank()) { args.add("--storage-options"); args.add(storageOptions) } + run(args) + } + + /** `projspec filebrowser mkdir [--storage-options JSON]` */ + fun runFbMkdir(url: String, storageOptions: String?) { + val args = mutableListOf(cli, "filebrowser", "mkdir", url) + if (!storageOptions.isNullOrBlank()) { args.add("--storage-options"); args.add(storageOptions) } + run(args) + } + + /** `projspec filebrowser bookmarks add [--label LABEL]` → JSON bookmarks list */ + fun runFbBookmarkAdd(url: String, label: String?, storageOptions: String?): String { + val args = mutableListOf(cli, "filebrowser", "bookmarks", "add", url) + if (!label.isNullOrBlank()) { args.add("--label"); args.add(label) } + return when (val r = run(args)) { + is CliResult.Success -> extractJson(r.stdout).ifBlank { "[]" } + is CliResult.Failure -> "[]" + } + } + + /** `projspec filebrowser bookmarks remove ` → JSON bookmarks list */ + fun runFbBookmarkRemove(url: String): String { + return when (val r = run(listOf(cli, "filebrowser", "bookmarks", "remove", url))) { + is CliResult.Success -> extractJson(r.stdout).ifBlank { "[]" } + is CliResult.Failure -> "[]" + } } // ------------------------------------------------------------------------- @@ -103,28 +191,48 @@ object ProjspecRunner { /** * Execute an external command and capture stdout/stderr. * + * Runs via the login shell (`$SHELL -l -c`) so the user's PATH + * (conda envs, pyenv, Homebrew, etc.) is available exactly as it + * would be in a terminal — even when launched from a macOS GUI app. + * + * Every call is logged with args, duration, and result. + * * NOTE: blocks the calling thread for up to 60 s. Never call from the EDT. */ fun run(args: List): CliResult { + // Shell-quote each argument so spaces and special chars survive + fun quote(s: String) = "'" + s.replace("'", "'\\''") + "'" + val shellCmd = args.joinToString(" ") { quote(it) } + val shell = System.getenv("SHELL")?.takeIf { it.isNotBlank() } ?: "/bin/sh" + val fullArgs = listOf(shell, "-l", "-c", shellCmd) + + PluginLogger.info("CLI call: $shellCmd") + val t0 = System.currentTimeMillis() return try { - val commandLine = GeneralCommandLine(args) + val commandLine = GeneralCommandLine(fullArgs) val handler = CapturingProcessHandler(commandLine) val output = handler.runProcess(60_000) + val ms = System.currentTimeMillis() - t0 when { - output.isTimeout -> + output.isTimeout -> { + PluginLogger.error("CLI timeout after 60 s: $shellCmd") CliResult.Failure("projspec timed out after 60 s", -1) - output.exitCode != 0 -> - CliResult.Failure( - output.stderr.ifBlank { output.stdout } - .ifBlank { "Exit code ${output.exitCode}" }, - output.exitCode, - ) - else -> + } + output.exitCode != 0 -> { + val err = output.stderr.ifBlank { output.stdout }.ifBlank { "Exit code ${output.exitCode}" } + PluginLogger.warn("CLI exit ${output.exitCode} (${ms}ms): $shellCmd\n stderr: ${output.stderr.trim().take(500)}\n stdout: ${output.stdout.trim().take(500)}") + CliResult.Failure(err, output.exitCode) + } + else -> { + PluginLogger.info("CLI ok (${ms}ms): $shellCmd stdout(${output.stdout.length}b)") CliResult.Success(output.stdout) + } } } catch (e: Exception) { - CliResult.Failure("Failed to launch projspec: ${e.message}") + val ms = System.currentTimeMillis() - t0 + PluginLogger.error("CLI exception (${ms}ms): $shellCmd\n ${e.javaClass.simpleName}: ${e.message}") + CliResult.Failure("Failed to launch: ${e.message}") } } diff --git a/src/projspec/__main__.py b/src/projspec/__main__.py index 981ae25..9eb18be 100755 --- a/src/projspec/__main__.py +++ b/src/projspec/__main__.py @@ -377,6 +377,23 @@ def fb_inspect(url, storage_options, max_text_bytes): ) +@filebrowser.command("inspect-as-project") +@click.argument("url") +@click.option("--storage-options", default="", help="fsspec storage options as JSON") +def fb_inspect_as_project(url, storage_options): + """Inspect a file and return a project-shaped dict for the UI scan panel. + + Outputs JSON with the same shape as ``projspec scan --json-out`` so the + file browser's embedded scan panel can render it identically to a directory + scan. Keys: url, project, name, size, last_modified, mime_type, + text_preview, error. + """ + from projspec.filebrowser import inspect_as_project + + so = json.loads(storage_options) if storage_options.strip() else None + print(json.dumps(inspect_as_project(url, storage_options=so))) + + @filebrowser.command("read-file") @click.argument("url") @click.option("--storage-options", default="", help="fsspec storage options as JSON") diff --git a/src/projspec/filebrowser.py b/src/projspec/filebrowser.py index f3e3123..5fc73c0 100644 --- a/src/projspec/filebrowser.py +++ b/src/projspec/filebrowser.py @@ -42,6 +42,7 @@ from __future__ import annotations import json +import logging import os import time from pathlib import Path @@ -49,6 +50,55 @@ import fsspec +# --------------------------------------------------------------------------- +# File-based logger — same config directory as server.py so all Python-side +# projspec activity is visible together. +# --------------------------------------------------------------------------- + + +def _get_logger() -> logging.Logger: + logger = logging.getLogger("projspec.filebrowser") + if logger.handlers: + return logger + logger.setLevel(logging.DEBUG) + conf_dir = Path( + os.environ.get("PROJSPEC_CONFIG_DIR", Path.home() / ".config" / "projspec") + ) + conf_dir.mkdir(parents=True, exist_ok=True) + log_path = conf_dir / "filebrowser.log" + fmt = logging.Formatter( + "%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S" + ) + fh = logging.FileHandler(log_path, mode="a", encoding="utf-8") + fh.setFormatter(fmt) + logger.addHandler(fh) + return logger + + +_log = _get_logger() + + +def _log_call(fn_name: str, url: str, so: Any = None, **extra: Any) -> None: + parts = [f"CALL {fn_name} url={url!r}"] + if so: + parts.append(f"so={json.dumps(so)[:80]}") + for k, v in extra.items(): + parts.append(f"{k}={v!r}"[:60]) + _log.info(" ".join(parts)) + + +def _log_result(fn_name: str, url: str, result: Any, t0: float) -> None: + ms = int((time.monotonic() - t0) * 1000) + if isinstance(result, dict): + err = result.get("error") + summary = f"error={err!r}" if err else f"keys={list(result.keys())[:6]}" + elif isinstance(result, list): + summary = f"len={len(result)}" + else: + summary = repr(result)[:80] + _log.info(f"DONE {fn_name} url={url!r} ({ms}ms) {summary}") + + # --------------------------------------------------------------------------- # Bookmark persistence # --------------------------------------------------------------------------- @@ -225,16 +275,16 @@ def browse(url: str, storage_options: dict | None = None) -> dict: "error": null or , } """ + _log_call("browse", url, storage_options) + t0 = time.monotonic() try: fs, path = _get_fs(url, storage_options) try: raw = fs.ls(path, detail=True) except NotADirectoryError: - # Treat as a single-file listing info = fs.info(path) raw = [info] entries = [_entry_info(fs, e) for e in raw] - # Sort: directories first, then files, both alphabetically entries.sort( key=lambda e: (0 if e["type"] == "directory" else 1, e["basename"].lower()) ) @@ -245,14 +295,21 @@ def browse(url: str, storage_options: dict | None = None) -> dict: if isinstance(fs.protocol, str) else (fs.protocol[0] if fs.protocol else "file") ) - return { + result = { "url": canonical, "entries": entries, "parent": parent_path, "protocol": protocol, "error": None, } + _log.info( + f"DONE browse url={url!r} ({int((time.monotonic()-t0)*1000)}ms) entries={len(entries)}" + ) + return result except Exception as exc: + _log.error( + f"ERR browse url={url!r} ({int((time.monotonic()-t0)*1000)}ms): {exc}" + ) return { "url": url, "entries": [], @@ -843,8 +900,13 @@ def inspect_as_project( Returns the same keys as ``scan_directory`` plus the raw inspect fields for the top-half metadata strip. """ + _log_call("inspect_as_project", url, storage_options) + t0 = time.monotonic() result = inspect_file(url, storage_options=storage_options) if result.get("error"): + _log.error( + f"ERR inspect_as_project url={url!r} ({int((time.monotonic()-t0)*1000)}ms): {result['error']}" + ) return { "url": url, "project": None, @@ -941,7 +1003,7 @@ def inspect_as_project( "scanned_at": None, } - return { + _ret = { "url": url, "project": project, # Top-level fields for the inspectResult / renderMeta strip @@ -953,6 +1015,11 @@ def inspect_as_project( "text_preview": text_preview, "error": None, } + _log.info( + f"DONE inspect_as_project url={url!r} ({int((time.monotonic()-t0)*1000)}ms) " + f"project={_ret['project'] is not None} mime={mime!r}" + ) + return _ret def supported_protocols() -> list[str]: @@ -979,15 +1046,25 @@ def scan_directory( "error": null or , } """ + _log_call("scan_directory", url, storage_options) + t0 = time.monotonic() try: from projspec.proj import Project so = storage_options or {} proj = Project(url, storage_options=so, walk=False) - return { + result = { "url": url, "project": proj.to_dict(compact=False), "error": None, } + _log.info( + f"DONE scan_directory url={url!r} ({int((time.monotonic()-t0)*1000)}ms) " + f"specs={list(proj.specs.keys())}" + ) + return result except Exception as exc: + _log.error( + f"ERR scan_directory url={url!r} ({int((time.monotonic()-t0)*1000)}ms): {exc}" + ) return {"url": url, "project": None, "error": str(exc)} diff --git a/src/projspec/qtapp/main.py b/src/projspec/qtapp/main.py index 1a5a000..bb6a700 100644 --- a/src/projspec/qtapp/main.py +++ b/src/projspec/qtapp/main.py @@ -1,14 +1,10 @@ -"""Qt-based desktop UI for projspec - functional equivalent of the VSCode extension. +"""Qt-based desktop UI for projspec — two-tab panel: Library + File Browser. -This app is a Python port of the `vsextension/` WebView UI. A single -QMainWindow hosts a QWebEngineView rendering the same HTML/CSS/JS two-pane -layout (Library + Details) described in `vsextension/ACTIONS.md`, while the -Python side plays the role of the "extension host": it calls projspec APIs -directly (no subprocess), scans/creates/removes projects, and invokes -artifacts' ``make`` methods. - -The look-and-feel is intentionally identical to the VSCode extension so the -two UIs diverge as little as possible. +A single QMainWindow hosts a QWebEngineView rendering the combined +HTML/CSS/JS panel with a *Project Library* tab and a *File Browser* tab. +Python plays the role of the "extension host": it calls projspec and +filebrowser APIs directly (no subprocess) and communicates with both tabs +via two separate QWebChannel bridge objects. """ from __future__ import annotations @@ -50,7 +46,7 @@ warnings.warn("PyQt5 not installed", ImportWarning) qt = False -from projspec.qtapp.views import get_panel_html +from projspec.qtapp.views import get_qt_html library = ProjectLibrary() @@ -99,7 +95,7 @@ def send(self, msg: dict) -> None: class ProjspecWindow(QMainWindow): - """Single-window Qt app that mirrors the VSCode Project Library panel.""" + """Single-window Qt app: Project Library tab + File Browser tab.""" def __init__(self) -> None: super().__init__() @@ -109,18 +105,21 @@ def __init__(self) -> None: self._info_data: dict = {} self._enum_members: dict = {} - # Bridge + webview + # ── Library bridge ─────────────────────────────────────────────────── self._bridge = JsBridge(self) - self._bridge.set_handler(self._on_message) + self._bridge.set_handler(self._on_lib_message) + + # ── File-browser bridge ────────────────────────────────────────────── + self._fb_bridge = JsBridge(self) + self._fb_bridge.set_handler(self._on_fb_message) + + # ── Webview + channel ──────────────────────────────────────────────── self._view = QWebEngineView(self) channel = QWebChannel(self._view.page()) channel.registerObject("bridge", self._bridge) + channel.registerObject("fb_bridge", self._fb_bridge) self._view.page().setWebChannel(channel) - # Qt WebEngine's default settings block a ``file://`` page from - # loading webfonts referenced by ``data:`` URIs in its CSS. - # Flipping these three switches tells Chromium to treat the page - # the same way it would an HTTP page so ``@font-face`` resolves. settings = self._view.settings() for attr in ( "LocalContentCanAccessRemoteUrls", @@ -143,26 +142,20 @@ def __init__(self) -> None: layout.addWidget(self._view) self.setCentralWidget(central) - # Load the shared HTML UI. We write to a temp file and ``setUrl`` - # it rather than calling ``setHtml``: the latter gives the page an - # opaque origin that Chromium treats like a cross-origin document, - # breaking anything that touches the page origin (external links, - # relative anchors, future ``fetch`` calls). Loading from a real - # ``file://`` URL sidesteps all of that. + # Write the combined HTML to a temp file and load via file:// URL. import tempfile tmp = tempfile.NamedTemporaryFile( "w", suffix=".html", delete=False, encoding="utf-8" ) - tmp.write(get_panel_html()) + tmp.write(get_qt_html()) tmp.close() - self._html_tempfile = tmp.name # keep alive for Qt's loader + self._html_tempfile = tmp.name self._view.setUrl(QUrl.fromLocalFile(tmp.name)) - - # Kick off the initial load after the page has finished rendering. self._view.loadFinished.connect(self._on_load_finished) - self._busy = 0 + self._lib_busy = 0 + self._fb_busy = 0 def closeEvent(self, event) -> None: # - Qt naming """Remove the temp HTML file on window close.""" @@ -172,59 +165,93 @@ def closeEvent(self, event) -> None: # - Qt naming pass super().closeEvent(event) - # ── Busy indicator ────────────────────────────────────────────────────── + # ── Busy indicators ───────────────────────────────────────────────────── def _set_busy(self, busy: bool) -> None: - """Tell the webview whether any in-flight operation is running. - - Uses a reference count so composed actions (scan + reload) keep the - spinner up rather than flashing off between steps. - """ if busy: - self._busy += 1 - if self._busy == 1: + self._lib_busy += 1 + if self._lib_busy == 1: self._bridge.send({"type": "loading", "loading": True}) else: - self._busy = max(0, self._busy - 1) - if self._busy == 0: + self._lib_busy = max(0, self._lib_busy - 1) + if self._lib_busy == 0: self._bridge.send({"type": "loading", "loading": False}) + def _set_fb_busy(self, busy: bool) -> None: + if busy: + self._fb_busy += 1 + if self._fb_busy == 1: + self._fb_bridge.send({"type": "loading", "loading": True}) + else: + self._fb_busy = max(0, self._fb_busy - 1) + if self._fb_busy == 0: + self._fb_bridge.send({"type": "loading", "loading": False}) + # ── Initial load ──────────────────────────────────────────────────────── def _on_load_finished(self, ok: bool) -> None: # - signal arg self._reload(initial=True) + self._fb_init() - def _reload(self, initial: bool = False) -> None: + def _reload(self, initial: bool = False, select_url: str | None = None) -> None: self._set_busy(True) try: if initial or not self._info_data: self._info_data = class_infos() self._enum_members = _collect_enum_members() - library.load() # re-read the on-disk library file + library.load() lib_dict = { url: proj.to_dict(compact=False) for url, proj in library.entries.items() } - self._bridge.send( + msg: dict = { + "type": "data", + "info": self._info_data, + "enums": self._enum_members, + "library": lib_dict, + } + if select_url: + msg["selectUrl"] = select_url + self._bridge.send(msg) + except Exception as e: + QMessageBox.warning(self, "projspec", f"Reload failed: {e}") + finally: + self._set_busy(False) + + def _fb_init(self) -> None: + """Send initial data to the file browser tab.""" + import os + from projspec.filebrowser import ( + bookmarks_list, + supported_protocols, + ) + + self._set_fb_busy(True) + try: + bms = bookmarks_list() + protos = supported_protocols() + lib_urls = list(library.entries.keys()) + self._fb_bridge.send( { - "type": "data", - "info": self._info_data, - "enums": self._enum_members, - "library": lib_dict, + "type": "init", + "bookmarks": bms, + "protocols": protos, + "libraryUrls": lib_urls, } ) + # Navigate to home directory + self._fb_browse(os.path.expanduser("~"), push_history=False) except Exception as e: - QMessageBox.warning(self, "projspec", f"Reload failed: {e}") + self._fb_bridge.send({"type": "error", "message": str(e)}) finally: - self._set_busy(False) + self._set_fb_busy(False) - # ── Inbound message dispatcher ────────────────────────────────────────── + # ── Library message dispatcher ────────────────────────────────────────── - def _on_message(self, msg: dict) -> None: + def _on_lib_message(self, msg: dict) -> None: cmd = msg.get("cmd") try: if cmd == "ready": - # Webview is up and re-asking for data. self._reload(initial=True) elif cmd == "reload": self._reload() @@ -260,6 +287,64 @@ def _on_message(self, msg: dict) -> None: except Exception as e: QMessageBox.warning(self, "projspec", f"{cmd}: {e}") + # ── Filebrowser message dispatcher ────────────────────────────────────── + + def _on_fb_message(self, msg: dict) -> None: + cmd = msg.get("cmd") + try: + if cmd == "ready": + self._fb_init() + elif cmd == "browse": + self._fb_browse( + msg["url"], + storage_options=msg.get("storageOptions") or None, + push_history=msg.get("push", True), + ) + elif cmd == "inspect": + self._fb_inspect(msg["url"], msg.get("storageOptions") or None) + elif cmd == "scanDir": + self._fb_scan_dir(msg["url"], msg.get("storageOptions") or None) + elif cmd == "expandDir": + self._fb_expand_dir(msg["url"], msg.get("storageOptions") or None) + elif cmd == "openFile": + self._fb_open_file(msg["url"], msg.get("storageOptions") or None) + elif cmd == "writeFile": + self._fb_write_file( + msg["url"], msg["content"], msg.get("storageOptions") or None + ) + elif cmd == "createFile": + self._fb_create_file( + msg["parentUrl"], msg["name"], msg.get("storageOptions") or None + ) + elif cmd == "deleteEntry": + self._fb_delete_entry( + msg["url"], + msg.get("isDir", False), + msg.get("storageOptions") or None, + ) + elif cmd == "renameEntry": + self._fb_rename_entry( + msg["url"], msg["newName"], msg.get("storageOptions") or None + ) + elif cmd == "mkdir": + self._fb_mkdir( + msg["parentUrl"], msg["name"], msg.get("storageOptions") or None + ) + elif cmd == "addBookmark": + self._fb_bookmark_add( + msg["url"], msg.get("label"), msg.get("storageOptions") or None + ) + elif cmd == "removeBookmark": + self._fb_bookmark_remove(msg["url"]) + elif cmd == "addToLibrary": + self._fb_add_to_library(msg["url"], msg.get("storageOptions") or None) + elif cmd == "goToUrl": + self._fb_browse( + msg["url"], msg.get("storageOptions") or None, push_history=True + ) + except Exception as e: + self._fb_bridge.send({"type": "error", "message": str(e)}) + # ── Actions ───────────────────────────────────────────────────────────── def _action_add(self) -> None: @@ -289,7 +374,11 @@ def _action_open_with(self, tool: str, url: str) -> None: if tool == "vscode": _spawn_detached(["code", local]) elif tool == "filebrowser": - _open_with_default(local) + # Switch to the File Browser tab and navigate there + self._view.page().runJavaScript( + f"window.__projspecShowTab && window.__projspecShowTab('filebrowser');" + ) + self._fb_browse(url, push_history=False) elif tool == "pycharm": _spawn_detached(["pycharm", local, "nosplash", "dontReopenProjects"]) elif tool == "jupyter": @@ -396,6 +485,191 @@ def _action_reveal_file(self, fn: str) -> None: target = pick _open_with_default(os.path.dirname(target) or target) + # ── File browser actions (all in-process via filebrowser.py) ──────────── + + def _fb_browse( + self, url: str, storage_options=None, push_history: bool = True + ) -> None: + from projspec.filebrowser import browse + + so = _parse_so(storage_options) + data = browse(url, storage_options=so) + self._fb_bridge.send( + { + "type": "browseResult", + "pushHistory": push_history, + "storageOptions": json.dumps(so) if so else "", + **data, + } + ) + + def _fb_inspect(self, url: str, storage_options=None) -> None: + from projspec.filebrowser import inspect_as_project + + so = _parse_so(storage_options) + data = inspect_as_project(url, storage_options=so) + self._fb_bridge.send({"type": "inspectResult", **data}) + self._fb_bridge.send( + { + "type": "projectScanned", + "url": url, + "project": data.get("project"), + "error": data.get("error"), + "text_preview": data.get("text_preview"), + "info": self._info_data, + "enums": self._enum_members, + } + ) + + def _fb_scan_dir(self, url: str, storage_options=None) -> None: + from projspec.filebrowser import scan_directory + + so = _parse_so(storage_options) + data = scan_directory(url, storage_options=so) + self._fb_bridge.send( + { + "type": "projectScanned", + "info": self._info_data, + "enums": self._enum_members, + **data, + } + ) + + def _fb_expand_dir(self, url: str, storage_options=None) -> None: + from projspec.filebrowser import browse + + so = _parse_so(storage_options) + data = browse(url, storage_options=so) + self._fb_bridge.send({"type": "expandResult", "parentUrl": url, **data}) + + def _fb_open_file(self, url: str, storage_options=None) -> None: + """Open a remote file: fetch content, write to temp file, open in default app.""" + import os, tempfile + + local = _url_to_local(url) + if os.path.exists(local): + _open_with_default(local) + return + from projspec.filebrowser import read_file + + so = _parse_so(storage_options) + result = read_file(url, storage_options=so) + if result.get("error"): + QMessageBox.warning(self, "Open file", result["error"]) + return + ext = os.path.splitext(url)[1] or ".txt" + tmp = tempfile.NamedTemporaryFile( + "w", suffix=ext, delete=False, encoding="utf-8" + ) + tmp.write(result.get("content", "")) + tmp.close() + _open_with_default(tmp.name) + + def _fb_write_file(self, url: str, content: str, storage_options=None) -> None: + from projspec.filebrowser import write_file + + so = _parse_so(storage_options) + result = write_file(url, content, storage_options=so) + if result.get("error"): + QMessageBox.warning(self, "Write file", result["error"]) + return + parent = url.rstrip("/").rsplit("/", 1)[0] or "/" + self._fb_browse(parent, storage_options, push_history=False) + + def _fb_create_file(self, parent_url: str, name: str, storage_options=None) -> None: + from projspec.filebrowser import write_file + + so = _parse_so(storage_options) + new_url = parent_url.rstrip("/") + "/" + name + result = write_file(new_url, "", storage_options=so) + if result.get("error"): + QMessageBox.warning(self, "Create file", result["error"]) + return + self._fb_browse(parent_url, storage_options, push_history=False) + + def _fb_delete_entry(self, url: str, is_dir: bool, storage_options=None) -> None: + from projspec.filebrowser import delete + + label = url.rstrip("/").rsplit("/", 1)[-1] or url + reply = QMessageBox.question( + self, + "Delete", + f'Delete "{label}"?', + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return + so = _parse_so(storage_options) + result = delete(url, storage_options=so, recursive=is_dir) + if result.get("error"): + QMessageBox.warning(self, "Delete", result["error"]) + return + parent = url.rstrip("/").rsplit("/", 1)[0] or "/" + self._fb_browse(parent, storage_options, push_history=False) + + def _fb_rename_entry(self, url: str, new_name: str, storage_options=None) -> None: + from projspec.filebrowser import move + + so = _parse_so(storage_options) + parent = url.rstrip("/").rsplit("/", 1)[0] or "/" + dst = parent.rstrip("/") + "/" + new_name + result = move(url, dst, storage_options=so) + if result.get("error"): + QMessageBox.warning(self, "Rename", result["error"]) + return + self._fb_browse(parent, storage_options, push_history=False) + + def _fb_mkdir(self, parent_url: str, name: str, storage_options=None) -> None: + from projspec.filebrowser import mkdir + + so = _parse_so(storage_options) + new_url = parent_url.rstrip("/") + "/" + name + result = mkdir(new_url, storage_options=so) + if result.get("error"): + QMessageBox.warning(self, "New folder", result["error"]) + return + self._fb_browse(parent_url, storage_options, push_history=False) + + def _fb_bookmark_add(self, url: str, label=None, storage_options=None) -> None: + from projspec.filebrowser import bookmark_add + + so = _parse_so(storage_options) + bms = bookmark_add(url, label=label or "", storage_options=so) + self._fb_bridge.send({"type": "bookmarksUpdated", "bookmarks": bms}) + + def _fb_bookmark_remove(self, url: str) -> None: + from projspec.filebrowser import bookmark_remove + + bms = bookmark_remove(url) + self._fb_bridge.send({"type": "bookmarksUpdated", "bookmarks": bms}) + + def _fb_add_to_library(self, url: str, storage_options=None) -> None: + from projspec.filebrowser import add_to_projspec_library + + self._set_fb_busy(True) + try: + so = _parse_so(storage_options) + result = add_to_projspec_library(url, storage_options=so) + if result.get("error"): + QMessageBox.warning(self, "Add to library", result["error"]) + return + # Refresh library URL badges in the filebrowser + self._fb_bridge.send( + { + "type": "libraryUrlsUpdated", + "libraryUrls": list(library.entries.keys()), + } + ) + # Reload library tab and select the new entry + self._reload(select_url=url) + # Switch to library tab + self._view.page().runJavaScript( + "window.__projspecShowTab && window.__projspecShowTab('library');" + ) + finally: + self._set_fb_busy(False) + # ── Scan helper ───────────────────────────────────────────────────────── def _rescan(self, url: str) -> None: @@ -457,6 +731,20 @@ def _scan_and_reload(self, url: str, walk: bool) -> None: # --------------------------------------------------------------------------- +def _parse_so(storage_options) -> dict | None: + """Parse a storage_options value that may be a JSON string, dict, or None.""" + if not storage_options: + return None + if isinstance(storage_options, dict): + return storage_options or None + if isinstance(storage_options, str): + try: + return json.loads(storage_options) or None + except Exception: + return None + return None + + def _url_to_local(url: str) -> str: """Strip ``file://`` prefix so the result is a plain path.""" if url.startswith("file://"): diff --git a/src/projspec/qtapp/views.py b/src/projspec/qtapp/views.py index c402629..a6b4eb2 100644 --- a/src/projspec/qtapp/views.py +++ b/src/projspec/qtapp/views.py @@ -1,102 +1,205 @@ -"""HTML/CSS/JS for the Qt webview panel. - -The shared HTML/CSS/JS lives in :mod:`projspec.webui` and is reused by the -VSCode extension (in spirit — via TS port), the PyCharm plugin, the Qt app, -and the Jupyter ipywidget. This module now only contributes the small -Qt-specific bootstrap that wires :class:`QWebChannel`'s ``bridge`` object -to the shared transport protocol. - -Icons are emoji characters. ``projspec`` itself stores an emoji in each -spec / content / artifact class's ``icon`` attribute, so the webview just -renders whatever ``class_infos()`` returns. The small set of *chrome* -icons (toolbar buttons, kebab trigger, etc.) lives in -:mod:`projspec.webui`'s ``chrome.json`` so the four UIs share a single -source of truth. +"""HTML/CSS/JS for the Qt webview combined panel (Library + File Browser). + +The shared HTML/CSS/JS lives in :mod:`projspec.webui`. This module only +contributes the small Qt-specific bootstrap scripts that wire +:class:`QWebChannel` bridge objects to the shared transport protocols. + +Two bridges are registered on the same channel: + - ``bridge`` → library panel (window.projspecTransport) + - ``fb_bridge`` → file browser (window.projspecFbTransport) + +A third, no-send bridge handles the embedded scan sub-panel inside the file +browser (window.projspecTransport scoped to #fb-scan-panel-root). """ from __future__ import annotations import json -from projspec.webui import chrome_icons, get_panel_html +from projspec.webui import chrome_icons, get_combined_html -# Re-exported for backwards compatibility with the handful of callers that -# still pull CHROME from here. +# Re-exported for backwards compatibility. CHROME = chrome_icons() - # --------------------------------------------------------------------------- -# Bootstrap script — installs window.projspecTransport for QWebChannel. +# Darcula-theme CSS variable fallbacks +# Injected into so that --vscode-* variables resolve correctly in +# JCEF (which does not provide them natively the way VS Code does). # --------------------------------------------------------------------------- -# -# QWebChannel's JS helper is loaded from a Qt resource URL. Once the -# channel is up, the Python-side ``JsBridge`` object is available as -# ``channel.objects.bridge`` with two slots: -# -# - ``bridge.handleMessage(json_string)``: JS -> Python -# - ``bridge.from_python(signal)``: Python -> JS, emitted with a JSON -# string -# -# The shared panel.js doesn't know about any of that; it only consults -# ``window.projspecTransport``. The bootstrap below adapts QWebChannel -# to the transport protocol. - -_QT_EXTRA_HEAD = '' - -_QT_BOOTSTRAP = r""" +_QT_THEME_CSS = """""" + +_QT_EXTRA_HEAD = ( + '\n' + _QT_THEME_CSS +) + +# Bootstrap for the *library* tab — wires channel.objects.bridge. +_QT_LIB_BOOTSTRAP = r""" +""" +# Bootstrap for the embedded scan sub-panel inside the file browser. +# Uses a no-send transport — the sub-panel only displays data, never sends +# commands back to the host. +_QT_SCAN_PANEL_BOOTSTRAP = r""" + +""" + +# Bootstrap for the *file browser* tab — wires channel.objects.fb_bridge. +# Initialises the shared QWebChannel and connects both bridges. +_QT_FB_BOOTSTRAP = r""" + """ -def get_panel_html() -> str: - """Return the full HTML document served to the Qt webview. - - Delegates to :func:`projspec.webui.get_panel_html`, supplying the - Qt-specific ```` (for ``qwebchannel.js``) and bootstrap script - that installs the QWebChannel-backed transport. - """ - bootstrap = _QT_BOOTSTRAP.replace( - "__CHROME_ICONS_JSON__", - json.dumps(chrome_icons(), separators=(",", ":")), - ) - from projspec import webui - - return webui.get_panel_html( +def get_qt_html(initial_tab: str = "library") -> str: + """Return the full combined HTML document for the Qt webview.""" + chrome_json = json.dumps(chrome_icons(), separators=(",", ":")) + lib_bootstrap = _QT_LIB_BOOTSTRAP.replace("__CHROME_ICONS_JSON__", chrome_json) + return get_combined_html( extra_head=_QT_EXTRA_HEAD, - bootstrap_js=bootstrap, + lib_bootstrap_js=lib_bootstrap, + scan_panel_bootstrap_js=_QT_SCAN_PANEL_BOOTSTRAP, + fb_bootstrap_js=_QT_FB_BOOTSTRAP, + initial_tab=initial_tab, ) -__all__ = ["CHROME", "get_panel_html"] +# Backwards-compat alias used by existing callers +def get_panel_html() -> str: + return get_qt_html(initial_tab="library") + + +__all__ = ["CHROME", "get_panel_html", "get_qt_html"] diff --git a/src/projspec/server.py b/src/projspec/server.py index 6e63653..a647463 100644 --- a/src/projspec/server.py +++ b/src/projspec/server.py @@ -44,11 +44,15 @@ from __future__ import annotations import json +import logging +import os import threading +import time +from pathlib import Path from typing import Any try: - from fastapi import FastAPI + from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from pydantic import BaseModel except ImportError as _e: # pragma: no cover @@ -57,6 +61,39 @@ "Install them with: pip install 'projspec[serve]'" ) from _e +# --------------------------------------------------------------------------- +# File-based logger — writes to the projspec config dir so the PyCharm and +# VS Code plugins can read the same file as the Python-side server logs. +# --------------------------------------------------------------------------- + + +def _log_path() -> Path: + conf_dir = Path( + os.environ.get("PROJSPEC_CONFIG_DIR", Path.home() / ".config" / "projspec") + ) + conf_dir.mkdir(parents=True, exist_ok=True) + return conf_dir / "server.log" + + +def _setup_logging() -> logging.Logger: + logger = logging.getLogger("projspec.server") + if logger.handlers: + return logger + logger.setLevel(logging.DEBUG) + fmt = logging.Formatter( + "%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S" + ) + fh = logging.FileHandler(_log_path(), mode="a", encoding="utf-8") + fh.setFormatter(fmt) + logger.addHandler(fh) + sh = logging.StreamHandler() + sh.setFormatter(fmt) + logger.addHandler(sh) + return logger + + +_log = _setup_logging() + # --------------------------------------------------------------------------- # App # --------------------------------------------------------------------------- @@ -64,6 +101,39 @@ app = FastAPI(title="projspec", docs_url=None, redoc_url=None) +@app.middleware("http") +async def _log_requests(request: Request, call_next): + """Log every request and response with timing.""" + t0 = time.monotonic() + _log.info( + "REQ %s %s body=%db", + request.method, + request.url.path, + int(request.headers.get("content-length", 0)), + ) + try: + response = await call_next(request) + ms = int((time.monotonic() - t0) * 1000) + _log.info( + "RESP %s %s -> %d (%dms)", + request.method, + request.url.path, + response.status_code, + ms, + ) + return response + except Exception as exc: + ms = int((time.monotonic() - t0) * 1000) + _log.error( + "ERR %s %s -> exception %s (%dms)", + request.method, + request.url.path, + exc, + ms, + ) + raise + + # Serialise to JSON ourselves so NaN/Infinity in Python floats become null # (the default FastAPI JSONResponse would raise on those). def _json(obj: Any, **kw) -> JSONResponse: @@ -380,12 +450,7 @@ def fb_bookmark_remove(req: BookmarkRemoveRequest): def run(host: str = "127.0.0.1", port: int = 0, port_file: str | None = None) -> None: - """Start the uvicorn server. - - If *port* is 0 a free port is chosen automatically. When *port_file* is - given the chosen port number is written to that path as a plain integer - string so the caller can discover it. - """ + """Start the uvicorn server.""" import socket import uvicorn @@ -394,14 +459,17 @@ def run(host: str = "127.0.0.1", port: int = 0, port_file: str | None = None) -> s.bind((host, 0)) port = s.getsockname()[1] + _log.info("SERVER starting on %s:%d — log: %s", host, port, _log_path()) + if port_file: import os os.makedirs(os.path.dirname(os.path.abspath(port_file)), exist_ok=True) with open(port_file, "w") as fh: fh.write(str(port)) + _log.info("SERVER port file written: %s", port_file) - uvicorn.run(app, host=host, port=port, log_level="warning") + uvicorn.run(app, host=host, port=port, log_level="info") def main() -> None: diff --git a/src/projspec/textapp/main.py b/src/projspec/textapp/main.py index 78cd02b..fcce97b 100644 --- a/src/projspec/textapp/main.py +++ b/src/projspec/textapp/main.py @@ -53,9 +53,11 @@ ListItem, ListView, Static, + TabbedContent, + TabPane, ) except ImportError as e: - warnings.warn("Texutal is required for the TUI") + warnings.warn("Textual is required for the TUI") App = None @@ -348,7 +350,7 @@ def on_mount(self) -> None: if self._is_local: items = [ ("openVSCode", "Open with VSCode"), - ("openFilebrowser", "Open with system filebrowser"), + ("openFilebrowser", "Show in file browser"), ("openPyCharm", "Open with PyCharm"), ("openJupyter", "Open with jupyter"), ("_sep", ""), @@ -968,6 +970,7 @@ def _expand_glob(pattern: str) -> list[str]: APP_CSS = """ Screen { background: #1e1e1e; } +/* ── Library tab ──────────────────────────────────────────────────────── */ #library-pane { width: 2fr; min-width: 40; border-right: solid #3c3c3c; @@ -988,12 +991,212 @@ def _expand_glob(pattern: str) -> list[str]: #details-list { padding: 1; } +/* ── File browser tab ─────────────────────────────────────────────────── */ +#fb-url-row { height: 3; padding: 0 1; background: #252526; border-bottom: solid #3c3c3c; } +#fb-url-row Input { width: 1fr; } +#fb-url-row Button { min-width: 6; margin-left: 1; } + +#fb-toolbar { height: 3; padding: 0 1; background: #252526; border-bottom: solid #3c3c3c; } +#fb-toolbar Button { margin-right: 1; min-width: 8; } + +#fb-entries-pane { width: 2fr; min-width: 36; border-right: solid #3c3c3c; padding: 0 1; } +#fb-info-pane { width: 3fr; padding: 1; } +#fb-info-title { color: #4fc1ff; text-style: bold; } +#fb-info-body { color: #858585; } + +/* ── Status bar ───────────────────────────────────────────────────────── */ #status { dock: bottom; height: 1; background: #007acc; color: white; padding: 0 1; } """ +# --------------------------------------------------------------------------- +# File browser entry widget +# --------------------------------------------------------------------------- + + +class FbEntry(Static): + """One row in the file browser listing. + + A single click selects the entry; two clicks within 500 ms on a + directory navigate into it (Textual has no native double-click event, + so we implement it via click-time tracking). + """ + + COMPONENT_CLASSES = {"fb-entry--dir", "fb-entry--file"} + + class Selected(Message): + def __init__(self, url: str, entry_type: str) -> None: + super().__init__() + self.url = url + self.entry_type = entry_type + + class NavigateTo(Message): + def __init__(self, url: str) -> None: + super().__init__() + self.url = url + + def __init__(self, entry: dict) -> None: + self._entry = entry + self._url = entry.get("name", "") + self._type = entry.get("type", "file") + name = ( + entry.get("basename") + or self._url.rstrip("/").rsplit("/", 1)[-1] + or self._url + ) + icon = "📁" if self._type == "directory" else "📄" + label = f"{icon} {name}" + super().__init__(label) + self._last_click: float = 0.0 + + def on_click(self) -> None: + import time + + now = time.monotonic() + if self._type == "directory" and (now - self._last_click) < 0.5: + # Two clicks within 500 ms → navigate + self._last_click = 0.0 + self.post_message(FbEntry.NavigateTo(self._url)) + else: + self._last_click = now + self.post_message(FbEntry.Selected(self._url, self._type)) + + +class StorageOptionsModal(ModalScreen[str | None]): + """Edit the current storage-options JSON string. + + Returns the new JSON string (possibly empty) or ``None`` if cancelled. + """ + + DEFAULT_CSS = """ + StorageOptionsModal { align: center middle; } + #so-box { + background: #252526; border: solid #454545; + padding: 1 2; width: 70; height: auto; + } + #so-hint { color: #858585; margin-bottom: 1; } + #so-btn-row { margin-top: 1; height: 3; } + #so-btn-row Button { margin-right: 1; } + """ + + BINDINGS = [Binding("escape", "app.pop_screen()", "Cancel")] + + def __init__(self, current: str = "") -> None: + super().__init__() + self._current = current + + def compose(self) -> ComposeResult: + with Vertical(id="so-box"): + yield Label( + "Storage options (JSON) — fsspec credentials / endpoint " + 'overrides, e.g. {"key":"AKIA…","secret":"…"}. ' + "Leave blank for public / local access.", + id="so-hint", + ) + yield Input( + value=self._current, + placeholder='{"key": "…", "secret": "…"}', + id="so-input", + ) + with Horizontal(id="so-btn-row"): + yield Button("Apply", variant="primary", id="so-ok") + yield Button("Cancel", id="so-cancel") + + def on_mount(self) -> None: + self.query_one("#so-input", Input).focus() + + @on(Input.Submitted, "#so-input") + def _on_submitted(self) -> None: + self._apply() + + @on(Button.Pressed, "#so-ok") + def _on_ok(self) -> None: + self._apply() + + @on(Button.Pressed, "#so-cancel") + def _on_cancel(self) -> None: + self.dismiss(None) + + def _apply(self) -> None: + value = self.query_one("#so-input", Input).value.strip() + if value: + import json as _json + + try: + _json.loads(value) + except ValueError: + self.query_one("#so-hint", Label).update( + "[red]Invalid JSON — please correct it.[/red]" + ) + return + self.dismiss(value) + + +class BookmarksModal(ModalScreen[dict | None]): + """Show saved bookmarks and let the user navigate to one or remove it. + + Returns the URL to navigate to, or ``None`` if closed without selection. + """ + + DEFAULT_CSS = """ + BookmarksModal { align: center middle; } + #bm-box { + background: #252526; border: solid #454545; + padding: 1 2; width: 70; height: auto; max-height: 30; + } + #bm-title { text-style: bold; margin-bottom: 1; } + #bm-list { height: auto; max-height: 20; } + #bm-empty { color: #858585; } + #bm-btn-row { margin-top: 1; height: 3; } + #bm-btn-row Button { margin-right: 1; } + """ + + BINDINGS = [Binding("escape", "app.pop_screen()", "Close")] + + def __init__(self, bookmarks: list[dict], current_url: str = "") -> None: + super().__init__() + self._bookmarks = bookmarks + self._current_url = current_url + # Map safe widget id → full bookmark dict (URLs can't be used as + # Textual widget ids — they contain "://", ".", "/" etc.) + self._idx_to_bm: dict[str, dict] = {} + + def compose(self) -> ComposeResult: + with Vertical(id="bm-box"): + yield Label("★ Bookmarks", id="bm-title") + with VerticalScroll(id="bm-list"): + if not self._bookmarks: + yield Label("No bookmarks yet.", id="bm-empty") + else: + for i, bm in enumerate(self._bookmarks): + url = bm.get("url", "") + label = bm.get("label") or url + wid = f"bm-item-{i}" + self._idx_to_bm[wid] = bm + yield Button(label, id=wid, classes="bm-item") + with Horizontal(id="bm-btn-row"): + yield Button("+ Bookmark current", variant="primary", id="bm-add") + yield Button("Close", id="bm-close") + + def on_mount(self) -> None: + items = self.query(".bm-item") + if items: + items.first().focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + bid = event.button.id or "" + if bid == "bm-close": + self.dismiss(None) + elif bid == "bm-add": + # Return a synthetic bookmark dict for the current location + self.dismiss({"url": self._current_url, "_add": True}) + elif bid in self._idx_to_bm: + self.dismiss(self._idx_to_bm[bid]) + event.stop() + + class ProjspecApp(App): - """Projspec terminal browser - two-pane library + details UI.""" + """Projspec terminal browser — Project Library + File Browser tabs.""" TITLE = "Projspec Browser" CSS = APP_CSS @@ -1003,6 +1206,7 @@ class ProjspecApp(App): Binding("r", "reload", "Reload"), Binding("a", "add", "Add"), Binding("/", "focus_search", "Search"), + Binding("f", "focus_fb_url", "FB URL", show=False), ] status_message: reactive[str] = reactive("Ready", init=False) @@ -1013,42 +1217,74 @@ def __init__(self) -> None: self._enums: dict[str, dict[str, Any]] = {} self._selection: tuple[str, str, str | None] | None = None self._busy = 0 + self._fb_current_url: str = "" + self._fb_selected_url: str = "" + self._fb_selected_type: str = "" + self._fb_storage_options: str = "" # current SO JSON string (empty = none) + self._fb_bookmarks: list[dict] = [] # cached from filebrowser.bookmarks_list() - # ── Layout ───────────────────────────────────────────────────────────── + # ── Layout ────────────────────────────────────────────────────────────── def compose(self) -> ComposeResult: yield Header() - with Horizontal(): - with Vertical(id="library-pane"): - with Horizontal(id="toolbar"): - yield Button( - f"{CHROME_ICONS['add']} Add", - id="btn-add", - variant="primary", - ) - yield Button(f"{CHROME_ICONS['reload']} Reload", id="btn-reload") - yield Button( - f"{CHROME_ICONS['configure']} Configure", - id="btn-configure", - ) - with Horizontal(id="search-row"): - yield Input(placeholder="Search projects", id="search") - btn_clear = Button( - CHROME_ICONS["clear"], id="btn-clear", variant="default" - ) - btn_clear.tooltip = "Clear search" - yield btn_clear - yield VerticalScroll(id="projects") - with Vertical(id="details-pane"): - with Vertical(id="details-header"): - yield Static("Details", id="details-title") - yield Static("", id="details-doc") - yield VerticalScroll(id="details-list") + with TabbedContent(initial="tab-library"): + # ── Project Library tab ───────────────────────────────────── + with TabPane("📚 Project Library", id="tab-library"): + with Horizontal(): + with Vertical(id="library-pane"): + with Horizontal(id="toolbar"): + yield Button( + f"{CHROME_ICONS['add']} Add", + id="btn-add", + variant="primary", + ) + yield Button( + f"{CHROME_ICONS['reload']} Reload", id="btn-reload" + ) + yield Button( + f"{CHROME_ICONS['configure']} Configure", + id="btn-configure", + ) + with Horizontal(id="search-row"): + yield Input(placeholder="Search projects", id="search") + btn_clear = Button( + CHROME_ICONS["clear"], id="btn-clear", variant="default" + ) + btn_clear.tooltip = "Clear search" + yield btn_clear + yield VerticalScroll(id="projects") + with Vertical(id="details-pane"): + with Vertical(id="details-header"): + yield Static("Details", id="details-title") + yield Static("", id="details-doc") + yield VerticalScroll(id="details-list") + # ── File Browser tab ───────────────────────────────────────── + with TabPane("📁 File Browser", id="tab-filebrowser"): + with Vertical(): + with Horizontal(id="fb-toolbar"): + yield Button("⬆ Up", id="btn-fb-up", variant="default") + yield Button( + "↻ Refresh", id="btn-fb-refresh", variant="default" + ) + yield Button("★ Bookmarks", id="btn-fb-bm", variant="default") + yield Button("🔑 SO", id="btn-fb-so", variant="default") + yield Button("+ Lib", id="btn-fb-add-lib", variant="primary") + with Horizontal(id="fb-url-row"): + yield Input(placeholder="Path or URL", id="fb-url-input") + yield Button("Go", id="btn-fb-go", variant="primary") + with Horizontal(): + yield VerticalScroll(id="fb-entries-pane") + with Vertical(id="fb-info-pane"): + yield Static("No selection", id="fb-info-title") + yield Static("", id="fb-info-body") yield Static(self.status_message, id="status") yield Footer() def on_mount(self) -> None: self._reload(initial=True) + # Load bookmarks then kick off the file browser at the home directory + self._fb_load_bookmarks() + self._fb_navigate(os.path.expanduser("~")) def watch_status_message(self, msg: str) -> None: try: @@ -1087,6 +1323,230 @@ def action_focus_search(self) -> None: except Exception: pass + def action_focus_fb_url(self) -> None: + try: + self.query_one("#fb-url-input", Input).focus() + except Exception: + pass + + # ── File browser button handlers ──────────────────────────────────────── + + @on(Button.Pressed, "#btn-fb-go") + def _on_fb_go(self) -> None: + try: + url = self.query_one("#fb-url-input", Input).value.strip() + if url: + self._fb_navigate(url) + except Exception: + pass + + @on(Button.Pressed, "#btn-fb-up") + def _on_fb_up(self) -> None: + if self._fb_current_url: + parent = _url_to_parent(self._fb_current_url) + if parent and parent != self._fb_current_url: + self._fb_navigate(parent) + + @on(Button.Pressed, "#btn-fb-refresh") + def _on_fb_refresh(self) -> None: + if self._fb_current_url: + self._fb_navigate(self._fb_current_url) + + @on(Button.Pressed, "#btn-fb-so") + def _on_fb_so(self) -> None: + """Open the storage-options modal.""" + + def _cb(result: str | None) -> None: + if result is not None: + self._fb_storage_options = result + so_label = " [🔑]" if result else "" + self.status_message = f"Storage options set{so_label}" + + self.push_screen(StorageOptionsModal(self._fb_storage_options), _cb) + + @on(Button.Pressed, "#btn-fb-bm") + def _on_fb_bm(self) -> None: + """Open the bookmarks modal.""" + + def _cb(result: dict | None) -> None: + if not result: + return + url = result.get("url", "") + if not url: + return + if result.get("_add"): + # Save current location as a bookmark + self._fb_bookmark_add(url) + return + # Apply the bookmark's stored storage_options to the session SO, + # then navigate. This is the key step: without it, credentials + # stored on the bookmark are silently ignored. + bm_so = result.get("storage_options") or {} + if bm_so: + self._fb_storage_options = json.dumps(bm_so) + self._fb_navigate(url) + + self.push_screen(BookmarksModal(self._fb_bookmarks, self._fb_current_url), _cb) + + @on(Button.Pressed, "#btn-fb-add-lib") + def _on_fb_add_lib(self) -> None: + if self._fb_selected_url and self._fb_selected_type == "directory": + self._fb_add_to_library(self._fb_selected_url) + + @on(FbEntry.Selected) + def _on_fb_entry_selected(self, event: "FbEntry.Selected") -> None: + self._fb_selected_url = event.url + self._fb_selected_type = event.entry_type + self._fb_show_info(event.url, event.entry_type) + event.stop() + + @on(FbEntry.NavigateTo) + def _on_fb_navigate_to(self, event: "FbEntry.NavigateTo") -> None: + self._fb_navigate(event.url) + event.stop() + + # ── File browser core logic ────────────────────────────────────────────── + + def _fb_load_bookmarks(self) -> None: + try: + from projspec.filebrowser import bookmarks_list + + self._fb_bookmarks = bookmarks_list() + except Exception: + self._fb_bookmarks = [] + + def _fb_so_dict(self) -> dict | None: + """Parse ``self._fb_storage_options`` and return a dict, or None.""" + so_str = self._fb_storage_options + if not so_str: + return None + try: + import json as _json + + return _json.loads(so_str) or None + except Exception: + return None + + def _fb_navigate(self, url: str) -> None: + from projspec.filebrowser import browse + + self._set_busy(True) + try: + so = self._fb_so_dict() + result = browse(url, storage_options=so) + if result.get("error"): + # Show the full diagnostic in the info pane where it's always visible + try: + self.query_one("#fb-info-title", Static).update("Browse error") + self.query_one("#fb-info-body", Static).update( + f"URL: {url!r}\nSO: {so!r}\nError: {result['error']}" + ) + except Exception: + pass + return + self._fb_current_url = result.get("url", url) + try: + self.query_one("#fb-url-input", Input).value = self._fb_current_url + except Exception: + pass + entries_pane = self.query_one("#fb-entries-pane", VerticalScroll) + for child in list(entries_pane.children): + child.remove() + entries = result.get("entries", []) + if not entries: + entries_pane.mount(Static("(empty)", classes="url")) + else: + for entry in entries: + entries_pane.mount(FbEntry(entry)) + try: + self.query_one("#fb-info-title", Static).update("No selection") + self.query_one("#fb-info-body", Static).update("") + except Exception: + pass + self._fb_selected_url = "" + self._fb_selected_type = "" + so_indicator = " [🔑]" if self._fb_storage_options else "" + self.status_message = f"📁 {self._fb_current_url}{so_indicator}" + except Exception as e: + try: + self.query_one("#fb-info-title", Static).update("Browse exception") + self.query_one("#fb-info-body", Static).update( + f"URL: {url!r}\nSO: {self._fb_so_dict()!r}\nException: {e}" + ) + except Exception: + pass + finally: + self._set_busy(False) + + def _fb_show_info(self, url: str, entry_type: str) -> None: + name = url.rstrip("/").rsplit("/", 1)[-1] or url + so = self._fb_so_dict() + try: + info_title = self.query_one("#fb-info-title", Static) + info_body = self.query_one("#fb-info-body", Static) + if entry_type == "directory": + info_title.update(f"📁 {name}") + from projspec.filebrowser import scan_directory + + result = scan_directory(url, storage_options=so) + proj = result.get("project") + if proj and proj.get("specs"): + spec_names = ", ".join(proj["specs"].keys()) + info_body.update(f"Specs: {spec_names}") + else: + info_body.update("(no projspec data)") + else: + from projspec.filebrowser import inspect_file + + result = inspect_file(url, storage_options=so) + mime = result.get("mime_type", "") + size = result.get("size") + parts = [] + if mime: + parts.append(mime) + if size is not None: + parts.append(_fmt_size(size)) + preview = result.get("text_preview", "") + if preview: + parts.append("\n" + preview[:200]) + info_title.update(f"📄 {name}") + info_body.update("\n".join(parts) if parts else "(no info)") + except Exception as e: + try: + self.query_one("#fb-info-body", Static).update(f"Error: {e}") + except Exception: + pass + + def _fb_add_to_library(self, url: str) -> None: + from projspec.filebrowser import add_to_projspec_library + + self._set_busy(True) + try: + result = add_to_projspec_library(url, storage_options=self._fb_so_dict()) + if result.get("error"): + self.status_message = f"Add to library failed: {result['error']}" + else: + self.status_message = f"Added to library: {url}" + self._reload() + try: + self.query_one(TabbedContent).active = "tab-library" + except Exception: + pass + except Exception as e: + self.status_message = f"Add to library failed: {e}" + finally: + self._set_busy(False) + + def _fb_bookmark_add(self, url: str) -> None: + """Bookmark *url* and refresh the cached list.""" + try: + from projspec.filebrowser import bookmark_add + + self._fb_bookmarks = bookmark_add(url, storage_options=self._fb_so_dict()) + self.status_message = f"Bookmarked: {url}" + except Exception as e: + self.status_message = f"Bookmark failed: {e}" + @on(Button.Pressed, "#btn-add") def _on_add(self) -> None: self.action_add() @@ -1349,7 +1809,12 @@ def _cb(key: str | None) -> None: if key == "openVSCode": _spawn_detached(["code", _url_to_local(url)]) elif key == "openFilebrowser": - _open_default(_url_to_local(url)) + # Switch to the File Browser tab and navigate there + try: + self.query_one(TabbedContent).active = "tab-filebrowser" + except Exception: + pass + self._fb_navigate(url) elif key == "openPyCharm": _spawn_detached( [ @@ -1469,6 +1934,33 @@ def _action_info(self, kind: str, klass: str) -> None: self.push_screen(InfoPopupModal(klass, doc)) +def _url_to_parent(url: str) -> str: + """Return the parent directory of *url*.""" + s = url.rstrip("/") + proto_end = s.find("://") + if proto_end >= 0: + path_part = s[proto_end + 3 :] + slash = path_part.rfind("/") + if slash <= 0: + return s[: proto_end + 3] or s + return s[: proto_end + 3 + slash] + slash = s.rfind("/") + return s[:slash] if slash > 0 else "/" + + +def _fmt_size(n) -> str: + """Human-readable file size.""" + try: + n = float(n) + except (TypeError, ValueError): + return "" + for unit in ("B", "KB", "MB", "GB", "TB"): + if n < 1024: + return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}" + n /= 1024 + return f"{n:.1f} PB" + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- diff --git a/src/projspec/webui/__init__.py b/src/projspec/webui/__init__.py index 6bb3fdb..0c09701 100644 --- a/src/projspec/webui/__init__.py +++ b/src/projspec/webui/__init__.py @@ -1,22 +1,28 @@ -"""Shared web UI resources for the projspec Project Library panel. +"""Shared web UI resources for the projspec combined panel. -The VSCode extension, the Qt app, the PyCharm plugin and the ipywidget -representation of :class:`projspec.library.ProjectLibrary` all render the -same two-pane UI (library on the left, details on the right). This package -owns the canonical HTML / CSS / JS / icon set used by all of them. +All GUI hosts (VSCode, Qt, PyCharm, ipywidget) render a combined two-tab +panel: **Project Library** on the left tab (library list + details) and +**File Browser** on the right tab (fsspec directory browser). This package +owns the canonical HTML / CSS / JS for both tabs. Public helpers -------------- +:func:`get_combined_html` + Return a complete HTML document with both tabs. This is the preferred + entry point for all GUI hosts. + :func:`get_panel_html` - Return a complete HTML document string with the shared panel embedded. - Individual hosts pass extra ```` content (e.g. qwebchannel.js) and - a short bootstrap snippet that installs ``window.projspecTransport`` - before the main panel script runs. + Backwards-compatible: return only the library panel document (no tabs). :func:`get_panel_css` / :func:`get_panel_js` - Return the CSS and JS as plain strings, for hosts (PyCharm, ipywidget) - that embed the panel without building a full HTML document. + Return the library panel CSS and JS as plain strings. + +:func:`get_filebrowser_css` / :func:`get_filebrowser_js` + Return the file browser CSS and JS as plain strings. + +:func:`get_tabs_css` / :func:`get_tabs_js` + Return the tab-bar CSS and JS as plain strings. :func:`chrome_icons` Return the chrome emoji map (toolbar icons, kebab glyph, etc.). @@ -24,20 +30,33 @@ Transport contract ------------------ -The shared JS does not know how it is being loaded. The host must define -``window.projspecTransport`` *before* ``panel.js`` runs. The transport is -an object of shape:: +Library panel +~~~~~~~~~~~~~ +The host must define ``window.projspecTransport`` *before* ``panel.js`` runs:: + + { + send: function(msg) { ... }, // JS -> host (cmd vocabulary) + onReady: function(dispatch) { ... }, // host calls dispatch(msg) to push data + } + +File browser panel +~~~~~~~~~~~~~~~~~~ +The host must define ``window.projspecFbTransport`` *before* ``filebrowser.js`` +runs:: { - send: function(msg) { ... }, // JS -> host - onReady: function(dispatch) { ... }, // host calls dispatch(msg) - // for every host -> JS msg + send: function(msg) { ... }, // JS -> host (fb cmd vocabulary) + onReady: function(dispatch) { ... }, // host calls dispatch(msg) to push data } -Messages are plain objects; the command vocabulary matches the VSCode -extension's ``ACTIONS.md``. Outbound (JS -> host) messages carry ``cmd``; -inbound messages carry ``type`` (``data``, ``loading``, or -``openCreateSpecModal``). +Optionally set ``window.projspecFbRoot`` to scope the file browser DOM queries +to a subtree (needed when both panels share a document). + +The embedded scan sub-panel (shown inside the file browser when a directory is +selected) is a second instance of the library panel; its transport is set +separately using ``window.projspecTransport`` (or the equivalent) *before* the +second ``panel.js`` invocation, with ``window.projspecRoot`` pointed at +``#fb-scan-panel-root``. """ from __future__ import annotations @@ -48,9 +67,15 @@ __all__ = [ "chrome_icons", + "get_combined_html", + "get_filebrowser_css", + "get_filebrowser_html", + "get_filebrowser_js", "get_panel_css", "get_panel_html", "get_panel_js", + "get_tabs_css", + "get_tabs_js", "resource_path", ] @@ -58,10 +83,7 @@ def resource_path(name: str) -> Path: - """Return the absolute path to a bundled webui resource. - - ``name`` is a filename inside this package (e.g. ``"panel.css"``). - """ + """Return the absolute path to a bundled webui resource.""" return _HERE / name @@ -77,62 +99,178 @@ def chrome_icons() -> dict[str, str]: @lru_cache(maxsize=1) def get_panel_css() -> str: - """Return the shared panel stylesheet as a plain string.""" + """Return the shared library-panel stylesheet as a plain string.""" return (_HERE / "panel.css").read_text(encoding="utf-8") @lru_cache(maxsize=1) def get_panel_js() -> str: - """Return the shared panel JavaScript as a plain string.""" + """Return the shared library-panel JavaScript as a plain string.""" return (_HERE / "panel.js").read_text(encoding="utf-8") +@lru_cache(maxsize=1) +def get_filebrowser_css() -> str: + """Return the file-browser stylesheet as a plain string.""" + return (_HERE / "filebrowser.css").read_text(encoding="utf-8") + + +@lru_cache(maxsize=1) +def get_filebrowser_js() -> str: + """Return the file-browser JavaScript as a plain string.""" + return (_HERE / "filebrowser.js").read_text(encoding="utf-8") + + +@lru_cache(maxsize=1) +def get_tabs_css() -> str: + """Return the tab-bar / combined-layout stylesheet as a plain string.""" + return (_HERE / "tabs.css").read_text(encoding="utf-8") + + +@lru_cache(maxsize=1) +def get_tabs_js() -> str: + """Return the tab-switching coordination JavaScript as a plain string.""" + return (_HERE / "tabs.js").read_text(encoding="utf-8") + + @lru_cache(maxsize=1) def _html_template() -> str: return (_HERE / "panel.html").read_text(encoding="utf-8") +@lru_cache(maxsize=1) +def _filebrowser_html_template() -> str: + return (_HERE / "filebrowser.html").read_text(encoding="utf-8") + + +def _resolve_panel_body(html_template: str) -> str: + """Resolve icon markers in a panel HTML template and return the body.""" + html = html_template + for key, glyph in chrome_icons().items(): + html = html.replace(f"", glyph) + html = html.replace("/*__CSS__*/", get_panel_css()) + html = html.replace("", "") + html = html.replace("", "") + html = html.replace("", "") + body_start = html.index("") + len("") + body_end = html.rindex("") + return html[body_start:body_end].strip() + + +def _panel_body_html() -> str: + """Return the resolved body HTML of the library panel (no scripts).""" + return _resolve_panel_body(_html_template()) + + def get_panel_html( *, extra_head: str = "", bootstrap_js: str = "", embedded: bool = False, ) -> str: - """Return a self-contained HTML document hosting the projspec panel. + """Return a self-contained HTML document hosting only the library panel. - Parameters - ---------- - extra_head: - Optional HTML injected into the ```` *before* the inline - stylesheet. Hosts that need an external script (for instance - ``qwebchannel.js`` under Qt) should pass it here. - bootstrap_js: - Optional `` +{scan_panel_bootstrap_js} + + +{fb_bootstrap_js} + + + +""" diff --git a/src/projspec/webui/ipywidget.py b/src/projspec/webui/ipywidget.py index 5133eb3..cb4ab72 100644 --- a/src/projspec/webui/ipywidget.py +++ b/src/projspec/webui/ipywidget.py @@ -1,4 +1,4 @@ -"""Jupyter / marimo widget representation of :class:`ProjectLibrary`. +"""Jupyter / marimo combined widget: Project Library + File Browser tabs. This module owns the *host* side of the shared webui transport for the Jupyter Notebook / JupyterLab / VSCode-notebook / Colab / marimo @@ -7,21 +7,17 @@ Python with the same command vocabulary as the VSCode extension and the Qt app. -Only :mod:`anywidget` is required — :mod:`ipywidgets` is **not** needed, -which means the widget runs under marimo as well as classic Jupyter. +Only :mod:`anywidget` is required — :mod:`ipywidgets` is **not** needed. -Current limitations -------------------- +The widget now hosts two tabs: -The shared JS uses ``document.getElementById`` with global IDs (``#app``, -``#projects``, etc.), which is fine for a single widget per notebook but -means two widgets on the same page would fight for those IDs. This -matches the existing VSCode/Qt/PyCharm design; scoping is a follow-up. +* **Project Library** — the searchable library list + details panel. +* **File Browser** — an fsspec-backed directory browser with bookmarks, + file info, dataset summaries, and "Add to Library" integration. -The widget is interactive: the toolbar's *Add / Reload / Configure* -buttons, the kebab menu (Rescan / Create spec / Remove from library / -Open with …), and the per-artifact ``Make`` button all round-trip to the -Python kernel and modify the underlying :class:`ProjectLibrary`. +Filebrowser commands are tagged with ``_fb: True`` in the message envelope +so the single ``msg:custom`` channel can route them to the correct Python +handler without ambiguity. """ from __future__ import annotations @@ -29,150 +25,214 @@ import json from typing import TYPE_CHECKING, Any -from projspec.webui import chrome_icons, get_panel_css, get_panel_js - -if TYPE_CHECKING: # pragma: no cover - type-only import +from projspec.webui import ( + chrome_icons, + get_filebrowser_css, + get_filebrowser_js, + get_panel_css, + get_panel_js, + get_tabs_css, + get_tabs_js, +) + +if TYPE_CHECKING: # pragma: no cover from projspec.library import ProjectLibrary # --------------------------------------------------------------------------- -# ESM module text (anywidget _esm) +# ESM module text # --------------------------------------------------------------------------- -# -# anywidget loads ``_esm`` as a JavaScript module whose default export is -# ``{render({model, el}) -> cleanup?}``. We embed the shared panel HTML -# into ``el``, install a transport that proxies to the widget's -# ``model.send`` / ``model.on('msg:custom', ...)``, and then execute the -# shared panel script. -# -# Each render re-installs the transport on ``window.projspecTransport`` and -# re-runs the panel JS. See module docstring for the single-instance -# caveat this carries. -# -# The panel HTML must be inserted inline (not via an iframe) so the host -# CSS variables propagate from the hosting notebook / JupyterLab theme. - -# NOTE: kept as a triple-quoted Python string. The JS template-string -# placeholders ${...} inside the code below are the *JavaScript* kind and -# must stay literal - we use a non-f-string so Python does not touch them. _ESM_TEMPLATE = r""" const PANEL_HTML_BODY = __PANEL_HTML_BODY__; -const PANEL_CSS = __PANEL_CSS__; -const PANEL_JS = __PANEL_JS__; +const FB_HTML_BODY = __FB_HTML_BODY__; +const PANEL_CSS = __PANEL_CSS__; +const PANEL_JS = __PANEL_JS__; +const FB_CSS = __FB_CSS__; +const FB_JS = __FB_JS__; +const TABS_CSS = __TABS_CSS__; +const TABS_JS = __TABS_JS__; const CHROME_ICONS = __CHROME_ICONS__; +const INITIAL_TAB = __INITIAL_TAB__; + +// CSS variable fallbacks so --vscode-* tokens resolve in notebook environments +// that don't provide them (JupyterLab, Colab, VS Code notebooks, marimo). +// Values match the Darcula / VS Code dark palette used across all other hosts. +const THEME_CSS = ` +:root { + --vscode-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --vscode-editor-font-family: "JetBrains Mono", Consolas, Menlo, monospace; + --vscode-foreground: #cccccc; + --vscode-editor-background: #1e1e1e; + --vscode-editorWidget-background: #252526; + --vscode-editorWidget-foreground: #cccccc; + --vscode-editorWidget-border: #454545; + --vscode-panel-border: #3c3c3c; + --vscode-focusBorder: #007acc; + --vscode-descriptionForeground: #858585; + --vscode-button-background: #0e639c; + --vscode-button-foreground: #ffffff; + --vscode-button-hoverBackground: #1177bb; + --vscode-button-secondaryBackground: #3a3d41; + --vscode-button-secondaryForeground: #cccccc; + --vscode-input-background: #3c3c3c; + --vscode-input-foreground: #cccccc; + --vscode-input-border: #3c3c3c; + --vscode-list-hoverBackground: #2a2d2e; + --vscode-list-activeSelectionBackground: #094771; + --vscode-list-activeSelectionForeground: #ffffff; + --vscode-menu-background: #252526; + --vscode-menu-foreground: #cccccc; + --vscode-menu-border: #454545; + --vscode-menu-selectionBackground: #094771; + --vscode-menu-selectionForeground: #ffffff; + --vscode-menu-separatorBackground: #454545; + --vscode-toolbar-hoverBackground: #2a2d2e; + --vscode-disabledForeground: #858585; + --vscode-textLink-foreground: #3794ff; + --vscode-textBlockQuote-background: rgba(255,255,255,0.06); + --vscode-symbolIcon-propertyForeground: #cccccc; + --vscode-symbolIcon-stringForeground: #ce9178; + --vscode-symbolIcon-numberForeground: #b5cea8; + --vscode-symbolIcon-keywordForeground: #569cd6; + --vscode-symbolIcon-enumeratorMemberForeground: #4ec9b0; + --vscode-editorInfo-foreground: #3794ff; + --vscode-editorInfo-border: #3794ff; + --vscode-editorWarning-foreground: #cca700; + --vscode-editorWarning-border: #cca700; + --vscode-errorForeground: #f48771; + --vscode-badge-background: #4d4d4d; + --vscode-badge-foreground: #ffffff; + --vscode-editorGroupHeader-tabsBackground: #252526; +} +`; export function render({ model, el }) { - // Per-widget root element. We install it on ``window.projspecRoot`` - // before evaluating the shared panel script; ``panel.js`` scopes every - // element lookup to that root so multiple widget instances (or stale - // nodes from an earlier render) don't fight over the same global IDs. + // ── Root container ────────────────────────────────────────────────── const root = document.createElement('div'); root.className = 'projspec-root'; root.style.width = '100%'; - root.innerHTML = PANEL_HTML_BODY; - // Install the chrome-icons map and the scoped-root reference the panel - // script reads at startup. + root.style.height = '600px'; + + const styleEl = document.createElement('style'); + styleEl.textContent = THEME_CSS + '\n' + TABS_CSS + '\n' + PANEL_CSS + '\n' + FB_CSS; + root.appendChild(styleEl); + + // Build tab structure + const tabBar = document.createElement('div'); + tabBar.id = 'tab-bar'; + tabBar.innerHTML = + '' + + ''; + root.appendChild(tabBar); + + const libPane = document.createElement('div'); + libPane.id = 'tab-library'; + libPane.className = 'tab-pane hidden'; + libPane.innerHTML = PANEL_HTML_BODY; + root.appendChild(libPane); + + const fbPane = document.createElement('div'); + fbPane.id = 'tab-filebrowser'; + fbPane.className = 'tab-pane hidden'; + fbPane.innerHTML = FB_HTML_BODY; + root.appendChild(fbPane); + window.__PROJSPEC_CHROME_ICONS__ = CHROME_ICONS; - window.projspecRoot = root; - - // Transport: JS -> kernel via model.send; kernel -> JS via - // msg:custom. The panel script will call transport.onReady(dispatch) - // exactly once; we stash the dispatcher so late-arriving messages - // are delivered correctly. - let dispatcher = null; - const inbox = []; - function onHostMessage(msg) { - if (dispatcher) dispatcher(msg); - else inbox.push(msg); - } - model.on('msg:custom', onHostMessage); + // ── Library transport ────────────────────────────────────────────── + let libDispatch = null; + const libInbox = []; + function onLibMessage(raw) { + if (raw._fb) return; + if (libDispatch) libDispatch(raw); else libInbox.push(raw); + } + model.on('msg:custom', onLibMessage); + window.projspecRoot = libPane; window.projspecTransport = { send: (msg) => model.send(msg), - onReady: (dispatch) => { - dispatcher = dispatch; - while (inbox.length) dispatcher(inbox.shift()); + onReady: (d) => { libDispatch = d; while (libInbox.length) libDispatch(libInbox.shift()); }, + }; + try { new Function(PANEL_JS).call(window); } catch (e) { console.error('panel.js:', e); } + + // ── Scan sub-panel transport ──────────────────────────────────────── + const scanRoot = fbPane.querySelector('#fb-scan-panel-root'); + let fbPanelDispatch = null; + const fbPanelInbox = []; + window.__fbPanelDeliver = function(msg) { + if (fbPanelDispatch) fbPanelDispatch(msg); else fbPanelInbox.push(msg); + }; + window.projspecRoot = scanRoot; + window.projspecTransport = { + send: function() {}, + onReady: function(d) { + fbPanelDispatch = d; + fbPanelInbox.forEach(m => d(m)); fbPanelInbox.length = 0; + delete window.projspecRoot; delete window.projspecTransport; }, }; + try { new Function(PANEL_JS).call(window); } catch (e) { console.error('scan panel.js:', e); } + + // ── Filebrowser transport ────────────────────────────────────────── + let fbDispatch = null; + const fbInbox = []; + function onFbMessage(raw) { + if (!raw._fb) return; + const msg = Object.assign({}, raw); delete msg._fb; + if (fbDispatch) fbDispatch(msg); else fbInbox.push(msg); + } + model.on('msg:custom', onFbMessage); + window.projspecFbRoot = fbPane; + window.projspecFbTransport = { + send: (msg) => model.send(Object.assign({}, msg, {_fb: true})), + onReady: (d) => { fbDispatch = d; while (fbInbox.length) fbDispatch(fbInbox.shift()); }, + }; + try { new Function(FB_JS).call(window); } catch (e) { console.error('filebrowser.js:', e); } - // Scoped stylesheet (per-widget ` block (full CSS) and a `` - # block (full JS) inside the body; strip both. - body_start = html.find("") - body_end = html.rfind("") - if body_start < 0 or body_end < 0: - raise RuntimeError("panel.html missing ..") - body_inner = html[body_start + len("") : body_end] - # get_panel_html emits in , but the body also - # ends with - strip that. We find the *last* - # pair to avoid chewing on inline bootstrap JS - # (there's none here). - last_script = body_inner.rfind("", last_script) + len("") :] - ) + panel_body = _panel_body_html() + fb_body = get_filebrowser_html(panel_body) return ( - _ESM_TEMPLATE.replace("__PANEL_HTML_BODY__", json.dumps(body_inner)) + _ESM_TEMPLATE.replace("__PANEL_HTML_BODY__", json.dumps(panel_body)) + .replace("__FB_HTML_BODY__", json.dumps(fb_body)) .replace("__PANEL_CSS__", json.dumps(get_panel_css())) .replace("__PANEL_JS__", json.dumps(get_panel_js())) - .replace("__CHROME_ICONS__", json.dumps(icons)) + .replace("__FB_CSS__", json.dumps(get_filebrowser_css())) + .replace("__FB_JS__", json.dumps(get_filebrowser_js())) + .replace("__TABS_CSS__", json.dumps(get_tabs_css())) + .replace("__TABS_JS__", json.dumps(get_tabs_js())) + .replace("__CHROME_ICONS__", json.dumps(chrome_icons())) + .replace("__INITIAL_TAB__", json.dumps(initial_tab)) ) @@ -182,14 +242,10 @@ def _build_esm() -> str: def _build_widget(library: "ProjectLibrary"): - """Construct the anywidget-backed DOMWidget for ``library``. - - Import is deferred so :mod:`projspec.library` stays usable in - environments where :mod:`anywidget` is not installed. - """ + """Construct the anywidget-backed DOMWidget for ``library``.""" try: import anywidget - except ImportError as exc: # pragma: no cover - optional dep + except ImportError as exc: # pragma: no cover raise ImportError( "The ipywidget representation of ProjectLibrary requires the " "'anywidget' package. Install it with " @@ -198,21 +254,11 @@ def _build_widget(library: "ProjectLibrary"): ) from exc class ProjectLibraryWidget(anywidget.AnyWidget): - """Interactive ipywidget rendering of a :class:`ProjectLibrary`. - - Mirrors the vsextension / qtapp UI: Library list on the left, - Details on the right. Commands (add, reload, rescan, make, …) - are round-tripped to the Python kernel and modify the underlying - library in place. - """ + """Combined Library + File Browser widget.""" _esm = _build_esm() - # CSS is embedded in the ESM; anywidget ignores an empty _css. _css = "" - # No traitlets state: the widget exchanges messages via - # ``send`` / ``msg:custom`` instead of syncing a model attribute. - def __init__(self, library_obj: "ProjectLibrary", **kwargs: Any): super().__init__(**kwargs) self._library = library_obj @@ -220,92 +266,402 @@ def __init__(self, library_obj: "ProjectLibrary", **kwargs: Any): self._enum_members: dict[str, Any] = {} self.on_msg(self._on_frontend_message) - # --- Inbound frontend -> Python --------------------------------- + # --- Routing -------------------------------------------------------- def _on_frontend_message( self, _widget: Any, content: Any, _buffers: Any ) -> None: if not isinstance(content, dict): return - cmd = content.get("cmd") try: - if cmd == "ready": - self._send_initial_data() - elif cmd == "reload": - self._reload() - elif cmd == "add": - self._offer_add() - elif cmd == "addConfirmed": - self._add_confirmed( - content.get("path", ""), - content.get("storageOptions", ""), - ) - elif cmd == "configure": - _open_config_file(self._toast) - elif cmd == "rescan": - self._rescan(content.get("url", "")) - elif cmd == "createSpec": - self._offer_create_spec(content.get("url", "")) - elif cmd == "createSpecConfirmed": - self._create_spec_confirmed( - content.get("url", ""), content.get("spec", "") - ) - elif cmd == "removeFromLibrary": - url = content.get("url", "") - self._library.entries.pop(url, None) - if self._library.auto_save: - self._library.save() - self._send_initial_data() - elif cmd == "make": - self._make( - content.get("url", ""), - content.get("spec"), - content.get("artifactType", ""), - content.get("name"), - ) - elif cmd == "openWith": - _open_with( - content.get("tool", ""), - content.get("url", ""), - self._toast, + if content.get("_fb"): + # Strip the tag before dispatching + msg = {k: v for k, v in content.items() if k != "_fb"} + self._on_fb_message(msg) + else: + self._on_lib_message(content) + except Exception as exc: + self._toast(f"{content.get('cmd')}: {exc!r}") + + # --- Library messages ----------------------------------------------- + def _on_lib_message(self, content: dict) -> None: + cmd = content.get("cmd") + if cmd == "ready": + self._send_initial_data() + elif cmd == "reload": + self._reload() + elif cmd == "add": + self._offer_add() + elif cmd == "addConfirmed": + self._add_confirmed( + content.get("path", ""), content.get("storageOptions", "") + ) + elif cmd == "configure": + _open_config_file(self._toast) + elif cmd == "rescan": + self._rescan(content.get("url", "")) + elif cmd == "createSpec": + self._offer_create_spec(content.get("url", "")) + elif cmd == "createSpecConfirmed": + self._create_spec_confirmed( + content.get("url", ""), content.get("spec", "") + ) + elif cmd == "removeFromLibrary": + url = content.get("url", "") + self._library.entries.pop(url, None) + if self._library.auto_save: + self._library.save() + self._send_initial_data() + elif cmd == "make": + self._make( + content.get("url", ""), + content.get("spec"), + content.get("artifactType", ""), + content.get("name"), + ) + elif cmd == "openWith": + tool = content.get("tool", "") + url = content.get("url", "") + if tool == "filebrowser": + # Switch to file browser tab and navigate there + self.send({"_switch_tab": "filebrowser"}) + self.send( + { + "_fb": True, + "type": "browseResult", + "pushHistory": False, + "storageOptions": "", + **_fb_browse_data(url), + } ) - elif cmd == "revealFile": - _reveal_file(content.get("fn", ""), self._toast) - elif cmd == "copyToLocal": - self._toast("Copy to local: not implemented") - except Exception as exc: # log but never raise into the kernel - self._toast(f"{cmd}: {exc!r}") - - # --- Outbound Python -> frontend -------------------------------- - def _send_initial_data(self) -> None: + else: + _open_with(tool, url, self._toast) + elif cmd == "revealFile": + _reveal_file(content.get("fn", ""), self._toast) + elif cmd == "copyToLocal": + self._toast("Copy to local: not implemented") + + # --- Filebrowser messages ------------------------------------------- + def _on_fb_message(self, content: dict) -> None: + import os + + cmd = content.get("cmd") + if cmd == "ready": + self._fb_init() + elif cmd == "browse": + so = _parse_so(content.get("storageOptions")) + data = _fb_browse_data(content["url"], so) + self._fb_send( + { + "type": "browseResult", + "pushHistory": content.get("push", True), + "storageOptions": json.dumps(so) if so else "", + **data, + } + ) + elif cmd == "inspect": + self._fb_inspect( + content["url"], _parse_so(content.get("storageOptions")) + ) + elif cmd == "scanDir": + self._fb_scan_dir( + content["url"], _parse_so(content.get("storageOptions")) + ) + elif cmd == "expandDir": + so = _parse_so(content.get("storageOptions")) + from projspec.filebrowser import browse + + data = browse(content["url"], storage_options=so) + self._fb_send( + {"type": "expandResult", "parentUrl": content["url"], **data} + ) + elif cmd == "openFile": + self._fb_open_file( + content["url"], _parse_so(content.get("storageOptions")) + ) + elif cmd == "writeFile": + self._fb_write_file( + content["url"], + content["content"], + _parse_so(content.get("storageOptions")), + ) + elif cmd == "createFile": + self._fb_create_file( + content["parentUrl"], + content["name"], + _parse_so(content.get("storageOptions")), + ) + elif cmd == "deleteEntry": + self._fb_delete_entry( + content["url"], + content.get("isDir", False), + _parse_so(content.get("storageOptions")), + ) + elif cmd == "renameEntry": + self._fb_rename_entry( + content["url"], + content["newName"], + _parse_so(content.get("storageOptions")), + ) + elif cmd == "mkdir": + self._fb_mkdir( + content["parentUrl"], + content["name"], + _parse_so(content.get("storageOptions")), + ) + elif cmd == "addBookmark": + self._fb_bookmark_add( + content["url"], + content.get("label"), + _parse_so(content.get("storageOptions")), + ) + elif cmd == "removeBookmark": + self._fb_bookmark_remove(content["url"]) + elif cmd == "addToLibrary": + self._fb_add_to_library( + content["url"], _parse_so(content.get("storageOptions")) + ) + elif cmd == "goToUrl": + so = _parse_so(content.get("storageOptions")) + from projspec.filebrowser import browse + + data = browse(content["url"], storage_options=so) + self._fb_send( + { + "type": "browseResult", + "pushHistory": True, + "storageOptions": json.dumps(so) if so else "", + **data, + } + ) + + def _fb_send(self, msg: dict) -> None: + """Send a message to the filebrowser tab.""" + self.send({**msg, "_fb": True}) + + def _fb_init(self) -> None: + import os + from projspec.filebrowser import bookmarks_list, supported_protocols + + bms = bookmarks_list() + protos = supported_protocols() + lib_urls = list(self._library.entries.keys()) + self._fb_send( + { + "type": "init", + "bookmarks": bms, + "protocols": protos, + "libraryUrls": lib_urls, + } + ) + # Navigate to home + from projspec.filebrowser import browse + + data = browse(os.path.expanduser("~")) + self._fb_send( + { + "type": "browseResult", + "pushHistory": False, + "storageOptions": "", + **data, + } + ) + + def _fb_inspect(self, url: str, so=None) -> None: + from projspec.filebrowser import inspect_as_project + + data = inspect_as_project(url, storage_options=so) + self._fb_send({"type": "inspectResult", **data}) + self._fb_send( + { + "type": "projectScanned", + "url": url, + "project": data.get("project"), + "error": data.get("error"), + "text_preview": data.get("text_preview"), + "info": self._info_data, + "enums": self._enum_members, + } + ) + + def _fb_scan_dir(self, url: str, so=None) -> None: + from projspec.filebrowser import scan_directory + + data = scan_directory(url, storage_options=so) + self._fb_send( + { + "type": "projectScanned", + "info": self._info_data, + "enums": self._enum_members, + **data, + } + ) + + def _fb_open_file(self, url: str, so=None) -> None: + import os, tempfile + + local = _url_to_local(url) + if os.path.exists(local): + _open_with_default(local, self._toast) + return + from projspec.filebrowser import read_file + + result = read_file(url, storage_options=so) + if result.get("error"): + self._toast(f"Open file: {result['error']}") + return + ext = os.path.splitext(url)[1] or ".txt" + tmp = tempfile.NamedTemporaryFile( + "w", suffix=ext, delete=False, encoding="utf-8" + ) + tmp.write(result.get("content", "")) + tmp.close() + _open_with_default(tmp.name, self._toast) + + def _fb_write_file(self, url: str, content: str, so=None) -> None: + from projspec.filebrowser import write_file, browse + + result = write_file(url, content, storage_options=so) + if result.get("error"): + self._toast(f"Write: {result['error']}") + return + parent = url.rstrip("/").rsplit("/", 1)[0] or "/" + data = browse(parent, storage_options=so) + self._fb_send( + { + "type": "browseResult", + "pushHistory": False, + "storageOptions": json.dumps(so) if so else "", + **data, + } + ) + + def _fb_create_file(self, parent_url: str, name: str, so=None) -> None: + from projspec.filebrowser import write_file, browse + + new_url = parent_url.rstrip("/") + "/" + name + result = write_file(new_url, "", storage_options=so) + if result.get("error"): + self._toast(f"Create: {result['error']}") + return + data = browse(parent_url, storage_options=so) + self._fb_send( + { + "type": "browseResult", + "pushHistory": False, + "storageOptions": json.dumps(so) if so else "", + **data, + } + ) + + def _fb_delete_entry(self, url: str, is_dir: bool, so=None) -> None: + from projspec.filebrowser import delete, browse + + result = delete(url, storage_options=so, recursive=is_dir) + if result.get("error"): + self._toast(f"Delete: {result['error']}") + return + parent = url.rstrip("/").rsplit("/", 1)[0] or "/" + data = browse(parent, storage_options=so) + self._fb_send( + { + "type": "browseResult", + "pushHistory": False, + "storageOptions": json.dumps(so) if so else "", + **data, + } + ) + + def _fb_rename_entry(self, url: str, new_name: str, so=None) -> None: + from projspec.filebrowser import move, browse + + parent = url.rstrip("/").rsplit("/", 1)[0] or "/" + dst = parent.rstrip("/") + "/" + new_name + result = move(url, dst, storage_options=so) + if result.get("error"): + self._toast(f"Rename: {result['error']}") + return + data = browse(parent, storage_options=so) + self._fb_send( + { + "type": "browseResult", + "pushHistory": False, + "storageOptions": json.dumps(so) if so else "", + **data, + } + ) + + def _fb_mkdir(self, parent_url: str, name: str, so=None) -> None: + from projspec.filebrowser import mkdir, browse + + new_url = parent_url.rstrip("/") + "/" + name + result = mkdir(new_url, storage_options=so) + if result.get("error"): + self._toast(f"New folder: {result['error']}") + return + data = browse(parent_url, storage_options=so) + self._fb_send( + { + "type": "browseResult", + "pushHistory": False, + "storageOptions": json.dumps(so) if so else "", + **data, + } + ) + + def _fb_bookmark_add(self, url: str, label=None, so=None) -> None: + from projspec.filebrowser import bookmark_add + + bms = bookmark_add(url, label=label or "", storage_options=so) + self._fb_send({"type": "bookmarksUpdated", "bookmarks": bms}) + + def _fb_bookmark_remove(self, url: str) -> None: + from projspec.filebrowser import bookmark_remove + + bms = bookmark_remove(url) + self._fb_send({"type": "bookmarksUpdated", "bookmarks": bms}) + + def _fb_add_to_library(self, url: str, so=None) -> None: + from projspec.filebrowser import add_to_projspec_library + + result = add_to_projspec_library(url, storage_options=so) + if result.get("error"): + self._toast(f"Add to library: {result['error']}") + return + self._toast(f"Added to library: {url}") + # Refresh filebrowser library badges + self._fb_send( + { + "type": "libraryUrlsUpdated", + "libraryUrls": list(self._library.entries.keys()), + } + ) + # Reload library tab and select the new entry + self._send_initial_data(select_url=url) + # Tell the frontend to switch to the library tab + self.send({"_switch_tab": "library"}) + + # --- Outbound Python -> library frontend ---------------------------- + def _send_initial_data(self, select_url: str | None = None) -> None: from projspec.utils import class_infos if not self._info_data: self._info_data = class_infos() self._enum_members = _collect_enum_members() - lib_dict = { url: proj.to_dict(compact=False) for url, proj in self._library.entries.items() } - self.send( - { - "type": "data", - "info": self._info_data, - "enums": self._enum_members, - "library": lib_dict, - } - ) + msg: dict = { + "type": "data", + "info": self._info_data, + "enums": self._enum_members, + "library": lib_dict, + } + if select_url: + msg["selectUrl"] = select_url + self.send(msg) def _reload(self) -> None: - """Re-read the on-disk library, but only when doing so won't - destroy in-memory state. - - ``ProjectLibrary.load()`` resets ``self.entries = {}`` when the - backing file is missing, which would wipe an in-memory-only - library (the common case when the widget is driven from user - code, e.g. ``library.add_entry(...)`` in a cell). We only - reload from disk when a backing file actually exists. - """ import os path = self._library.path @@ -317,18 +673,10 @@ def _set_busy(self, busy: bool) -> None: self.send({"type": "loading", "loading": bool(busy)}) def _toast(self, message: str) -> None: - """Surface a short status message to the user. - - In a notebook we just print so the message lands in the cell - output; we deliberately do not raise. The Qt app uses a modal - dialog here; the widget has no equivalent without more JS. - """ print(f"[projspec] {message}") - # --- Action helpers -------------------------------------------- + # --- Action helpers (library) -------------------------------------- def _offer_add(self) -> None: - """Ask the frontend to open the text-entry modal for a new - project path.""" self.send({"type": "openAddModal"}) def _add_confirmed(self, path: str, storage_options: str = "") -> None: @@ -363,25 +711,6 @@ def _add_confirmed(self, path: str, storage_options: str = "") -> None: self._set_busy(False) def _resolve_entry_path(self, url: str) -> str | None: - """Return the path used to re-open the library entry *url*. - - The path must keep its protocol so remote projects (``memory://``, - ``s3://``, …) re-open against the right filesystem. We prefer the - library key *url* when it already carries a protocol - it is the - authoritative, protocol-qualified identifier the UI holds, and is - reliable even when an older serialised library reconstructed the - entry's filesystem as local. Otherwise we use the stored project's - protocol-qualified URL (``fs.unstrip_protocol(proj.url)``), since - ``proj.path``/``proj.url`` have the protocol stripped by - ``fsspec.url_to_fs`` (e.g. ``/proj`` for ``memory://proj``) and a - bare path would resolve against the *local* filesystem. - - The library key is otherwise an opaque identity used by the UI; - reusing it as a path breaks for entries keyed on a basename or - relative sub-path (e.g. walked children added under the library - root), so we only fall back to ``_url_to_local`` when there is no - matching entry. - """ if url and "://" in url: return url proj = self._library.entries.get(url) @@ -390,32 +719,13 @@ def _resolve_entry_path(self, url: str) -> str | None: return proj.fs.unstrip_protocol(proj.url) except Exception: return proj.path - # Fall back to the URL, minus any ``file://`` scheme prefix, so - # the caller still gets *something* usable when there is no - # matching entry (e.g., the UI is about to create one). return _url_to_local(url) if url else None def _entry_storage_options(self, url: str) -> dict: - """Storage options stored on the library entry *url* (or ``{}``). - - Remote projects (s3://, gcs://, authenticated http, …) need their - ``storage_options`` to be re-supplied when reconstructing the - ``Project`` on rescan, otherwise the filesystem access fails. - """ proj = self._library.entries.get(url) return dict(getattr(proj, "storage_options", None) or {}) def _rescan(self, url: str) -> None: - """Re-run ``Project(...)`` for the entry *url* and replace it. - - The library key is preserved verbatim so the UI's identity for - the entry does not drift (the JS selection state is keyed on - that url). Crucially, the *path* used for the new ``Project`` - is taken from the stored entry's ``proj.path`` - not from the - library key - because library keys may be opaque identifiers - (e.g. a walked child's basename) that would resolve against - the kernel's cwd if passed to ``Project(...)``. - """ import projspec if not url: @@ -427,12 +737,8 @@ def _rescan(self, url: str) -> None: self._set_busy(True) try: proj = projspec.Project( - path, - walk=False, - storage_options=self._entry_storage_options(url), + path, walk=False, storage_options=self._entry_storage_options(url) ) - # Keep the *original* library key so we don't duplicate the - # entry under a different protocol prefix. self._library.entries[url] = proj if self._library.auto_save: self._library.save() @@ -468,7 +774,6 @@ def _create_spec_confirmed(self, url: str, spec: str) -> None: proj = projspec.Project(path, walk=False, storage_options=so) proj.create(spec) fresh = projspec.Project(path, walk=False, storage_options=so) - # Same key-preservation rule as _rescan. self._library.entries[url] = fresh if self._library.auto_save: self._library.save() @@ -477,34 +782,21 @@ def _create_spec_confirmed(self, url: str, spec: str) -> None: self._set_busy(False) def _make( - self, - url: str, - spec: str | None, - artifact_type: str, - name: str | None, + self, url: str, spec: str | None, artifact_type: str, name: str | None ) -> None: + import os + qname = ".".join(p for p in (spec, artifact_type, name) if p) proj = self._library.entries.get(url) if proj is None: self._toast(f"Project not found: {url}") return - # Guard against a Project whose stored ``path`` is not absolute - # (possible for library entries that were keyed under a walked - # child's basename pre-fix). Artifacts launch subprocesses with - # ``cwd=self.proj.path``; a relative cwd would resolve against - # the kernel's working directory - i.e. the notebook's launch - # directory - instead of the project's own location. Absolutize - # against the library key (which, for entries added via the - # widget, is the project's ``unstrip_protocol(url)``). - import os - if proj.path and not os.path.isabs(proj.path): fallback = _url_to_local(url) if os.path.isabs(fallback): proj.path = fallback else: proj.path = os.path.abspath(proj.path) - # Keep proj.url in sync so any fs.ls() calls still work. proj.url = proj.path self._set_busy(True) try: @@ -526,6 +818,27 @@ def _make( # dialog, etc.) are handled via additional frontend messages above. +def _parse_so(storage_options) -> dict | None: + """Parse a storage_options value that may be a JSON string, dict, or None.""" + if not storage_options: + return None + if isinstance(storage_options, dict): + return storage_options or None + if isinstance(storage_options, str): + try: + return json.loads(storage_options) or None + except Exception: + return None + return None + + +def _fb_browse_data(url: str, so: dict | None = None) -> dict: + """Return browse() result dict for a URL.""" + from projspec.filebrowser import browse + + return browse(url, storage_options=so) + + def _url_to_local(url: str) -> str: """Strip a leading ``file://`` from *url* so the result is a plain path.""" if url.startswith("file://"): @@ -573,24 +886,10 @@ def _open_with_default(path: str, toast) -> None: def _open_with(tool: str, url: str, toast) -> None: - """Dispatch the *Open with …* kebab-menu choices. - - Supported tools match the other projspec UIs: - - ``vscode`` - ``code `` - ``filebrowser`` - OS file manager (``xdg-open`` / ``open`` / Explorer) - ``pycharm`` - ``pycharm nosplash dontReopenProjects`` - ``jupyter`` - ``jupyter lab `` - """ + """Dispatch the *Open with …* kebab-menu choices.""" local = _url_to_local(url) if tool == "vscode": _spawn_detached(["code", local], toast) - elif tool == "filebrowser": - _open_with_default(local, toast) elif tool == "pycharm": _spawn_detached(["pycharm", local, "nosplash", "dontReopenProjects"], toast) elif tool == "jupyter": From 2538eed0d4b5e5326b5e03dcb1596129bbf9d805 Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Wed, 8 Jul 2026 12:30:30 -0400 Subject: [PATCH 10/13] Add missing file --- src/projspec/server.py | 3 +- src/projspec/webui/filebrowser.css | 627 ++++++++++++++++ src/projspec/webui/filebrowser.html | 125 ++++ src/projspec/webui/filebrowser.js | 1080 +++++++++++++++++++++++++++ src/projspec/webui/tabs.css | 76 ++ src/projspec/webui/tabs.js | 51 ++ 6 files changed, 1960 insertions(+), 2 deletions(-) create mode 100644 src/projspec/webui/filebrowser.css create mode 100644 src/projspec/webui/filebrowser.html create mode 100644 src/projspec/webui/filebrowser.js create mode 100644 src/projspec/webui/tabs.css create mode 100644 src/projspec/webui/tabs.js diff --git a/src/projspec/server.py b/src/projspec/server.py index a647463..679ee42 100644 --- a/src/projspec/server.py +++ b/src/projspec/server.py @@ -43,6 +43,7 @@ from __future__ import annotations +from contextlib import asynccontextmanager import json import logging import os @@ -77,8 +78,6 @@ def _log_path() -> Path: def _setup_logging() -> logging.Logger: logger = logging.getLogger("projspec.server") - if logger.handlers: - return logger logger.setLevel(logging.DEBUG) fmt = logging.Formatter( "%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S" diff --git a/src/projspec/webui/filebrowser.css b/src/projspec/webui/filebrowser.css new file mode 100644 index 0000000..fd4cbb6 --- /dev/null +++ b/src/projspec/webui/filebrowser.css @@ -0,0 +1,627 @@ +/* reset / base */ +*, *::before, *::after { box-sizing: border-box; } +body { margin: 0; padding: 0; + font-family: var(--vscode-font-family); + color: var(--vscode-foreground); + background: var(--vscode-editor-background); + font-size: 13px; +} + +/* layout */ +#fb-app { + display: flex; + height: 100vh; + overflow: hidden; +} +#fb-tree-pane { + width: 45%; + min-width: 260px; + max-width: 600px; + border-right: 1px solid var(--vscode-panel-border); + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; +} +#fb-info-pane { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} +#fb-info-top { + display: flex; + flex-direction: column; + overflow: hidden; + flex: 0 0 auto; + max-height: 45%; +} +#fb-scan-pane { + flex: 1; + display: flex; + flex-direction: column; + border-top: 2px solid var(--vscode-panel-border); + overflow: hidden; + min-height: 0; +} +#fb-scan-panel-root { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} +/* Inline file content display — used instead of the embedded panel for files */ +#fb-file-content { + flex: 1; + overflow-y: auto; + padding: 10px 14px; + font-size: 12px; +} +.fc-section-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--vscode-descriptionForeground); + margin-bottom: 6px; +} +.fc-dataset { + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + padding: 8px 10px; + margin-bottom: 8px; +} +.fc-datatype { + font-weight: 600; + font-size: 13px; + margin-bottom: 6px; + color: var(--vscode-foreground); +} +.fc-schema-hdr { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--vscode-descriptionForeground); + margin: 6px 0 3px; +} +.fc-col-row { + display: flex; + gap: 8px; + padding: 1px 0; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 11px; +} +.fc-col-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.fc-col-dtype { color: var(--vscode-descriptionForeground); flex-shrink: 0; } +.fc-kv { display: flex; gap: 8px; margin-bottom: 2px; flex-wrap: wrap; } +.fc-k { color: var(--vscode-descriptionForeground); min-width: 80px; flex-shrink: 0; } +.fc-v { word-break: break-all; } +.fc-text-preview { + font-family: var(--vscode-editor-font-family, monospace); + font-size: 11px; + white-space: pre-wrap; + word-break: break-all; + background: var(--vscode-textBlockQuote-background, rgba(128,128,128,0.08)); + padding: 8px; + border-radius: 3px; + max-height: 300px; + overflow-y: auto; + border: 1px solid var(--vscode-panel-border); +} +.fc-html-repr { + font-size: 11px; + overflow-x: auto; + margin-bottom: 4px; +} +.fc-html-repr table { border-collapse: collapse; font-size: 11px; } +.fc-html-repr th, .fc-html-repr td { + border: 1px solid var(--vscode-panel-border); + padding: 2px 6px; +} +.fc-html-repr th { background: var(--vscode-list-hoverBackground); } +.fc-thumbnail { + max-width: 100%; + height: auto; + margin: 4px 0; + border-radius: 3px; +} +#fb-scan-panel-root #app { + flex-direction: column; + height: 100%; + overflow: hidden; +} +#fb-scan-panel-root #library { + width: 100% !important; + max-width: 100% !important; + border-right: none !important; + flex: 0 0 auto; + max-height: 50%; + overflow: hidden; +} +#fb-scan-panel-root .toolbar, +#fb-scan-panel-root .search, +#fb-scan-panel-root #spinner { display: none !important; } +#fb-scan-panel-root #projects { flex: 1; overflow-y: auto; padding: 4px; } +#fb-scan-panel-root #details { + flex: 1; + border-top: 1px solid var(--vscode-panel-border); + overflow: hidden; + min-height: 0; +} + +/* toolbar */ +#fb-toolbar { + display: flex; + align-items: center; + gap: 2px; + padding: 5px 6px; + border-bottom: 1px solid var(--vscode-panel-border); +} +.fb-icon-btn { + background: transparent; + border: none; + color: var(--vscode-foreground); + cursor: pointer; + padding: 3px 7px; + border-radius: 3px; + font-size: 13px; + line-height: 1.2; +} +.fb-icon-btn:hover { background: var(--vscode-toolbar-hoverBackground); } +.fb-icon-btn:disabled { opacity: 0.4; cursor: default; } +.fb-spacer { flex: 1; } +.fb-go-btn { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + cursor: pointer; + padding: 4px 10px; + border-radius: 3px; + font-size: 12px; +} +.fb-go-btn:hover { background: var(--vscode-button-hoverBackground); } + +/* URL bar */ +#fb-url-bar { + display: flex; + padding: 4px 6px; + gap: 4px; + border-bottom: 1px solid var(--vscode-panel-border); +} +#fb-url-input { + flex: 1; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border, transparent); + padding: 4px 8px; + font-size: 12px; + border-radius: 2px; + outline: none; + font-family: var(--vscode-editor-font-family, monospace); +} +#fb-url-input:focus { border-color: var(--vscode-focusBorder); } + +/* breadcrumb */ +#fb-breadcrumb { + padding: 3px 8px; + font-size: 11px; + color: var(--vscode-descriptionForeground); + border-bottom: 1px solid var(--vscode-panel-border); + display: flex; + flex-wrap: wrap; + gap: 2px; + min-height: 22px; + align-items: center; +} +.bc-seg { + cursor: pointer; + color: var(--vscode-textLink-foreground); + text-decoration: none; +} +.bc-seg:hover { text-decoration: underline; } +.bc-sep { color: var(--vscode-descriptionForeground); } + +/* file list */ +#fb-file-list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; +} +#fb-col-headers { + display: grid; + grid-template-columns: 1fr 72px 112px; + padding: 2px 10px 2px 26px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--vscode-descriptionForeground); + border-bottom: 1px solid var(--vscode-panel-border); + flex-shrink: 0; +} +.fb-col-hdr { + cursor: pointer; + user-select: none; + display: inline-flex; + align-items: center; + gap: 2px; + border-radius: 2px; + padding: 1px 2px; +} +.fb-col-hdr:hover { color: var(--vscode-foreground); } +.fb-col-hdr.active { color: var(--vscode-foreground); } +.fb-sort-arrow { font-size: 9px; opacity: 0.6; min-width: 8px; } +.fb-col-size, .fb-col-mtime { + text-align: right; + justify-content: flex-end; +} +#fb-entries { flex: 1; overflow-y: auto; } + +/* Tree entry row */ +.fb-entry { + display: grid; + grid-template-columns: 1fr 72px 112px; + align-items: center; + padding: 2px 10px; + cursor: pointer; + gap: 0; + border: 1px solid transparent; + border-radius: 2px; + position: relative; + min-width: 0; +} +.fb-entry:hover { background: var(--vscode-list-hoverBackground); } +.fb-entry.active { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} +/* name cell: indent + toggle + icon + text */ +.fb-entry-name-cell { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + overflow: hidden; +} +.fb-entry-indent { display: inline-block; flex-shrink: 0; } +.fb-entry-toggle { + width: 14px; + flex-shrink: 0; + text-align: center; + font-size: 9px; + color: var(--vscode-descriptionForeground); + cursor: pointer; + user-select: none; +} +.fb-entry-toggle:hover { color: var(--vscode-foreground); } +.fb-entry-icon { width: 16px; text-align: center; flex-shrink: 0; font-size: 13px; } +.fb-entry-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; } +.fb-entry.is-dir .fb-entry-name { font-weight: 500; } +.fb-entry-size { + font-size: 11px; + color: var(--vscode-descriptionForeground); + text-align: right; + white-space: nowrap; +} +.fb-entry-mtime { + font-size: 11px; + color: var(--vscode-descriptionForeground); + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.fb-entry.active .fb-entry-size, +.fb-entry.active .fb-entry-mtime { + color: inherit; + opacity: 0.8; +} +/* Children container — indented child rows */ +.fb-children { display: none; } +.fb-children.expanded { display: block; } +.fb-child-loading { + padding: 3px 10px 3px 36px; + font-size: 11px; + color: var(--vscode-descriptionForeground); + font-style: italic; +} + +.fb-status { padding: 20px; color: var(--vscode-descriptionForeground); text-align: center; } +.fb-error { padding: 12px; color: var(--vscode-errorForeground); } + +/* spinner */ +#fb-spinner { + position: absolute; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 12px; + padding: 4px 12px; + font-size: 12px; + z-index: 20; +} +.spin { display: inline-block; animation: spin 1.2s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* info pane */ +#fb-info-header { + padding: 8px 12px; + border-bottom: 1px solid var(--vscode-panel-border); + display: flex; + align-items: flex-start; + flex-wrap: wrap; + gap: 6px; +} +#fb-info-title { + font-weight: bold; + font-size: 13px; + flex: 1; + min-width: 0; + word-break: break-all; +} +#fb-info-actions { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +#fb-info-actions button { + background: var(--vscode-button-secondaryBackground, var(--vscode-button-background)); + color: var(--vscode-button-secondaryForeground, var(--vscode-button-foreground)); + border: none; + cursor: pointer; + padding: 3px 9px; + border-radius: 3px; + font-size: 11px; +} +#fb-info-actions button:hover { background: var(--vscode-button-hoverBackground); } +#fb-info-actions button.primary { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} +#fb-info-actions button.danger { + background: var(--vscode-errorForeground, #f44); + color: #fff; +} +#fb-info-meta { + padding: 10px 12px; + font-size: 12px; + color: var(--vscode-descriptionForeground); + border-bottom: 1px solid var(--vscode-panel-border); +} +.info-row { display: flex; gap: 6px; margin-bottom: 4px; } +.info-key { font-weight: 600; color: var(--vscode-foreground); min-width: 100px; } +#fb-info-preview { + flex: 1; + overflow-y: auto; + padding: 8px 12px; +} +.preview-label { + font-size: 11px; + font-weight: 600; + color: var(--vscode-descriptionForeground); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.preview-text { + font-family: var(--vscode-editor-font-family, monospace); + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; + background: var(--vscode-textBlockQuote-background, rgba(128,128,128,0.1)); + padding: 8px; + border-radius: 3px; + max-height: 280px; + overflow-y: auto; +} +.intake-card { + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + padding: 8px 10px; + font-size: 12px; +} +.intake-type-badge { + display: inline-block; + background: var(--vscode-badge-background, #4d4d4d); + color: var(--vscode-badge-foreground, #fff); + border-radius: 4px; + padding: 2px 8px; + font-weight: 600; + font-size: 12px; + margin-bottom: 6px; +} +.intake-schema-hdr { + font-weight: 600; + font-size: 11px; + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 6px 0 3px; +} +.intake-col-row { + display: flex; + gap: 8px; + padding: 1px 0; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 11px; +} +.intake-col-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.intake-col-dtype { color: var(--vscode-descriptionForeground); flex-shrink: 0; } +.intake-row { display: flex; gap: 6px; margin-bottom: 3px; flex-wrap: wrap; } +.intake-key { font-weight: 600; min-width: 80px; } +.intake-val { word-break: break-all; } + +.fc-reader-details { + margin-top: 6px; + border-top: 1px solid var(--vscode-panel-border); + padding-top: 4px; +} +.fc-reader-summary { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--vscode-descriptionForeground); + cursor: pointer; + user-select: none; + list-style: none; + display: flex; + align-items: center; + gap: 4px; + padding: 2px 0; +} +.fc-reader-summary::-webkit-details-marker { display: none; } +.fc-reader-summary::before { + content: '\\25B6'; + font-size: 8px; + transition: transform 0.15s; +} +details[open] > .fc-reader-summary::before { transform: rotate(90deg); } +.fc-reader-details .fc-kv { margin-top: 3px; } + +/* Bookmarks panel */ +#bm-panel { + position: absolute; + top: 38px; + left: 6px; + z-index: 50; + background: var(--vscode-menu-background, var(--vscode-editorWidget-background)); + color: var(--vscode-menu-foreground, var(--vscode-foreground)); + border: 1px solid var(--vscode-menu-border, var(--vscode-panel-border)); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0,0,0,0.35); + min-width: 260px; + max-width: 340px; + max-height: 360px; + display: flex; + flex-direction: column; +} +.bm-header { + display: flex; + align-items: center; + padding: 7px 10px; + font-weight: bold; + font-size: 12px; + border-bottom: 1px solid var(--vscode-panel-border); + gap: 6px; +} +.bm-header .fb-icon-btn { margin-left: auto; } +#bm-list { + flex: 1; + overflow-y: auto; +} +.bm-item { + display: flex; + align-items: center; + padding: 5px 10px; + gap: 6px; + cursor: pointer; + font-size: 12px; +} +.bm-item:hover { background: var(--vscode-list-hoverBackground); } +.bm-item-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.bm-item-url { font-size: 10px; color: var(--vscode-descriptionForeground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.bm-item-so { font-size: 10px; color: var(--vscode-descriptionForeground); font-style: italic; margin-top: 1px; } +.bm-remove { + background: transparent; border: none; cursor: pointer; + color: var(--vscode-descriptionForeground); padding: 1px 4px; + border-radius: 2px; font-size: 11px; +} +.bm-remove:hover { color: var(--vscode-errorForeground); } +.bm-empty { padding: 12px 10px; color: var(--vscode-descriptionForeground); font-size: 12px; } +.bm-footer { + border-top: 1px solid var(--vscode-panel-border); + padding: 6px 10px; +} +.bm-footer button { + background: transparent; + border: none; + color: var(--vscode-textLink-foreground); + cursor: pointer; + font-size: 11px; + padding: 0; +} +.bm-footer button:hover { text-decoration: underline; } + +/* Modals / overlays */ +.overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} +.fb-modal { + background: var(--vscode-editorWidget-background); + color: var(--vscode-editorWidget-foreground, var(--vscode-foreground)); + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-panel-border)); + border-radius: 6px; + min-width: 340px; + max-width: 85%; + box-shadow: 0 8px 24px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; +} +.fb-modal-title { + padding: 10px 14px; + font-weight: bold; + font-size: 14px; + border-bottom: 1px solid var(--vscode-panel-border); +} +.fb-modal-body { padding: 12px 14px; } +.fb-modal-body label { + display: block; + font-size: 12px; + margin-bottom: 4px; + color: var(--vscode-descriptionForeground); +} +.fb-modal-body input, .fb-modal-body textarea { + width: 100%; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border, var(--vscode-focusBorder, transparent)); + padding: 6px 8px; + font-size: 13px; + border-radius: 3px; + outline: none; + font-family: var(--vscode-editor-font-family, monospace); + resize: vertical; +} +.fb-modal-body input:focus, .fb-modal-body textarea:focus { border-color: var(--vscode-focusBorder); } +.hint { font-size: 11px; color: var(--vscode-descriptionForeground); margin: 0 0 10px; line-height: 1.5; } +.fb-modal-footer { + display: flex; + justify-content: flex-end; + gap: 6px; + padding: 10px 14px; + border-top: 1px solid var(--vscode-panel-border); +} +.fb-modal-footer button { + border: none; + cursor: pointer; + padding: 6px 14px; + font-size: 12px; + border-radius: 3px; +} +.fb-modal-footer button.primary { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} +.fb-modal-footer button.primary:hover { background: var(--vscode-button-hoverBackground); } +.fb-modal-footer button.secondary { + background: var(--vscode-button-secondaryBackground, transparent); + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); + border: 1px solid var(--vscode-panel-border); +} +.fb-modal-footer button.secondary:hover { background: var(--vscode-toolbar-hoverBackground); } + +.hidden { display: none !important; } diff --git a/src/projspec/webui/filebrowser.html b/src/projspec/webui/filebrowser.html new file mode 100644 index 0000000..15deccc --- /dev/null +++ b/src/projspec/webui/filebrowser.html @@ -0,0 +1,125 @@ +
+ + +
+
+ + + + + +
+ + +
+ +
+ + +
+ +
+ +
+
+ Name + Size + Modified +
+ + +
+
+ + + + +
+ + +
+
+
No file selected
+ +
+
+
+
+
+ +
+ +
+ + + + + + + + + + + + diff --git a/src/projspec/webui/filebrowser.js b/src/projspec/webui/filebrowser.js new file mode 100644 index 0000000..27566ff --- /dev/null +++ b/src/projspec/webui/filebrowser.js @@ -0,0 +1,1080 @@ +/* projspec filebrowser panel — transport-agnostic. + * + * Like panel.js this script requires the host to set + * window.projspecFbTransport = { send(msg), onReady(dispatch) } + * BEFORE this script runs. The transport bridges JS↔host for all + * filebrowser operations (browse, inspect, createFile, …). + * + * Optionally set window.projspecFbRoot to scope DOM queries to a + * subtree (needed when the panel is embedded alongside other content). + * + * Hosts that embed the library panel inside the scan pane must also set + * window.projspecFbPanelBootstrap = bootstrapFn (called once to + * initialise the embedded panel.js instance for directory scan results). + */ +(function() { + // ── Transport ----------------------------------------------------------- + const _transport = window.projspecFbTransport || { + send: (msg) => { console.warn('projspec-fb: no transport configured', msg); }, + onReady: (dispatch) => { window.__projspecFbDeliver = dispatch; }, + }; + + // Root scoping (mirrors panel.js pattern) + const _fbRoot = window.projspecFbRoot || document; + function $fbId(id) { + if (_fbRoot === document) return document.getElementById(id); + return _fbRoot.querySelector('#' + CSS.escape(id)); + } + + // Outbound (JS → host) + const vscode = { postMessage: (msg) => _transport.send(msg) }; + + // Show any uncaught JS errors as a red banner + window.onerror = function(msg, src, line, col, err) { + var d = document.createElement('div'); + d.style.cssText = 'position:fixed;top:0;left:0;right:0;padding:10px;background:#c00;color:#fff;font-family:monospace;font-size:12px;z-index:9999;white-space:pre-wrap;'; + d.textContent = 'FB error: ' + msg + ' (' + src + ':' + line + ')'; + document.body.appendChild(d); + }; + + // ── debug log ───────────────────────────────────────────────────────── + const debugEl = $fbId('fb-debug'); + function dbg(msg) { + if (debugEl) { + const line = document.createElement('div'); + line.textContent = '[' + new Date().toISOString().slice(11,23) + '] ' + msg; + debugEl.appendChild(line); + debugEl.scrollTop = debugEl.scrollHeight; + } + console.log('[fb] ' + msg); + } + dbg('script started'); + + // ── state ────────────────────────────────────────────────────────────── + let bookmarks = []; + let protocols = []; + var libraryUrls = new Set(); // canonical URLs of entries in the project library + var selectedIsFile = false; // true when the current selection is a file (not a dir) + let currentUrl = ''; + let currentSo = ''; + let history = []; + let histIdx = -1; + let selected = null; + let newentryMode = 'file'; + + // ── DOM refs ─────────────────────────────────────────────────────────── + // NOTE: #fb-entries is the scrollable entry list. #fb-empty and + // #fb-error are siblings of #fb-entries inside #fb-file-list and must + // NEVER be cleared by setting innerHTML on their parent. + const entriesEl = $fbId('fb-entries'); + const emptyEl = $fbId('fb-empty'); + const errorEl = $fbId('fb-error'); + const urlInput = $fbId('fb-url-input'); + const breadcrumb = $fbId('fb-breadcrumb'); + const spinner = $fbId('fb-spinner'); + const infoTitle = $fbId('fb-info-title'); + const infoActions = $fbId('fb-info-actions'); + const infoMeta = $fbId('fb-info-meta'); + const infoPreview = $fbId('fb-info-preview'); + const scanPane = $fbId('fb-scan-pane'); + const scanStatus = $fbId('fb-scan-status'); + const scanPanelRoot = $fbId('fb-scan-panel-root'); + const fileContent = $fbId('fb-file-content'); + const bmPanel = $fbId('bm-panel'); + const bmList = $fbId('bm-list'); + const soOverlay = $fbId('so-overlay'); + const soInput = $fbId('so-input'); + const neOverlay = $fbId('newentry-overlay'); + const neTitle = $fbId('newentry-title'); + const neInput = $fbId('newentry-name'); + const renOverlay = $fbId('rename-overlay'); + const renInput = $fbId('rename-input'); + + // Verify critical elements exist + const missing = ['fb-entries','fb-empty','fb-error','fb-url-input','fb-breadcrumb', + 'fb-spinner','fb-info-title','fb-info-actions'].filter(id => !$fbId(id)); + if (missing.length) { dbg('ERROR: missing elements: ' + missing.join(', ')); } + else { dbg('all DOM elements found'); } + + // ── utilities ────────────────────────────────────────────────────────── + function basename(url) { + const s = (url || '').replace(/\/+$/, ''); + const i = s.lastIndexOf('/'); + return i >= 0 ? s.slice(i + 1) : s; + } + function parentUrl(url) { + const s = (url || '').replace(/\/+$/, ''); + const protoEnd = s.indexOf('://'); + if (protoEnd >= 0) { + const pathPart = s.slice(protoEnd + 3); + const slash = pathPart.lastIndexOf('/'); + if (slash <= 0) return s.slice(0, protoEnd + 3) || s; + return s.slice(0, protoEnd + 3 + slash); + } + const slash = s.lastIndexOf('/'); + if (slash <= 0) return '/'; + return s.slice(0, slash); + } + function fmtSize(bytes) { + if (bytes == null) return ''; + const u = ['B','KB','MB','GB','TB']; + let n = parseFloat(bytes); + for (let i = 0; i < u.length; i++) { + if (n < 1024 || i === u.length - 1) return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + u[i]; + n /= 1024; + } + } + function fmtDate(ts) { + if (!ts) return ''; + const d = new Date(parseFloat(ts) * 1000); + return d.toLocaleString(); + } + function escHtml(s) { + return String(s || '').replace(/[&<>"']/g, + c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + } + function fileIcon(entry, inLibrary) { + if (entry.type === 'directory') return inLibrary ? '\uD83D\uDDC2\uFE0F' : '\uD83D\uDCC1'; // 🗂️ or 📁 + const name = (entry.basename || entry.name || '').toLowerCase(); + if (/\.(py|pyx|pyi)$/.test(name)) return '\uD83D\uDC0D'; // snake + if (/\.(js|ts|jsx|tsx)$/.test(name)) return '\uD83D\uDCDC'; // scroll + if (/\.(json|yaml|yml|toml|ini|cfg)$/.test(name)) return '\u2699\uFE0F'; // gear + if (/\.(md|rst|txt|org)$/.test(name)) return '\uD83D\uDCC4'; // page + if (/\.(csv|tsv|parquet|hdf5?|nc|zarr|feather)$/.test(name)) return '\uD83D\uDCCA'; // chart + if (/\.(png|jpg|jpeg|gif|svg|webp|bmp|tiff?)$/.test(name)) return '\uD83D\uDDBC\uFE0F'; // picture + if (/\.(zip|tar|gz|bz2|xz|7z|rar)$/.test(name)) return '\uD83D\uDCE6'; // package + if (/\.(sh|bash|zsh|fish|ps1|bat|cmd)$/.test(name)) return '\uD83D\uDCBB'; // computer + return '\uD83D\uDCC4'; // page + } + + // ── browse result ────────────────────────────────────────────────────── + // ── tree rendering ───────────────────────────────────────────────────── + + // Sort state + var sortCol = 'name'; // 'name' | 'size' | 'mtime' + var sortAsc = true; + // Last browse entries (root level) — kept for re-sort without re-fetch + var lastBrowseEntries = []; + + function sortEntries(entries) { + // Stable sort: directories always before files, then by chosen column + function key(e) { + if (sortCol === 'size') return e.size == null ? -1 : e.size; + if (sortCol === 'mtime') return e.last_modified == null ? 0 : parseFloat(e.last_modified); + // name: case-insensitive + return (e.basename || basename(e.name || '')).toLowerCase(); + } + return entries.slice().sort(function(a, b) { + var aDir = a.type === 'directory' ? 0 : 1; + var bDir = b.type === 'directory' ? 0 : 1; + if (aDir !== bDir) return aDir - bDir; // dirs before files always + var ak = key(a), bk = key(b); + var cmp = ak < bk ? -1 : ak > bk ? 1 : 0; + return sortAsc ? cmp : -cmp; + }); + } + + function updateSortHeaders() { + (_fbRoot === document ? document : _fbRoot).querySelectorAll('.fb-col-hdr').forEach(function(el) { + var col = el.dataset.col; + var arrow = el.querySelector('.fb-sort-arrow'); + if (col === sortCol) { + el.classList.add('active'); + if (arrow) arrow.textContent = sortAsc ? '\u25B4' : '\u25BE'; // ▴ ▾ + } else { + el.classList.remove('active'); + if (arrow) arrow.textContent = ''; + } + }); + } + + // Wire up column header clicks + (_fbRoot === document ? document : _fbRoot).querySelectorAll('.fb-col-hdr').forEach(function(el) { + el.addEventListener('click', function() { + var col = el.dataset.col; + if (col === sortCol) { + sortAsc = !sortAsc; + } else { + sortCol = col; + sortAsc = col === 'name'; // name defaults asc, size/mtime default desc + } + updateSortHeaders(); + // Re-render root entries with new sort (no network call) + if (lastBrowseEntries.length > 0) { + treeNodes = {}; + entriesEl.innerHTML = ''; + var sorted = sortEntries(lastBrowseEntries); + for (var i = 0; i < sorted.length; i++) { + entriesEl.appendChild(makeEntryRow(sorted[i], 0)); + } + } + }); + }); + updateSortHeaders(); + + // Map from directory URL -> child container element (for expand/collapse) + var treeNodes = {}; + + function makeEntryRow(entry, depth) { + var isDir = entry.type === 'directory'; + var url = entry.name; + + // Outer wrapper: row + (for dirs) a children container + var wrapper = document.createElement('div'); + wrapper.className = 'fb-entry-wrapper'; + + var row = document.createElement('div'); + row.className = 'fb-entry' + (isDir ? ' is-dir' : ''); + row.dataset.url = url; + row.dataset.type = entry.type || 'file'; + + // Name cell: indent + toggle + icon + name + var nameCell = document.createElement('span'); + nameCell.className = 'fb-entry-name-cell'; + + var indent = document.createElement('span'); + indent.className = 'fb-entry-indent'; + indent.style.width = (depth * 12) + 'px'; + nameCell.appendChild(indent); + + var toggle = document.createElement('span'); + toggle.className = 'fb-entry-toggle'; + toggle.textContent = isDir ? '\u25B6' : ''; // ▶ for dirs, blank for files + nameCell.appendChild(toggle); + + var icon = document.createElement('span'); + icon.className = 'fb-entry-icon'; + icon.textContent = fileIcon(entry, isDir && libraryUrls.has(url)); + nameCell.appendChild(icon); + + var nameEl = document.createElement('span'); + nameEl.className = 'fb-entry-name'; + nameEl.textContent = entry.basename || basename(url); + nameEl.title = url; + nameCell.appendChild(nameEl); + + // Size cell + var sizeEl = document.createElement('span'); + sizeEl.className = 'fb-entry-size'; + if (entry.size != null) sizeEl.textContent = fmtSize(entry.size); + + // Modified cell + var mtimeEl = document.createElement('span'); + mtimeEl.className = 'fb-entry-mtime'; + if (entry.last_modified) mtimeEl.textContent = fmtMtime(entry.last_modified); + + row.appendChild(nameCell); + row.appendChild(sizeEl); + row.appendChild(mtimeEl); + + // Children container (lazy-populated) + var childrenEl = null; + if (isDir) { + childrenEl = document.createElement('div'); + childrenEl.className = 'fb-children'; + treeNodes[url] = childrenEl; + } + + // Click: select + row.addEventListener('click', function(e) { + e.stopPropagation(); + selectEntry(url, entry.type, row); + }); + + // Toggle click: expand/collapse (stop propagation so row click doesn't fire) + if (isDir) { + toggle.addEventListener('click', function(e) { + e.stopPropagation(); + toggleDir(url, toggle, childrenEl); + }); + // Double-click on row: navigate to dir as new root + row.addEventListener('dblclick', function(e) { + e.stopPropagation(); + navigateTo(url, currentSo); + }); + } + + wrapper.appendChild(row); + if (childrenEl) wrapper.appendChild(childrenEl); + return wrapper; + } + + function fmtMtime(ts) { + if (!ts) return ''; + var d = new Date(parseFloat(ts) * 1000); + var now = new Date(); + var diffDays = (now - d) / 86400000; + if (diffDays < 1) { + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + if (diffDays < 180) { + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); + } + return d.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }); + } + + function toggleDir(url, toggle, childrenEl) { + var expanded = childrenEl.classList.contains('expanded'); + if (expanded) { + // Collapse + childrenEl.classList.remove('expanded'); + toggle.textContent = '\u25B6'; // ▶ + } else { + // Expand: lazy-load if not yet populated + childrenEl.classList.add('expanded'); + toggle.textContent = '\u25BC'; // ▼ + if (!childrenEl.dataset.loaded) { + // Show loading indicator + var loader = document.createElement('div'); + loader.className = 'fb-child-loading'; + loader.textContent = 'Loading...'; + childrenEl.appendChild(loader); + // Request children from host + vscode.postMessage({ cmd: 'expandDir', url: url, storageOptions: currentSo || undefined }); + } + } + } + + function handleExpandResult(data) { + var parentUrl = data.parentUrl || data.url; + var childrenEl = treeNodes[parentUrl]; + if (!childrenEl) { dbg('no treeNode for ' + parentUrl); return; } + + childrenEl.innerHTML = ''; + childrenEl.dataset.loaded = '1'; + + if (data.error) { + var errEl = document.createElement('div'); + errEl.className = 'fb-child-loading'; + errEl.textContent = 'Error: ' + data.error; + childrenEl.appendChild(errEl); + return; + } + + var entries = data.entries || []; + if (entries.length === 0) { + var emptyMsg = document.createElement('div'); + emptyMsg.className = 'fb-child-loading'; + emptyMsg.textContent = 'Empty'; + childrenEl.appendChild(emptyMsg); + return; + } + + // Find the depth of the parent by looking at the DOM + var parentRow = childrenEl.previousSibling; + var parentIndent = parentRow ? (parseInt(parentRow.querySelector('.fb-entry-indent').style.width) || 0) : 0; + var childDepth = Math.round(parentIndent / 12) + 1; + + var sorted = sortEntries(entries); + for (var i = 0; i < sorted.length; i++) { + childrenEl.appendChild(makeEntryRow(sorted[i], childDepth)); + } + dbg('expanded ' + parentUrl + ': ' + sorted.length + ' children'); + } + + function renderBrowse(data) { + dbg('renderBrowse url=' + data.url + ' entries=' + (data.entries ? data.entries.length : 'none') + ' error=' + data.error); + currentUrl = data.url || ''; + urlInput.value = currentUrl; + renderBreadcrumb(currentUrl); + + // Reset tree node map and stored entries + treeNodes = {}; + lastBrowseEntries = []; + + entriesEl.innerHTML = ''; + emptyEl.classList.add('hidden'); + errorEl.classList.add('hidden'); + + if (data.error) { + errorEl.textContent = 'Error: ' + data.error; + errorEl.classList.remove('hidden'); + return; + } + + var entries = data.entries || []; + if (entries.length === 0) { + emptyEl.classList.remove('hidden'); + return; + } + + lastBrowseEntries = entries; + var sorted = sortEntries(entries); + for (var i = 0; i < sorted.length; i++) { + entriesEl.appendChild(makeEntryRow(sorted[i], 0)); + } + dbg('rendered ' + sorted.length + ' entries'); + } + + function selectEntry(url, type, rowEl) { + (_fbRoot === document ? document : _fbRoot).querySelectorAll('.fb-entry.active').forEach(function(el) { el.classList.remove('active'); }); + if (rowEl) rowEl.classList.add('active'); + selected = { url: url, type: type, so: currentSo }; + dbg('selected ' + type + ': ' + url); + + infoTitle.textContent = basename(url); + infoActions.classList.remove('hidden'); + + const isFile = type !== 'directory'; + selectedIsFile = isFile; + $fbId('btn-open-editor').style.display = isFile ? '' : 'none'; + $fbId('btn-add-to-lib').style.display = type === 'directory' ? '' : 'none'; + + infoMeta.innerHTML = ''; + infoPreview.innerHTML = ''; + + // Reset both scan pane variants on every selection change. + // Also send an empty library to the embedded panel so the previous + // directory's project widget and details are cleared immediately. + if (scanPane) { + scanPane.classList.add('hidden'); + if (scanStatus) scanStatus.textContent = ''; + } + if (scanPanelRoot) scanPanelRoot.classList.remove('hidden'); + if (fileContent) { fileContent.classList.add('hidden'); fileContent.innerHTML = ''; } + if (typeof window.__fbPanelDeliver === 'function') { + window.__fbPanelDeliver({ type: 'data', library: {}, info: {}, enums: {} }); + } + + if (isFile) { + // Show scan pane immediately with loading indicator — it gets populated + // when both inspectResult and projectScanned arrive + if (scanPane) { + scanPane.classList.remove('hidden'); + if (scanStatus) scanStatus.textContent = 'inspecting...'; + } + dbg('posting inspect for ' + url); + vscode.postMessage({ cmd: 'inspect', url: url, storageOptions: currentSo || undefined }); + } else { + renderMeta({ type: 'directory', url: url }); + // Trigger projspec scan on directory selection + if (scanPane) { + scanPane.classList.remove('hidden'); + if (scanStatus) scanStatus.textContent = 'scanning...'; + } + dbg('posting scanDir for ' + url); + vscode.postMessage({ cmd: 'scanDir', url: url, storageOptions: currentSo || undefined }); + } + } + + function renderBreadcrumb(url) { + breadcrumb.innerHTML = ''; + if (!url) return; + const protoMatch = url.match(/^([a-z][a-z0-9+.\-]*):\/\//i); + let proto = ''; + let rest = url; + if (protoMatch) { + proto = protoMatch[0]; + rest = url.slice(proto.length); + } + const parts = rest.replace(/\/+$/, '').split('/').filter(Boolean); + if (proto) { + const link = document.createElement('span'); + link.className = 'bc-seg'; + link.textContent = proto; + link.title = proto; + link.addEventListener('click', function() { navigateTo(proto, currentSo); }); + breadcrumb.appendChild(link); + } + let accumulated = proto; + for (let i = 0; i < parts.length; i++) { + accumulated += (accumulated.slice(-1) === '/' ? '' : '/') + parts[i]; + const sep = document.createElement('span'); + sep.className = 'bc-sep'; + sep.textContent = ' / '; + breadcrumb.appendChild(sep); + const link = document.createElement('span'); + link.className = 'bc-seg'; + link.textContent = parts[i]; + const target = accumulated; + link.title = target; + link.addEventListener('click', function() { navigateTo(target, currentSo); }); + breadcrumb.appendChild(link); + } + } + + function renderMeta(data) { + infoMeta.innerHTML = ''; + var rows = []; + // Don't show data.type — it's the JS message type, not a file type + if (data.size != null) rows.push(['Size', fmtSize(data.size)]); + if (data.last_modified) rows.push(['Modified', fmtDate(data.last_modified)]); + if (data.mime_type) rows.push(['MIME', data.mime_type]); + for (var i = 0; i < rows.length; i++) { + const row = document.createElement('div'); + row.className = 'info-row'; + row.innerHTML = '' + escHtml(rows[i][0]) + '' + escHtml(String(rows[i][1])) + ''; + infoMeta.appendChild(row); + } + } + + function renderInspect(data) { + dbg('renderInspect name=' + data.name + ' error=' + data.error); + // For files: only show MIME in the meta strip — size/modified are already + // visible in the file tree columns and would be redundant here. + // The intake type and text preview now live in the scan pane below. + infoMeta.innerHTML = ''; + if (data.mime_type) { + const row = document.createElement('div'); + row.className = 'info-row'; + row.innerHTML = 'MIME' + escHtml(data.mime_type) + ''; + infoMeta.appendChild(row); + } + // Clear preview — content is in the scan pane + infoPreview.innerHTML = ''; + } + + // ── navigation ───────────────────────────────────────────────────────── + function navigateTo(url, so, push) { + const shouldPush = push !== false; + dbg('navigateTo push=' + shouldPush + ' url=' + url); + vscode.postMessage({ cmd: 'browse', url: url, storageOptions: so || undefined, push: shouldPush }); + selected = null; + infoTitle.textContent = 'Loading...'; + infoActions.classList.add('hidden'); + infoMeta.innerHTML = ''; + infoPreview.innerHTML = ''; + } + + // ── bookmarks ────────────────────────────────────────────────────────── + function refreshLibraryBadges() { + // Walk every directory row in the DOM and swap the folder icon + document.querySelectorAll('.fb-entry[data-type="directory"]').forEach(function(row) { + var url = row.dataset.url || ''; + var iconEl = row.querySelector('.fb-entry-icon'); + if (!iconEl) return; + iconEl.textContent = libraryUrls.has(url) ? '\uD83D\uDDC2\uFE0F' : '\uD83D\uDCC1'; // 🗂️ or 📁 + }); + } + + function renderBookmarks() { + bmList.innerHTML = ''; + if (!bookmarks.length) { + const e = document.createElement('div'); + e.className = 'bm-empty'; + e.textContent = 'No bookmarks yet.'; + bmList.appendChild(e); + return; + } + for (var i = 0; i < bookmarks.length; i++) { + const bm = bookmarks[i]; + const row = document.createElement('div'); + row.className = 'bm-item'; + const info = document.createElement('div'); + info.style.flex = '1'; + info.style.overflow = 'hidden'; + const lbl = document.createElement('div'); + lbl.className = 'bm-item-label'; + lbl.textContent = bm.label || bm.url; + const urlDiv = document.createElement('div'); + urlDiv.className = 'bm-item-url'; + urlDiv.textContent = bm.url; + info.appendChild(lbl); + info.appendChild(urlDiv); + // Show a key icon if the bookmark has stored storage_options + if (bm.storage_options && Object.keys(bm.storage_options).length > 0) { + const soTag = document.createElement('div'); + soTag.className = 'bm-item-so'; + soTag.title = 'Saved storage options: ' + JSON.stringify(bm.storage_options); + soTag.textContent = '\uD83D\uDD11 credentials stored'; + info.appendChild(soTag); + } + const rm = document.createElement('button'); + rm.className = 'bm-remove'; + rm.title = 'Remove bookmark'; + rm.textContent = 'X'; + const bmUrl = bm.url; + // Serialise the bookmark's own storage_options as a JSON string (or ''). + // These are scoped to this URL only — they replace currentSo entirely + // when navigating to a different protocol/host. + const bmSo = (bm.storage_options && Object.keys(bm.storage_options).length > 0) + ? JSON.stringify(bm.storage_options) : ''; + rm.addEventListener('click', function(e) { + e.stopPropagation(); + vscode.postMessage({ cmd: 'removeBookmark', url: bmUrl }); + }); + row.appendChild(info); + row.appendChild(rm); + row.addEventListener('click', function(ev) { + if (ev.target === rm) return; + bmPanel.classList.add('hidden'); + // Switch currentSo to the bookmark's own options (may be empty) + currentSo = bmSo; + navigateTo(bmUrl, bmSo); + }); + bmList.appendChild(row); + } + } + + // ── toolbar ──────────────────────────────────────────────────────────── + $fbId('btn-back').addEventListener('click', function() { + if (histIdx > 0) { + histIdx--; + const h = history[histIdx]; + currentSo = h.so; + dbg('back to ' + h.url); + vscode.postMessage({ cmd: 'browse', url: h.url, storageOptions: h.so || undefined, push: false }); + selected = null; + infoTitle.textContent = 'Loading...'; + infoActions.classList.add('hidden'); + infoMeta.innerHTML = ''; + infoPreview.innerHTML = ''; + } + }); + $fbId('btn-up').addEventListener('click', function() { + const p = parentUrl(currentUrl); + if (p && p !== currentUrl) { navigateTo(p, currentSo); } + }); + $fbId('btn-refresh').addEventListener('click', function() { + navigateTo(currentUrl, currentSo, false); + }); + $fbId('btn-bm-dropdown').addEventListener('click', function(e) { + e.stopPropagation(); + bmPanel.classList.toggle('hidden'); + if (!bmPanel.classList.contains('hidden')) renderBookmarks(); + }); + $fbId('bm-close').addEventListener('click', function() { + bmPanel.classList.add('hidden'); + }); + $fbId('btn-bm-add-current').addEventListener('click', function() { + bmPanel.classList.add('hidden'); + vscode.postMessage({ cmd: 'addBookmark', url: currentUrl, storageOptions: currentSo || undefined }); + }); + document.addEventListener('click', function(e) { + if (!bmPanel.contains(e.target) && e.target !== $fbId('btn-bm-dropdown')) { + bmPanel.classList.add('hidden'); + } + }); + + // Storage options + $fbId('btn-so').addEventListener('click', function(e) { + e.stopPropagation(); + soInput.value = currentSo; + soOverlay.classList.remove('hidden'); + setTimeout(function() { soInput.focus(); }, 0); + }); + $fbId('so-cancel').addEventListener('click', function() { + soOverlay.classList.add('hidden'); + }); + $fbId('so-ok').addEventListener('click', function() { + const val = soInput.value.trim(); + try { + if (val) JSON.parse(val); + currentSo = val; + } catch(ex) { + alert('Storage options must be valid JSON: ' + ex.message); + return; + } + soOverlay.classList.add('hidden'); + dbg('storage options set: ' + currentSo); + }); + soOverlay.addEventListener('click', function(e) { + if (e.target === soOverlay) soOverlay.classList.add('hidden'); + }); + + // Go / URL bar + $fbId('btn-go').addEventListener('click', function() { + const url = urlInput.value.trim(); + if (url) { navigateTo(url, currentSo); } + }); + urlInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + const url = urlInput.value.trim(); + if (url) { navigateTo(url, currentSo); } + } + }); + + // New file / folder + $fbId('btn-new-file').addEventListener('click', function() { + newentryMode = 'file'; + neTitle.textContent = 'New file'; + neInput.value = ''; + neOverlay.classList.remove('hidden'); + setTimeout(function() { neInput.focus(); }, 0); + }); + $fbId('btn-new-dir').addEventListener('click', function() { + newentryMode = 'dir'; + neTitle.textContent = 'New folder'; + neInput.value = ''; + neOverlay.classList.remove('hidden'); + setTimeout(function() { neInput.focus(); }, 0); + }); + $fbId('newentry-cancel').addEventListener('click', function() { + neOverlay.classList.add('hidden'); + }); + $fbId('newentry-ok').addEventListener('click', function() { + const name = neInput.value.trim(); + if (!name) return; + neOverlay.classList.add('hidden'); + if (newentryMode === 'file') { + dbg('createFile ' + name); + vscode.postMessage({ cmd: 'createFile', parentUrl: currentUrl, name: name, storageOptions: currentSo || undefined }); + } else { + dbg('mkdir ' + name); + vscode.postMessage({ cmd: 'mkdir', parentUrl: currentUrl, name: name, storageOptions: currentSo || undefined }); + } + }); + neInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') $fbId('newentry-ok').click(); + if (e.key === 'Escape') neOverlay.classList.add('hidden'); + }); + neOverlay.addEventListener('click', function(e) { + if (e.target === neOverlay) neOverlay.classList.add('hidden'); + }); + + // Info panel actions + $fbId('btn-open-editor').addEventListener('click', function() { + if (!selected || selected.type === 'directory') return; + dbg('openFile ' + selected.url); + vscode.postMessage({ cmd: 'openFile', url: selected.url, storageOptions: selected.so || undefined }); + }); + $fbId('btn-add-to-lib').addEventListener('click', function() { + if (!selected) return; + dbg('addToLibrary ' + selected.url); + vscode.postMessage({ cmd: 'addToLibrary', url: selected.url, storageOptions: selected.so || undefined }); + }); + $fbId('btn-bookmark').addEventListener('click', function() { + const url = selected ? selected.url : currentUrl; + dbg('addBookmark ' + url); + vscode.postMessage({ cmd: 'addBookmark', url: url, storageOptions: currentSo || undefined }); + }); + $fbId('btn-delete-sel').addEventListener('click', function() { + if (!selected) return; + dbg('deleteEntry ' + selected.url); + vscode.postMessage({ + cmd: 'deleteEntry', + url: selected.url, + isDir: selected.type === 'directory', + storageOptions: selected.so || undefined, + }); + }); + $fbId('btn-rename-sel').addEventListener('click', function() { + if (!selected) return; + renInput.value = basename(selected.url); + renOverlay.classList.remove('hidden'); + setTimeout(function() { renInput.focus(); }, 0); + }); + + // Rename modal + $fbId('rename-cancel').addEventListener('click', function() { + renOverlay.classList.add('hidden'); + }); + $fbId('rename-ok').addEventListener('click', function() { + const newName = renInput.value.trim(); + if (!newName || !selected) return; + renOverlay.classList.add('hidden'); + dbg('rename ' + selected.url + ' -> ' + newName); + vscode.postMessage({ cmd: 'renameEntry', url: selected.url, newName: newName, storageOptions: selected.so || undefined }); + }); + renInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') $fbId('rename-ok').click(); + if (e.key === 'Escape') renOverlay.classList.add('hidden'); + }); + renOverlay.addEventListener('click', function(e) { + if (e.target === renOverlay) renOverlay.classList.add('hidden'); + }); + + // ── embedded projspec panel ──────────────────────────────────────────── + // The shared panel JS was already run by the inline bootstrap script in + // getHtml() before this IIFE executed. The bootstrap installed + // window.__fbPanelDeliver(msg) — call it to push a data message into + // the embedded panel. + + // ── file content display (inline, no project widget) ───────────────── + // For single-file selections we render content directly rather than + // routing through the embedded library panel. The rule: + // - If the file has meaningful data info (datatype + schema/metadata), + // show that. Text preview is suppressed — the data description is + // the useful thing. + // - Otherwise show the text preview (first N lines). + // - If neither is available, hide the scan pane. + function showFileInScanPane(data) { + if (!scanPane || !fileContent) return; + + // Switch: hide the embedded panel root, show the file content div + if (scanPanelRoot) scanPanelRoot.classList.add('hidden'); + fileContent.classList.remove('hidden'); + fileContent.innerHTML = ''; + + var proj = data.project; + var textPreview = data.text_preview || ''; + + // Extract the dataset content from the data_project spec + var dataset = null; + if (proj && proj.specs && proj.specs.data_project) { + var cont = proj.specs.data_project._contents || {}; + var keys = Object.keys(cont); + for (var i = 0; i < keys.length; i++) { + var item = cont[keys[i]]; + if (item && item.klass && item.klass[1] === 'dataset') { + dataset = item; + break; + } + } + } + + var hasData = dataset && ( + dataset.datatype || + (dataset.schema && Object.keys(dataset.schema).length > 0) || + (dataset.metadata && Object.keys(dataset.metadata).length > 0) + ); + + if (hasData) { + var card = document.createElement('div'); + card.className = 'fc-dataset'; + + // Datatype heading + if (dataset.datatype) { + var dtEl = document.createElement('div'); + dtEl.className = 'fc-datatype'; + dtEl.textContent = dataset.datatype; + card.appendChild(dtEl); + } + + var meta = dataset.metadata || {}; + + // HTML repr — render inline (sanitised: strip scripts/iframes) + var htmlRepr = typeof meta.html_repr === 'string' ? meta.html_repr : null; + if (htmlRepr) { + var reprDiv = document.createElement('div'); + reprDiv.className = 'fc-html-repr'; + reprDiv.innerHTML = sanitizeHtmlRepr(htmlRepr); + card.appendChild(reprDiv); + } + + // Thumbnail image (data: URI only) + var thumb = typeof meta.thumbnail === 'string' ? meta.thumbnail : null; + if (thumb && /^data:image\//i.test(thumb)) { + var img = document.createElement('img'); + img.src = thumb; + img.className = 'fc-thumbnail'; + img.alt = 'thumbnail'; + card.appendChild(img); + } + + // Schema / columns — only if no richer HTML repr + if (!htmlRepr) { + var schema = dataset.schema; + if (schema && typeof schema === 'object' && !Array.isArray(schema)) { + var cols = Object.keys(schema); + if (cols.length > 0) { + var schemaHdr = document.createElement('div'); + schemaHdr.className = 'fc-schema-hdr'; + schemaHdr.textContent = 'Columns'; + card.appendChild(schemaHdr); + for (var ci = 0; ci < cols.length; ci++) { + var colRow = document.createElement('div'); + colRow.className = 'fc-col-row'; + colRow.innerHTML = '' + escHtml(cols[ci]) + '' + + '' + escHtml(String(schema[cols[ci]])) + ''; + card.appendChild(colRow); + } + } + } + } + + // Metadata key-values — skip html_repr/thumbnail (already rendered above). + // reader_* / readers* fields are collected into a collapsible box at the end. + var SKIP_META = new Set(['html_repr', 'thumbnail']); + var mkeys = Object.keys(meta); + var readerRows = []; + for (var mi = 0; mi < mkeys.length; mi++) { + var mk = mkeys[mi], mv = meta[mk]; + if (SKIP_META.has(mk) || mv == null) continue; + var mvStr = typeof mv === 'object' ? JSON.stringify(mv) : String(mv); + if (!mvStr || mvStr === '{}' || mvStr === '[]') continue; + if (/^readers?(_|$)/i.test(mk) || mk === 'errors') { + readerRows.push([mk, mvStr]); + continue; + } + var kvEl = document.createElement('div'); + kvEl.className = 'fc-kv'; + kvEl.innerHTML = '' + escHtml(mk) + ':' + + '' + escHtml(mvStr) + ''; + card.appendChild(kvEl); + } + if (readerRows.length > 0) { + var det = document.createElement('details'); + det.className = 'fc-reader-details'; + var sum = document.createElement('summary'); + sum.className = 'fc-reader-summary'; + sum.textContent = 'Reader info'; + det.appendChild(sum); + for (var ri = 0; ri < readerRows.length; ri++) { + var rkvEl = document.createElement('div'); + rkvEl.className = 'fc-kv'; + rkvEl.innerHTML = '' + escHtml(readerRows[ri][0]) + ':' + + '' + escHtml(readerRows[ri][1]) + ''; + det.appendChild(rkvEl); + } + card.appendChild(det); + } + + // Structure tags (e.g. ["table"]) + var structure = dataset.structure; + if (Array.isArray(structure) && structure.length > 0) { + var stEl = document.createElement('div'); + stEl.className = 'fc-kv'; + stEl.innerHTML = 'structure:' + + '' + escHtml(structure.join(', ')) + ''; + card.appendChild(stEl); + } + + fileContent.appendChild(card); + + // For data files, also show text preview below if there's no html_repr + // (e.g. CSV — the first few rows are more useful than just column names) + if (!htmlRepr && textPreview) { + var prevHdr = document.createElement('div'); + prevHdr.className = 'fc-schema-hdr'; + prevHdr.style.marginTop = '10px'; + prevHdr.textContent = 'Preview'; + fileContent.appendChild(prevHdr); + var pre2 = document.createElement('pre'); + pre2.className = 'fc-text-preview'; + pre2.textContent = textPreview; + fileContent.appendChild(pre2); + } + + } else if (textPreview) { + // Pure text file — just show the preview + var pre = document.createElement('pre'); + pre.className = 'fc-text-preview'; + pre.textContent = textPreview; + fileContent.appendChild(pre); + + } else { + // Nothing to show — hide the scan pane + scanPane.classList.add('hidden'); + } + } + + // Minimal sanitiser for html_repr content (strips scripts, iframes, on* handlers) + function sanitizeHtmlRepr(html) { + var tpl = document.createElement('template'); + tpl.innerHTML = String(html); + var walker = document.createTreeWalker(tpl.content, NodeFilter.SHOW_ELEMENT); + var toRemove = []; + var n = walker.nextNode(); + while (n) { + var tag = n.tagName.toLowerCase(); + if (tag === 'script' || tag === 'iframe' || tag === 'object' || tag === 'embed') { + toRemove.push(n); + } else { + var attrs = Array.from(n.attributes); + for (var ai = 0; ai < attrs.length; ai++) { + var an = attrs[ai].name.toLowerCase(); + if (an.startsWith('on')) { n.removeAttribute(attrs[ai].name); continue; } + if ((an === 'href' || an === 'src') && /^\s*javascript:/i.test(attrs[ai].value)) { + n.removeAttribute(attrs[ai].name); + } + } + } + n = walker.nextNode(); + } + for (var ri = 0; ri < toRemove.length; ri++) toRemove[ri].remove(); + return tpl.innerHTML; + } + + function showProjectInPanel(data) { + if (!scanPanelRoot) return; + if (scanStatus) scanStatus.textContent = ''; + + var proj = data.project; + var url = data.url || ''; + if (!proj) { + if (scanStatus) scanStatus.textContent = data.error || 'no project data'; + return; + } + + var lib = {}; + lib[url] = proj; + // Pass info and enums so spec documentation popups and enum labels work + var dataMsg = { type: 'data', library: lib, info: data.info || {}, enums: data.enums || {} }; + + if (typeof window.__fbPanelDeliver === 'function') { + dbg('delivering to embedded panel: ' + url); + window.__fbPanelDeliver(dataMsg); + } else { + dbg('ERROR: __fbPanelDeliver not available'); + } + } + + // ── message bus ──────────────────────────────────────────────────────── + // Inbound messages are delivered via transport.onReady(dispatch). + function _fbDispatch(msg) { + // (type logged selectively in each case handler) + switch (msg.type) { + case 'loading': + spinner.classList.toggle('hidden', !msg.loading); + break; + + case 'init': + bookmarks = msg.bookmarks || []; + protocols = msg.protocols || []; + if (msg.libraryUrls) { + libraryUrls = new Set(msg.libraryUrls); + dbg('init: ' + bookmarks.length + ' bookmarks, ' + libraryUrls.size + ' library entries'); + } else { + dbg('init: ' + bookmarks.length + ' bookmarks, ' + protocols.length + ' protocols'); + } + break; + + case 'browseResult': + if (typeof msg.storageOptions === 'string') { + currentSo = msg.storageOptions; + } + renderBrowse(msg); + if (msg.pushHistory) { + history = history.slice(0, histIdx + 1); + history.push({ url: msg.url, so: currentSo }); + histIdx = history.length - 1; + dbg('history push, depth=' + history.length); + } + break; + + case 'inspectResult': + renderInspect(msg); + break; + + case 'expandResult': + handleExpandResult(msg); + break; + + case 'projectScanned': + dbg('projectScanned url=' + msg.url); + if (selectedIsFile) { + // Single file: inline display, no project widget + showFileInScanPane(msg); + } else { + // Directory: full embedded library panel + showProjectInPanel(msg); + } + break; + + case 'bookmarksUpdated': + bookmarks = msg.bookmarks || []; + dbg('bookmarks updated: ' + bookmarks.length); + if (!bmPanel.classList.contains('hidden')) renderBookmarks(); + break; + + case 'libraryUrlsUpdated': + libraryUrls = new Set(msg.libraryUrls || []); + dbg('library URLs updated: ' + libraryUrls.size); + refreshLibraryBadges(); + break; + + case 'error': + errorEl.textContent = 'Error: ' + (msg.message || 'unknown'); + errorEl.classList.remove('hidden'); + dbg('error: ' + msg.message); + break; + } + } // end _fbDispatch + + dbg('sending ready'); + // Register dispatch with transport and notify host + window.__projspecFbDeliver = _fbDispatch; + _transport.onReady(_fbDispatch); + vscode.postMessage({ cmd: 'ready' }); +})(); diff --git a/src/projspec/webui/tabs.css b/src/projspec/webui/tabs.css new file mode 100644 index 0000000..c70e527 --- /dev/null +++ b/src/projspec/webui/tabs.css @@ -0,0 +1,76 @@ +/* Tab bar layout for the combined projspec panel. + * + * Shared by all GUI hosts (Qt, ipywidget, PyCharm, VSCode). + * Include this stylesheet alongside panel.css and filebrowser.css. + */ + +html, body { height: 100%; margin: 0; padding: 0; overflow: hidden; } + +/* ── Tab bar ─────────────────────────────────────────────────────────────── */ +#tab-bar { + display: flex; + gap: 0; + border-bottom: 2px solid var(--vscode-panel-border, #3c3c3c); + background: var(--vscode-editorGroupHeader-tabsBackground, + var(--vscode-editor-background, #1e1e1e)); + flex-shrink: 0; +} +.tab-btn { + background: transparent; + color: var(--vscode-foreground, #cccccc); + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + padding: 7px 18px; + font-size: 13px; + font-family: var(--vscode-font-family, sans-serif); + cursor: pointer; + outline: none; + white-space: nowrap; +} +.tab-btn:hover { background: var(--vscode-list-hoverBackground, #2a2d2e); } +.tab-btn.active { + border-bottom-color: var(--vscode-focusBorder, + var(--vscode-button-background, #007acc)); + font-weight: 600; +} + +/* ── Tab panes ───────────────────────────────────────────────────────────── */ +.tab-pane { + display: flex; + flex-direction: column; + height: calc(100vh - 36px); + overflow: hidden; +} +.tab-pane.hidden { display: none !important; } + +/* The library panel's #app fills the full pane height */ +#tab-library #app { height: 100%; overflow: hidden; } + +/* The filebrowser #fb-app fills the full pane height */ +#tab-filebrowser #fb-app { height: 100%; } + +/* Embedded scan panel inside the filebrowser info pane */ +#fb-scan-panel-root #app { + flex-direction: column; + height: 100%; + overflow: hidden; +} +#fb-scan-panel-root #library { + width: 100% !important; + max-width: 100% !important; + border-right: none !important; + flex: 0 0 auto; + max-height: 50%; + overflow: hidden; +} +#fb-scan-panel-root .toolbar, +#fb-scan-panel-root .search, +#fb-scan-panel-root #spinner { display: none !important; } +#fb-scan-panel-root #projects { flex: 1; overflow-y: auto; padding: 4px; } +#fb-scan-panel-root #details { + flex: 1; + border-top: 1px solid var(--vscode-panel-border, #3c3c3c); + overflow: hidden; + min-height: 0; +} diff --git a/src/projspec/webui/tabs.js b/src/projspec/webui/tabs.js new file mode 100644 index 0000000..531efec --- /dev/null +++ b/src/projspec/webui/tabs.js @@ -0,0 +1,51 @@ +/* projspec combined-panel tab coordination. + * + * Shared by all GUI hosts. Must be loaded AFTER both panel.js and + * filebrowser.js have been initialised. + * + * Requires: + * window.__projspecTabInit(initialTab) — called once by the host + * bootstrap to activate the correct starting tab. + * + * The host delivers messages to each tab by calling: + * window.__libDispatch(msg) — deliver to library panel + * window.__projspecFbDeliver(msg) — deliver to filebrowser panel + * + * Both of these are set up by the respective panel bootstraps before + * this script calls __projspecTabInit. + */ +(function () { + var _activeTab = 'library'; + + function showTab(tab) { + _activeTab = tab; + ['library', 'filebrowser'].forEach(function (t) { + var pane = document.getElementById('tab-' + t); + var btn = document.getElementById('tab-btn-' + t); + if (!pane || !btn) return; + if (t === tab) { + pane.classList.remove('hidden'); + pane.classList.add('active'); + btn.classList.add('active'); + } else { + pane.classList.add('hidden'); + pane.classList.remove('active'); + btn.classList.remove('active'); + } + }); + } + + // Wire tab buttons + var libBtn = document.getElementById('tab-btn-library'); + var fbBtn = document.getElementById('tab-btn-filebrowser'); + if (libBtn) libBtn.addEventListener('click', function () { showTab('library'); }); + if (fbBtn) fbBtn.addEventListener('click', function () { showTab('filebrowser'); }); + + // Public API used by host to switch tabs programmatically + window.__projspecShowTab = showTab; + + // Called by host bootstrap once everything is set up + window.__projspecTabInit = function (initialTab) { + showTab(initialTab || 'library'); + }; +})(); From bb1a226ffabaffc53e4c9b6ad9cfbf28a1449c4e Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Wed, 8 Jul 2026 13:07:24 -0400 Subject: [PATCH 11/13] fix --- src/projspec/webui/ipywidget.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/projspec/webui/ipywidget.py b/src/projspec/webui/ipywidget.py index cb4ab72..1017f21 100644 --- a/src/projspec/webui/ipywidget.py +++ b/src/projspec/webui/ipywidget.py @@ -45,15 +45,15 @@ _ESM_TEMPLATE = r""" const PANEL_HTML_BODY = __PANEL_HTML_BODY__; -const FB_HTML_BODY = __FB_HTML_BODY__; -const PANEL_CSS = __PANEL_CSS__; -const PANEL_JS = __PANEL_JS__; -const FB_CSS = __FB_CSS__; -const FB_JS = __FB_JS__; -const TABS_CSS = __TABS_CSS__; -const TABS_JS = __TABS_JS__; +const FB_HTML_BODY = __FB_HTML_BODY__; +const PANEL_CSS = __PANEL_CSS__; +const PANEL_JS = __PANEL_JS__; +const FB_CSS = __FB_CSS__; +const FB_JS = __FB_JS__; +const TABS_CSS = __TABS_CSS__; +const TABS_JS = __TABS_JS__; const CHROME_ICONS = __CHROME_ICONS__; -const INITIAL_TAB = __INITIAL_TAB__; +const INITIAL_TAB = __INITIAL_TAB__; // CSS variable fallbacks so --vscode-* tokens resolve in notebook environments // that don't provide them (JupyterLab, Colab, VS Code notebooks, marimo). @@ -890,6 +890,8 @@ def _open_with(tool: str, url: str, toast) -> None: local = _url_to_local(url) if tool == "vscode": _spawn_detached(["code", local], toast) + elif tool == "filebrowser": + _open_with_default(local, toast) elif tool == "pycharm": _spawn_detached(["pycharm", local, "nosplash", "dontReopenProjects"], toast) elif tool == "jupyter": From 441865bce9ace252e1bb57951dc56f7e4d8af82a Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Wed, 8 Jul 2026 16:04:06 -0400 Subject: [PATCH 12/13] Fix pycharm plugin --- .../com/projspec/toolwindow/HtmlContent.kt | 56 ++++++++++++++++--- .../toolwindow/ProjspecToolWindowPanel.kt | 21 ++++--- .../com/projspec/util/ProjspecRunner.kt | 41 +++++++++++--- 3 files changed, 96 insertions(+), 22 deletions(-) diff --git a/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/HtmlContent.kt b/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/HtmlContent.kt index 30ba64c..898df47 100644 --- a/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/HtmlContent.kt +++ b/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/HtmlContent.kt @@ -23,25 +23,28 @@ object HtmlContent { @Volatile private var _cachedHtml: String? = null private fun buildHtmlFresh(): String { - // Build the bootstrap strings in Kotlin and pass them as argv[1..3] + // Build the bootstrap strings in Kotlin and pass them as argv[1..4] // so the Python script stays trivially simple and escaping-safe. val extraHead = "" - val libBootstrap = LIB_BRIDGE_BOOTSTRAP - val fbBootstrap = FB_BRIDGE_BOOTSTRAP + val libBootstrap = LIB_BRIDGE_BOOTSTRAP + val fbBootstrap = FB_BRIDGE_BOOTSTRAP + val scanPanelBootstrap = SCAN_PANEL_BOOTSTRAP // Minimal Python script — just imports and prints. All variable // content comes in through sys.argv to avoid any quoting issues. val script = """ import sys, json -extra_head = sys.argv[1] -lib_bootstrap = sys.argv[2] -fb_bootstrap = sys.argv[3] +extra_head = sys.argv[1] +lib_bootstrap = sys.argv[2] +fb_bootstrap = sys.argv[3] +scan_panel_bootstrap = sys.argv[4] try: from projspec.webui import get_combined_html html = get_combined_html( extra_head=extra_head, lib_bootstrap_js=lib_bootstrap, fb_bootstrap_js=fb_bootstrap, + scan_panel_bootstrap_js=scan_panel_bootstrap, ) sys.stdout.buffer.write(html.encode('utf-8')) except Exception as e: @@ -53,7 +56,7 @@ except Exception as e: return try { val cmd = GeneralCommandLine( - listOf("python3", "-c", script, extraHead, libBootstrap, fbBootstrap) + listOf("python3", "-c", script, extraHead, libBootstrap, fbBootstrap, scanPanelBootstrap) ) cmd.charset = Charsets.UTF_8 val output = CapturingProcessHandler(cmd).runProcess(30_000) @@ -148,6 +151,45 @@ and on PATH, then restart the IDE.

while (pending.length) bridge.query(JSON.stringify(pending.shift())); }; })(); +""" + + /** + * Bootstrap for the embedded scan sub-panel inside the file browser. + * Runs before the second panel.js invocation (which powers the directory + * scan widget inside #fb-scan-panel-root). Sets up: + * - window.projspecRoot → the #fb-scan-panel-root element + * - window.projspecTransport → no-op send; onReady captures the dispatch fn + * - window.__fbPanelDeliver → called by filebrowser.js to push data in + * + * The sub-panel only displays data; it never sends commands back. + */ + val SCAN_PANEL_BOOTSTRAP = """""" // ------------------------------------------------------------------------- diff --git a/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/ProjspecToolWindowPanel.kt b/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/ProjspecToolWindowPanel.kt index edd6226..6c3b064 100644 --- a/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/ProjspecToolWindowPanel.kt +++ b/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/ProjspecToolWindowPanel.kt @@ -773,14 +773,19 @@ class ProjspecToolWindowPanel( val so = server.parseSo(storageOptions) val data: Map = server.scanDirectory(url, so) ?: run { - // CLI fallback: projspec scan --json-out - val res = ProjspecRunner.runScan(url, storageOptions) - val rawJson = if (res is CliResult.Success) ProjspecRunner.extractJson(res.stdout) else "" - val proj = if (rawJson.isNotBlank()) { - try { gson.fromJson(rawJson, Map::class.java) } catch (_: Exception) { null } - } else null - mapOf("url" to url, "project" to proj, - "error" to if (rawJson.isBlank()) (res as? CliResult.Failure)?.message else null) + // CLI fallback: calls projspec.filebrowser.scan_directory directly via + // a short Python script so the result is always a {url, project, error} + // dict — even for directories with no matching spec types. + val rawJson = ProjspecRunner.runScanDirectory(url, storageOptions) + try { + @Suppress("UNCHECKED_CAST") + if (rawJson.isNotBlank() && rawJson != "{}") + gson.fromJson(rawJson, Map::class.java) as Map + else + mapOf("url" to url, "project" to null, "error" to "CLI scan produced no output") + } catch (_: Exception) { + mapOf("url" to url, "project" to null, "error" to "Could not parse CLI scan output") + } } deliverToFbWebview(mapOf( "type" to "projectScanned", diff --git a/pycharm_plugin/src/main/kotlin/com/projspec/util/ProjspecRunner.kt b/pycharm_plugin/src/main/kotlin/com/projspec/util/ProjspecRunner.kt index 923e668..0088482 100644 --- a/pycharm_plugin/src/main/kotlin/com/projspec/util/ProjspecRunner.kt +++ b/pycharm_plugin/src/main/kotlin/com/projspec/util/ProjspecRunner.kt @@ -39,14 +39,41 @@ object ProjspecRunner { * (a JSON string) so remote projects (s3://, gcs://, …) can be re-scanned * with their filesystem credentials/flags intact. */ - fun runScan(path: String, storageOptions: String? = null): CliResult { - val args = mutableListOf(cli, "scan", "--library") - if (!storageOptions.isNullOrBlank()) { - args.add("--storage_options") - args.add(storageOptions) + fun runScan(path: String, storageOptions: String? = null): CliResult { + val args = mutableListOf(cli, "scan", "--library") + if (!storageOptions.isNullOrBlank()) { + args.add("--storage_options") + args.add(storageOptions) + } + args.add(path) + return run(args) + } + + /** + * CLI fallback for the file-browser's directory scan panel. + * Calls `projspec.filebrowser.scan_directory` via a short Python script so + * the result always has the `{url, project, error}` shape that + * `showProjectInPanel` expects, even for directories that contain no + * recognised projspec spec types (where `projspec scan --json-out` would + * produce no output). + */ + fun runScanDirectory(url: String, storageOptions: String? = null): String { + val soArg = storageOptions?.takeIf { it.isNotBlank() } ?: "null" + val script = """ +import json, sys +url = sys.argv[1] +so = json.loads(sys.argv[2]) if sys.argv[2] != 'null' else None +try: + from projspec.filebrowser import scan_directory + result = scan_directory(url, storage_options=so) +except Exception as e: + result = {'url': url, 'project': None, 'error': str(e)} +print(json.dumps(result)) +""".trimIndent() + return when (val r = run(listOf("python", "-c", script, url, soArg))) { + is CliResult.Success -> extractJson(r.stdout).ifBlank { "{}" } + is CliResult.Failure -> "{}" } - args.add(path) - return run(args) } /** `projspec create ` — create a new spec inside a project. */ From f26c7a1bd8ce483a257e3e0d3101c1367c59aee4 Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Thu, 9 Jul 2026 10:27:12 -0400 Subject: [PATCH 13/13] Fix depr java --- .../com/projspec/toolwindow/ProjspecToolWindowPanel.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/ProjspecToolWindowPanel.kt b/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/ProjspecToolWindowPanel.kt index 6c3b064..2dc1e4c 100644 --- a/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/ProjspecToolWindowPanel.kt +++ b/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/ProjspecToolWindowPanel.kt @@ -14,6 +14,7 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VfsUtil import com.intellij.openapi.wm.ToolWindow import com.intellij.ui.jcef.JBCefBrowser +import com.intellij.ui.jcef.JBCefBrowserBase import com.intellij.ui.jcef.JBCefJSQuery import com.projspec.settings.ProjspecSettings import com.projspec.util.CliResult @@ -58,9 +59,9 @@ class ProjspecToolWindowPanel( private val gson = Gson() private val browser: JBCefBrowser = JBCefBrowser() /** Library-panel bridge (window.__javaBridge) */ - private val jsQuery: JBCefJSQuery = JBCefJSQuery.create(browser) + private val jsQuery: JBCefJSQuery = JBCefJSQuery.create(browser as JBCefBrowserBase) /** File-browser bridge (window.__javaFbBridge) */ - private val fbJsQuery: JBCefJSQuery = JBCefJSQuery.create(browser) + private val fbJsQuery: JBCefJSQuery = JBCefJSQuery.create(browser as JBCefBrowserBase) /** HTTP server — started off-EDT alongside the HTML build. */ private val server = ProjspecServer()