From e84c960369c19a64edef4f160174d57d1686c434 Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Fri, 10 Apr 2026 13:47:03 -0400 Subject: [PATCH 1/5] Copy layout --- qtapp/main.py | 423 +++++++---- qtapp/views.py | 1232 ++++++++++++++++++++++++++++++++ src/projspec/__main__.py | 7 + src/projspec/content/base.py | 2 +- src/projspec/proj/hf.py | 4 +- src/projspec/proj/published.py | 10 +- src/projspec/utils.py | 4 +- vsextension/README.md | 2 + 8 files changed, 1516 insertions(+), 168 deletions(-) create mode 100644 qtapp/views.py diff --git a/qtapp/main.py b/qtapp/main.py index ec15aba..ac34188 100644 --- a/qtapp/main.py +++ b/qtapp/main.py @@ -1,3 +1,4 @@ +import json import os.path import sys @@ -14,74 +15,140 @@ QStyle, QHBoxLayout, QVBoxLayout, - QDockWidget, QLineEdit, + QMessageBox, + QSplitter, ) from PyQt5.QtWebEngineWidgets import QWebEngineView -from PyQt5.QtCore import Qt, pyqtSignal # just Signal in PySide +from PyQt5.QtWebChannel import QWebChannel +from PyQt5.QtCore import Qt, pyqtSignal, QObject, pyqtSlot, QUrl +from PyQt5.QtGui import QIcon from projspec.library import ProjectLibrary +from projspec.utils import class_infos import projspec +from views import get_library_html, get_details_html + library = ProjectLibrary() +# --------------------------------------------------------------------------- +# WebChannel bridge — receives messages from JS and dispatches to a handler +# --------------------------------------------------------------------------- + + +class JsBridge(QObject): + """Exposed to JavaScript as ``bridge`` on the WebChannel. + + All JavaScript → Python calls go through ``handleMessage``. + The handler callback is set by the owner widget. + """ + + message_received = pyqtSignal(str) # emits raw JSON string + + def __init__(self, parent=None): + super().__init__(parent) + self._handler = None # callable(dict) + + def set_handler(self, handler): + self._handler = handler + + @pyqtSlot(str) + def handleMessage(self, message: str): + try: + data = json.loads(message) + except json.JSONDecodeError: + return + if self._handler: + self._handler(data) + + +def _make_web_view_with_channel( + bridge: JsBridge, +) -> tuple[QWebEngineView, QWebChannel]: + """Create a QWebEngineView with a QWebChannel pre-configured.""" + view = QWebEngineView() + channel = QWebChannel(view.page()) + channel.registerObject("bridge", bridge) + view.page().setWebChannel(channel) + return view, channel + + +# --------------------------------------------------------------------------- +# FileBrowserWindow +# --------------------------------------------------------------------------- + + class FileBrowserWindow(QMainWindow): - """A mini filesystem browser with project information + """A mini filesystem browser with project information. - The right-hand pane will populate with an HTML view of the selected item, - if that item is a directory and can be interpreted as any project type. + Left pane: filesystem tree. + Right pane: project details as the VS Code-style HTML panel. + Bottom dock: Library panel with the same HTML view as the VS Code extension. """ def __init__(self, path=None, parent=None): super().__init__(parent) - self.library = Library() + if path is None: - # implicitly local path = os.path.expanduser("~") self.fs: fsspec.AbstractFileSystem self.path: str self.fs, self.path = fsspec.url_to_fs(path) - self.addDockWidget(Qt.BottomDockWidgetArea, self.library) self.setWindowTitle("Projspec Browser") - self.setGeometry(100, 100, 950, 600) + self.setGeometry(100, 100, 1400, 700) + # Left pane — file browser left = QVBoxLayout() - # Create tree widget self.path_text = QLineEdit(path) self.path_text.returnPressed.connect(self.path_set) self.tree = QTreeWidget(self) self.tree.setHeaderLabels(["Name", "Size"]) - self.tree.setColumnWidth(0, 300) + self.tree.setColumnWidth(0, 250) self.tree.setColumnWidth(1, 50) left.addWidget(self.path_text) left.addWidget(self.tree) - # Connect signals self.tree.itemExpanded.connect(self.on_item_expanded) self.tree.currentItemChanged.connect(self.on_item_changed) - self.detail = QWebEngineView(self) - # self.detail.load(QUrl("https://qt-project.org/")) - self.detail.setFixedWidth(600) - self.library.project_selected.connect(self.detail.setHtml) + left_widget = QWidget(self) + left_widget.setLayout(left) + + # Middle pane — library + self.library_widget = LibraryWidget(self) + + # Right pane — details + self._detail_bridge = JsBridge(self) + self._detail_bridge.set_handler(self._on_detail_message) + self.detail, _ = _make_web_view_with_channel(self._detail_bridge) + self.detail.setHtml(_empty_detail_html()) + + self.library_widget.show_details.connect(self._show_project_details) - # Create central widget and layout central_widget = QWidget(self) self.setCentralWidget(central_widget) - layout = QHBoxLayout(central_widget) - layout.addLayout(left) - layout.addWidget(self.detail) - central_widget.setLayout(layout) + splitter = QSplitter(Qt.Horizontal, central_widget) + splitter.addWidget(left_widget) + splitter.addWidget(self.library_widget) + splitter.addWidget(self.detail) + splitter.setStretchFactor(0, 1) + splitter.setStretchFactor(1, 1) + splitter.setStretchFactor(2, 2) - # Status bar - self.statusBar().showMessage("Ready") + outer = QHBoxLayout(central_widget) + outer.setContentsMargins(0, 0, 0, 0) + outer.addWidget(splitter) + central_widget.setLayout(outer) - # Populate with home directory + self.statusBar().showMessage("Ready") self.populate_tree() + # ── Path / tree helpers ──────────────────────────────────────────────── + def path_set(self): try: self.fs, _ = fsspec.url_to_fs(self.path_text.text()) @@ -92,38 +159,25 @@ def path_set(self): self.populate_tree() def populate_tree(self): - """Populate the tree with the user's home directory""" self.tree.clear() root_item = QTreeWidgetItem(self.tree) root_item.setText(0, self.path) - - # Add a dummy child to make it expandable self.add_children(root_item, self.path) - - # Expand the root root_item.setExpanded(True) def add_children(self, parent_item, path): - """Add child items for a directory""" try: - # Get all items in directory details = self.fs.ls(path, detail=True) items = sorted(details, key=lambda x: (x["type"], x["name"].lower())) - for item in items: - # Skip hidden files (optional) name = item["name"].rsplit("/", 1)[-1] if name.startswith("."): continue - child_item = QTreeWidgetItem(parent_item) child_item.setText(0, name) child_item.setData(0, Qt.ItemDataRole.UserRole, item) - style = app.style() if item["type"] == "directory": - # TODO: change icon if it is in the library - # Add dummy child to make it expandable dummy = QTreeWidgetItem(child_item) dummy.setText(0, "Loading...") if ( @@ -138,7 +192,6 @@ def add_children(self, parent_item, path): else: child_item.setText(1, format_size(item["size"])) child_item.setIcon(0, style.standardIcon(QStyle.SP_FileIcon)) - except PermissionError: error_item = QTreeWidgetItem(parent_item) error_item.setText(0, "Permission Denied") @@ -149,10 +202,7 @@ def add_children(self, parent_item, path): error_item.setForeground(0, Qt.GlobalColor.red) def on_item_expanded(self, item): - """Handle item expansion - load children if not already loaded""" - # Check if we need to load children (has dummy child) if item.childCount() == 1 and item.child(0).text(0) == "Loading...": - # Remove dummy child item.removeChild(item.child(0)) path = item.data(0, Qt.ItemDataRole.UserRole)["name"] if path: @@ -160,11 +210,8 @@ def on_item_expanded(self, item): self.statusBar().showMessage(f"Loaded: {path}") def on_item_changed(self, item: QTreeWidgetItem): - import projspec - if not item: return - detail = item.data(0, Qt.ItemDataRole.UserRole) if detail is None: return @@ -174,83 +221,166 @@ def on_item_changed(self, item: QTreeWidgetItem): if proj.specs: style = app.style() item.setIcon(0, style.standardIcon(QStyle.SP_FileDialogInfoView)) - body = f"{proj._repr_html_()}" library.add_entry(path, proj) + self.library_widget.refresh() + self._show_project_details(path) else: - body = "" - self.library.refresh() # only on new item? - self.detail.setHtml(f"{body}") + self.detail.setHtml(_empty_detail_html()) + # ── Details panel ────────────────────────────────────────────────────── -def format_size(size: None | int) -> str: - """Format file size in human-readable format""" - if size is None: - return "" - for unit in ["B", "KB", "MB", "GB", "TB"]: - if size < 1024.0: - return f"{size:.1f} {unit}" - size /= 1024.0 - return f"{size:.1f} PB" + def _show_project_details(self, project_url: str, highlight_key: str = ""): + proj = library.entries.get(project_url) + if proj is None: + return + basename = project_url.split("/")[-1] or project_url + html = get_details_html(basename, project_url, proj.to_dict(), highlight_key) + self.detail.setHtml(html) + + def _on_detail_message(self, msg: dict): + cmd = msg.get("command") + if cmd == "makeArtifact": + item = msg.get("item", {}) + qname = item.get("qname") + project_url = item.get("projectUrl") + if qname and project_url: + self._make_artifact(project_url, qname) + + # ── Artifact make ────────────────────────────────────────────────────── + + def _make_artifact(self, project_url: str, qname: str): + proj = library.entries.get(project_url) + if proj is None: + QMessageBox.warning( + self, "Make Artifact", f"Project not found: {project_url}" + ) + return + try: + self.statusBar().showMessage(f"Making {qname} in {project_url}…") + art = proj.make(qname) + self.statusBar().showMessage(f"Done: {art}") + except Exception as e: + self.statusBar().showMessage(f"Make failed: {e}") + QMessageBox.warning( + self, "Make Artifact", f"Failed to make '{qname}':\n{e}" + ) + + +# --------------------------------------------------------------------------- +# LibraryWidget (replaces Library) +# --------------------------------------------------------------------------- -class Library(QDockWidget): - """Shows all scanned projects and allows filtering by various criteria""" - - project_selected = pyqtSignal(str) - - def __init__(self): - super().__init__() - self.setWindowTitle("Project Library") - self.widget = QWidget(self) - - # search control - swidget = QWidget(self.widget) - upper_layout = QHBoxLayout() - search = QPushButton("🔍") - search.clicked.connect(self.on_search_clicked) - clear = QPushButton("🧹") - upper_layout.addWidget(search) - upper_layout.addWidget(clear) - upper_layout.addStretch() - swidget.setLayout(upper_layout) - - # main list - self.list = QTreeWidget(self.widget) - self.list.setHeaderLabels(["Path", "Types"]) - self.list.itemClicked.connect(self.on_selection_changed) - self.list.setColumnWidth(0, 300) - - # main layout - layout = QVBoxLayout(self.widget) - layout.addWidget(self.list) - layout.addWidget(swidget) - self.setWidget(self.widget) - self.dia = SearchDialog(self) - self.dia.accepted.connect(self.refresh) - clear.clicked.connect(self.dia.clear) +class LibraryWidget(QWidget): + """Panel showing all scanned projects as an HTML view. + + Uses the same HTML view as the VS Code extension's Library panel. + """ + + show_details = pyqtSignal(str, str) # emits (project_url, highlight_key) + + def __init__(self, parent=None): + super().__init__(parent) + + self._bridge = JsBridge(self) + self._bridge.set_handler(self._on_message) + self._view, _ = _make_web_view_with_channel(self._bridge) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._view) + self.setLayout(layout) self.refresh() - def on_search_clicked(self): - self.dia.exec_() - - def on_selection_changed(self, item: QTreeWidgetItem): - path = item.text(0) - proj = library.entries[path] - body = f"{proj._repr_html_()}" - self.project_selected.emit(body) - - def refresh(self): - # any refresh reopens the pane if it was closed - self.list.clear() - data = library.filter(self.dia.search_criteria) - for path in sorted(data): - self.list.addTopLevelItem( - QTreeWidgetItem( - [path, library.entries[path].text_summary().rsplit(":", 1)[-1]] - ) - ) - self.show() + def refresh(self, scroll_to: str | None = None): + """Re-render the library HTML panel.""" + data = {url: proj.to_dict() for url, proj in library.entries.items()} + info_data = class_infos() + spec_names = list(info_data.get("specs", {}).keys()) + html = get_library_html(data, spec_names, scroll_to_project_url=scroll_to) + self._view.setHtml(html) + + def _on_message(self, msg: dict): + cmd = msg.get("command") + + if cmd == "scan": + # Scan the current workspace (use the first top-level path in the tree) + window = self.parent() + if isinstance(window, FileBrowserWindow): + path = window.path + try: + proj = projspec.Project(path, walk=True, fs=window.fs) + for url, child in proj.children.items(): + if child.specs: + library.add_entry(url, child) + if proj.specs: + library.add_entry(path, proj) + self.refresh(scroll_to=path) + except Exception as e: + self.refresh() + QMessageBox.warning(self, "Scan", f"Scan failed:\n{e}") + else: + self.refresh() + + elif cmd == "removeProject": + item = msg.get("item", {}) + project_url = item.get("infoData") + if project_url and project_url in library.entries: + del library.entries[project_url] + library.save() + self.refresh() + + elif cmd == "selectItem": + item = msg.get("item", {}) + project_url = item.get("projectUrl") + key = item.get("key", "") + if project_url: + self.show_details.emit(project_url, key) + + elif cmd == "makeArtifact": + item = msg.get("item", {}) + qname = item.get("qname") + project_url = item.get("projectUrl") + if qname and project_url: + window = self.parent() + if isinstance(window, FileBrowserWindow): + window._make_artifact(project_url, qname) + + elif cmd == "createProject": + project_type = msg.get("projectType", "") + window = self.parent() + if isinstance(window, FileBrowserWindow) and project_type: + path = window.path + try: + proj = projspec.Project(path, walk=False, fs=window.fs) + proj.create(project_type) + library.add_entry(path, proj) + self.refresh(scroll_to=path) + except Exception as e: + self.refresh() + QMessageBox.warning( + self, + "Create Project", + f"Failed to create '{project_type}':\n{e}", + ) + + elif cmd == "openProject": + # Open in the file-browser tree by updating path_text + item = msg.get("item", {}) + project_url = item.get("infoData", "") + this = self + while this is not None: + this = this.parent() + if isinstance(this, FileBrowserWindow) and project_url: + this.path_text.setText(project_url) + this.path_set() + break + + +# --------------------------------------------------------------------------- +# SearchDialog (unchanged from original) +# --------------------------------------------------------------------------- class SearchItem(QWidget): @@ -294,62 +424,39 @@ def on_which_changed(self, text): self.select.addItems([str(_) for _ in projspec.content.base.registry]) -class SearchDialog(QDialog): - """Set search criteria""" +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- - def __init__(self, parent=None): - super().__init__(parent) - self.criteria = [] - - right = QVBoxLayout() - ok = QPushButton("OK") - ok.clicked.connect(self.accept) - cancel = QPushButton("Cancel") - cancel.clicked.connect(self.reject) - right.addWidget(ok) - right.addWidget(cancel) - right.addStretch(0) - - mini_layout = QHBoxLayout() - add = QPushButton("+") - add.clicked.connect(self.on_add) - mini_layout.addWidget(add) - mini_layout.addStretch(0) - - self.layout = QVBoxLayout() - self.layout.addLayout(mini_layout) - self.layout.addStretch(0) - - all_layout = QHBoxLayout(self) - all_layout.addLayout(self.layout, 1) - all_layout.addLayout(right) - self.setLayout(all_layout) - - def on_add(self): - search = SearchItem(self) - search.removed.connect(self._on_search_removed) - self.layout.insertWidget(0, search) - self.criteria.append(search) - @property - def search_criteria(self): - return [_.criterion for _ in self.criteria if _.criterion is not None] +def format_size(size: None | int) -> str: + if size is None: + return "" + for unit in ["B", "KB", "MB", "GB", "TB"]: + if size < 1024.0: + return f"{size:.1f} {unit}" + size /= 1024.0 + return f"{size:.1f} PB" + + +def _empty_detail_html() -> str: + return """ +

Select a project directory to see its details.

""" - def clear(self): - for item in self.criteria: - self.layout.removeWidget(item) - self.criteria = [] - self.accepted.emit() - def _on_search_removed(self, search_widget): - self.layout.removeWidget(search_widget) - self.criteria.remove(search_widget) +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- def main(): global app app = QApplication(sys.argv) + icon = QIcon(os.path.join(os.path.dirname(__file__), "..", "logo.png")) + app.setWindowIcon(icon) window = FileBrowserWindow() + window.setWindowIcon(icon) window.show() sys.exit(app.exec()) diff --git a/qtapp/views.py b/qtapp/views.py new file mode 100644 index 0000000..a41b912 --- /dev/null +++ b/qtapp/views.py @@ -0,0 +1,1232 @@ +"""HTML view generators for the Qt application. + +These are ports of the TypeScript HTML panel generators in vsextension/src/extension.ts. +The Qt app calls Python directly instead of shelling out to subprocesses. +""" + +import json +import html as _html_mod +from typing import Any + +from projspec.utils import class_infos + + +def _escape(s: str) -> str: + return _html_mod.escape(str(s), quote=True) + + +def _get_info_data() -> dict: + """Return {specs, content, artifact} info dict (equivalent to getInfoData()).""" + return class_infos() + + +# --------------------------------------------------------------------------- +# Shared CSS strings +# --------------------------------------------------------------------------- + +_TREE_SHARED_CSS = """ + * { box-sizing: border-box; } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 13px; + color: #cccccc; + background-color: #1e1e1e; + margin: 0; + padding: 0; + position: relative; + } + + .tree { list-style: none; margin: 0; padding: 0; } + .tree-item { margin: 0; padding: 0; } + + .tree-node { + display: flex; + align-items: center; + padding: 4px 8px; + cursor: pointer; + border-radius: 4px; + transition: background-color 0.1s ease; + } + + .tree-node:hover { background-color: #2a2d2e; } + + .tree-node.selected { + background-color: #094771; + color: #ffffff; + } + + .tree-icon { + width: 16px; height: 16px; margin-right: 4px; + display: flex; align-items: center; justify-content: center; + cursor: pointer; flex-shrink: 0; + } + + .tree-icon.expandable::before { content: "▶"; font-size: 10px; transition: transform 0.1s ease; } + .tree-icon.expanded::before { transform: rotate(90deg); } + .tree-icon.leaf::before { + content: ""; width: 6px; height: 6px; + background: #888; border-radius: 50%; display: block; + } + + .tree-label { flex: 1; padding: 2px 4px; } + + .tree-children { list-style: none; margin: 0; padding-left: 20px; display: none; } + .tree-children.expanded { display: block; } + + .project-node { font-weight: bold; color: #dcb67a; } + .content-node { color: #4ec9b0; } + .artifact-node { color: #ce9178; } + .spec-node { color: #dcdcaa; } + .folder-node { color: #dcb67a; font-weight: 500; } + .field-node { color: #cccccc; } + + .field-value { color: #9cdcfe; font-style: italic; } + + .info-button { + width: 20px; height: 20px; border-radius: 50%; + background: #0e639c; color: #ffffff; + border: none; cursor: pointer; + display: flex; align-items: center; justify-content: center; + font-size: 12px; font-weight: bold; margin-left: 8px; + opacity: 0.7; transition: all 0.2s ease; flex-shrink: 0; + } + .info-button:hover { opacity: 1; background: #1177bb; transform: scale(1.1); } + + .make-button { + padding: 2px 8px; + background-color: #0e639c; color: #ffffff; + border: 1px solid #0e639c; border-radius: 2px; cursor: pointer; + font-size: 10px; font-family: inherit; margin-left: 8px; + opacity: 0.8; transition: all 0.2s ease; flex-shrink: 0; + } + .make-button:hover { opacity: 1; background-color: #1177bb; } + + .info-popup { + position: absolute; + background: #252526; border: 1px solid #454545; border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.5); + padding: 16px; max-width: 400px; min-width: 250px; + z-index: 1000; font-size: 13px; line-height: 1.5; display: none; + } + .info-popup.visible { display: block; } + .popup-header { + display: flex; align-items: center; margin-bottom: 12px; + padding-bottom: 8px; border-bottom: 1px solid #454545; + } + .popup-icon { + width: 20px; height: 20px; margin-right: 8px; border-radius: 50%; + background-color: #0e639c; color: #fff; + display: flex; align-items: center; justify-content: center; + font-weight: bold; font-size: 12px; flex-shrink: 0; + } + .popup-title { font-weight: bold; margin: 0; color: #cccccc; } + .popup-content { margin-bottom: 8px; } + .popup-section { margin-bottom: 12px; } + .popup-section:last-child { margin-bottom: 0; } + .section-title { + font-weight: bold; margin-bottom: 4px; color: #9e9e9e; + font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; + } + .section-content { white-space: pre-wrap; word-wrap: break-word; } + .popup-link { color: #3794ff; text-decoration: none; word-break: break-all; } + .popup-link:hover { text-decoration: underline; } + .no-info { color: #9e9e9e; font-style: italic; } + .info-popup::before { content: none; } + .info-popup::after { content: none; } + + .control-button { + padding: 2px 8px; + background-color: #3c3c3c; color: #cccccc; + border: 1px solid #454545; border-radius: 2px; + cursor: pointer; font-size: 11px; font-family: inherit; + } + .control-button:hover { background-color: #494949; } + .control-button.active { background-color: #0e639c; color: #fff; } + + .loading-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,0.35); + display: none; align-items: center; justify-content: center; + z-index: 4000; cursor: wait; + } + .loading-overlay.visible { display: flex; } + .loading-spinner { + width: 28px; height: 28px; + border: 3px solid #cccccc; border-top-color: transparent; + border-radius: 50%; animation: spin 0.7s linear infinite; opacity: 0.8; + } + @keyframes spin { to { transform: rotate(360deg); } } +""" + +_INFO_POPUP_JS = """ + function showInfoPopup(button, itemData) { + const popup = document.getElementById('info-popup'); + const popupTitle = document.getElementById('popup-title'); + const popupContent = document.getElementById('popup-content'); + const rect = button.getBoundingClientRect(); + const container = document.getElementById('tree-container'); + const containerRect = container.getBoundingClientRect(); + popup.style.top = (rect.top - containerRect.top - 10) + 'px'; + popupTitle.textContent = itemData.key || itemData.label || ''; + + let contentHtml = ''; + if (itemData.infoData && itemData.infoData.trim() !== '') { + let doc = '', link = ''; + try { + const info = JSON.parse(itemData.infoData); + doc = info.doc || ''; link = info.link || ''; + } catch(e) { + const parts = itemData.infoData.split('\\n\\n'); + doc = parts[0] || ''; link = parts[1] || ''; + } + const docParts = doc.split('\\n').map(p => p.trim()).filter(p => p.length > 0); + const summary = docParts[0] || ''; + const extra = docParts.slice(1); + if (summary) contentHtml += ''; + if (extra.length > 0) contentHtml += ''; + if (link) contentHtml += ''; + } + if (!contentHtml) { + contentHtml = '
Information for ' + (itemData.itemType || 'item') + ' type "' + (itemData.key || itemData.label || '') + '" is not currently available.
'; + } + popupContent.innerHTML = contentHtml; + // Position to the left of the button; measure after making visible so width is known + popup.style.left = '-9999px'; + popup.classList.add('visible'); + const popupRect = popup.getBoundingClientRect(); + popup.style.left = (rect.left - containerRect.left - popupRect.width - 10) + 'px'; + // If that goes off the left edge, fall back to right of the button + if (rect.left - popupRect.width - 10 < 0) { + popup.style.left = (rect.right - containerRect.left + 10) + 'px'; + } + if (popupRect.bottom > window.innerHeight) popup.style.top = (rect.bottom - containerRect.top - popupRect.height + 10) + 'px'; + } + + function hideInfoPopup() { + document.getElementById('info-popup').classList.remove('visible'); + } +""" + +_INFO_POPUP_HTML = """ +
+ + +
+""" + + +# --------------------------------------------------------------------------- +# Library / tree panel +# --------------------------------------------------------------------------- + + +def _build_tree_nodes(project_url: str, project: dict, info_data: dict) -> list: + """Equivalent to buildTreeNodes() in extension.ts.""" + children = [] + + def _build_tooltip(doc, link): + return json.dumps({"doc": doc or "", "link": link or ""}) + + # Top-level contents + for name in (project.get("contents") or {}).keys(): + basename = name.split("/")[-1] + content_type = basename.split(".")[0] + info = (info_data.get("content") or {}).get(content_type) + info_text = ( + _build_tooltip(info.get("doc"), info.get("link", "")) if info else None + ) + children.append( + { + "key": basename, + "infoData": info_text, + "projectUrl": project_url, + "itemType": "content", + } + ) + + # Top-level artifacts + for artifact_type, artifact_data in (project.get("artifacts") or {}).items(): + info = (info_data.get("artifact") or {}).get(artifact_type) + info_text = ( + _build_tooltip(info.get("doc"), info.get("link", "")) if info else None + ) + if isinstance(artifact_data, str): + children.append( + { + "key": artifact_type, + "infoData": info_text, + "projectUrl": project_url, + "itemType": "artifact", + "qname": artifact_type, + } + ) + elif isinstance(artifact_data, dict): + for name in artifact_data.keys(): + children.append( + { + "key": f"{artifact_type}.{name}", + "infoData": info_text, + "projectUrl": project_url, + "itemType": "artifact", + "qname": f"{artifact_type}.{name}", + } + ) + + # Specs + for spec_name, spec_data in (project.get("specs") or {}).items(): + info = (info_data.get("specs") or {}).get(spec_name) + info_text = ( + _build_tooltip(info.get("doc"), info.get("link", "")) if info else None + ) + spec_children = [] + for artifact_type, artifact_data in (spec_data.get("_artifacts") or {}).items(): + art_info = (info_data.get("artifact") or {}).get(artifact_type) + art_info_text = ( + _build_tooltip(art_info.get("doc"), art_info.get("link", "")) + if art_info + else None + ) + if isinstance(artifact_data, str): + spec_children.append( + { + "key": artifact_type, + "infoData": art_info_text, + "projectUrl": project_url, + "itemType": "artifact", + "qname": f"{spec_name}.{artifact_type}", + } + ) + elif isinstance(artifact_data, dict): + for name in artifact_data.keys(): + spec_children.append( + { + "key": f"{artifact_type}.{name}", + "infoData": art_info_text, + "projectUrl": project_url, + "itemType": "artifact", + "qname": f"{spec_name}.{artifact_type}.{name}", + } + ) + node = { + "key": spec_name, + "infoData": info_text, + "projectUrl": project_url, + "itemType": "spec", + } + if spec_children: + node["children"] = spec_children + children.append(node) + + return children + + +def _generate_tree_html(node: dict, level: int = 0) -> str: + """Equivalent to generateTreeHTML() in extension.ts.""" + html = "" + for child in node.get("children") or []: + has_children = bool(child.get("children")) + node_class = _get_node_class(child) + icon_class = "tree-icon expandable" if has_children else "tree-icon leaf" + has_info = child.get("itemType") in ("content", "artifact", "spec") + is_artifact = child.get("itemType") == "artifact" + item_data_json = _escape(json.dumps(child)) + key_text = _escape(str(child.get("key", ""))) + make_btn = ( + f'' + if is_artifact + else "" + ) + info_btn = ( + f'' + if has_info + else "" + ) + children_html = "" + if has_children: + children_html = f'' + html += f""" +
  • +
    + + {key_text} + {make_btn} + {info_btn} +
    + {children_html} +
  • """ + return html + + +def _get_node_class(node: dict) -> str: + if node.get("isProject"): + return "project-node" + elif node.get("itemType") == "content": + return "content-node" + elif node.get("itemType") == "artifact": + return "artifact-node" + elif node.get("itemType") == "spec": + return "spec-node" + elif node.get("children"): + return "folder-node" + return "" + + +def get_library_html( + library_data: dict, + spec_names: list[str], + scroll_to_project_url: str | None = None, +) -> str: + """Generate the Library panel HTML. + + Equivalent to getTreeWebviewContent() in extension.ts. + + Parameters + ---------- + library_data: + ``{project_url: project_dict}`` — the full library JSON. + spec_names: + List of known spec type names for the Create project autocomplete. + scroll_to_project_url: + If given, this project will be expanded and selected on load. + """ + info_data = _get_info_data() + + # Build tree data + project_children = [] + for project_url, project in library_data.items(): + pchildren = _build_tree_nodes(project_url, project, info_data) + project_basename = project_url.split("/")[-1] or project_url + node = { + "key": f"{project_basename} ({project_url})", + "infoData": project_url, + "children": pchildren, + "data": project, + "isProject": True, + } + project_children.append(node) + + tree_root = {"key": "projects", "children": project_children} + tree_html = _generate_tree_html(tree_root) + + spec_names_json = json.dumps(spec_names) + scroll_url_js = ( + f"'{_escape(scroll_to_project_url)}'" if scroll_to_project_url else "null" + ) + + return f""" + + + + + Project Library + + + + +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    + +
    + +
    +
    +
    + + {_INFO_POPUP_HTML} + +
    +
    Open in file manager
    +
    Remove from library
    +
    + + + + + +""" + + +# --------------------------------------------------------------------------- +# Details panel +# --------------------------------------------------------------------------- + +_SKIP_KEYS = {"klass", "proc", "storage_options", "children", "url"} + + +def _build_tooltip(doc, link): + return json.dumps({"doc": doc or "", "link": link or ""}) + + +def _build_detail_nodes( + obj: Any, role: str, qname_path: str, project_url: str, info_data: dict +) -> list: + """Equivalent to buildNodes() in extension.ts.""" + if obj is None: + return [] + + def scalar_label(v): + if v is None: + return "null" + return str(v) + + if isinstance(obj, list): + result = [] + for i, item in enumerate(obj): + if item is not None and isinstance(item, dict): + result.append( + { + "label": str(i), + "role": role, + "children": _build_detail_nodes( + item, + role, + f"{qname_path}.{i}", + project_url, + info_data, + ), + } + ) + else: + result.append({"label": scalar_label(item), "role": "field"}) + return result + + if not isinstance(obj, dict): + return [{"label": scalar_label(obj), "role": "field"}] + + nodes = [] + for key, value in obj.items(): + if key in _SKIP_KEYS: + continue + child_path = f"{qname_path}.{key}" if qname_path else key + + # Container keys — inline children with correct role + if key in ("specs", "_contents", "contents", "_artifacts", "artifacts"): + child_role = ( + "spec" + if key == "specs" + else "content" + if key in ("_contents", "contents") + else "artifact" + ) + nodes.extend( + _build_detail_nodes( + value, child_role, qname_path, project_url, info_data + ) + ) + continue + + # Artifact special handling + if role == "artifact": + art_info = (info_data.get("artifact") or {}).get(key) + art_info_data = ( + _build_tooltip(art_info.get("doc"), art_info.get("link", "")) + if art_info + else None + ) + + if isinstance(value, str) or value is None: + nodes.append( + { + "label": key, + "role": "artifact", + "qname": child_path, + "projectUrl": project_url, + "infoData": art_info_data, + "itemType": "artifact", + } + ) + elif isinstance(value, dict): + entries = list(value.items()) + all_strings = all(isinstance(v, (str, type(None))) for _, v in entries) + if all_strings: + named_children = [ + { + "label": name, + "role": "artifact", + "qname": f"{child_path}.{name}", + "projectUrl": project_url, + "itemType": "artifact", + } + for name, _ in entries + ] + nodes.append( + { + "label": key, + "role": "artifact", + "children": named_children or None, + "infoData": art_info_data, + "itemType": "artifact", + } + ) + else: + children = _build_detail_nodes( + value, "field", child_path, project_url, info_data + ) + nodes.append( + { + "label": key, + "role": "artifact", + "qname": child_path, + "projectUrl": project_url, + "children": children or None, + "infoData": art_info_data, + "itemType": "artifact", + } + ) + continue + + # Scalar leaf + if value is None or not isinstance(value, (dict, list)): + effective_role = ( + "content" + if role == "content" + else ("spec" if role == "spec" else "field") + ) + nodes.append( + { + "label": key, + "value": scalar_label(value), + "role": effective_role, + } + ) + continue + if isinstance(value, list): + if all(not isinstance(v, dict) for v in value): + array_children = [ + {"label": scalar_label(v), "role": "field"} for v in value + ] + effective_role = ( + "content" + if role == "content" + else ("spec" if role == "spec" else "field") + ) + nodes.append( + { + "label": key, + "role": effective_role, + "children": array_children or None, + } + ) + else: + nodes.append( + { + "label": key, + "role": role, + "children": _build_detail_nodes( + value, role, child_path, project_url, info_data + ), + } + ) + continue + + # Object value + children = _build_detail_nodes(value, role, child_path, project_url, info_data) + node_info_data = None + if role == "spec": + info = (info_data.get("specs") or {}).get(key) + if info: + node_info_data = _build_tooltip(info.get("doc"), info.get("link", "")) + elif role == "content": + info = (info_data.get("content") or {}).get(key) + if info: + node_info_data = _build_tooltip(info.get("doc"), "") + nodes.append( + { + "label": key, + "role": role, + "children": children or None, + "infoData": node_info_data, + "itemType": role if role not in ("none", "field") else None, + } + ) + + return nodes + + +def _is_leaf_artifact(node: dict) -> bool: + if node.get("role") != "artifact" or not node.get("qname"): + return False + children = node.get("children") + if not children: + return True + return not any(c.get("role") == "artifact" for c in children) + + +def _render_detail_node(node: dict, depth: int) -> str: + has_children = bool(node.get("children")) + can_make = _is_leaf_artifact(node) + has_info_popup = node.get("infoData") is not None and node.get("role") not in ( + "field", + "none", + ) + + role = node.get("role", "") + node_class = "tree-node" + if role == "spec": + node_class += " spec-node" + elif role == "content": + node_class += " content-node" + elif role == "artifact": + node_class += " artifact-node" + elif role == "field": + node_class += " field-node" + + icon_class = "tree-icon expandable" if has_children else "tree-icon leaf" + + node_data = _escape( + json.dumps( + { + "key": node.get("label", ""), + "label": node.get("label", ""), + "qname": node.get("qname"), + "projectUrl": node.get("projectUrl"), + "itemType": node.get("itemType"), + "infoData": node.get("infoData"), + } + ) + ) + + label = node.get("label", "") + value = node.get("value") + if value is not None: + label_html = ( + f'{_escape(label)}: {_escape(value)}' + ) + else: + label_html = _escape(label) + + children_html = "" + if has_children: + inner = "".join(_render_detail_node(c, depth + 1) for c in node["children"]) + children_html = ( + f'' + ) + + make_btn = ( + f'' + if can_make + else "" + ) + info_btn = ( + f'' + if has_info_popup + else "" + ) + + return f"""
  • +
    + + {label_html} + {make_btn} + {info_btn} +
    + {children_html} +
  • """ + + +def get_details_html( + project_basename: str, + project_url: str, + project: dict, + highlight_key: str | None = None, +) -> str: + """Generate the Details panel HTML for a single project. + + Equivalent to getDetailsWebviewContent() in extension.ts. + + Parameters + ---------- + project_basename: + Display name of the project (last path component). + project_url: + Full URL/path of the project. + project: + The project dict (from ``Project.to_dict()``). + highlight_key: + Dot-separated key path to scroll to and highlight on load. + """ + info_data = _get_info_data() + detail_nodes = _build_detail_nodes(project, "none", "", project_url, info_data) + tree_html = "".join(_render_detail_node(n, 0) for n in detail_nodes) + initial_key_js = json.dumps(highlight_key) if highlight_key else "null" + + return f""" + + + + + {_escape(project_basename)} — details + + + + +
    +
    {_escape(project_basename)}
    +
    {_escape(project_url)}
    +
    +
    + + + +
    +
    + +
    + + {_INFO_POPUP_HTML} + + + +""" diff --git a/src/projspec/__main__.py b/src/projspec/__main__.py index c71b703..ad9eb04 100755 --- a/src/projspec/__main__.py +++ b/src/projspec/__main__.py @@ -213,6 +213,13 @@ def list(json_out): print(f"{proj.text_summary(bare=True)}") +@library.command("clear") +def clear(): + from projspec.library import ProjectLibrary + + ProjectLibrary().clear() + + @library.command("delete") @click.argument("url") def delete(url): diff --git a/src/projspec/content/base.py b/src/projspec/content/base.py index c33f6d5..9b31d76 100644 --- a/src/projspec/content/base.py +++ b/src/projspec/content/base.py @@ -46,5 +46,5 @@ def to_dict(self, compact=False): dic["klass"] = ["content", self.snake_name()] for k in list(dic): if isinstance(dic[k], Enum): - dic[k] = dic[k].value + dic[k] = dic[k].to_dict(compact=False) return dic diff --git a/src/projspec/proj/hf.py b/src/projspec/proj/hf.py index 80c5bc4..7e62617 100644 --- a/src/projspec/proj/hf.py +++ b/src/projspec/proj/hf.py @@ -42,7 +42,9 @@ def parse(self) -> None: meta = txt.split("---\n")[1] try: meta = yaml.safe_load(StringIO(meta)) - except yaml.YAMLError: + except Exception as e: + raise ParseFailed from e + if not isinstance(meta, dict): raise ParseFailed if { "dataset_info", diff --git a/src/projspec/proj/published.py b/src/projspec/proj/published.py index 1fcb96a..99039ca 100644 --- a/src/projspec/proj/published.py +++ b/src/projspec/proj/published.py @@ -16,9 +16,7 @@ def parse(self) -> None: with self.proj.fs.open(self.proj.basenames["CITATION.cff"], "rt") as f: meta = yaml.safe_load(f) - self.contents["descriptive_metadata"] = DescriptiveMetadata( - proj=self.proj, meta=meta - ) + self.contents["descriptive_metadata"] = Citation(proj=self.proj, meta=meta) class Zenodo(ProjectExtra): @@ -31,11 +29,9 @@ def match(self): return ".zenodo.json" in self.proj.basenames def parse(self) -> None: - from projspec.content.metadata import DescriptiveMetadata + from projspec.content.metadata import Citation with self.proj.fs.open(self.proj.basenames[".zenodo.json"], "rt") as f: meta = yaml.safe_load(f) # TODO: extract known contents such as license. - self.contents["descriptive_metadata"] = DescriptiveMetadata( - proj=self.proj, meta=meta - ) + self.contents["descriptive_metadata"] = Citation(proj=self.proj, meta=meta) diff --git a/src/projspec/utils.py b/src/projspec/utils.py index 51d9888..0fcea37 100644 --- a/src/projspec/utils.py +++ b/src/projspec/utils.py @@ -112,6 +112,8 @@ def from_dict(dic, proj=None): return Project.from_dict(dic) category, name = dic.pop("klass") cls = get_cls(name, category) + if category == "enum": + return cls(dic["value"]) obj = object.__new__(cls) obj.proj = proj obj.__dict__.update({k: from_dict(v, proj=proj) for k, v in dic.items()}) @@ -375,7 +377,7 @@ def spec_class_qnames(registry="proj"): for cls in reg.values() ) ): - print(" ", s), + (print(" ", s),) for s in sorted( ( ".. autoclass:: " + ".".join([cls.__module__, cls.__name__]) diff --git a/vsextension/README.md b/vsextension/README.md index 7d57e74..18ca90a 100644 --- a/vsextension/README.md +++ b/vsextension/README.md @@ -7,6 +7,8 @@ code window to the library, show details (and search) any library entry, open a new Code window for a given library entry or "make" any artifact. +To run: open the directory vsextension/ in vscode and press F5! + ![screenshot](./im.png) Like the qt-app, this is POC experimental only. From 347f386649b423137c993c776e4e3d904e89dc21 Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Fri, 10 Apr 2026 16:30:48 -0400 Subject: [PATCH 2/5] fix file browser --- qtapp/main.py | 112 ++++++++++++++++++++++++++------------------------ 1 file changed, 59 insertions(+), 53 deletions(-) diff --git a/qtapp/main.py b/qtapp/main.py index ac34188..bb61fbd 100644 --- a/qtapp/main.py +++ b/qtapp/main.py @@ -1,5 +1,6 @@ import json import os.path +import posixpath import sys import fsspec @@ -102,17 +103,32 @@ def __init__(self, path=None, parent=None): # Left pane — file browser left = QVBoxLayout() + + nav_bar = QHBoxLayout() + home_btn = QPushButton("⌂") + home_btn.setToolTip("Home") + home_btn.setFixedWidth(32) + home_btn.clicked.connect(self.go_home) + up_btn = QPushButton("↑") + up_btn.setToolTip("Up") + up_btn.setFixedWidth(32) + up_btn.clicked.connect(self.go_up) self.path_text = QLineEdit(path) self.path_text.returnPressed.connect(self.path_set) + nav_bar.addWidget(home_btn) + nav_bar.addWidget(up_btn) + nav_bar.addWidget(self.path_text) + self.tree = QTreeWidget(self) self.tree.setHeaderLabels(["Name", "Size"]) self.tree.setColumnWidth(0, 250) self.tree.setColumnWidth(1, 50) - left.addWidget(self.path_text) + left.addLayout(nav_bar) left.addWidget(self.tree) self.tree.itemExpanded.connect(self.on_item_expanded) self.tree.currentItemChanged.connect(self.on_item_changed) + self.tree.itemDoubleClicked.connect(self.on_item_double_clicked) left_widget = QWidget(self) left_widget.setLayout(left) @@ -158,6 +174,27 @@ def path_set(self): self.path = self.path_text.text() self.populate_tree() + def go_home(self): + self.path_text.setText(os.path.expanduser("~")) + self.path_set() + + def go_up(self): + # Strip protocol so dirname doesn't eat into "bucket" or the leading slash. + stripped = str(self.fs._strip_protocol(self.path)) + parent_stripped = posixpath.dirname(stripped.rstrip("/")) + # If stripping consumed everything (e.g. already at root), stay put. + if not parent_stripped or parent_stripped == stripped: + return + self.path_text.setText(self.fs.unstrip_protocol(parent_stripped)) + self.path_set() + + def on_item_double_clicked(self, item: QTreeWidgetItem, column: int): + detail = item.data(0, Qt.ItemDataRole.UserRole) + if detail and detail.get("type") == "directory": + path = self.fs.unstrip_protocol(detail["name"]) + self.path_text.setText(path) + self.path_set() + def populate_tree(self): self.tree.clear() root_item = QTreeWidgetItem(self.tree) @@ -369,59 +406,17 @@ def _on_message(self, msg: dict): # Open in the file-browser tree by updating path_text item = msg.get("item", {}) project_url = item.get("infoData", "") - this = self - while this is not None: - this = this.parent() - if isinstance(this, FileBrowserWindow) and project_url: - this.path_text.setText(project_url) - this.path_set() - break - - -# --------------------------------------------------------------------------- -# SearchDialog (unchanged from original) -# --------------------------------------------------------------------------- - - -class SearchItem(QWidget): - """A single search criterion""" - - removed = pyqtSignal(QWidget) - - def __init__(self, parent=None): - super().__init__(parent) - layout = QHBoxLayout() - self.which = QComboBox(parent=self) - self.which.addItems(["..", "spec", "artifact", "content"]) - self.which.currentTextChanged.connect(self.on_which_changed) - layout.addWidget(self.which, 1) - - self.select = QComboBox(parent=self) - self.select.addItem("..") - layout.addWidget(self.select, 1) - - self.x = QPushButton("❌") - self.x.clicked.connect(self.on_x_clicked) - layout.addWidget(self.x) - self.setLayout(layout) - - @property - def criterion(self): - sel = self.select.currentText() - return (self.which.currentText(), sel) if sel != ".." else None - - def on_x_clicked(self, _): - self.removed.emit(self) + if project_url: + if project_url.startswith("file://"): + open_path(project_url) - def on_which_changed(self, text): - self.select.clear() - self.select.addItem("..") - if text == "spec": - self.select.addItems([str(_) for _ in projspec.proj.base.registry]) - elif text == "artifact": - self.select.addItems([str(_) for _ in projspec.artifact.base.registry]) - elif text == "content": - self.select.addItems([str(_) for _ in projspec.content.base.registry]) + # this = self + # while this is not None: + # this = this.parent() + # if isinstance(this, FileBrowserWindow) and project_url: + # this.path_text.setText(project_url) + # this.path_set() + # break # --------------------------------------------------------------------------- @@ -445,6 +440,17 @@ def _empty_detail_html() -> str:

    Select a project directory to see its details.

    """ +def open_path(path: str): + import subprocess + + if sys.platform == "darwin": + subprocess.call(["open", path]) + elif sys.platform == "win32": + os.startfile(path) + else: + subprocess.call(["xdg-open", path]) + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- From 4d7c513bfadd751f70ae313475937431f9bfe7b0 Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Fri, 10 Apr 2026 16:33:24 -0400 Subject: [PATCH 3/5] fix info links --- qtapp/main.py | 10 ++++++++-- qtapp/views.py | 8 +++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/qtapp/main.py b/qtapp/main.py index bb61fbd..371bd0b 100644 --- a/qtapp/main.py +++ b/qtapp/main.py @@ -2,6 +2,7 @@ import os.path import posixpath import sys +import webbrowser import fsspec from PyQt5.QtWidgets import ( @@ -276,7 +277,9 @@ def _show_project_details(self, project_url: str, highlight_key: str = ""): def _on_detail_message(self, msg: dict): cmd = msg.get("command") - if cmd == "makeArtifact": + if cmd == "openUrl": + webbrowser.open(msg.get("url", "")) + elif cmd == "makeArtifact": item = msg.get("item", {}) qname = item.get("qname") project_url = item.get("projectUrl") @@ -341,7 +344,10 @@ def refresh(self, scroll_to: str | None = None): def _on_message(self, msg: dict): cmd = msg.get("command") - if cmd == "scan": + if cmd == "openUrl": + webbrowser.open(msg.get("url", "")) + + elif cmd == "scan": # Scan the current workspace (use the first top-level path in the tree) window = self.parent() if isinstance(window, FileBrowserWindow): diff --git a/qtapp/views.py b/qtapp/views.py index a41b912..d783999 100644 --- a/qtapp/views.py +++ b/qtapp/views.py @@ -131,6 +131,12 @@ def _get_info_data() -> dict: .section-content { white-space: pre-wrap; word-wrap: break-word; } .popup-link { color: #3794ff; text-decoration: none; word-break: break-all; } .popup-link:hover { text-decoration: underline; } + .popup-link-btn { + background: none; border: none; padding: 0; cursor: pointer; + color: #3794ff; font-family: inherit; font-size: inherit; + text-align: left; word-break: break-all; white-space: normal; + } + .popup-link-btn:hover { text-decoration: underline; } .no-info { color: #9e9e9e; font-style: italic; } .info-popup::before { content: none; } .info-popup::after { content: none; } @@ -185,7 +191,7 @@ def _get_info_data() -> dict: const extra = docParts.slice(1); if (summary) contentHtml += ''; if (extra.length > 0) contentHtml += ''; - if (link) contentHtml += ''; + if (link) contentHtml += ''; } if (!contentHtml) { contentHtml = '
    Information for ' + (itemData.itemType || 'item') + ' type "' + (itemData.key || itemData.label || '') + '" is not currently available.
    '; From 9a1f183348a76e356a8eefd010aa979304b90e97 Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Fri, 10 Apr 2026 16:41:32 -0400 Subject: [PATCH 4/5] Add open-in-system-filebrowser --- qtapp/main.py | 32 ++++++++++++++++++++------------ qtapp/views.py | 9 +++++++-- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/qtapp/main.py b/qtapp/main.py index 371bd0b..803c94f 100644 --- a/qtapp/main.py +++ b/qtapp/main.py @@ -408,21 +408,29 @@ def _on_message(self, msg: dict): f"Failed to create '{project_type}':\n{e}", ) - elif cmd == "openProject": - # Open in the file-browser tree by updating path_text + elif cmd == "setBrowserPath": item = msg.get("item", {}) project_url = item.get("infoData", "") + this = self + while this is not None: + this = this.parent() + if isinstance(this, FileBrowserWindow) and project_url: + this.path_text.setText(project_url) + this.path_set() + break + elif cmd == "openInFileBrowser": + item = msg.get("item", {}) + project_url = item.get("infoData", "") + window = self.parent() if project_url: - if project_url.startswith("file://"): - open_path(project_url) - - # this = self - # while this is not None: - # this = this.parent() - # if isinstance(this, FileBrowserWindow) and project_url: - # this.path_text.setText(project_url) - # this.path_set() - # break + if project_url.startswith("file:///") or not "://" in project_url: + local_path = project_url.replace("file://", "") + open_path(local_path) + else: + if isinstance(window, FileBrowserWindow): + window.statusBar().showMessage( + f"Cannot open in file browser: not a local path ({project_url})" + ) # --------------------------------------------------------------------------- diff --git a/qtapp/views.py b/qtapp/views.py index d783999..7114416 100644 --- a/qtapp/views.py +++ b/qtapp/views.py @@ -545,7 +545,8 @@ def get_library_html( {_INFO_POPUP_HTML}
    -
    Open in file manager
    +
    Set browser path
    +
    Open in system file browser
    Remove from library
    @@ -768,7 +769,11 @@ def get_library_html( hideContextMenu(); }}); document.getElementById('context-open').addEventListener('click', () => {{ - if (contextMenuItem) postMessage({{ command: 'openProject', item: contextMenuItem }}); + if (contextMenuItem) postMessage({{ command: 'setBrowserPath', item: contextMenuItem }}); + hideContextMenu(); + }}); + document.getElementById('context-file-browser').addEventListener('click', () => {{ + if (contextMenuItem) postMessage({{ command: 'openInFileBrowser', item: contextMenuItem }}); hideContextMenu(); }}); document.getElementById('context-remove').addEventListener('click', () => {{ From bca2e945f3ff379d04a11f6b195921c004fa7eed Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Fri, 10 Apr 2026 16:50:37 -0400 Subject: [PATCH 5/5] Add open in ... --- qtapp/main.py | 48 +++++++++++++++++++++++++++++++-------- qtapp/views.py | 10 ++++++++ src/projspec/proj/rust.py | 29 +++++++++++------------ 3 files changed, 64 insertions(+), 23 deletions(-) diff --git a/qtapp/main.py b/qtapp/main.py index 803c94f..3e4cd23 100644 --- a/qtapp/main.py +++ b/qtapp/main.py @@ -423,14 +423,24 @@ def _on_message(self, msg: dict): project_url = item.get("infoData", "") window = self.parent() if project_url: - if project_url.startswith("file:///") or not "://" in project_url: - local_path = project_url.replace("file://", "") - open_path(local_path) - else: - if isinstance(window, FileBrowserWindow): - window.statusBar().showMessage( - f"Cannot open in file browser: not a local path ({project_url})" - ) + if project_url.startswith("file:///") or "://" not in project_url: + open_path(project_url.replace("file://", "")) + elif isinstance(window, FileBrowserWindow): + window.statusBar().showMessage( + f"Cannot open in file browser: not a local path ({project_url})" + ) + + elif cmd == "openInVSCode": + item = msg.get("item", {}) + project_url = item.get("infoData", "") + window = self.parent() + _open_local_with(project_url, ["code"], "VSCode") + + elif cmd == "openInJupyter": + item = msg.get("item", {}) + project_url = item.get("infoData", "") + window = self.parent() + _open_local_with(project_url, ["jupyter", "lab"], "Jupyter") # --------------------------------------------------------------------------- @@ -465,13 +475,33 @@ def open_path(path: str): subprocess.call(["xdg-open", path]) +def _open_local_with(project_url: str, cmd: list, app_name: str): + """Launch cmd + local_path for a project URL, showing a status message on error.""" + import subprocess + + if not project_url: + return + if project_url.startswith("file:///") or "://" not in project_url: + local_path = project_url.replace("file://", "") + try: + subprocess.call(cmd + [local_path]) + except Exception as e: + if isinstance(window, FileBrowserWindow): + window.statusBar().showMessage(f"Could not open in {app_name}: {e}") + else: + if isinstance(window, FileBrowserWindow): + window.statusBar().showMessage( + f"Cannot open in {app_name}: not a local path ({project_url})" + ) + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def main(): - global app + global app, window app = QApplication(sys.argv) icon = QIcon(os.path.join(os.path.dirname(__file__), "..", "logo.png")) app.setWindowIcon(icon) diff --git a/qtapp/views.py b/qtapp/views.py index 7114416..dbe4812 100644 --- a/qtapp/views.py +++ b/qtapp/views.py @@ -547,6 +547,8 @@ def get_library_html(
    Set browser path
    Open in system file browser
    +
    Open in VSCode
    +
    Open in Jupyter
    Remove from library
    @@ -776,6 +778,14 @@ def get_library_html( if (contextMenuItem) postMessage({{ command: 'openInFileBrowser', item: contextMenuItem }}); hideContextMenu(); }}); + document.getElementById('context-vscode').addEventListener('click', () => {{ + if (contextMenuItem) postMessage({{ command: 'openInVSCode', item: contextMenuItem }}); + hideContextMenu(); + }}); + document.getElementById('context-jupyter').addEventListener('click', () => {{ + if (contextMenuItem) postMessage({{ command: 'openInJupyter', item: contextMenuItem }}); + hideContextMenu(); + }}); document.getElementById('context-remove').addEventListener('click', () => {{ if (contextMenuItem) {{ setLoading(true); diff --git a/src/projspec/proj/rust.py b/src/projspec/proj/rust.py index 5ff2123..991b700 100644 --- a/src/projspec/proj/rust.py +++ b/src/projspec/proj/rust.py @@ -20,20 +20,21 @@ def parse(self): self.contents["desciptive_metadata"] = DescriptiveMetadata( proj=self.proj, meta=meta.get("package") ) - bin = AttrDict() - bin["debug"] = FileArtifact( - proj=self.proj, - cmd=["cargo", "build"], - # extension is platform specific - fn=f"{self.proj.url}/target/debug/{meta['package']['name']}.*", - ) - bin["release"] = FileArtifact( - proj=self.proj, - cmd=["cargo", "build", "--release"], - # extension is platform specific - fn=f"{self.proj.url}/target/release/{meta['package']['name']}.*", - ) - self.artifacts["file"] = bin + if "package" in meta: + bin = AttrDict() + bin["debug"] = FileArtifact( + proj=self.proj, + cmd=["cargo", "build"], + # extension is platform specific + fn=f"{self.proj.url}/target/debug/{meta['package']['name']}.*", + ) + bin["release"] = FileArtifact( + proj=self.proj, + cmd=["cargo", "build", "--release"], + # extension is platform specific + fn=f"{self.proj.url}/target/release/{meta['package']['name']}.*", + ) + self.artifacts["file"] = bin @staticmethod def _create(path: str) -> None: