diff --git a/qtapp/main.py b/qtapp/main.py index ec15aba..3e4cd23 100644 --- a/qtapp/main.py +++ b/qtapp/main.py @@ -1,5 +1,8 @@ +import json import os.path +import posixpath import sys +import webbrowser import fsspec from PyQt5.QtWidgets import ( @@ -14,74 +17,155 @@ 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 + + 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, 300) + self.tree.setColumnWidth(0, 250) self.tree.setColumnWidth(1, 50) - left.addWidget(self.path_text) + left.addLayout(nav_bar) left.addWidget(self.tree) - # Connect signals 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) + + # Middle pane — library + self.library_widget = LibraryWidget(self) - self.detail = QWebEngineView(self) - # self.detail.load(QUrl("https://qt-project.org/")) - self.detail.setFixedWidth(600) - self.library.project_selected.connect(self.detail.setHtml) + # 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()) @@ -91,39 +175,47 @@ 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): - """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 +230,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 +240,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 +248,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,16 +259,196 @@ 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: + self.detail.setHtml(_empty_detail_html()) + + # ── Details panel ────────────────────────────────────────────────────── + + 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 == "openUrl": + webbrowser.open(msg.get("url", "")) + elif 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 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 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 == "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): + 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: - body = "" - self.library.refresh() # only on new item? - self.detail.setHtml(f"{body}") + 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 == "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:///") 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") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- 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"]: @@ -193,163 +458,55 @@ def format_size(size: None | int) -> str: return f"{size:.1f} PB" -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) - - 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() - - -class SearchItem(QWidget): - """A single search criterion""" +def _empty_detail_html() -> str: + return """ +Select a project directory to see its details.
""" - 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) +def open_path(path: str): + import subprocess - @property - def criterion(self): - sel = self.select.currentText() - return (self.which.currentText(), sel) if sel != ".." else None + if sys.platform == "darwin": + subprocess.call(["open", path]) + elif sys.platform == "win32": + os.startfile(path) + else: + subprocess.call(["xdg-open", path]) - def on_x_clicked(self, _): - self.removed.emit(self) - 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]) +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})" + ) -class SearchDialog(QDialog): - """Set search criteria""" - 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 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 + global app, window 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..dbe4812 --- /dev/null +++ b/qtapp/views.py @@ -0,0 +1,1253 @@ +"""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; } + .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; } + + .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 += '' + p + '
').join('') + '