diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ed2c2a..e15727a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,9 @@ jobs: - name: Compile run: npm run compile + - name: Verify VSIX contents + run: npm run verify:package + - name: Unit tests run: npm run test:unit diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 300b11c..a323861 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -59,6 +59,9 @@ jobs: - name: Unit tests run: npm run test:unit + - name: Verify VSIX contents + run: npm run verify:package + - name: Package extension id: package run: | diff --git a/.vscodeignore b/.vscodeignore index 103937a..80da432 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -5,6 +5,7 @@ azure-pipelines.yml src/** webview-ui/** +scripts/** dist/test/** docs/** node_modules/** @@ -21,6 +22,11 @@ node_modules/** *.vsix .cursor/** agent-transcripts/** +.claude/** .impeccable/** +.kilo/** .productfeeling/** .redteam/** +design-qa.md +onboarding-design-qa/** +graphforge-quickstart/** diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0683b84 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to the GraphForge VS Code extension are documented here. + +## 0.1.2 + +### Added + +- Saved geospatial and temporal visualization artifacts, including arced airport routes. +- First-class AntV G6, G2, and L7 renderer options alongside Cytoscape, Sigma, and Plotly. +- Renderer-specific loading and lifecycle status for graph, chart, map, and timeline views. +- A Python/Jupyter air-routes analysis that uses the same sample data as the extension. +- Package-content verification before VSIX packaging. + +### Changed + +- Restored Cytoscape and Plotly as the default graph and chart renderers. +- Replaced the startup mode chooser with an artifact-backed path from environment to saved views. +- Start Projects, Ontology, and Knowledge sections collapsed. +- Kept all sample visualizations explicit and reopenable from project artifacts. + +### Fixed + +- Reopening saved visualizations now follows the same renderer-ready lifecycle as the E2E path. +- Existing quickstart projects repair newly added sample artifacts without replacing user results. +- Release packages exclude private review, agent, and local-workspace material. + +## 0.1.1 + +### Changed + +- Initial Marketplace and Open VSX release of GraphForge for VS Code. diff --git a/README.md b/README.md index bd8a3b3..e0dbf78 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,10 @@ Publisher: **CurateLabsAI** (`CurateLabsAI.graphforge`). | **Projects** | Activity-bar explorer for folders with a valid `FORMAT` marker | | **Ontology** | Mode badge + entity/relation tree; Ontology Viewer webview with a helpful exploratory empty state, **Load Ontology…**, and an Advanced section (open `ontology.json`, explain mode) | | **Knowledge** | Inspect and create assertions: list/empty states, **Create Assertion…** (minimal fields), **Show Assertion** / **Show on Graph**, plus Advanced attach-evidence / assess-confidence / record-status commands | -| **Result Graph** | AntV G6 Canvas by default, with retained Cytoscape and Sigma adapters; explicit artifact-owned layout, styling, interactions, and optional time configuration | -| **Charts, maps, and timelines** | AntV G2 analytical/temporal charts and L7 geospatial views from saved project artifacts; retained Plotly figure and v1 artifact compatibility | +| **Result Graph** | Cytoscape Canvas by default, with optional AntV G6 and Sigma adapters; explicit artifact-owned layout, styling, interactions, and optional time configuration | +| **Charts, maps, and timelines** | Plotly analytical charts by default; optional AntV G2 charts, G2 timelines, and L7 geospatial views from saved project artifacts | | **Results** | Interactive table in the bottom Panel; expandable nested JSON and row/cell selection linked to matching Result Graph nodes/edges | +| **Python notebooks** | A VS Code Jupyter path over the same air-routes sample: pandas inspection, native GraphForge bulk construction and PageRank, Plotly output, and saved `results/` / `visualizations/` artifacts | | **Modules** | One Module Bay for default, non-removable Query/Visualize/Import modules, future GraphForge-catalog modules, and advanced side-loads | ## Requirements @@ -51,6 +52,12 @@ The extension can run Cypher and analyst verbs through either engine binding: - **Node (`@curatelabs/graphforge`)** — the default. Fast, in-process, no subprocess. - **Python (`graphforge` on PyPI)** — a first-class alternative for analysts already living in a Python/notebook workflow, or when a native Node binding isn't available for your platform. + In the air-routes sample, choose **Open Python notebook** to run the same dataset through + pandas, PyArrow, GraphForge, and Plotly while publishing normal project artifacts. + +The command bridge preserves runtime parity, but the primary Python experience is +analyst-authored scripts and notebooks in the selected environment. GraphForge opens and +indexes those project files; it does not turn Python work into hidden extension state. Which one is used is controlled by `graphforge.runtime` (`auto` | `node` | `python`, default `auto`). In `auto`, **Node is the global default** — except when the workspace looks like a @@ -173,10 +180,10 @@ Run **GraphForge: Check Environment** any time to see where things stand — a 3 - **Visualization artifacts** — `Create Project Visualization` (`graphforge.createProjectVisualization`) creates a complete v2 graph, chart, geospatial, or temporal artifact from explicit bindings; `Save Project Visualization` and `Open Saved Visualization` return the saved `path` and `spec` (plus panel status when opened) Get Started's **Hub / Query / Visualize** pages are an editor over durable -project files: `queries/*.cypher`, `results/*`, `visualizations/*.gfviz.json`, -and `mutations/*.cypher`. New visualizations use the strict +project files: `queries/*.cypher`, `notebooks/*.ipynb`, `results/*`, +`visualizations/*.gfviz.json`, and `mutations/*.cypher`. New visualizations use the strict `graphforge.visualization/v2` contract; existing v1 Cytoscape/Sigma/Plotly files -remain readable and are not rewritten on open. The G6/G2 settings are creation +remain readable and are not rewritten on open. The graph/chart renderer settings are creation templates only: the resolved renderer, backend, layout, bindings, filters, coordinates, time settings, and presentation are saved into the artifact and remain authoritative when global defaults change. Missing or unsupported @@ -186,9 +193,11 @@ renderer substitution, or layout fallback. G2/L7/temporal panels expose the filtered rows in an accessible companion table. Material viewport or temporal-range changes become visibly dirty and require an explicit **Save**; **Revert** restores the committed artifact. The air-routes -sample copies its queries, results, and v1/v2 visualization specs into this -layout and generates its seed mutation there before execution; its bindings are -project files rather than extension constants. +sample copies its source data, query, notebook, supporting result, and v1/v2 +visualization specs into this layout and generates its seed mutation there +before execution. The notebook writes PageRank output under `results/` and an +extension-readable Plotly spec under `visualizations/`; its work is project +state rather than hidden extension state. Selecting a Results cell that contains a node/edge identity (including airport-style codes) highlights that element in an open Result Graph. Selecting a metric cell or whole row falls back diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..be1261d --- /dev/null +++ b/design-qa.md @@ -0,0 +1,41 @@ +# Get Started design QA + +## Comparison evidence + +- Source visual truth: `/Users/davidspencer/.codex/generated_images/019fc2ef-c3e0-7982-b174-c1c5478b3f11/exec-1b80fd0c-5fd4-46ef-97ba-af375ef3b8b8.png` +- Earlier AntV comparison capture: `onboarding-design-qa/implementation-e2e-g6.jpeg` +- Source pixels: 779 x 2018. Implementation capture: 1229 x 768 at the live VS Code Extension Development Host viewport. +- Density normalization: the implementation's GraphForge activity-bar column is approximately 296 CSS pixels wide; the source is a high-density single-column concept. Comparison therefore uses the source column against the implementation's left sidebar, not the unrelated editor, Results panel, or Chat surfaces in the full-host screenshot. +- Tested state: completed quickstart path with the durable query result restored. The current quickstart E2E opens the default saved v2 Cytoscape artifact and waits for its terminal readiness contract; the earlier image remains evidence of the explicit G6 path. The source depicts Environment as current; the different state is intentional and verifies that the same persistent spine survives completion. + +## Mandatory comparison passes + +| Surface | Evidence and result | +| --- | --- | +| Typography | Uses the VS Code font tokens at a compact sidebar scale. Heading, step title, detail, and artifact-path hierarchy remain distinct and readable at the live 296 px column width. No clipped or overlapping text was observed. | +| Spacing and layout | The implementation preserves the selected concept's centered hero, vertical five-node spine, one numbered marker per step, attached descriptions, and in-context primary action. It intentionally compresses vertical gaps to keep the entire path and the three closed supporting sections visible in a normal-height workbench. | +| Viewport resilience | Verified in the real narrow VS Code primary sidebar. Long project and artifact paths wrap within the column. Buttons remain inside the view, and Projects, Ontology, and Knowledge remain collapsed. | +| Colors and tokens | Accent, foreground, muted copy, border, button, error, and focus colors use VS Code theme tokens. Completed nodes use the GraphForge accent; current state uses the accent ring. No design-only hardcoded dark surface was introduced. | +| Image and icon fidelity | The real packaged `media/graphforge.svg` is used. Existing VS Code title-action icons remain native. No custom CSS art, inline SVG substitute, fake artifact icon, or decorative blob was added. | +| Copy and content | Replaced invented time estimates and the nonexistent `.graphforge/env.json` with truthful live progress, the selected runtime, the actual `FORMAT` marker, and real project artifact paths. | +| States and interactions | Verified Project current, Query current, query completion, saved result restoration, completed path, and saved visualization reopening through a terminal readiness wait. Missing visualization sources render a disabled `Needs result` control with an explanation. Every command now produces inline working/success/error status instead of appearing inert. | +| Accessibility | Uses an ordered list, `aria-current="step"`, screen-reader-only status labels, a polite live journey status, a polite command status, semantic buttons, disabled state, theme contrast tokens, and the packaged logo as intentionally decorative. | + +## Findings and fixes + +1. **P1, behavior:** the quickstart advertised `visualizations/airports-map.gfviz.json` before its referenced `results/query-result.json` existed. Clicking Open returned an artifact error and looked inert. Fixed by selecting only a resolvable result/visualization pair for journey progress and disabling unresolved visualization rows with explicit source-result copy. +2. **P1, artifact visibility:** scanning hid `results/query-result.json` whenever another named result existed. This removed the real routes result from Result History and prevented the journey from recognizing it after the query completed. Fixed by listing every valid durable result. +3. **P2, feedback:** Get Started fire-and-forgot commands and did not surface structured command outcomes inside the view. Fixed with an inline live action status that reports working, success, and error states. +4. **P1, E2E parity:** the journey chose the first matching artifact alphabetically and returned before renderer readiness, while the E2E deliberately opens the saved v2 Result Graph and waits for `renderReady` or `renderFailed`. Fixed by preferring the saved v2 graph for the configured renderer, passing the E2E readiness options, and treating a terminal renderer failure as an inline action error. +5. **P1, Cytoscape readiness:** renderer, options, and graph protocol messages each rebuilt the dense graph while opening a saved artifact. Fixed by applying those messages as one render snapshot so one open produces one layout and one terminal lifecycle. + +## Post-fix verification + +- The sample initially stops at Query and names `queries/templates/routes-overview.cypher`. +- Running it produced and displayed `results/query-result.json` with 7,430 rows and 10 columns. +- The completed journey names `visualizations/routes-network-default.gfviz.json`. +- **Open saved visualization** and the E2E both open **US routes network — Cytoscape**, wait for the same terminal lifecycle, and report ready only after the 579-node / 7,430-edge graph completes its render path. +- The live Extension Host log set was checked for `ERROR`, `Unhandled`, and `renderFailed`; no matches were present after the verified path. +- Unit/type checks passed after the fixes; full repository and packaging gates are recorded in the task handoff. + +final result: passed diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 0acba60..afe0d8c 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -16,7 +16,7 @@ | Moment | Beat | |---|---| -| **First open** | Premium concierge — Kilo-like guided welcome; clear path in | +| **First open** | One visible path from environment to a saved, inspectable graph view | | **Success** | Clear next options (never a dead end) | | **Failure** | Easy recovery; clear what failed | | **Wait** | Honest progress — what is happening, and whether it may be stuck | @@ -49,61 +49,46 @@ Visualization creation requires explicit kind and field bindings and returns the saved `{ path, spec, panel? }`; agents do not need to infer state from a canvas. The quickstart project carries an `AGENTS.md` copy of this local contract. -## Kilo-inspired workbench onboarding +## Path-first workbench onboarding First-run, missing-runtime, and no-project states share one **Get Started** sidebar webview (not raw error dumps): - Branded header (GraphForge logo), short headline, one sentence of context -- Checklist cards with step status (pending / current / done) and primary CTAs -- Flow: runtime → project (**Try sample project** / Open Project) → query (done on `hasLastResult`) → **see results** (Result Graph **and** Figure CTAs; done when both have been shown this session) -- The **Starter space** card stays visible before runtime setup, explains that the - sample needs a runtime, and keeps **Try sample project** directly reachable instead - of hiding it behind the runtime milestone. -- After the first result, the checklist gives way to a persistent **control hub**: - project/sample switching, Run Query, Results Table, Result Graph, Figure, - Find/Inspect, and Ontology. Get Started remains a return-to-work surface rather - than ending as a completed checklist. -- The control hub has **Hub / Query / Visualize** pages. Query authors save +- One persistent journey tree: **Environment → Project → Query → Result → Visualize** +- Exactly one current node, one primary next action, and quiet pending nodes so the + user sees both their current location and the entire shortest path to value. +- Progress comes from visible evidence: an active runtime, the project `FORMAT` + marker, a project-owned query, saved result JSON, and saved visualization JSON. + The map never advances from an opaque onboarding-complete flag. +- **Try the air-routes sample** is the secondary Project action. It follows the + same path and materializes its data, query, notebook, supporting result, and + visualization files inside the sample project. +- The sample Query node exposes **Open Python notebook** as a parallel analyst + path. The notebook reads the same copied CSVs and writes standard `results/` + and `visualizations/` artifacts; the extension does not hide or synthesize its + Python work. +- The journey remains visible after completion. Its final node reopens the saved + visualization rather than replacing the map with a feature dashboard. +- **Hub / Query / Visualize** title actions remain available. Query authors save `.cypher` files and reopen durable result history; visualization settings are saved as `.gfviz.json` files referencing a project result. Renderer/backend, layout, bindings, filters, chart encodings, geospatial coordinates/projection, and temporal range/playback are project state, not hidden webview state. - Buttons dispatch palette commands. Sample actions name project files under - `queries/` and `visualizations/`; query text and chart bindings never live in - extension constants. Nothing auto-opens both viz surfaces in Guided mode. + `queries/`, `notebooks/`, and `visualizations/`; query text, notebook analysis, + and chart bindings never live in extension constants. Result Graph does not auto-open by default; users may opt + into that independent preference in Settings. - Semantic kinds remain distinct even though their adapters share one artifact policy: Result Graph consumes `GraphPayload`; charts and timelines consume tabular results; maps consume explicit coordinate or GeoJSON fields. Raw Plotly Figure JSON remains a separate preview/interchange path. -- **Check Environment** link for full JSON diagnostics — never inline stack traces in the panel or toasts +- **Check details** at the Environment node opens full JSON diagnostics — never inline stack traces in the panel or toasts - Status bar click and setup recovery (`offerSetupRecovery`) open Get Started; capabilities doc only when a project is already open -Emulate Kilo Code’s **interaction patterns** (guided sidebar, cards, one primary CTA per step), not their brand colors — GraphForge keeps indigo accent (`#4c6ef5`) and the existing activity-bar icon. - -## Welcome + experience modes (phase 2) - -Kilo's "Choose how you want to work" step (REVIEW FIRST vs. HIGH AUTONOMY) maps -onto GraphForge's own vocabulary, not agent-autonomy language: - -- **Guided** (default) — confirms before Initialize on an empty folder, leaves - project auto-detection to the analyst, and keeps Result Graph closed until - asked. This is the checklist experience that already existed. -- **Autonomous** — auto-opens the first detected project on activation, skips - the Initialize confirmation for empty folders, and opens Result Graph after - every query. Still fails closed on destructive operations (non-empty-folder - init still confirms; the engine's write-mode/ontology-strictness guards are - unaffected). - -Persisted as `graphforge.experienceMode` (`guided` | `autonomous`, default -`guided`). The Get Started webview gains a **Welcome** screen — logo, one -sentence, two selectable mode cards, a single primary **Continue** — shown -before the runtime → project → query → see-results checklist the first time a -workspace opens the panel (detected via whether the setting has ever been -written, not a separate flag). "Change mode" in the checklist banner reopens -Welcome at any time; Continue re-applies the mode's settings. Status bar -clicks and `offerSetupRecovery` still land on this same panel (Kilo's -"Get Started / Next" promo-banner role is filled by the status bar + this -sidebar — GraphForge has no editor-level banner surface yet). +GraphForge uses VS Code's interaction language and its own indigo accent +(`#4c6ef5`). There is no startup persona or experience-mode choice. Opening a +project, initializing an empty folder, running a query, and creating a view are +explicit actions; destructive replacement of a non-empty target still confirms. ## Settings webview (phase 3, #24) @@ -113,7 +98,7 @@ It is a friendlier surface over the existing `graphforge.*` settings, not a second store: reads/writes go through `workspace.getConfiguration`, and the panel live-syncs with edits made in the VS Code Settings UI. -- Categories: **Runtime** (engine choice) / **Experience** (mode, Result Graph +- Categories: **Runtime** (engine choice) / **Visualizations** (Result Graph auto-open, G6/Cytoscape/Sigma and G2/Plotly creation templates) / **Advanced** (manual binding/interpreter paths). Copy is analyst-facing; "Project" waits until a project-scoped setting exists (no stub categories). @@ -162,10 +147,11 @@ The manifest and provider contracts live in ## Figure and analytical charts (#62 / #67) -New saved analytical and temporal artifacts use G2 by default. Their complete +New saved analytical chart artifacts use Plotly by default; G2 remains an +explicit chart option and the temporal adapter. Their complete encodings, transforms, filters, axes, theme, timezone, range, and playback configuration live in v2 JSON. The Vite-built **Figure** webview remains for raw -Plotly JSON, v1 Plotly artifacts, and an explicit v2 Plotly choice. Agents may +Plotly JSON, v1 Plotly artifacts, and v2 Plotly charts. Agents may still call `showFigure({ figure })` or `figureFromResult(...)`; this preview does not replace the project artifact contract. Optional Plotly limits default off. Dash is not the IDE host—see ADR-0001. @@ -173,21 +159,23 @@ Dash is not the IDE host—see ADR-0001. ## Result Graph workbench (#65) Result Graph is a Vite-built interactive network canvas over the existing -`GraphPayload` contract. New artifacts default to **G6 Canvas** with an explicit -worker ForceAtlas2 configuration; Cytoscape Canvas and Sigma WebGL remain -selectable adapters. All preserve epistemic/class colors, legends, empty states, +`GraphPayload` contract. New artifacts default to **Cytoscape Canvas** with an +explicit CoSE configuration; AntV G6 Canvas and Sigma WebGL remain selectable +adapters. All preserve epistemic/class colors, legends, empty states, pan, zoom, fit, re-layout, and click-to-inspect. The renderer setting chooses the next artifact template only. Saved configuration wins on reopen, and failures remain visible rather than changing renderer/layout or applying graph-size magic. -The G6 creation template keeps all graph elements but explicitly starts with +The optional G6 creation template keeps all graph elements but explicitly starts with edge labels and arrowheads disabled; those optional per-edge decorations remain editable and saved in the artifact. Readiness requires visible Canvas output, so an invisible scene graph with working hit targets is reported as a render failure. ## Geospatial and temporal artifacts (#67) -L7 renders geospatial artifacts from explicit longitude/latitude or GeoJSON -bindings, CRS, projection, layers, explicit blank offline basemap, and viewport. G2 renders +L7 renders geospatial artifacts from explicit point coordinates, source/target +link coordinates, or GeoJSON bindings, CRS, projection, ordered point/arc/line/polygon +layers, explicit blank offline basemap, and viewport. Link maps materialize unique +endpoint nodes and draw their saved arc layers without guessing field names. G2 renders temporal artifacts from explicit timestamp, timezone, granularity, value/series, range, window, and playback settings. Neither surface guesses fields or reaches for a remote provider. Both include a textual summary and accessible filtered @@ -197,6 +185,7 @@ commits validated JSON and Revert restores the last committed artifact. ## Visual notes - Activity Bar icon: simple node/edge mark (`media/graphforge.svg`). +- Get Started is the primary surface when the GraphForge container first opens; Projects, Ontology, and Knowledge are present but initially collapsed. - Result Graph: bundled G6/Cytoscape/Sigma adapters under strict CSP and the stable `GraphPayload` protocol. - Artifact visualization panel: bundled G2/L7 chart, map, and timeline adapters with Save/Revert and accessible rows. - Figure panel: retained full bundled `plotly.js` (`figure.js` / `figure.css`). diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index bdca6f7..97e51d9 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -12,9 +12,9 @@ context, and view epistemic-aware result graphs. | FR-2 | The system shall provide **Run Query** using editor selection or document text via `forge.execute`, persist the canonical `{ columns, rows, rowCount }` JSON result under the open project, and show it in an interactive table in the bottom Panel area. | PRODUCT — Cypher | Given an open project, When Run Query succeeds, Then `results/query-result.json` and `results/query-result.md` are written inside that project; the Results panel reveals a table with expandable nested/mixed values, and optionally a result graph opens. | | FR-3 | The system shall expose analyst verb commands: Rank, Cluster, Paths, Analyze, Similar, Find. | PRODUCT — verbs first-class | Given an open project, When a verb command runs with QuickPick inputs, Then the engine is invoked and results are shown. | | FR-4 | The system shall detect GraphForge projects only when `FORMAT` equals `graphforge-project/v1\n`. | Engine project format | Given a folder with exact FORMAT bytes, When the explorer refreshes, Then the project appears; wrong FORMAT does not. | -| FR-5 | The system shall provide Activity Bar views: Projects, Ontology, Knowledge. | PRODUCT — workbench | Given activation, When the GraphForge container opens, Then three views are present. | +| FR-5 | The system shall provide Activity Bar views: Projects, Ontology, Knowledge. These supporting views shall start collapsed when the GraphForge container first opens, leaving Get Started as the primary surface; users may expand them normally. | PRODUCT — workbench | Given activation, When the GraphForge container first opens, Then all three supporting views are present and collapsed. When a user expands a view, Then ordinary VS Code view-state behavior applies. | | FR-6 | The system shall open an Ontology Viewer webview showing mode and entity/relation/property trees. | PRODUCT — ontology | Given a project, When Show Ontology runs, Then mode badge and types render (or empty exploratory state). | -| FR-7 | The system shall open a Vite-built Result Graph over the stable `GraphPayload`. New saved graphs default to AntV G6 Canvas with a fully materialized worker ForceAtlas2 configuration; Cytoscape Canvas and Sigma WebGL remain explicit alternatives. The selected renderer, backend, layout, style, interactions, filters, and optional Timebar bindings belong to the visualization artifact. A saved choice is not overridden by later setting changes and a renderer/layout failure never silently substitutes another. | PRODUCT — epistemic viz; Issues #65/#67 | Given a new graph visualization, When it is saved, Then its complete G6 configuration exists in a v2 artifact before open. Given a v1 or v2 saved graph, When it opens, Then its recorded renderer is used and the file is not rewritten. Given renderer failure, Then a structured failure is shown and no fallback runs. | +| FR-7 | The system shall open a Vite-built Result Graph over the stable `GraphPayload`. New saved graphs default to Cytoscape Canvas with a fully materialized CoSE configuration; AntV G6 Canvas and Sigma WebGL remain explicit alternatives. The selected renderer, backend, layout, style, interactions, filters, and optional Timebar bindings belong to the visualization artifact. A saved choice is not overridden by later setting changes and a renderer/layout failure never silently substitutes another. While a renderer is not yet usable, the provisional canvas remains covered by a renderer-specific, accessible pipeline status naming the current truthful phase without invented percentages. | PRODUCT — epistemic viz; Issues #65/#67 | Given a new graph visualization, When it is saved, Then its complete Cytoscape configuration exists in a v2 artifact before open. Given a v1 or v2 saved graph, When it opens, Then its recorded renderer is used and the file is not rewritten. Given G6, Cytoscape, Sigma, G2, L7, or Plotly is rendering, Then the panel names its data/layout-or-composition/paint phase and exposes the canvas only after readiness. Given renderer failure, Then a structured failure is shown and no fallback runs. | | FR-8 | The system shall fail closed with a clear message when `@curatelabs/graphforge` cannot be loaded. | PRODUCT — quality | Given no native binding, When Open Project / Run Query is attempted, Then an error explains linking the binding. | | FR-9 | The system shall provide **Create Assertion…** with minimal fields (claim, subject UUID, subject kind) via the palette, minting UUIDv7 identities client-side. | PRODUCT — Knowledge (#6) | Given an open project, When Create Assertion runs and the claim/subject are provided, Then one assertion is written and the Knowledge view refreshes to show it. | | FR-10 | The system shall provide separate Advanced commands — **Attach Evidence…**, **Assess Confidence…**, **Record Assertion Status…** — kept out of Create Assertion. | PRODUCT — Knowledge (#6) | Given an existing assertion, When an Advanced command runs, Then the corresponding immutable ledger write completes without needing to know it exists during Create Assertion. | @@ -26,17 +26,17 @@ context, and view epistemic-aware result graphs. | FR-16 | The system shall provide `GraphForge: Setup Python Binding` as a single QuickPick (≤3 choices: use detected interpreter, select interpreter, install `graphforge` via **uv** with explicit consent). Package installs use **uv only, never pip**; if `uv` is not installed, the command tells the user to install uv first instead of falling back to pip. | Issue #12 (UX doctrine from #1: palette-first, no cascading menus; uv-only per product feedback) | Given the command runs, When a choice is made, Then `graphforge.pythonInterpreterPath` is set and/or `uv add graphforge` (in a uv project) or `uv pip install graphforge` runs only after an explicit confirmation dialog; if `uv` is missing, no install command runs and the user is directed to install uv. | | FR-17 | The system shall detect Python interpreters in priority order: explicit config, VS Code Python extension selection, workspace venv (`.venv`/`venv`/`env`/`.conda`), then PATH. | Issue #12 | Given multiple candidates exist, When resolving, Then the first one with an importable `graphforge` wins. | | FR-18 | The system shall classify the workspace as Python-first, Node-ish, or ambiguous based on Python markers (`pyproject.toml`, `requirements.txt`, `uv.lock`, `.python-version`, `Pipfile`, `environment.yml`, `setup.py`, notebook-dominant root, or an explicitly selected VS Code Python interpreter) versus Node markers (`package.json`), and use this to bias `graphforge.runtime: auto` toward Python in Python-first workspaces even when `@curatelabs/graphforge` is available. When both marker sets are present, Python wins only given a strong signal (`pyproject.toml`/`uv.lock`, or Python `graphforge` already usable); otherwise the workspace is ambiguous and Node stays the default. | Issue #12 (product feedback: Python-prefer in Python repos) | Given a workspace with `pyproject.toml` and no `package.json`, When `graphforge.runtime` is `auto` and both Node and Python are usable, Then Python is selected. Given a workspace with both `package.json` and `pyproject.toml`/`uv.lock`, When `auto` resolves, Then Python is still selected. An explicit `graphforge.runtime` of `node` or `python` always overrides project-kind detection. | -| FR-19 | The system shall make AntV G2 the creation default for saved analytical and temporal artifacts while retaining the **Figure** webview for raw Plotly figure JSON, v1 Plotly artifacts, and explicit v2 Plotly charts. Optional Plotly size limits default off. All renderer assets are bundled locally; failures return structured errors without replacing a prior valid artifact or panel state. | Issues #62/#67 | Given explicit chart bindings, When a chart artifact is created, Then a complete G2 v2 spec is saved and returned. Given raw Plotly JSON or an existing Plotly artifact, When opened, Then the retained Figure adapter runs without migration. | -| FR-20 | The system shall provide a **quickstart sample** (`graphforge.openSampleProject`) built from the vendored Apache-2.0 US air-routes dataset. Its query, source data, seed mutation, supporting temporal result, and v1/v2 graph/chart/geospatial/temporal specs shall be project files copied into the sample. | Issue #63 + #67 | Given a usable runtime, When Try sample runs, Then a FORMAT project opens with visible `queries/`, `results/`, `visualizations/`, `data/`, and `mutations/`. When its saved specs are opened, Then legacy adapters and AntV views use their explicit project-owned inputs. | +| FR-19 | The system shall make Plotly the creation default for saved analytical chart artifacts while retaining AntV G2 as an explicit chart option and the required temporal adapter. The **Figure** webview handles raw Plotly figure JSON plus v1 and v2 Plotly charts. Optional Plotly size limits default off. All renderer assets are bundled locally; failures return structured errors without replacing a prior valid artifact or panel state. | Issues #62/#67 | Given explicit chart bindings with no renderer override, When a chart artifact is created, Then a complete Plotly v2 spec is saved and returned. Given an explicit G2 renderer, Then a complete G2 v2 spec is saved. Given raw Plotly JSON or an existing Plotly artifact, When opened, Then the retained Figure adapter runs without migration. | +| FR-20 | The system shall provide a **quickstart sample** (`graphforge.openSampleProject`) built from the vendored Apache-2.0 US air-routes dataset. Its query, Python/Jupyter notebook, source data, seed mutation, supporting temporal result, and v1/v2 graph/chart/geospatial/temporal specs shall be project files copied into the sample. The notebook shall use GraphForge Python directly for native graph analytics and publish normal result/visualization artifacts rather than hidden extension state. | Issue #63 + #67 | Given a usable runtime, When Try sample runs, Then a FORMAT project opens with visible `queries/`, `notebooks/`, `results/`, `visualizations/`, `data/`, and `mutations/`. Given a Python kernel with GraphForge, pandas, PyArrow, and Plotly, When the notebook runs top to bottom, Then it reads the copied 586-airport/7,430-route CSVs, runs native PageRank, renders Plotly, and writes an extension-readable result plus v2 Plotly spec. | | FR-21 | The Results panel shall link table selections to an open Result Graph using explicit node/edge IDs, identity-like values (including codes), or graph-shaped row endpoints; graph selections shall reveal matching result rows. | PRODUCT — linked exploration | Given a result row containing `source`/`target` or an identity cell, When the user selects it, Then matching graph elements highlight. Given a graph node/edge click, Then matching table rows highlight. Figure linking is excluded until figure traces retain source-row provenance. | -| FR-22 | Get Started shall remain a persistent **Hub / Query / Visualize** control panel. Visualize lists, creates, filters, saves, and reopens graph, chart, geospatial, and temporal artifacts from explicit result-column choices. `graphforge.resultGraph.renderer` and `graphforge.chart.renderer` are creation templates, not live overrides for saved work. | Product feedback + #67 | Given an open project, When Visualize creates a view, Then a complete v2 artifact is saved before its renderer opens. Given a saved artifact, When global defaults change, Then reopen still uses the saved configuration. | +| FR-22 | Get Started shall show one persistent **Environment → Project → Query → Result → Visualize** journey tree, with exactly one current node and its simplest primary next action. Progress shall come from runtime state and user-visible project artifacts, not a startup persona, hidden completion flag, or automatic project selection. The sample Query node shall also expose its project-owned Python notebook as a parallel analyst path. **Hub / Query / Visualize** remain available; Query lists every durable result including `results/query-result.json`, while Visualize lists, creates, filters, saves, and reopens graph, chart, geospatial, and temporal artifacts from explicit result-column choices. An artifact whose source result does not exist is visibly unavailable rather than exposed as a dead Open action. `graphforge.resultGraph.renderer` and `graphforge.chart.renderer` are creation templates, not live overrides for saved work. | Product feedback + #67 | Given the sample project, When Query is current or complete, Then **Open Python notebook** opens `notebooks/air-routes-analysis.ipynb` without installing packages or selecting a kernel. Given any startup state, Then all five nodes remain visible and one available incomplete node is current. Given Visualize creates a view, Then a complete v2 artifact is saved before its renderer opens. | | FR-23 | Durable workbench state shall live inside the open project using `queries/`, `results/`, `visualizations/`, and `mutations/` (or `data/`). Material visualization changes shall show dirty state and require explicit Save; Revert restores the last committed artifact. Hover, transient selection, and the current playback frame may remain session-only. | Product mandate + #67 | Given a viewport or temporal range change, When it occurs, Then Save/Revert become available. Given Save, Then the validated spec is replaced; Given Revert, Then the committed spec is restored. | | FR-24 | IDE coding agents shall have versioned context plus stable path-driven artifact operations. `graphforge.createProjectVisualization` requires explicit bindings and `create`/`save` return `{ path, spec, panel? }`; open returns the saved spec and panel outcome. Mutations remain separately confirmed. | Agent interoperability tuning + #67 | Given an agent creates a visualization with complete arguments, When the command resolves, Then the returned path identifies a saved project artifact and the returned spec contains every material renderer decision without webview scraping. | | FR-25 | Query, Visualize, Import, and Integration (shown as Connect) shall use one manifest-driven module manager for discovery, installation, enablement, activation, deactivation, and status. Query, Visualize, and Import are default modules: they ship installed and enabled, may be disabled, and cannot be removed through the module lifecycle. | PRODUCT — modular workbench | Given first activation, When module state is initialized, Then all three default modules are installed, enabled, and marked non-removable. When a module is disabled, its registrations are disposed without reloading the extension host. | | FR-26 | The module manager shall discover modules from three provider kinds: default manifests, the optional GraphForge `moduleCatalog()` binding surface, and side-loaded manifests. The future GraphForge catalog is the normal distribution path for modules not shipped in this repository; side-loading is an advanced/local path. All providers shall be validated by the same manifest contract. | Module architecture | Given catalog or side-loaded providers return valid manifests, When discovery refreshes, Then every module appears in one inventory with its source and status. Given an older binding without `moduleCatalog()`, Then discovery continues with an empty GraphForge catalog. Given an invalid or reserved side-loaded manifest, Then installation fails without executing module code. | | FR-27 | `GraphForge: Manage Modules` shall open a Vite-built Module Bay that filters by capability and shows source, version, status, install, enable/disable, remove, and available module actions. The extension host shall remain authoritative for every state transition. | DESIGN — Module Bay | Given modules from multiple providers, When the Module Bay opens or an action completes, Then it renders the latest host snapshot in VS Code themes with keyboard-accessible controls; first-party modules do not offer Remove. | | FR-28 | The first-party Import module shall import CSV, JSON, JSONL, and NDJSON files as nodes through a parameterized engine mutation. It shall support create or merge mode, an analyst-selected label and identifier property, explicit confirmation, a 25 MB file limit, and a 100,000-row limit. | PRODUCT — Import module | Given a supported local file and confirmed options, When Import Data runs, Then rows are parsed and written using parameters and a structured result reports the imported count. Given invalid data, a missing merge identifier, cancellation, or a size/row limit violation, Then no mutation is submitted and the failure is actionable. | -| FR-29 | The system shall render v2 geospatial artifacts with L7 and v2 temporal artifacts with G2 from explicit bindings. Geospatial specs record coordinates/GeoJSON, CRS, projection, layers, an explicit blank offline basemap, and viewport. Temporal specs record timestamp, timezone, granularity, value/series, range, window, and playback. Both expose an accessible filtered-data companion. | Issue #67 | Given complete geo or temporal bindings, When the artifact opens offline, Then its saved settings drive the view and companion table. Given an incomplete or remote-data configuration, Then validation fails without inference or network fallback. | +| FR-29 | The system shall render v2 geospatial artifacts with L7 and v2 temporal artifacts with G2 from explicit bindings. Geospatial specs record point coordinates, source/target link coordinates, or GeoJSON; CRS, projection, ordered point/arc/line/polygon layers, an explicit blank offline basemap, and viewport. Link artifacts render saved arc layers between their endpoint nodes without inferred field names. Temporal specs record timestamp, timezone, granularity, value/series, range, window, and playback. Both expose an accessible filtered-data companion. | Issue #67 | Given explicit source and target longitude/latitude bindings plus an arc layer, When the artifact opens offline, Then L7 draws curved links between the deduplicated endpoint points. Given complete geo or temporal bindings, When the artifact opens offline, Then its saved settings drive the view and companion table. Given an incomplete or remote-data configuration, Then validation fails without inference or network fallback. | ## Non-functional requirements @@ -87,12 +87,12 @@ context, and view epistemic-aware result graphs. - `graphforge` (PyPI) + `pyarrow` — Python engine facade (alternative runtime, #12); resolved in a user-selected interpreter, never bundled with the extension - `apache-arrow` — IPC table decode (shared by both runtimes) -- `@antv/g6` + `@antv/layout` — default Result Graph Canvas renderer and worker ForceAtlas2 layout -- `@antv/g2` — default analytical and temporal chart renderer +- `cytoscape` — default Result Graph Canvas renderer and CoSE force layout +- `@antv/g6` + `@antv/layout` — explicit Result Graph Canvas renderer and worker ForceAtlas2 layout +- `@antv/g2` — explicit analytical chart renderer and required temporal renderer - `@antv/l7` + `@antv/l7-maps` — first-class geospatial renderer with an explicit blank offline map -- `cytoscape` — retained Canvas Result Graph adapter and COSE force layout - `sigma` + `graphology` + `graphology-layout-forceatlas2` — retained WebGL Result Graph adapter and force layout -- `plotly.js` — retained raw Figure, v1 artifact, and explicit v2 Plotly adapter +- `plotly.js` — default analytical chart renderer plus raw Figure and v1 compatibility paths - GraphForge engine project format and ontology participant layout - Optional GraphForge engine module catalog (`moduleCatalog()`); absence is a supported compatibility state diff --git a/docs/engineering/ARCHITECTURE.md b/docs/engineering/ARCHITECTURE.md index 431410d..0c1aa03 100644 --- a/docs/engineering/ARCHITECTURE.md +++ b/docs/engineering/ARCHITECTURE.md @@ -169,7 +169,8 @@ any breaking wire change, and document it here. - **ProjectVisualizationSpecV2** — strict writer contract for `result-graph`, `chart`, `geospatial`, and `temporal`. It references a project result and explicitly records renderer, bindings, filters, layout/coordinate/time, and - presentation configuration. + presentation configuration. Geospatial link sources name both endpoint + coordinate pairs and their ordered L7 point/arc layers. Workbench artifact layout: @@ -179,6 +180,7 @@ results/query-result.json + query-result.md + timestamped history visualizations/*.gfviz.json mutations/*.cypher data/… (sample source data and attribution) +notebooks/… (project-owned Python/Jupyter analysis that publishes normal artifacts) ``` Epistemic statuses: `hypothesis | supported | refuted | disputed | retracted | superseded | statusless`. @@ -237,9 +239,11 @@ intentionally out of v0 rather than inferred from point order. 2. The host opens the G2/L7 artifact panel. An explicitly selected v2 Plotly **chart** uses the retained Figure adapter; v2 temporal artifacts are G2-only. The command returns the artifact path, complete spec, and panel outcome. -3. The webview renders an accessible filtered-row companion and reports local +3. L7 link sources materialize unique endpoint points and pass each valid + source/target row to the saved arc layer without field-name inference. +4. The webview renders an accessible filtered-row companion and reports local lifecycle events without graph properties, rows, coordinates, timestamps, or paths. -4. Viewport or temporal-range changes create a host-validated draft and visible +5. Viewport or temporal-range changes create a host-validated draft and visible dirty state. Save atomically replaces the artifact; Revert restores committed JSON. ### Open ontology @@ -253,11 +257,11 @@ intentionally out of v0 rather than inferred from point order. - **Error handling:** Fail closed on missing binding or invalid FORMAT; `showErrorMessage`. When no runtime is usable, the message and recovery actions cover **both** Setup commands (#12). - **Configuration:** `graphforge.nativeModulePath`, `graphforge.openResultGraphOnQuery`, - `graphforge.resultGraph.renderer` (`g6` creation default; retained `cytoscape`/`sigma`), - `graphforge.chart.renderer` (`g2` creation default; retained `plotly`), `graphforge.runtime`, - `graphforge.pythonInterpreterPath`, `graphforge.experienceMode` - (`guided` | `autonomous`, default `guided` — set from the Get Started Welcome screen; see - `docs/DESIGN.md` "Welcome + experience modes"). + `graphforge.resultGraph.renderer` (`cytoscape` creation default; optional `g6`/`sigma`), + `graphforge.chart.renderer` (`plotly` creation default; optional `g2`), `graphforge.runtime`, + and `graphforge.pythonInterpreterPath`. Get Started derives its journey from + runtime/session state plus project-owned artifacts; it has no separate onboarding + mode or completion setting. - **Package manager policy:** Python package installs use **`uv` only, never `pip`** — `GraphForge: Setup Python Binding`'s install choice runs `uv add graphforge` in a uv-managed project (`pyproject.toml`/`uv.lock` present) or `uv pip install --python @@ -283,8 +287,9 @@ intentionally out of v0 rather than inferred from point order. ## Decisions - Optional peer on `@curatelabs/graphforge` so the extension installs without a prebuilt napi binary. -- AntV G6 Canvas and G2 are creation defaults; L7 is the geospatial adapter. - Cytoscape, Sigma, and Plotly remain explicit alternatives. v1 remains readable, +- Cytoscape Canvas and Plotly are the graph/chart creation defaults. G6 and G2 + remain explicit options; Sigma remains an explicit graph alternative; G2 remains + the temporal adapter and L7 the geospatial adapter. v1 remains readable, v2 is written, and saved artifacts—not current settings or hidden thresholds—own behavior. - Demo graph when result rows are not graph-shaped, so the epistemic/class legend is reviewable without data. - Python is reached via a **long-lived subprocess bridge**, not per-call spawns: engine startup @@ -309,6 +314,12 @@ intentionally out of v0 rather than inferred from point order. - Bundling `apache-arrow` increases extension size but simplifies runtime. - Python bridge adds subprocess lifecycle risk (hangs, unexpected exits) — mitigated by a 30s per-request timeout and `exit`/`error` handlers that reject all pending requests. +- Notebook analysis is intentionally separate from the bridge: VS Code Jupyter + runs the user's selected Python kernel, imports GraphForge directly, and writes + explicit project artifacts. The extension only opens the notebook and later + discovers those files through the normal project scan and agent artifact index. + The bridge preserves command parity, while analyst-authored scripts and notebooks + are the primary Python product surface. - The Python `graphforge` package's API can move independently of `@curatelabs/graphforge`'s; the host script and `EngineBackend` mapping may need updates when it does (defensive coding, not a compatibility guarantee). diff --git a/docs/engineering/MODULES.md b/docs/engineering/MODULES.md index c7e7b49..152d1cc 100644 --- a/docs/engineering/MODULES.md +++ b/docs/engineering/MODULES.md @@ -16,7 +16,8 @@ through the module lifecycle; modifying or deleting their packaged code is outsi module contract and breaks the extension installation. The first-party Visualize module owns Studio's visualization command and adapter -surface. New artifacts default to G6/G2/L7 through one extension-owned registry; +surface. New graph/chart artifacts default to Cytoscape/Plotly through one +extension-owned registry; G2 remains the temporal adapter and L7 the geospatial adapter; Cytoscape, Sigma, and Plotly remain adapters. This module reads v1 compatibility files and creates strict v2 project artifacts. It does not move layout, chart, map, or temporal presentation into GraphForge Core: Core continues to return diff --git a/docs/engineering/OBSERVABILITY.md b/docs/engineering/OBSERVABILITY.md index 64ce704..ce6b888 100644 --- a/docs/engineering/OBSERVABILITY.md +++ b/docs/engineering/OBSERVABILITY.md @@ -17,6 +17,14 @@ Visualization adapters report the same phases to the extension host: - `renderReady` when the view can be used - `renderFailed` with a stable error code and next action +The same lifecycle is visible inside the panel. A full-canvas render-pipeline +surface covers provisional output until it is usable and names renderer-specific +work: G6/Sigma ForceAtlas2, Cytoscape CoSE, G2 mark composition, L7 scene/layer +construction, or Plotly trace composition. It shows data, layout/composition, +and paint as discrete states because the adapters do not expose trustworthy +percentage completion. Screen readers receive the same status through a polite +live region; a stable summary remains after readiness. + A diagnostic may include only: - visualization format and semantic kind; diff --git a/docs/engineering/PUBLISHING.md b/docs/engineering/PUBLISHING.md index da3de58..7a1826c 100644 --- a/docs/engineering/PUBLISHING.md +++ b/docs/engineering/PUBLISHING.md @@ -126,10 +126,10 @@ npm run check && npm run compile && npm test npx vsce package ``` -This produces `graphforge-.vsix` at the repo root. Sanity-check its contents before -publishing — it should **not** contain `dist/test/**`, `src/**`, `.map` files, or `docs/**` -(enforced by `.vscodeignore`; re-check that file if the package looks bloated or the runtime -`dist/extension.js` is missing). +This produces `graphforge-.vsix` at the repo root. Run `npm run verify:package` before +packaging. It fails when required runtime or sample files are absent, the file/size budget is +exceeded, or the package contains test, source, documentation, agent, or internal review paths. +Never publish a locally built VSIX that has not passed this contract. `.github/workflows/ci.yml`'s `package` job already runs `npx vsce package --no-dependencies` as a packaging gate on every PR/push to `main` (see `TESTING.md`) and uploads the `.vsix` as a @@ -162,7 +162,8 @@ republishing an existing version. - Install from the Marketplace into a clean VS Code profile and run `GraphForge: Check Environment` to confirm activation and command registration work outside the dev tree. -- Tag the release in git (`vX.Y.Z`) and note the published version in the tracking issue. +- Confirm the release tag (`vX.Y.Z`) resolves to the same commit that passed CI, and note the + verified published version in the tracking issue. ## Release publishing @@ -175,9 +176,9 @@ release. The service connection's branch-control and approval checks are the aut `.github/workflows/publish.yml` owns Open VSX publishing. It has two jobs: 1. **`build`** — always runs on `push` of a `v*` tag or manual `workflow_dispatch`. Runs - `npm ci`, `npm run check`, `npm run compile`, `npm run test:unit`, then `vsce package` and - uploads the `.vsix` as a workflow artifact. This job needs no secrets and is safe to run at - any time (package-only dry run). + `npm ci`, type checks, unit tests, the Extension Development Host suite, the package-content + contract, and `vsce package`, then uploads the `.vsix` as a workflow artifact. A tag build + also fails unless `vX.Y.Z` exactly matches `package.json`. 2. **`publish`** — runs after `build` when triggered by a `v*` tag push, or by `workflow_dispatch` with `dry_run: false`. Uses the GitHub `production` environment for optional required-reviewer protection and publishes only to Open VSX. diff --git a/docs/engineering/TESTING.md b/docs/engineering/TESTING.md index 191da05..d32ffee 100644 --- a/docs/engineering/TESTING.md +++ b/docs/engineering/TESTING.md @@ -23,12 +23,13 @@ Requires `@vscode/test-electron` ≥ 3.1.0 on macOS (VS Code 1.110+ ships `Code` | Project-kind detection | `src/test/projectKind.test.ts` | `detectProjectKind` heuristic (Python/Node/ambiguous markers, both-present tie-break) and `isNotebookDominant` | | Python interpreter probe | `src/test/pythonProbe.test.ts` | Nonexistent interpreter, `graphforge` not importable, and (when the sibling `graphforge/.venv` dev venv exists) a real successful import + version | | Python bridge protocol | `src/test/pythonBridge.test.ts` | Framing/id-correlation/error-marshalling against a fake Node-run host (`fixtures/fakeGraphforgeHost.js`); `PythonEngineBackend` contract | +| Python notebook quickstart | `src/test/quickstartSample.test.ts` plus top-to-bottom `nbclient` execution | Notebook v4 structure, clean committed outputs, shared dataset counts, native Python bulk construction/PageRank, and extension-readable result/Plotly artifacts | | Module manifests | `src/test/moduleManifest.test.ts` | Entrypoint parsing, malformed/unknown-capability rejection, context-key derivation, and workspace-script path-shape rejection | | Import module | `src/test/importData.test.ts` | CSV/JSON/JSONL parsing, format inference, Cypher identifier escaping, create/merge query shape, duplicate headers, and reserved property keys | | Result Graph | `src/test/resultGraphModel.test.ts`, `settingsSchema.test.ts`, `extension.test.ts` | Renderer default/options, styling helpers, selection-message resolution, live setting-switch host smoke | | Module activation | `src/test/extension.test.ts` | First-party module commands, exported registration API, and Module Bay command/panel activation | | Results ↔ graph linking | `src/test/resultTableModel.test.ts`, `quickstart.e2e.test.ts` | Identity/endpoint matching plus the air-routes-scale integration path | -| Visualization artifacts (#67) | `src/test/projectArtifacts.test.ts`, `settingsSchema.test.ts`, `quickstartSample.test.ts` | v1 read compatibility; strict v2 validation; explicit G6/G2/L7 defaults, bindings, coordinates, and time configuration; project-owned sample artifacts | +| Visualization artifacts (#67) | `src/test/projectArtifacts.test.ts`, `settingsSchema.test.ts`, `quickstartSample.test.ts` | v1 read compatibility; strict v2 validation; Cytoscape/Plotly graph/chart defaults; explicit G6/G2/L7 alternatives, bindings, coordinates, and time configuration; project-owned sample artifacts | ### Python runtime testing notes (#12) @@ -84,10 +85,12 @@ developer console open to catch CSP and worker errors. transforms, filters, scales, and presentation. Exercise the retained raw Plotly preview and confirm it is visibly unsaved until **Save visualization** creates a project artifact. -5. Create and reopen an L7 geospatial artifact with explicit longitude/latitude - or GeoJSON binding, CRS, projection, layer order, blank offline basemap, and - viewport. Disable the network and confirm there are no fetch attempts, tokens, - CSP violations, or hidden remote tiles. +5. Create and reopen L7 geospatial artifacts with explicit point coordinates, + source/target link coordinates, or GeoJSON binding, plus CRS, projection, + layer order, blank offline basemap, and viewport. For the airport-route sample, + confirm every valid route row produces an arc and the point layer contains the + unique source/target endpoints. Disable the network and confirm there are no + fetch attempts, tokens, CSP violations, or hidden remote tiles. 6. Create and reopen a G2 temporal artifact with explicit timestamp, timezone, granularity, range, aggregation, and playback. Enable G6 Timebar only through explicit graph bindings; a date-like column alone must not create one. @@ -113,6 +116,12 @@ developer console open to catch CSP and worker errors. `GF_G6_CANVAS_RENDER_ERROR` for an asynchronous renderer exception), never readiness. The medium sample keeps all 579 nodes and 7,430 edges while its artifact explicitly disables edge labels and arrowheads. +12. For G6, Cytoscape, Sigma, G2, L7, and Plotly, confirm the provisional output + stays covered while rendering. Verify the visible and screen-reader status + names the selected renderer and advances through its truthful data, + layout/composition, and paint stages without a fabricated percentage. The + overlay must clear only after readiness and remain as an actionable failed + stage when rendering fails. The required Extension Development Host job should retain bounded construction, reopen, schema, and lifecycle smoke coverage. Browser stress and comparative diff --git a/docs/engineering/adrs/0003-antv-visualization-artifacts.md b/docs/engineering/adrs/0003-antv-visualization-artifacts.md index ad500e9..bd9c618 100644 --- a/docs/engineering/adrs/0003-antv-visualization-artifacts.md +++ b/docs/engineering/adrs/0003-antv-visualization-artifacts.md @@ -1,6 +1,6 @@ # ADR-0003: AntV defaults with explicit visualization artifacts -- Status: Accepted +- Status: Superseded by ADR-0004 - Date: 2026-08-02 - Issue: [#67](https://github.com/CurateLabs/graphforge-vscode/issues/67) @@ -45,7 +45,9 @@ spec behave differently across sessions. 8. Specs are JSON data, never executable configuration. Webviews bundle assets locally under the existing strict CSP: no CDN, remote data fetch, arbitrary callbacks, credentials in artifacts, `eval`, or broader implicit network - access. The initial L7 adapter uses an explicit blank offline background. + access. The L7 adapter uses an explicit blank offline background and accepts + saved point coordinates, source/target link coordinates, or GeoJSON. Link + sources render only their explicitly ordered point and arc layers. 9. Canvas/WebGL output is accompanied by keyboard-reachable controls, a textual summary, and an accessible data/entity surface. Reduced-motion preferences apply to layout animation, temporal playback, and transitions. diff --git a/docs/engineering/adrs/0004-restore-cytoscape-plotly-defaults.md b/docs/engineering/adrs/0004-restore-cytoscape-plotly-defaults.md new file mode 100644 index 0000000..0fd0567 --- /dev/null +++ b/docs/engineering/adrs/0004-restore-cytoscape-plotly-defaults.md @@ -0,0 +1,51 @@ +# ADR-0004: Restore Cytoscape and Plotly creation defaults + +- Status: Accepted +- Date: 2026-08-02 +- Supersedes: ADR-0003 default selections only + +## Context + +ADR-0003 made AntV G6 and G2 the graph and analytical-chart creation defaults +while preserving Cytoscape, Sigma, and Plotly as explicit adapters. Extension +Development Host use showed that the AntV paths remain less reliable than the +previous Cytoscape and Plotly experience. Renderer lifecycle diagnostics now +make those failures visible, but visibility does not make a buggy adapter the +right first-run choice. + +The artifact-owned v2 contract remains sound: saved work records its renderer +and complete configuration, and opening a saved artifact must never reinterpret +it using a newer global default. + +## Decision + +1. New Result Graph artifacts default to Cytoscape Canvas with an explicit CoSE + configuration. +2. New analytical chart artifacts default to Plotly. +3. AntV G6 and G2 remain first-class explicit choices. Sigma remains an explicit + graph choice. Existing saved G6/G2 artifacts continue to open unchanged. +4. Temporal artifacts remain G2 and geospatial artifacts remain L7 because those + visualization kinds do not yet have retained non-AntV adapters. +5. The quickstart journey prefers its saved Cytoscape Result Graph. The E2E path + exercises that same saved artifact and waits for the renderer's terminal + lifecycle state. Explicit G6/G2 creation and validation stay covered by unit + tests and the renderer matrix. +6. Changing these defaults is limited to the registry, configuration defaults, + direct-view fallback, documentation, and tests. It does not migrate or rewrite + project artifacts. +7. Opening a graph applies renderer, options, and payload as one render snapshot. + Separate protocol messages remain compatible, but do not trigger duplicate + force-layout work while a saved artifact is being restored. + +## Consequences + +- First-run graph and analytical-chart behavior returns to the more stable + Cytoscape and Plotly paths. +- Users can switch back to G6 or G2 from Settings or by recording the renderer + explicitly in a new artifact. +- Existing AntV work remains reproducible because saved artifacts stay + authoritative. +- AntV renderer defects remain actionable implementation work rather than costs + imposed on every new visualization. +- ADR-0003 still governs the explicit artifact contract, strict CSP, lifecycle + diagnostics, accessibility, and the rule against silent renderer fallback. diff --git a/docs/engineering/adrs/README.md b/docs/engineering/adrs/README.md index 4859f2c..e63b10a 100644 --- a/docs/engineering/adrs/README.md +++ b/docs/engineering/adrs/README.md @@ -26,4 +26,5 @@ carries inline guidance), then add a row to the log below. |---|---|---|---| | [0001](./0001-plotly-figure-webview-csp.md) | Plotly Figure webview under Settings-strict CSP | Accepted | 2026-08-01 | | [0002](./0002-unified-module-lifecycle.md) | Catalog-first unified module lifecycle | Accepted | 2026-08-02 | -| [0003](./0003-antv-visualization-artifacts.md) | AntV defaults with explicit visualization artifacts | Accepted | 2026-08-02 | +| [0003](./0003-antv-visualization-artifacts.md) | AntV defaults with explicit visualization artifacts | Superseded by ADR-0004 | 2026-08-02 | +| [0004](./0004-restore-cytoscape-plotly-defaults.md) | Restore Cytoscape and Plotly creation defaults | Accepted | 2026-08-02 | diff --git a/docs/experience/agent-interop.md b/docs/experience/agent-interop.md index 3b6d3fe..d93212d 100644 --- a/docs/experience/agent-interop.md +++ b/docs/experience/agent-interop.md @@ -94,7 +94,8 @@ Source of truth: `package.json#contributes.commands` (85 contributed commands on | `graphforge.setupPythonBinding` | none | `void` | 1 QuickPick (use detected interpreter / browse / install via `uv`) | No (this *sets up* the Python runtime, #12) | | `graphforge.initializeProjectHere` | none | `void` | 1 QuickPick (workspace folder vs. browse), plus confirmation dialogs for non-empty/missing targets | Needs a usable runtime first (fails closed with "Setup Native Binding" / "Setup Python Binding" action buttons otherwise) | | `graphforge.openProject` | `pathArg?: string` | `void` | Folder picker only when `pathArg` omitted | Needs the target to already be a valid `FORMAT` project | -| `graphforge.openSampleProject` | `{ path?, force? }` | `{ path, project, seeded }` or `{ error, code, nextAction? }` / `{ cancelled: true }` | Guided confirm when called with no args; skipped when args object passed | Needs a usable runtime; materializes project-owned air-routes data/query/viz/mutation files | +| `graphforge.openSampleProject` | `{ path?, force? }` | `{ path, project, seeded }` or `{ error, code, nextAction? }` / `{ cancelled: true }` | Interactive replacement of a non-empty target confirms; a new target does not | Needs a usable runtime; materializes project-owned air-routes data/query/notebook/result/viz/mutation files | +| `graphforge.openSampleNotebook` | none | `{ path, relativePath }` or `{ error, code, nextAction? }` | No | Opens the project-owned Python/Jupyter path for the active air-routes sample; never installs packages or selects a kernel | | `graphforge.closeProject` | none | `{ closed: true }` | None | No | | `graphforge.agent.getContext` | `{ projectPath?: string \| Uri }` | `graphforge.agent-context/v1` context (environment, settings, project marker, artifacts with absolute paths, last-result paths, schemas, compact command descriptors) | None | No — an optional path can be inspected without opening it | | `graphforge.agent.listArtifacts` | `{ projectPath?: string \| Uri }` | `graphforge.artifact-index/v1`, or structured `PROJECT_REQUIRED` | None | No runtime required; needs an active or explicit project path | @@ -159,7 +160,6 @@ Source of truth: `package.json#contributes.commands` (85 contributed commands on | `graphforge.explainOntologyMode` | none | `void` (opens a markdown explainer) | None | No | | `graphforge.refreshExplorer` | none | `void` | None | No | | `graphforge.getStarted` | none | `void` (reveals the Get Started sidebar) | None | No | -| `graphforge.chooseExperienceMode` | none | `void` (reveals the Welcome mode picker) | None | No | | `graphforge.openSettings` | none | `void` (opens the Settings webview) | None | No | | `graphforge.manageModules` | none | `void` (opens the Module Bay webview) | None | No | | `graphforge.refreshModules` | none | Module view-model array | None | No project required; refreshes GraphForge-owned catalog when one is open | @@ -174,6 +174,8 @@ Source of truth: `package.json#contributes.commands` (85 contributed commands on - Project marker: `FORMAT` must contain exactly `graphforge-project/v1\n`. - Queries: `.cypher`/`.cql` text, or JSON `{ "cypher": "...", "params": { ... } }`. +- Notebooks: `.ipynb` files under `notebooks/`; these run in the user's selected VS Code + Jupyter kernel and publish ordinary result and visualization artifacts. - Results: JSON `{ "columns": ["..."], "rows": [{ ... }], "rowCount": 0 }`. `results/query-result.json` is canonical latest; timestamped JSON/Markdown pairs are history. - Visualizations: new files use strict `graphforge.visualization/v2` with kind `result-graph`, `chart`, `geospatial`, or `temporal`; existing v1 @@ -226,7 +228,8 @@ flowchart TD 5. **Create durable visualization work** — call `graphforge.createProjectVisualization` with the result path, semantic kind, and explicit bindings. It saves first and returns the complete `{ path, spec, - panel? }`. G6 and G2 are current creation defaults; L7 owns geospatial views; + panel? }`. Cytoscape and Plotly are current graph/chart creation defaults; + G2 remains the temporal adapter and L7 owns geospatial views; Cytoscape, Sigma, and Plotly remain explicit alternatives. For unsaved raw Plotly interchange, `figureFromResult` and `showFigure({ figure })` remain available, but they are not a substitute for a project artifact. diff --git a/docs/experience/discovery-feeling.md b/docs/experience/discovery-feeling.md index 51edc8e..98ae31d 100644 --- a/docs/experience/discovery-feeling.md +++ b/docs/experience/discovery-feeling.md @@ -39,7 +39,7 @@ clear results and clear failures, and without anxiety from silent hangs. | Moment | Beat | Design implication | |---|---|---| -| First open | Premium hotel concierge; Kilo-like guided welcome | Get Started / Welcome: one composition, one primary CTA, guided checklist | +| First open | Calm, legible route to the first useful result | One persistent journey tree, one current node, and one primary next action | | Success | Clear next options | After query/verb: name what happened and offer obvious follow-ons | | Failure | Easy recovery | Structured next action; never raw dumps as the only path | | Wait | Understand real wait vs stuck | Progress / liveness signal for long analysis; never silent spin | @@ -67,7 +67,7 @@ plus a recent-projects list for resume, without burying either. ## Intended behavior (feeling contract) -- First-run, missing-runtime, and no-project share one guided Get Started surface +- First-run, missing-runtime, and no-project share one path-first Get Started surface (see [`../DESIGN.md`](../DESIGN.md)). - Success and failure paths always surface a next option. - Long-running engine work should communicate activity (and ideally allow cancel / diff --git a/docs/published/agent-interop.md b/docs/published/agent-interop.md index cbba38a..76afed9 100644 --- a/docs/published/agent-interop.md +++ b/docs/published/agent-interop.md @@ -12,7 +12,9 @@ Start with `executeCommand("graphforge.agent.getContext")`. Its `graphforge.agent-context/v1` result includes the environment report, effective settings, project marker, absolute artifact paths, last-result paths, file schemas, and the compact path-driven command set. Use -`graphforge.agent.listArtifacts` when only the artifact index is needed. +`graphforge.agent.listArtifacts` when only the artifact index is needed. The +index includes project-owned notebooks under `notebooks/`, so the Python path is +discoverable without relying on a hard-coded sample command. ```mermaid flowchart TD @@ -61,6 +63,7 @@ engine call fails. Destructive commands (`deleteCheckpoint`, `revertToCheckpoint | `graphforge.agent.getContext` / `agent.listArtifacts` | `{ projectPath?: string \| Uri }` | Versioned context / artifact index JSON | | `graphforge.openProject` | `string \| Uri \| { path }` | `{ path, project }` | | `graphforge.openSampleProject` | `{ path?, force? }` | `{ path, project, seeded }` | +| `graphforge.openSampleNotebook` | none | `{ path, relativePath }` or structured error | | `graphforge.runQuery` / `runQueryWithParams` | `{ cypher?: string; params?: Record }` | `QueryResult` (`{ columns, rows, rowCount, algorithm? }`) | | `graphforge.runProjectQuery` | `string \| Uri \| { path, resultName? }` | `QueryResult` | | `graphforge.openProjectResult` | `string \| Uri \| { path }` | `{ path, absolutePath, columns, rowCount }` | @@ -96,7 +99,7 @@ engine call fails. Destructive commands (`deleteCheckpoint`, `revertToCheckpoint | `graphforge.assessConfidence` | `{ assertionUuid?, policy?, value? }` | `QueryResult` | | `graphforge.recordAssertionStatus` | `{ assertionUuid?, status?, provenanceUuid? }` | `QueryResult` | | `graphforge.setupNativeBinding` / `setupPythonBinding` / `initializeProjectHere` / `loadOntology` / `showResultGraphAdvanced` | No — these are inherently human choices (which folder, which binding source) | — | -| `graphforge.showOntology` / `showResultGraph` / `showCapabilities` / `openOntologyFile` / `explainOntologyMode` / `refreshExplorer` / `getStarted` / `chooseExperienceMode` / `openSettings` / `manageModules` / `statusBarClick` | No prompts to skip | — (view-openers; their effect is the UI they open) | +| `graphforge.showOntology` / `showResultGraph` / `showCapabilities` / `openOntologyFile` / `explainOntologyMode` / `refreshExplorer` / `getStarted` / `openSettings` / `manageModules` / `statusBarClick` | No prompts to skip | — (view-openers; their effect is the UI they open) | ### Exact visualization creation arguments @@ -108,7 +111,8 @@ to `graphforge.createProjectVisualization`. Common optional fields are `name`, - `chart`: `mark: "bar" | "scatter" | "line" | "histogram"`, `x`, `y` (except histogram), optional `color`, and optional `renderer: "g2" | "plotly"`. -- `geospatial`: `longitude` and `latitude` (L7). +- `geospatial`: source `longitude` and `latitude`; optional target longitude and + latitude create an explicit L7 link source with arc and endpoint layers. - `temporal`: `timestamp` and `y`, plus optional `color`, `mark`, IANA `timezone`, and `granularity` (G2). diff --git a/docs/published/commands.md b/docs/published/commands.md index 97d80c0..3c778c2 100644 --- a/docs/published/commands.md +++ b/docs/published/commands.md @@ -14,17 +14,17 @@ Every command below is a stable ID, invokable from the Command Palette or progra | GraphForge: Setup Python Binding | `graphforge.setupPythonBinding` | QuickPick to select a Python interpreter or install `graphforge` via `uv`. | | GraphForge: Initialize Project Here | `graphforge.initializeProjectHere` | Creates a new GraphForge project in an empty/uninitialized folder. | | GraphForge: Open Project | `graphforge.openProject` | Opens an existing `FORMAT`-marked project; accepts a string, URI, or `{ path }`. | -| GraphForge: Open Sample Project | `graphforge.openSampleProject` | Seeds/opens the vendored US air-routes sample (Apache-2.0); accepts `{ path?, force? }` (skips Guided confirms when args are passed). | +| GraphForge: Open Sample Project | `graphforge.openSampleProject` | Seeds/opens the vendored US air-routes sample (Apache-2.0); accepts `{ path?, force? }`. Interactive replacement of a non-empty target confirms; creating a new sample does not add a redundant prompt. | +| GraphForge: Open Sample Python Notebook | `graphforge.openSampleNotebook` | Opens the active air-routes sample's project-owned VS Code Jupyter notebook and returns `{ path, relativePath }`. It never installs packages or selects a kernel. | | GraphForge: Close Project | `graphforge.closeProject` | Detaches the active engine session (no folder delete). | | GraphForge: Refresh Explorer | `graphforge.refreshExplorer` | Refreshes the Projects Activity Bar view. | | GraphForge: Agent: Get Context | `graphforge.agent.getContext` | Returns `graphforge.agent-context/v1` runtime, settings, marker, artifact, last-result, schema, and command JSON; optional `{ projectPath }`. | | GraphForge: Agent: List Project Artifacts | `graphforge.agent.listArtifacts` | Returns `graphforge.artifact-index/v1` with project-relative and absolute paths; optional `{ projectPath }`. | -| GraphForge: Get Started | `graphforge.getStarted` | Opens the Get Started sidebar: guided milestones before the first result, then a persistent project/query/results control hub. | +| GraphForge: Get Started | `graphforge.getStarted` | Opens the persistent Environment → Project → Query → Result → Visualize journey map. | | GraphForge: Show Hub | `graphforge.getStarted.showHub` | Opens Get Started's Hub surface; contributed as its Home title action. | | GraphForge: Show Query | `graphforge.getStarted.showQuery` | Opens Get Started's Query surface; contributed as its Search title action. | | GraphForge: Show Visualize | `graphforge.getStarted.showVisualize` | Opens Get Started's Visualize surface; contributed as its Graph title action. | -| GraphForge: Choose Experience Mode… | `graphforge.chooseExperienceMode` | Reopens the Welcome mode picker (Guided/Autonomous) inside Get Started. | -| GraphForge: Settings | `graphforge.openSettings` | Opens the GraphForge Settings panel — left-nav categories (Runtime / Experience / Advanced) over the same `graphforge.*` settings as the VS Code Settings UI. | +| GraphForge: Settings | `graphforge.openSettings` | Opens the GraphForge Settings panel — left-nav categories (Runtime / Visualizations / Advanced) over the same `graphforge.*` settings as the VS Code Settings UI. | ## Modules and import @@ -62,7 +62,8 @@ bindings object. In addition to the project-relative `result` and `kind`, pass: - `result-graph`: optional `renderer` (`g6`, `cytoscape`, or `sigma`). - `chart`: `mark`, `x`, `y` (except histogram), optional `color`, and optional `renderer` (`g2` or `plotly`). -- `geospatial`: `longitude` and `latitude` (L7 is persisted). +- `geospatial`: source `longitude` and `latitude`; optional `targetLongitude` + and `targetLatitude` persist an L7 link source with arc and endpoint layers. - `temporal`: `timestamp` and `y`, plus optional `color`, `mark`, IANA `timezone`, and `granularity` (G2 is persisted). @@ -168,9 +169,10 @@ UUIDv7 (engine-enforced). Knowledge-ledger writes require the Node runtime. | GraphForge: Figure from Result… | `graphforge.figureFromResult` | | GraphForge: Show Project Capabilities | `graphforge.showCapabilities` | -New Result Graph artifacts default to G6 Canvas; G2 is the default for new chart -and temporal artifacts, and L7 renders geospatial artifacts. Cytoscape, Sigma, -and Plotly remain explicit adapters. `graphforge.resultGraph.renderer` and +New Result Graph artifacts default to Cytoscape Canvas; Plotly is the default +for new analytical chart artifacts. G2 remains an explicit chart option and the +temporal adapter, while L7 renders geospatial artifacts. G6 and Sigma remain +explicit graph adapters. `graphforge.resultGraph.renderer` and `graphforge.chart.renderer` choose creation templates only: a saved artifact continues using its recorded renderer, backend, layout, bindings, and filters. Unsupported configuration reports a failure and never silently switches adapter. diff --git a/docs/published/install.md b/docs/published/install.md index 9d0ef9a..43ecc98 100644 --- a/docs/published/install.md +++ b/docs/published/install.md @@ -9,28 +9,40 @@ 1. Install **GraphForge** from the Marketplace (or Open VSX). 2. Open a folder, then click the **GraphForge icon** in the Activity Bar. The **Get Started** - view opens with a short setup checklist. -3. First visit only: the **Welcome** step asks how you want to work — **Guided** (confirms - before changes) or **Autonomous** (fewer prompts, auto-opens results). Pick one and press - **Continue**; change it anytime via **Change mode** or `graphforge.experienceMode`. -4. Work down the checklist: - 1. **Set up a runtime** (Node or Python — the primary button matches your workspace). - 2. **Open or create a project** — or choose **Try sample project** to seed the vendored + view opens with one persistent journey map. There is no startup-mode choice. +3. Follow the highlighted node in **Environment → Project → Query → Result → Visualize**: + 1. **Environment** — set up Node or Python; the primary button matches your workspace. + 2. **Project** — open a project, or choose **Try the air-routes sample** to seed the vendored **US air-routes** graph (586 airports / 7,430 routes, Apache-2.0) via `graphforge.openSampleProject`. - 3. **Run your first query** — with the sample open, use **Run sample query**. + 3. **Query** — with the sample open, use **Run sample query**. This executes `queries/templates/routes-overview.cypher` from the project. - 4. **See your results** — open the saved G6 route network, G2 route-distance - chart, L7 airport map, or G2 route-activity timeline. The sample also keeps - v1 Cytoscape and Plotly artifacts so compatibility is visible rather than - hidden. Views stay closed until you ask. -5. Use the Hub, Query, and Visualize icons in the Get Started view title. Query + Or choose **Open Python notebook** for the parallel VS Code Jupyter path. + It reads the same copied CSVs with pandas, builds the graph through + GraphForge Python, runs PageRank, renders Plotly, and publishes normal + result and visualization artifacts back into the project. + 4. **Result** — inspect the saved result in the Results panel. + 5. **Visualize** — open the saved Cytoscape route network. An explicit G6 + network, G2 route-distance chart, L7 airport map with arcs between airport + nodes, and G2 route-activity timeline remain available. The sample also + keeps v1 Cytoscape and Plotly artifacts so compatibility is visible rather + than hidden. Result Graph stays closed until you ask unless you opt into + auto-open. +4. The journey remains visible after completion and names the project files that + prove each step. Use the Hub, Query, and Visualize icons in the Get Started view title. Query saves reusable templates under `queries/templates/` and reopens timestamped `results/` history. Visualize saves strict v2 graph, chart, geospatial, or temporal settings under `visualizations/`; existing v1 artifacts remain - readable. G6/G2 settings affect newly created files only because resolved + readable. Renderer settings affect newly created files only because resolved behavior is stored in each artifact. +The notebook is `notebooks/air-routes-analysis.ipynb`. Select a kernel that can +import `graphforge`, `pyarrow`, `pandas`, and `plotly`; its setup instructions +use `uv` and never install packages automatically. After execution, +**GraphForge: Refresh Explorer** reveals +`results/python-airport-pagerank.json` and +`visualizations/python-airport-pagerank.gfviz.json`. + Prefer the Command Palette, or driving the extension from an agent? Run **`GraphForge: Check Environment`** — it reports both runtimes' full diagnostics and always names exactly one command to run next. diff --git a/docs/published/overview.md b/docs/published/overview.md index 7970ac2..a76a82b 100644 --- a/docs/published/overview.md +++ b/docs/published/overview.md @@ -20,30 +20,33 @@ graph. | Surface | What it does | |---|---| -| **Setup / control hub** | **Get Started** walks new users through runtime → project → query, then provides **Hub / Query / Visualize** pages. Queries, results, bindings, filters, renderer/layout, map, and time settings are files in the open project. | +| **Setup / journey map** | **Get Started** keeps Environment → Project → Query → Result → Visualize visible and highlights the simplest next action. **Hub / Query / Visualize** pages remain available, while queries, notebooks, results, bindings, filters, renderer/layout, map, and time settings stay as files in the open project. | | **Cypher** | `.cypher` / `.cql` language support with syntax highlighting, plus **Run Query** (and **Run Query with Parameters…**). | | **Analyst verbs** | Rank, Cluster, Paths, Analyze, Similar, Find — QuickPick-driven, each with an **Advanced…** variant for optional parameters. | | **Projects** | An Activity Bar explorer that lists folders containing a valid GraphForge `FORMAT` marker. | | **Ontology** | A mode badge (exploratory/advisory/strict) plus an entity/relation tree and an Ontology Viewer webview, including a **Load Ontology…** action. | | **Knowledge** | List, inspect, and create knowledge-ledger assertions, with Advanced commands for attaching evidence, assessing confidence, and recording status. | | **Results** | A tabular `WebviewView` in VS Code's bottom Panel. Scalar values stay compact, nested values expand as JSON, and row/cell selections highlight matching nodes or edges in an open Result Graph. | -| **Result Graph** | AntV G6 Canvas is the current creation default; Cytoscape Canvas and Sigma WebGL remain explicit adapters. Saved renderer/layout/style choices remain authoritative on reopen. | -| **Charts, maps, and timelines** | AntV G2 charts and timelines plus L7 geospatial views from explicit v2 artifacts, with Save/Revert and an accessible filtered-data companion. | -| **Plotly Figure** | Retained raw Plotly interchange and v1/explicit-v2 adapter (`showFigure` / `figureFromResult`); not a hidden replacement for saved G2 work. | +| **Result Graph** | Cytoscape Canvas is the current creation default; AntV G6 Canvas and Sigma WebGL remain explicit adapters. Saved renderer/layout/style choices remain authoritative on reopen. | +| **Charts, maps, and timelines** | Plotly analytical charts by default; explicit AntV G2 charts and timelines plus L7 geospatial views—including explicit source/target arc layers—from v2 artifacts, with Save/Revert and an accessible filtered-data companion. | +| **Plotly Figure** | Raw Plotly interchange plus v1/v2 Plotly artifacts (`showFigure` / `figureFromResult`) use the retained Figure adapter. | ## Project workbench files GraphForge keeps durable exploration state with the project: - `queries/` — editable `.cypher` queries +- `notebooks/` — project-owned Python/Jupyter analysis that publishes normal artifacts - `results/` — latest JSON/Markdown plus timestamped result history - `visualizations/` — strict v2 graph/chart/geospatial/temporal `.gfviz.json` specs plus readable, unchanged v1 Cytoscape/Sigma/Plotly specs - `mutations/` and `data/` — project-backed graph changes and source material -The air-routes starter uses this same layout; its query and visualization -settings are sample files, not hidden extension presets. Global G6/G2 settings -choose only the next artifact template; saved files explicitly own behavior. +The air-routes starter uses this same layout; its query, Python notebook, and +visualization settings are sample files, not hidden extension presets. The +notebook reads the same copied CSVs, runs native GraphForge PageRank, renders +Plotly in VS Code Jupyter, and publishes normal result/visualization artifacts. +Global renderer settings choose only the next artifact template; saved files explicitly own behavior. Invalid or unsupported configuration fails visibly without field inference, renderer substitution, or network fallback. diff --git a/media/samples/air-routes/README.md b/media/samples/air-routes/README.md index 03027b7..891c49d 100644 --- a/media/samples/air-routes/README.md +++ b/media/samples/air-routes/README.md @@ -3,10 +3,15 @@ Real-world airline route graph used by GraphForge **Try sample project** / e2e. `project/` contains the reusable `queries/templates/routes-overview.cypher` -query, a small documented route-activity result, and six visualization specs: -v2 G6 graph, G2 chart, L7 map, and G2 timeline examples plus v1 Cytoscape and -Plotly compatibility examples. Every renderer, binding, layout, coordinate, and -time choice is visible in those files. +query, a small documented route-activity result, and seven visualization specs: +a default v2 Cytoscape graph; explicit v2 G6 graph, G2 chart, L7 map, and G2 +timeline examples; plus v1 Cytoscape and Plotly compatibility examples. Every +renderer, binding, layout, coordinate, and time choice is visible in those +files. +`project/notebooks/air-routes-analysis.ipynb` is the parallel analyst path. It +reads the copied CSVs with pandas, bulk-builds the graph through GraphForge +Python, runs native PageRank, renders Plotly in VS Code Jupyter, and writes a +normal result plus Plotly visualization spec back into the sample project. During creation, the extension also copies this dataset to `data/air-routes/` and generates `mutations/seed-air-routes.cypher` before executing that project-backed mutation. diff --git a/media/samples/air-routes/project/AGENTS.md b/media/samples/air-routes/project/AGENTS.md index 9f4bf94..db4acc6 100644 --- a/media/samples/air-routes/project/AGENTS.md +++ b/media/samples/air-routes/project/AGENTS.md @@ -17,6 +17,9 @@ marker and keep generated work inside the project. Cytoscape/Sigma/Plotly specs remain readable and are not rewritten on open. - `mutations/**/*.cypher`, `.cql`, or `.json` — writes that require explicit confirmation before execution. +- `notebooks/air-routes-analysis.ipynb` — a visible Python/Jupyter path over the + same copied CSVs. It uses the Python binding directly and publishes its table + and Plotly specification under `results/` and `visualizations/`. Paths may be project-relative or absolute, but GraphForge rejects paths outside this project. @@ -38,7 +41,10 @@ Call VS Code commands through `vscode.commands.executeCommand`: reviewing the mutation file. For this sample, start with -`queries/templates/routes-overview.cypher`, then open the saved AntV graph, -chart, map, or timeline under `visualizations/`. The v1 files beside them prove -the retained compatibility path. Do not infer missing fields or silently switch -renderer, layout, projection, timezone, or data source; fix the artifact instead. +`queries/templates/routes-overview.cypher`, then open the saved Cytoscape graph. +For the analyst-owned Python path, open `notebooks/air-routes-analysis.ipynb`, +select a kernel with GraphForge/pandas/Plotly, and run it top to bottom. +The explicit G6 graph, G2 chart, L7 map, and G2 timeline remain available under +`visualizations/`. The v1 files beside them prove the retained compatibility +path. Do not infer missing fields or silently switch renderer, layout, +projection, timezone, or data source; fix the artifact instead. diff --git a/media/samples/air-routes/project/notebooks/air-routes-analysis.ipynb b/media/samples/air-routes/project/notebooks/air-routes-analysis.ipynb new file mode 100644 index 0000000..34a9168 --- /dev/null +++ b/media/samples/air-routes/project/notebooks/air-routes-analysis.ipynb @@ -0,0 +1,338 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "goal-intro", + "metadata": {}, + "source": [ + "# Air-routes analysis with GraphForge Python\n", + "\n", + "## Goal\n", + "\n", + "Use the same `data/air-routes/airports.csv` and `routes.csv` files as the GraphForge extension quickstart, but follow the workflow an analyst expects in a VS Code Jupyter notebook: inspect tabular data with pandas, build the graph through the native Python binding, run PageRank, visualize the result with Plotly, and save reusable project artifacts.\n", + "\n", + "This is a parallel path, not a fallback engine. Graph operations still run in GraphForge's Rust core through the Python binding." + ] + }, + { + "cell_type": "markdown", + "id": "setup-guide", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Select a Python kernel that can import `graphforge`, `pyarrow`, `pandas`, and `plotly`. GraphForge's extension setup remains `uv`-only. If the selected kernel is missing packages, use its exact interpreter path in a VS Code terminal:\n", + "\n", + "```text\n", + "uv pip install --python /path/to/python graphforge pyarrow pandas plotly ipykernel\n", + "```\n", + "\n", + "The notebook does not install packages or choose a kernel on your behalf." + ] + }, + { + "cell_type": "code", + "id": "setup-imports", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from importlib.util import find_spec\n", + "from pathlib import Path\n", + "import json\n", + "import sys\n", + "\n", + "required_packages = {\n", + " 'graphforge': 'graphforge',\n", + " 'pyarrow': 'pyarrow',\n", + " 'pandas': 'pandas',\n", + " 'plotly': 'plotly',\n", + "}\n", + "missing = [package for module, package in required_packages.items() if find_spec(module) is None]\n", + "if missing:\n", + " package_list = ' '.join(missing)\n", + " raise ModuleNotFoundError(\n", + " f\"Missing {package_list}. In a VS Code terminal run: \"\n", + " f\"uv pip install --python {sys.executable} {package_list}\"\n", + " )\n", + "\n", + "import graphforge as gf\n", + "import pandas as pd\n", + "import plotly.express as px\n", + "\n", + "print(f'Python: {sys.executable}')\n", + "print(f'GraphForge: {gf.__version__}')" + ] + }, + { + "cell_type": "markdown", + "id": "steps-data", + "metadata": {}, + "source": [ + "## Steps\n", + "\n", + "### 1. Load and validate the shared dataset\n", + "\n", + "The project marker anchors all paths. No data is downloaded and no hidden extension state is read." + ] + }, + { + "cell_type": "code", + "id": "load-data", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def find_project_root(start: Path) -> Path:\n", + " for candidate in (start.resolve(), *start.resolve().parents):\n", + " marker = candidate / 'FORMAT'\n", + " if marker.exists() and marker.read_text(encoding='utf-8').strip() == 'graphforge-project/v1':\n", + " return candidate\n", + " raise FileNotFoundError('Open this notebook from the GraphForge air-routes sample project.')\n", + "\n", + "project_root = find_project_root(Path.cwd())\n", + "data_root = project_root / 'data' / 'air-routes'\n", + "airports_path = data_root / 'airports.csv'\n", + "routes_path = data_root / 'routes.csv'\n", + "\n", + "airports = pd.read_csv(airports_path)\n", + "routes = pd.read_csv(routes_path)\n", + "assert len(airports) == 586, f'Expected 586 airports, found {len(airports)}'\n", + "assert len(routes) == 7_430, f'Expected 7,430 routes, found {len(routes)}'\n", + "assert set(routes['from']).union(routes['to']).issubset(set(airports['id']))\n", + "\n", + "display(airports.head(5))\n", + "print(f'{len(airports):,} airports · {len(routes):,} directed routes')" + ] + }, + { + "cell_type": "markdown", + "id": "build-heading", + "metadata": {}, + "source": [ + "### 2. Build the same graph through the Python binding\n", + "\n", + "Bulk publication keeps the notebook concise and returns Arrow receipts in logical input order. The receipts provide the native UUID endpoints used by the route batch. The graph is intentionally in-memory so rerunning the notebook starts cleanly." + ] + }, + { + "cell_type": "code", + "id": "build-graph", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "forge = gf.GraphForge()\n", + "airport_receipt = forge.add_nodes(\n", + " 'Airport',\n", + " airports.rename(columns={'id': 'source_id'}),\n", + " operation_uuid='018f0f4e-7b8c-7000-8000-00000000a001',\n", + ")\n", + "airport_uuid_by_id = dict(zip(\n", + " airports['id'],\n", + " airport_receipt.column('entity_uuid').to_pylist(),\n", + "))\n", + "route_batch = (\n", + " routes.assign(\n", + " src_id=routes['from'].map(airport_uuid_by_id),\n", + " dst_id=routes['to'].map(airport_uuid_by_id),\n", + " )\n", + " .drop(columns=['from', 'to'])\n", + ")\n", + "route_receipt = forge.add_edges(\n", + " 'ROUTE',\n", + " route_batch,\n", + " operation_uuid='018f0f4e-7b8c-7000-8000-00000000a002',\n", + ")\n", + "\n", + "assert airport_receipt.num_rows == len(airports)\n", + "assert route_receipt.num_rows == len(routes)\n", + "print(f'GraphForge graph: {forge.node_count(\"Airport\"):,} Airport nodes · {route_receipt.num_rows:,} ROUTE edges')" + ] + }, + { + "cell_type": "markdown", + "id": "rank-heading", + "metadata": {}, + "source": [ + "### 3. Rank airports with Rust-owned PageRank\n", + "\n", + "`rank()` returns a PyArrow table. Converting to pandas is an analyst-side presentation step; PageRank itself is not reimplemented in the notebook." + ] + }, + { + "cell_type": "code", + "id": "rank-airports", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ranked_airports = (\n", + " forge.rank('Airport', by='pagerank', via='ROUTE', directed=True)\n", + " .to_pandas()\n", + " .sort_values(['score', 'code'], ascending=[False, True])\n", + " .reset_index(drop=True)\n", + ")\n", + "ranked_airports['nodeUuid'] = ranked_airports['node_uuid'].map(bytes.hex)\n", + "ranked_airports['rank'] = ranked_airports.index + 1\n", + "analysis_columns = ['rank', 'code', 'city', 'region', 'score', 'lat', 'lon', 'nodeUuid']\n", + "top_airports = ranked_airports.loc[:, analysis_columns].head(25).copy()\n", + "\n", + "assert len(ranked_airports) == len(airports)\n", + "assert ranked_airports['score'].is_monotonic_decreasing\n", + "display(top_airports.head(15).round({'score': 6}))" + ] + }, + { + "cell_type": "markdown", + "id": "save-heading", + "metadata": {}, + "source": [ + "### 4. Save normal GraphForge project artifacts\n", + "\n", + "The notebook publishes a bounded result and a complete Plotly visualization specification. After running this cell, use **GraphForge: Refresh Explorer**; the result and visualization can then be opened by the extension like any other saved project work." + ] + }, + { + "cell_type": "code", + "id": "save-artifacts", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results_dir = project_root / 'results'\n", + "visualizations_dir = project_root / 'visualizations'\n", + "results_dir.mkdir(exist_ok=True)\n", + "visualizations_dir.mkdir(exist_ok=True)\n", + "\n", + "result_path = results_dir / 'python-airport-pagerank.json'\n", + "result_rows = json.loads(top_airports.to_json(orient='records'))\n", + "result_payload = {\n", + " 'columns': analysis_columns,\n", + " 'rows': result_rows,\n", + " 'rowCount': len(result_rows),\n", + "}\n", + "result_path.write_text(json.dumps(result_payload, indent=2) + '\\n', encoding='utf-8')\n", + "\n", + "visualization_path = visualizations_dir / 'python-airport-pagerank.gfviz.json'\n", + "visualization_payload = {\n", + " 'format': 'graphforge.visualization/v2',\n", + " 'name': 'Airport PageRank — Python notebook',\n", + " 'kind': 'chart',\n", + " 'result': 'results/python-airport-pagerank.json',\n", + " 'filters': [],\n", + " 'renderer': {'id': 'plotly'},\n", + " 'chart': {\n", + " 'mark': 'bar',\n", + " 'bindings': {\n", + " 'x': 'code', 'y': 'score', 'color': 'region',\n", + " 'size': None, 'shape': None, 'series': None,\n", + " },\n", + " 'aggregation': 'none',\n", + " 'binning': {'enabled': False, 'thresholds': None},\n", + " 'sort': [{'field': 'score', 'direction': 'descending'}],\n", + " 'axes': {'x': True, 'y': True},\n", + " 'legend': True,\n", + " 'theme': 'editor',\n", + " 'animation': False,\n", + " 'title': 'Top US airports by PageRank',\n", + " },\n", + "}\n", + "visualization_path.write_text(\n", + " json.dumps(visualization_payload, indent=2) + '\\n',\n", + " encoding='utf-8',\n", + ")\n", + "print(result_path.relative_to(project_root))\n", + "print(visualization_path.relative_to(project_root))" + ] + }, + { + "cell_type": "markdown", + "id": "plot-heading", + "metadata": {}, + "source": [ + "### 5. Visualize in the notebook\n", + "\n", + "Plotly is the default analytical chart path in GraphForge and also works naturally in Python. The HTML export is self-contained and sits beside the saved GraphForge visualization specification." + ] + }, + { + "cell_type": "code", + "id": "plot-result", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "figure = px.bar(\n", + " top_airports.head(15),\n", + " x='code',\n", + " y='score',\n", + " color='region',\n", + " hover_data=['city', 'rank'],\n", + " title='Top US airports by PageRank',\n", + " labels={'code': 'Airport', 'score': 'PageRank', 'region': 'Region'},\n", + ")\n", + "figure.update_layout(template='plotly_dark', xaxis_categoryorder='array', xaxis_categoryarray=top_airports.head(15)['code'])\n", + "figure.show()\n", + "\n", + "html_path = visualizations_dir / 'python-airport-pagerank.html'\n", + "figure.write_html(html_path, include_plotlyjs=True, full_html=True)\n", + "print(html_path.relative_to(project_root))" + ] + }, + { + "cell_type": "markdown", + "id": "checks-heading", + "metadata": {}, + "source": [ + "## Checks\n", + "\n", + "A successful top-to-bottom run proves all of the following from visible cells and files:\n", + "\n", + "- the notebook read 586 airports and 7,430 directed routes from the same vendored CSVs as the extension;\n", + "- GraphForge Python bulk-created the graph and returned Arrow receipts;\n", + "- native PageRank returned one row per airport;\n", + "- the top 25 rows were written to `results/python-airport-pagerank.json`; and\n", + "- both the extension-readable Plotly spec and a self-contained HTML chart were written under `visualizations/`." + ] + }, + { + "cell_type": "code", + "id": "checks-run", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "assert result_path.exists()\n", + "assert visualization_path.exists()\n", + "assert html_path.exists()\n", + "print(top_airports.head(3)[['rank', 'code', 'score']].to_string(index=False))\n", + "forge.close()\n", + "print('Air-routes Python notebook complete.')" + ] + }, + { + "cell_type": "markdown", + "id": "next-steps", + "metadata": {}, + "source": [ + "## Next Steps\n", + "\n", + "Change the analytic (`pagerank` can be replaced by another supported `rank()` algorithm), reshape the Arrow result in pandas, or create another explicit project visualization. Keep source data under `data/`, tabular outputs under `results/`, and reproducible visualization choices under `visualizations/` so the extension, scripts, notebooks, and coding agents all see the same work." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/media/samples/air-routes/project/queries/templates/routes-overview.cypher b/media/samples/air-routes/project/queries/templates/routes-overview.cypher index 4e98ef7..4c1dfc0 100644 --- a/media/samples/air-routes/project/queries/templates/routes-overview.cypher +++ b/media/samples/air-routes/project/queries/templates/routes-overview.cypher @@ -5,5 +5,7 @@ RETURN a.code AS source, a.code AS label, r.dist AS dist, a.region AS region, - a.lon AS longitude, - a.lat AS latitude + a.lon AS sourceLongitude, + a.lat AS sourceLatitude, + b.lon AS targetLongitude, + b.lat AS targetLatitude diff --git a/media/samples/air-routes/project/results/README.md b/media/samples/air-routes/project/results/README.md index 2e32fe7..207f5d4 100644 --- a/media/samples/air-routes/project/results/README.md +++ b/media/samples/air-routes/project/results/README.md @@ -2,7 +2,7 @@ Running a project query writes the latest canonical result to `query-result.json` / `query-result.md` and keeps timestamped JSON/Markdown -history beside it. The control hub can reopen any JSON result. +history beside it. The Get Started journey and Query page can reopen any JSON result. `route-activity.json` is a committed, illustrative 2025 monthly UTC series used by `route-activity-timeline.gfviz.json`. Its synthetic values are intentionally diff --git a/media/samples/air-routes/project/visualizations/airports-map.gfviz.json b/media/samples/air-routes/project/visualizations/airports-map.gfviz.json index 51735e6..11064a2 100644 --- a/media/samples/air-routes/project/visualizations/airports-map.gfviz.json +++ b/media/samples/air-routes/project/visualizations/airports-map.gfviz.json @@ -1,18 +1,25 @@ { "format": "graphforge.visualization/v2", - "name": "US route origins", + "name": "US airport routes", "kind": "geospatial", "result": "results/query-result.json", "filters": [], "renderer": { "id": "l7", "backend": "device" }, "geospatial": { - "source": { "type": "coordinates", "longitudeField": "longitude", "latitudeField": "latitude" }, + "source": { + "type": "links", + "source": { "longitudeField": "sourceLongitude", "latitudeField": "sourceLatitude" }, + "target": { "longitudeField": "targetLongitude", "latitudeField": "targetLatitude" } + }, "sourceCrs": "EPSG:4326", "projection": "EPSG:3857", "aggregation": "none", - "layers": [{ "id": "route-origins", "type": "point", "colorField": null, "sizeField": null, "shapeField": null, "color": "#4c6ef5", "opacity": 0.75, "size": 5 }], + "layers": [ + { "id": "routes", "type": "arc", "colorField": null, "sizeField": null, "shapeField": null, "color": "#6ea8fe", "opacity": 0.18, "size": 0.8 }, + { "id": "airports", "type": "point", "colorField": null, "sizeField": null, "shapeField": null, "color": "#f2cc60", "opacity": 0.95, "size": 3 } + ], "basemap": { "type": "blank" }, "viewport": { "longitude": -98.5, "latitude": 39.5, "zoom": 3, "bearing": 0, "pitch": 0, "bounds": null }, - "presentation": { "title": "US route origins", "legend": false, "theme": "editor" } + "presentation": { "title": "US airport routes", "legend": false, "theme": "editor" } } } diff --git a/media/samples/air-routes/project/visualizations/routes-network-default.gfviz.json b/media/samples/air-routes/project/visualizations/routes-network-default.gfviz.json new file mode 100644 index 0000000..4102fbb --- /dev/null +++ b/media/samples/air-routes/project/visualizations/routes-network-default.gfviz.json @@ -0,0 +1,31 @@ +{ + "format": "graphforge.visualization/v2", + "name": "US routes network — Cytoscape", + "kind": "result-graph", + "result": "results/query-result.json", + "filters": [], + "renderer": { "id": "cytoscape", "backend": "canvas" }, + "graph": { + "layout": { + "type": "cose", + "execution": "main", + "animation": false, + "maxIterations": 250, + "gravity": 0.7, + "nodeRepulsion": 180000, + "idealEdgeLength": 42 + }, + "style": { + "preset": "graphforge-epistemic/v1", + "nodeLabelFields": ["name", "label"], + "nodeLabelFallback": "label-or-id", + "edgeLabelField": "type", + "showEdgeLabels": false, + "nodeSize": 22, + "edgeWidth": 1.3, + "arrowheads": false + }, + "interactions": { "pan": true, "zoom": true, "select": true, "fit": true, "relayout": true }, + "timebar": { "enabled": false } + } +} diff --git a/onboarding-design-qa/implementation-e2e-g6.jpeg b/onboarding-design-qa/implementation-e2e-g6.jpeg new file mode 100644 index 0000000..0b8e779 Binary files /dev/null and b/onboarding-design-qa/implementation-e2e-g6.jpeg differ diff --git a/onboarding-design-qa/implementation-opened-map.jpeg b/onboarding-design-qa/implementation-opened-map.jpeg new file mode 100644 index 0000000..68b4295 Binary files /dev/null and b/onboarding-design-qa/implementation-opened-map.jpeg differ diff --git a/package-lock.json b/package-lock.json index d83d4c9..f69bd5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "graphforge", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "graphforge", - "version": "0.1.1", + "version": "0.1.2", "license": "Apache-2.0", "dependencies": { "@antv/g2": "^5.4.8", diff --git a/package.json b/package.json index adabdcc..7bc60cd 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "graphforge", "displayName": "GraphForge", "description": "Explore GraphForge projects in VS Code — Cypher, analyst verbs, ontology, and epistemic-aware result graphs.", - "version": "0.1.1", + "version": "0.1.2", "publisher": "CurateLabsAI", "icon": "media/icon.png", "license": "Apache-2.0", @@ -73,6 +73,11 @@ "title": "GraphForge: Open Sample Project", "icon": "$(preview)" }, + { + "command": "graphforge.openSampleNotebook", + "title": "GraphForge: Open Sample Python Notebook", + "icon": "$(notebook)" + }, { "command": "graphforge.closeProject", "title": "GraphForge: Close Project" @@ -162,10 +167,6 @@ "title": "GraphForge: Show Visualize", "icon": "$(graph)" }, - { - "command": "graphforge.chooseExperienceMode", - "title": "GraphForge: Choose Experience Mode…" - }, { "command": "graphforge.openSettings", "title": "GraphForge: Settings", @@ -503,15 +504,18 @@ }, { "id": "graphforge.projects", - "name": "Projects" + "name": "Projects", + "visibility": "collapsed" }, { "id": "graphforge.ontology", - "name": "Ontology" + "name": "Ontology", + "visibility": "collapsed" }, { "id": "graphforge.knowledge", - "name": "Knowledge" + "name": "Knowledge", + "visibility": "collapsed" } ], "graphforgeResults": [ @@ -533,35 +537,35 @@ }, "graphforge.openResultGraphOnQuery": { "type": "boolean", - "default": true, + "default": false, "description": "Open the Result Graph webview after a successful Cypher query or analyst verb." }, "graphforge.resultGraph.renderer": { "type": "string", "enum": [ - "g6", "cytoscape", + "g6", "sigma" ], "enumDescriptions": [ - "AntV G6 Canvas renderer with an explicit worker ForceAtlas2 layout.", "Canvas renderer with force layout, pan, zoom, fit, and selection.", + "AntV G6 Canvas renderer with an explicit worker ForceAtlas2 layout.", "WebGL renderer optimized for larger result graphs, with ForceAtlas2 layout and selection." ], - "default": "g6", + "default": "cytoscape", "description": "Renderer template for newly created Result Graph artifacts. Saved artifacts keep their recorded renderer." }, "graphforge.chart.renderer": { "type": "string", "enum": [ - "g2", - "plotly" + "plotly", + "g2" ], "enumDescriptions": [ - "AntV G2 declarative chart renderer.", - "Plotly renderer and Python/JavaScript figure JSON interchange." + "Plotly renderer and Python/JavaScript figure JSON interchange.", + "AntV G2 declarative chart renderer." ], - "default": "g2", + "default": "plotly", "description": "Renderer template for newly created chart artifacts. Saved artifacts keep their recorded renderer." }, "graphforge.runtime": { @@ -589,19 +593,6 @@ "default": "", "description": "Optional absolute path to a Python interpreter with `graphforge` installed. Leave empty to auto-detect (VS Code Python extension selection, workspace .venv/venv/env, then python3/python on PATH)." }, - "graphforge.experienceMode": { - "type": "string", - "enum": [ - "guided", - "autonomous" - ], - "enumDescriptions": [ - "Confirms before Initialize; quieter auto-run; Result Graph stays closed until opened.", - "Auto-opens detected projects and the Result Graph after queries; skips routine confirmations. Still fails closed on destructive operations." - ], - "default": "guided", - "description": "How much GraphForge confirms with you vs. acts on its own. Set from the Welcome step in Get Started, or here." - }, "graphforge.figureLimitsEnabled": { "type": "boolean", "default": false, @@ -644,8 +635,9 @@ "check": "tsc --noEmit && tsc --noEmit -p webview-ui", "lint": "npm run check", "test": "npm run compile:host && vscode-test", - "test:unit": "npm run compile && mocha --ui tdd dist/test/projectDetector.test.js dist/test/environmentReport.test.js dist/test/uuid.test.js dist/test/arrowCodec.test.js dist/test/runtimeSelection.test.js dist/test/errorPresentation.test.js dist/test/projectKind.test.js dist/test/pythonInstallCommand.test.js dist/test/engineVersion.test.js dist/test/versionDiscovery.test.js dist/test/pythonProbe.test.js dist/test/pythonBridge.test.js dist/test/experienceMode.test.js dist/test/settingsSchema.test.js dist/test/getStartedContent.test.js dist/test/quickstartSample.test.js dist/test/projectArtifacts.test.js dist/test/visualizationRegistry.test.js dist/test/visualizationDocumentState.test.js dist/test/agentContext.test.js dist/test/knowledgeStatus.test.js dist/test/pickerCopy.test.js dist/test/paletteTitles.test.js dist/test/figureFromResult.test.js dist/test/figureSchema.test.js dist/test/resultGraphModel.test.js dist/test/resultTableModel.test.js dist/test/entityMutation.test.js dist/test/resultDocument.test.js dist/test/moduleManifest.test.js dist/test/workspaceScript.test.js dist/test/importData.test.js", + "test:unit": "npm run compile && mocha --ui tdd dist/test/projectDetector.test.js dist/test/environmentReport.test.js dist/test/uuid.test.js dist/test/arrowCodec.test.js dist/test/runtimeSelection.test.js dist/test/errorPresentation.test.js dist/test/projectKind.test.js dist/test/pythonInstallCommand.test.js dist/test/engineVersion.test.js dist/test/versionDiscovery.test.js dist/test/pythonProbe.test.js dist/test/pythonBridge.test.js dist/test/settingsSchema.test.js dist/test/getStartedContent.test.js dist/test/viewContributions.test.js dist/test/quickstartSample.test.js dist/test/projectArtifacts.test.js dist/test/visualizationRegistry.test.js dist/test/visualizationDocumentState.test.js dist/test/visualizationLoadingModel.test.js dist/test/geospatialModel.test.js dist/test/agentContext.test.js dist/test/knowledgeStatus.test.js dist/test/pickerCopy.test.js dist/test/paletteTitles.test.js dist/test/figureFromResult.test.js dist/test/figureSchema.test.js dist/test/resultGraphModel.test.js dist/test/resultTableModel.test.js dist/test/entityMutation.test.js dist/test/resultDocument.test.js dist/test/moduleManifest.test.js dist/test/workspaceScript.test.js dist/test/importData.test.js", "benchmark:visualizations": "node scripts/benchmark-visualizations.mjs", + "verify:package": "node scripts/verify-package-contents.mjs", "package": "vsce package" }, "dependencies": { diff --git a/scripts/verify-package-contents.mjs b/scripts/verify-package-contents.mjs new file mode 100644 index 0000000..ad28691 --- /dev/null +++ b/scripts/verify-package-contents.mjs @@ -0,0 +1,53 @@ +import fs from "node:fs"; +import path from "node:path"; +import { listFiles } from "@vscode/vsce"; + +const cwd = process.cwd(); +const files = (await listFiles({ cwd })).map((file) => file.replaceAll("\\", "/")); + +const forbiddenPrefixes = [ + ".claude/", + ".git/", + ".impeccable/", + ".kilo/", + ".productfeeling/", + ".redteam/", + ".vscode-test/", + "agent-transcripts/", + "docs/", + "onboarding-design-qa/", + "scripts/", + "src/", + "webview-ui/", +]; +const forbiddenFiles = new Set(["design-qa.md"]); +const forbidden = files.filter( + (file) => forbiddenFiles.has(file) || forbiddenPrefixes.some((prefix) => file.startsWith(prefix)), +); + +const required = [ + "dist/extension.js", + "dist/webview-ui/getStarted.js", + "dist/webview-ui/getStarted.css", + "media/samples/air-routes/project/notebooks/air-routes-analysis.ipynb", + "package.json", + "README.md", +]; +const missing = required.filter((file) => !files.includes(file)); + +const totalBytes = files.reduce((sum, file) => { + const absolute = path.join(cwd, file); + return sum + (fs.existsSync(absolute) ? fs.statSync(absolute).size : 0); +}, 0); +const maxFiles = 100; +const maxBytes = 15 * 1024 * 1024; + +if (forbidden.length > 0 || missing.length > 0 || files.length > maxFiles || totalBytes > maxBytes) { + if (forbidden.length > 0) console.error(`Forbidden VSIX paths:\n${forbidden.join("\n")}`); + if (missing.length > 0) console.error(`Missing required VSIX paths:\n${missing.join("\n")}`); + if (files.length > maxFiles) console.error(`VSIX file count ${files.length} exceeds ${maxFiles}.`); + if (totalBytes > maxBytes) console.error(`VSIX unpacked size ${totalBytes} exceeds ${maxBytes} bytes.`); + process.exitCode = 1; +} else { + console.log(`VSIX contents verified: ${files.length} files, ${totalBytes} bytes unpacked.`); +} diff --git a/src/commands/agent.ts b/src/commands/agent.ts index aedc8b7..e226a3a 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -40,6 +40,11 @@ export const AGENT_OPERATION_COMMANDS: readonly AgentCommandDescriptor[] = [ args: "{ path?: string; force?: boolean }", returns: "{ path, project, seeded }", }, + { + id: "graphforge.openSampleNotebook", + args: "none", + returns: "{ path, relativePath }", + }, { id: "graphforge.openProject", args: "string | Uri | { path: string | Uri }", @@ -94,7 +99,6 @@ function effectiveSettings(): Record { engineVersion: config.get("engineVersion"), nativeModulePath: config.get("nativeModulePath"), pythonInterpreterPath: config.get("pythonInterpreterPath"), - experienceMode: config.get("experienceMode"), openResultGraphOnQuery: config.get("openResultGraphOnQuery"), resultGraphRenderer: config.get("resultGraph.renderer"), chartRenderer: config.get("chart.renderer"), diff --git a/src/commands/analystVerbs.ts b/src/commands/analystVerbs.ts index 2585417..4bf2add 100644 --- a/src/commands/analystVerbs.ts +++ b/src/commands/analystVerbs.ts @@ -239,7 +239,7 @@ async function runVerb( const openGraph = vscode.workspace .getConfiguration("graphforge") - .get("openResultGraphOnQuery", true); + .get("openResultGraphOnQuery", false); if (openGraph) { const commands = await vscode.commands.getCommands(true); if (commands.includes("graphforge.showResultGraph")) { diff --git a/src/commands/figures.ts b/src/commands/figures.ts index 4100085..6653b0c 100644 --- a/src/commands/figures.ts +++ b/src/commands/figures.ts @@ -81,7 +81,6 @@ export function registerFigures( return { error: checked.error, code: checked.code }; } const shown = presentFigure(context.extensionUri, checked.figure); - session.markSeenFigure(); return shown; }, ), @@ -135,7 +134,6 @@ export function registerFigures( } const shown = presentFigure(context.extensionUri, checked.figure); - session.markSeenFigure(); return { ...shown, chartType: chartType! }; }, ), diff --git a/src/commands/find.ts b/src/commands/find.ts index 67c3768..ad8d44e 100644 --- a/src/commands/find.ts +++ b/src/commands/find.ts @@ -74,7 +74,7 @@ async function runFind( const hasUuids = result.columns.some((c) => /uuid/i.test(c)); const openGraph = vscode.workspace .getConfiguration("graphforge") - .get("openResultGraphOnQuery", true); + .get("openResultGraphOnQuery", false); if (hasUuids && openGraph && result.rowCount > 0) { const commands = await vscode.commands.getCommands(true); if (commands.includes("graphforge.showResultGraph")) { diff --git a/src/commands/openSampleProject.ts b/src/commands/openSampleProject.ts index 5dd48db..9c6964f 100644 --- a/src/commands/openSampleProject.ts +++ b/src/commands/openSampleProject.ts @@ -1,10 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as vscode from "vscode"; -import { - defaultsForExperienceMode, - resolveExperienceMode, -} from "../session/experienceMode"; import type { GraphForgeSession } from "../session/graphForgeSession"; import { isGraphForgeProject } from "../session/projectDetector"; import { @@ -12,6 +8,8 @@ import { isQuickstartSamplePath, loadQuickstartDataset, materializeQuickstartProjectFiles, + QUICKSTART_NOTEBOOK_REL, + repairQuickstartProjectFiles, resolveQuickstartPath, writeQuickstartMarker, } from "../session/quickstartSample"; @@ -35,14 +33,13 @@ export type OpenSampleProjectSuccess = { path: string; project: DetectedProject; seeded: boolean; + repaired?: string[]; }; -function guidedConfirm(): boolean { - const mode = resolveExperienceMode( - vscode.workspace.getConfiguration("graphforge").get("experienceMode"), - ); - return defaultsForExperienceMode(mode).confirmBeforeInitialize; -} +export type OpenSampleNotebookSuccess = { + path: string; + relativePath: string; +}; /** Palette/Get Started calls with no args; agents/e2e pass an object. */ function isInteractiveCall(args?: OpenSampleProjectArgs): boolean { @@ -57,6 +54,7 @@ async function openExisting( session: GraphForgeSession, target: string, refreshTrees: () => void, + repaired: string[] = [], ): Promise> { try { await session.openProject(target); @@ -67,7 +65,12 @@ async function openExisting( return { error, code: "SAMPLE_OPEN_FAILED" }; } refreshTrees(); - return { path: target, project, seeded: false }; + return { + path: target, + project, + seeded: false, + ...(repaired.length > 0 ? { repaired } : {}), + }; } catch (err) { return reportEngineError("open sample project failed", err); } @@ -79,6 +82,43 @@ export function registerOpenSampleProject( refreshTrees: () => void, ): void { context.subscriptions.push( + vscode.commands.registerCommand( + "graphforge.openSampleNotebook", + async (): Promise> => { + const projectRoot = session.project?.rootPath; + if (!projectRoot || !isQuickstartSamplePath(projectRoot)) { + const error = "Open the air-routes sample before opening its Python notebook."; + presentError(`GraphForge: ${error}`); + return { + error, + code: "SAMPLE_NOTEBOOK_PROJECT_REQUIRED", + nextAction: "graphforge.openSampleProject", + }; + } + const notebookPath = path.join(projectRoot, QUICKSTART_NOTEBOOK_REL); + if (!fs.existsSync(notebookPath)) { + try { + const dataset = loadQuickstartDataset(context.extensionPath); + repairQuickstartProjectFiles(projectRoot, dataset); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + presentError(`GraphForge: ${error}`); + return { error, code: "SAMPLE_NOTEBOOK_MISSING", nextAction: "graphforge.openSampleProject" }; + } + } + if (!fs.existsSync(notebookPath)) { + const error = `Sample notebook is unavailable: ${QUICKSTART_NOTEBOOK_REL}`; + presentError(`GraphForge: ${error}`); + return { error, code: "SAMPLE_NOTEBOOK_MISSING", nextAction: "graphforge.openSampleProject" }; + } + await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(notebookPath)); + return { + path: notebookPath, + relativePath: QUICKSTART_NOTEBOOK_REL.split(path.sep).join("/"), + }; + }, + ), + vscode.commands.registerCommand("graphforge.closeProject", async () => { await session.closeProject(); refreshTrees(); @@ -116,11 +156,17 @@ export function registerOpenSampleProject( const isSample = isProject && isQuickstartSamplePath(target); if (isSample && !force) { - return openExisting(session, target, refreshTrees); + try { + const dataset = loadQuickstartDataset(context.extensionPath); + const repaired = repairQuickstartProjectFiles(target, dataset); + return openExisting(session, target, refreshTrees, repaired); + } catch (err) { + return reportEngineError("repair sample project failed", err); + } } if (exists && !isEmptyDir(target)) { - if (interactive && guidedConfirm()) { + if (interactive) { const choice = await vscode.window.showWarningMessage( isSample ? `Recreate the GraphForge quickstart sample at ${target}? Existing data will be replaced.` @@ -142,15 +188,6 @@ export function registerOpenSampleProject( }; } rmrf(target); - } else if (interactive && guidedConfirm()) { - const choice = await vscode.window.showInformationMessage( - `Create the GraphForge quickstart sample at ${target}?`, - { modal: true }, - "Create sample", - ); - if (choice !== "Create sample") { - return { cancelled: true }; - } } let dataset: ReturnType; diff --git a/src/commands/openViews.ts b/src/commands/openViews.ts index a4f43b3..2374330 100644 --- a/src/commands/openViews.ts +++ b/src/commands/openViews.ts @@ -116,17 +116,6 @@ export function registerOpenViews( showGetStartedPage("visualize"), ), - vscode.commands.registerCommand("graphforge.chooseExperienceMode", async () => { - const provider = GetStartedViewProvider.instance; - if (provider) { - await revealGetStarted(provider); - provider.showWelcome(); - } else { - await vscode.commands.executeCommand("workbench.view.extension.graphforge"); - await vscode.commands.executeCommand("graphforge.getStarted.focus"); - } - }), - vscode.commands.registerCommand("graphforge.statusBarClick", async () => { if (session.project && session.activeRuntime) { await vscode.commands.executeCommand("graphforge.showCapabilities"); diff --git a/src/commands/projectArtifacts.ts b/src/commands/projectArtifacts.ts index 612fb44..1b474eb 100644 --- a/src/commands/projectArtifacts.ts +++ b/src/commands/projectArtifacts.ts @@ -65,11 +65,19 @@ function validateVisualizationResultBindings( ...spec.chart.sort.map((item) => item.field), ], "Chart bindings"); } else if (spec.kind === "geospatial") { + const source = spec.geospatial.source; requireResultFields( result, - spec.geospatial.source.type === "coordinates" - ? [spec.geospatial.source.longitudeField, spec.geospatial.source.latitudeField] - : [spec.geospatial.source.geometryField], + source.type === "coordinates" + ? [source.longitudeField, source.latitudeField] + : source.type === "links" + ? [ + source.source.longitudeField, + source.source.latitudeField, + source.target.longitudeField, + source.target.latitudeField, + ] + : [source.geometryField], "Geospatial bindings", ); } else if (spec.kind === "temporal") { @@ -196,13 +204,13 @@ function waitForResultGraphLifecycle( } function configuredGraphRenderer(config: vscode.WorkspaceConfiguration): "g6" | "cytoscape" | "sigma" { - const renderer = config.get("resultGraph.renderer", "g6"); + const renderer = config.get("resultGraph.renderer", "cytoscape"); if (renderer === "g6" || renderer === "cytoscape" || renderer === "sigma") return renderer; throw new Error(`Configured result graph renderer ${String(renderer)} is invalid. Use g6, cytoscape, or sigma.`); } function configuredChartRenderer(config: vscode.WorkspaceConfiguration): "g2" | "plotly" { - const renderer = config.get("chart.renderer", "g2"); + const renderer = config.get("chart.renderer", "plotly"); if (renderer === "g2" || renderer === "plotly") return renderer; throw new Error(`Configured chart renderer ${String(renderer)} is invalid. Use g2 or plotly.`); } @@ -235,6 +243,8 @@ interface CreateVisualizationArgs { color?: string; longitude?: string; latitude?: string; + targetLongitude?: string; + targetLatitude?: string; timestamp?: string; timezone?: string; granularity?: TemporalVisualizationSpecV2["temporal"]["granularity"]; @@ -382,27 +392,63 @@ export function registerProjectArtifacts( if (!args.longitude || !args.latitude) { throw new Error("Geospatial creation requires explicit longitude and latitude fields."); } + if (Boolean(args.targetLongitude) !== Boolean(args.targetLatitude)) { + throw new Error("Geospatial route creation requires both target longitude and target latitude fields."); + } + const hasTarget = Boolean(args.targetLongitude && args.targetLatitude); spec = createDefaultGeospatialSpec({ name, result: args.result, filters, - source: { - type: "coordinates", - longitudeField: args.longitude, - latitudeField: args.latitude, - }, + source: hasTarget + ? { + type: "links", + source: { longitudeField: args.longitude, latitudeField: args.latitude }, + target: { + longitudeField: args.targetLongitude!, + latitudeField: args.targetLatitude!, + }, + } + : { + type: "coordinates", + longitudeField: args.longitude, + latitudeField: args.latitude, + }, sourceCrs: "EPSG:4326", projection: "EPSG:3857", - layers: [{ - id: "points", - type: "point", - colorField: null, - sizeField: null, - shapeField: null, - color: "#4c6ef5", - opacity: 0.8, - size: 6, - }], + layers: hasTarget + ? [ + { + id: "links", + type: "arc", + colorField: null, + sizeField: null, + shapeField: null, + color: "#6ea8fe", + opacity: 0.18, + size: 0.8, + }, + { + id: "endpoints", + type: "point", + colorField: null, + sizeField: null, + shapeField: null, + color: "#f2cc60", + opacity: 0.95, + size: 3, + }, + ] + : [{ + id: "points", + type: "point", + colorField: null, + sizeField: null, + shapeField: null, + color: "#4c6ef5", + opacity: 0.8, + size: 6, + }], viewport: { longitude: 0, latitude: 20, zoom: 1.5, bearing: 0, pitch: 0, bounds: null }, }); } else { diff --git a/src/commands/runQuery.ts b/src/commands/runQuery.ts index 6861686..17bead4 100644 --- a/src/commands/runQuery.ts +++ b/src/commands/runQuery.ts @@ -163,7 +163,7 @@ async function executeAndShowResult( const openGraph = vscode.workspace .getConfiguration("graphforge") - .get("openResultGraphOnQuery", true); + .get("openResultGraphOnQuery", false); if (openGraph) { const commands = await vscode.commands.getCommands(true); if (commands.includes("graphforge.showResultGraph")) { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index a924366..68e3b44 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -1,6 +1,5 @@ import * as fs from "node:fs"; import * as vscode from "vscode"; -import { defaultsForExperienceMode, resolveExperienceMode } from "../session/experienceMode"; import { EnvironmentReport, computeNextAction, @@ -288,19 +287,6 @@ async function runInitializeProjectHere( if (!proceed) { return; } - } else { - const mode = resolveExperienceMode( - vscode.workspace.getConfiguration("graphforge").get("experienceMode"), - ); - if (defaultsForExperienceMode(mode).confirmBeforeInitialize) { - const confirm = await vscode.window.showInformationMessage( - `Initialize a new GraphForge project in ${rootPath}?`, - "Initialize", - ); - if (!confirm) { - return; - } - } } try { diff --git a/src/commands/visualize.ts b/src/commands/visualize.ts index 8e094d8..089507a 100644 --- a/src/commands/visualize.ts +++ b/src/commands/visualize.ts @@ -31,7 +31,6 @@ export function registerVisualizationCommands( labels: args?.labels, timebar: args?.timebar, }); - if (shown.status !== "cancelled") session.markSeenResultGraph(); return { panel: shown.status, nodes: payload.nodes.length, diff --git a/src/extension.ts b/src/extension.ts index 6feca8c..62d8c48 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,7 +13,6 @@ import { registerProjectArtifacts } from "./commands/projectArtifacts"; import { registerRunCli } from "./commands/runCli"; import { registerSetup } from "./commands/setup"; import { registerSetupPython } from "./commands/setupPython"; -import { defaultsForExperienceMode, resolveExperienceMode } from "./session/experienceMode"; import { GraphForgeSession } from "./session/graphForgeSession"; import { resetNativeCache } from "./session/nativeLoader"; import { resetPythonCache } from "./session/pythonLoader"; @@ -121,30 +120,6 @@ export async function activate( void projects.refresh(); - const autoOpenFirstProject = async () => { - if (session.project || !(await session.hasUsableRuntime())) { - return; - } - // Guided (the default) leaves project selection to the analyst — Get - // Started still surfaces "Open Project" as the next step. Autonomous - // mode auto-detects, matching the Welcome card's promise. - const mode = resolveExperienceMode( - vscode.workspace.getConfiguration("graphforge").get("experienceMode"), - ); - if (!defaultsForExperienceMode(mode).autoOpenDetectedProject) { - return; - } - const found = await session.listProjects(); - if (found[0]) { - try { - await session.openProject(found[0].rootPath); - refreshTrees(); - } catch { - // leave unbound; user can open manually - } - } - }; - // Re-resolve the active runtime without a window reload after Setup UX // changes `nativeModulePath` (#2) or `pythonInterpreterPath`/`runtime` // (#12): Run Query becomes available without a full reload when possible. @@ -153,7 +128,6 @@ export async function activate( if (event.affectsConfiguration("graphforge.nativeModulePath")) { resetNativeCache(); session.notifyChanged(); - void autoOpenFirstProject(); } if ( event.affectsConfiguration("graphforge.pythonInterpreterPath") || @@ -161,7 +135,6 @@ export async function activate( ) { resetPythonCache(); session.notifyChanged(); - void autoOpenFirstProject(); } }), ); @@ -195,7 +168,6 @@ export async function activate( } })(); - void autoOpenFirstProject(); void modules.refreshGraphForgeCatalog(); setTimeout(() => void modules.activatePending(), 0); } diff --git a/src/session/agentContext.ts b/src/session/agentContext.ts index df7bb33..30e05d4 100644 --- a/src/session/agentContext.ts +++ b/src/session/agentContext.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { PROJECT_MUTATIONS_DIR, + PROJECT_NOTEBOOKS_DIR, PROJECT_QUERIES_DIR, PROJECT_QUERY_TEMPLATES_DIR, PROJECT_RESULTS_DIR, @@ -25,6 +26,7 @@ export type AgentVisualizationEntry = ProjectVisualizationEntry & { absolutePath export interface AgentArtifactIndex { queries: AgentArtifactEntry[]; queryTemplates: AgentArtifactEntry[]; + notebooks: AgentArtifactEntry[]; results: AgentResultEntry[]; visualizations: AgentVisualizationEntry[]; mutations: AgentArtifactEntry[]; @@ -43,6 +45,7 @@ export interface AgentProjectContext { directories: { queries: string; queryTemplates: string; + notebooks: string; results: string; visualizations: string; mutations: string; @@ -74,6 +77,7 @@ export function enrichArtifactIndex( return { queries: withAbsolutePath(projectRoot, index.queries), queryTemplates: withAbsolutePath(projectRoot, index.queryTemplates), + notebooks: withAbsolutePath(projectRoot, index.notebooks), results: withAbsolutePath(projectRoot, index.results), visualizations: withAbsolutePath(projectRoot, index.visualizations), mutations: withAbsolutePath(projectRoot, index.mutations), @@ -83,6 +87,11 @@ export function enrichArtifactIndex( function latestResultPath(results: AgentResultEntry[]): string | undefined { let latest: { path: string; modified: number } | undefined; for (const result of results) { + // Canonical last-result aliases are exposed separately; history picks only + // durable timestamped result files. + if (path.basename(result.path) === "query-result.json") { + continue; + } try { const modified = fs.statSync(result.absolutePath).mtimeMs; if (!latest || modified > latest.modified) { @@ -119,6 +128,7 @@ export function buildAgentProjectContext( directories: { queries: path.join(rootPath, PROJECT_QUERIES_DIR), queryTemplates: path.join(rootPath, PROJECT_QUERY_TEMPLATES_DIR), + notebooks: path.join(rootPath, PROJECT_NOTEBOOKS_DIR), results: path.join(rootPath, PROJECT_RESULTS_DIR), visualizations: path.join(rootPath, PROJECT_VISUALIZATIONS_DIR), mutations: path.join(rootPath, PROJECT_MUTATIONS_DIR), diff --git a/src/session/experienceMode.ts b/src/session/experienceMode.ts deleted file mode 100644 index 70ef149..0000000 --- a/src/session/experienceMode.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * `graphforge.experienceMode` (Welcome, #22 phase 2): the two ways an analyst - * can ask GraphForge to behave, inspired by Kilo Code's "Choose how you want - * to work" onboarding step but mapped onto GraphForge's own vocabulary - * (confirmations, project detection, Result Graph auto-open) rather than - * agent autonomy levels. Kept free of `vscode` imports so it can be unit - * tested directly under plain mocha — see `docs/DESIGN.md`. - */ -export type ExperienceMode = "guided" | "autonomous"; - -export const DEFAULT_EXPERIENCE_MODE: ExperienceMode = "guided"; - -export function isExperienceMode(value: unknown): value is ExperienceMode { - return value === "guided" || value === "autonomous"; -} - -/** Coerce an unknown config value (missing/stale/corrupt) to a valid mode. */ -export function resolveExperienceMode(value: unknown): ExperienceMode { - return isExperienceMode(value) ? value : DEFAULT_EXPERIENCE_MODE; -} - -/** Behavior toggles derived from the chosen mode; applied when Continue is pressed. */ -export interface ExperienceModeDefaults { - /** `graphforge.openResultGraphOnQuery` — quieter in Guided, on by default in Autonomous. */ - openResultGraphOnQuery: boolean; - /** Whether "Initialize Project Here" on an empty folder still asks to confirm. */ - confirmBeforeInitialize: boolean; - /** Whether activation silently opens the first detected project without asking. */ - autoOpenDetectedProject: boolean; -} - -export function defaultsForExperienceMode(mode: ExperienceMode): ExperienceModeDefaults { - if (mode === "autonomous") { - return { - openResultGraphOnQuery: true, - confirmBeforeInitialize: false, - autoOpenDetectedProject: true, - }; - } - return { - openResultGraphOnQuery: false, - confirmBeforeInitialize: true, - autoOpenDetectedProject: false, - }; -} - -export interface ExperienceModeCard { - mode: ExperienceMode; - title: string; - tagline: string; - bullets: string[]; -} - -/** Copy for the two Welcome cards — short, analyst-facing, no infra jargon. */ -export const EXPERIENCE_MODE_CARDS: readonly ExperienceModeCard[] = [ - { - mode: "guided", - title: "Guided", - tagline: "Confirm before changes; steady and checklist-driven.", - bullets: [ - "Confirms before initializing a project", - "Opens this checklist so setup stays visible", - "Result Graph stays closed until you ask for it", - ], - }, - { - mode: "autonomous", - title: "Autonomous", - tagline: "Fewer prompts; GraphForge moves for you.", - bullets: [ - "Auto-opens the first GraphForge project it detects", - "Skips the initialize confirmation for empty folders", - "Opens the Result Graph automatically after each query", - ], - }, -]; diff --git a/src/session/graphForgeSession.ts b/src/session/graphForgeSession.ts index af20a78..a839541 100644 --- a/src/session/graphForgeSession.ts +++ b/src/session/graphForgeSession.ts @@ -97,9 +97,6 @@ export class GraphForgeSession implements vscode.Disposable { private algorithmContractsCache: AlgorithmDescriptorContract[] | undefined; private lastResult: QueryResult | undefined; private lastResultTitle: string | undefined; - /** Get Started “see results” step — shown this session (#63). */ - private seenResultGraph = false; - private seenFigure = false; private beliefPolicy: BeliefPolicySettings = { ...DEFAULT_BELIEF_POLICY }; private readonly statusBar: vscode.StatusBarItem; private readonly _onDidChange = new vscode.EventEmitter(); @@ -181,8 +178,6 @@ export class GraphForgeSession implements vscode.Disposable { this.algorithmContractsCache = undefined; this.lastResult = undefined; this.lastResultTitle = undefined; - this.seenResultGraph = false; - this.seenFigure = false; this.activeWriteMode = "single_writer"; if (previous) { void previous.dispose(); @@ -241,8 +236,6 @@ export class GraphForgeSession implements vscode.Disposable { this.algorithmContractsCache = undefined; this.lastResult = undefined; this.lastResultTitle = undefined; - this.seenResultGraph = false; - this.seenFigure = false; // Write-mode coordination is Node-only; a Python-backed session always // reports the (only meaningful) single-writer default. this.activeWriteMode = backend.runtime === "node" ? writeMode : "single_writer"; @@ -1352,28 +1345,6 @@ export class GraphForgeSession implements vscode.Disposable { this._onDidChange.fire(); } - get hasSeenResultGraph(): boolean { - return this.seenResultGraph; - } - - get hasSeenFigure(): boolean { - return this.seenFigure; - } - - markSeenResultGraph(): void { - if (!this.seenResultGraph) { - this.seenResultGraph = true; - this._onDidChange.fire(); - } - } - - markSeenFigure(): void { - if (!this.seenFigure) { - this.seenFigure = true; - this._onDidChange.fire(); - } - } - private rememberResult(result: QueryResult, title?: string): void { this.lastResult = result; this.lastResultTitle = title; diff --git a/src/session/projectArtifacts.ts b/src/session/projectArtifacts.ts index dc42cdf..dd8dda5 100644 --- a/src/session/projectArtifacts.ts +++ b/src/session/projectArtifacts.ts @@ -12,6 +12,7 @@ import { export const PROJECT_QUERIES_DIR = "queries"; export const PROJECT_QUERY_TEMPLATES_DIR = "queries/templates"; +export const PROJECT_NOTEBOOKS_DIR = "notebooks"; export const PROJECT_RESULTS_DIR = "results"; export const PROJECT_VISUALIZATIONS_DIR = "visualizations"; export const PROJECT_MUTATIONS_DIR = "mutations"; @@ -35,11 +36,14 @@ export interface ProjectResultEntry extends ProjectArtifactEntry { export interface ProjectVisualizationEntry extends ProjectArtifactEntry { kind: ProjectVisualizationSpec["kind"]; result: string; + format: ProjectVisualizationSpec["format"]; + renderer: string; } export interface ProjectArtifactIndex { queries: ProjectArtifactEntry[]; queryTemplates: ProjectArtifactEntry[]; + notebooks: ProjectArtifactEntry[]; results: ProjectResultEntry[]; visualizations: ProjectVisualizationEntry[]; mutations: ProjectArtifactEntry[]; @@ -268,6 +272,9 @@ export function scanProjectArtifacts(projectRoot: string): ProjectArtifactIndex const queryTemplates = queryFiles .filter(isTemplate) .map((file) => artifactEntry(projectRoot, file)); + const notebooks = listFiles(path.join(projectRoot, PROJECT_NOTEBOOKS_DIR), [ + ".ipynb", + ]).map((file) => artifactEntry(projectRoot, file)); const mutations = listFiles(path.join(projectRoot, PROJECT_MUTATIONS_DIR), [ ".cypher", ".cql", @@ -275,11 +282,7 @@ export function scanProjectArtifacts(projectRoot: string): ProjectArtifactIndex ".json", ]).map((file) => artifactEntry(projectRoot, file)); const resultFiles = listFiles(path.join(projectRoot, PROJECT_RESULTS_DIR), [".json"]); - const visibleResultFiles = - resultFiles.length > 1 - ? resultFiles.filter((file) => path.basename(file).toLocaleLowerCase() !== "query-result.json") - : resultFiles; - const results = visibleResultFiles.flatMap( + const results = resultFiles.flatMap( (file) => { try { const result = readProjectResult(projectRoot, file); @@ -299,12 +302,19 @@ export function scanProjectArtifacts(projectRoot: string): ProjectArtifactIndex ...artifactEntry(projectRoot, file), kind: spec.kind, result: spec.result, + format: spec.format, + renderer: + spec.format === VISUALIZATION_SPEC_FORMAT_V2 + ? spec.renderer.id + : spec.kind === "result-graph" + ? spec.graph.renderer + : "plotly", }]; } catch { return []; } }); - return { queries, queryTemplates, results, visualizations, mutations }; + return { queries, queryTemplates, notebooks, results, visualizations, mutations }; } /** UTC timestamp used by all unnamed project artifacts: YYYYMMDD-HHMMSS-mmm. */ diff --git a/src/session/quickstartSample.ts b/src/session/quickstartSample.ts index 57085e0..ab5eeb6 100644 --- a/src/session/quickstartSample.ts +++ b/src/session/quickstartSample.ts @@ -14,6 +14,12 @@ export const QUICKSTART_MARKER_BYTES = "graphforge-quickstart/air-routes-us/v1\n /** Default folder name when materializing under a workspace root. */ export const QUICKSTART_DIR_NAME = "graphforge-quickstart"; +/** Analyst-facing Python path copied into every air-routes sample project. */ +export const QUICKSTART_NOTEBOOK_REL = path.join( + "notebooks", + "air-routes-analysis.ipynb", +); + /** Relative path (from the extension root) to the vendored Apache-2.0 sample. */ export const QUICKSTART_DATASET_REL = path.join("media", "samples", "air-routes"); @@ -202,6 +208,39 @@ export function materializeQuickstartProjectFiles( return { seedMutationPath: relativeProjectPath(projectRoot, seedMutationPath) }; } +/** + * Add sample-owned files introduced by a newer extension without replacing + * project results or anything the analyst has already changed. + */ +export function repairQuickstartProjectFiles( + projectRoot: string, + dataset: QuickstartDataset, +): string[] { + const templateRoot = path.join(dataset.datasetDir, "project"); + if (!fs.existsSync(templateRoot)) { + throw new Error(`Quickstart project template missing under ${templateRoot}.`); + } + + const added: string[] = []; + const copyMissing = (sourceDir: string, targetDir: string): void => { + for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { + const source = path.join(sourceDir, entry.name); + const target = path.join(targetDir, entry.name); + if (entry.isDirectory()) { + fs.mkdirSync(target, { recursive: true }); + copyMissing(source, target); + } else if (entry.isFile() && !fs.existsSync(target)) { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(source, target); + added.push(relativeProjectPath(projectRoot, target)); + } + } + }; + + copyMissing(templateRoot, projectRoot); + return added.sort(); +} + export function isQuickstartSamplePath(rootPath: string): boolean { try { const marker = path.join(rootPath, QUICKSTART_MARKER); diff --git a/src/session/visualizationRegistry.ts b/src/session/visualizationRegistry.ts index bf47de8..d8abdd8 100644 --- a/src/session/visualizationRegistry.ts +++ b/src/session/visualizationRegistry.ts @@ -134,17 +134,27 @@ export interface ChartVisualizationSpecV2 extends VisualizationV2Base { }; } +export interface GeospatialCoordinateBindingV2 { + longitudeField: string; + latitudeField: string; +} + export type GeospatialSourceV2 = | { type: "coordinates"; longitudeField: string; latitudeField: string; } + | { + type: "links"; + source: GeospatialCoordinateBindingV2; + target: GeospatialCoordinateBindingV2; + } | { type: "geojson"; geometryField: string }; export interface GeospatialLayerV2 { id: string; - type: "point" | "line" | "polygon"; + type: "point" | "line" | "arc" | "polygon"; /** v2 currently supports constant layer styling only; mappings are explicit nulls. */ colorField: null; sizeField: null; @@ -223,27 +233,18 @@ export type ProjectVisualizationSpecV2 = export const DEFAULT_VISUALIZATION_POLICY = Object.freeze({ resultGraph: Object.freeze({ - renderer: Object.freeze({ id: "g6" as const, backend: "canvas" as const }), + renderer: Object.freeze({ id: "cytoscape" as const, backend: "canvas" as const }), layout: Object.freeze({ - type: "force-atlas2" as const, - execution: "worker" as const, + type: "cose" as const, + execution: "main" as const, animation: false, - maxIteration: 500, - barnesHut: true, - prune: true, - preventOverlap: true, - dissuadeHubs: false, - nodeSize: 22, - nodeSpacing: 4, - kr: 5, - kg: 1, - ks: 0.1, - ksmax: 10, - tao: 0.1, - mode: "normal" as const, + maxIterations: 400, + gravity: 0.7, + nodeRepulsion: 180_000, + idealEdgeLength: 42, }), }), - chart: Object.freeze({ renderer: Object.freeze({ id: "g2" as const }) }), + chart: Object.freeze({ renderer: Object.freeze({ id: "plotly" as const }) }), geospatial: Object.freeze({ renderer: Object.freeze({ id: "l7" as const, backend: "device" as const }) }), temporal: Object.freeze({ renderer: Object.freeze({ id: "g2" as const }) }), }); @@ -262,21 +263,15 @@ export function createResultGraphSpec( input: TemplateBaseInput & { renderer?: GraphRendererV2["id"] }, ): ResultGraphVisualizationSpecV2 { const renderer: GraphRendererV2 = - input.renderer === "cytoscape" - ? { id: "cytoscape", backend: "canvas" } + input.renderer === "g6" + ? { id: "g6", backend: "canvas" } : input.renderer === "sigma" ? { id: "sigma", backend: "webgl" } : { ...DEFAULT_VISUALIZATION_POLICY.resultGraph.renderer }; const layout: GraphLayoutV2 = renderer.id === "cytoscape" ? { - type: "cose", - execution: "main", - animation: false, - maxIterations: 900, - gravity: 0.7, - nodeRepulsion: 90_000, - idealEdgeLength: 70, + ...DEFAULT_VISUALIZATION_POLICY.resultGraph.layout, } : renderer.id === "sigma" ? { @@ -288,7 +283,24 @@ export function createResultGraphSpec( slowDown: 3, barnesHutOptimize: true, } - : { ...DEFAULT_VISUALIZATION_POLICY.resultGraph.layout }; + : { + type: "force-atlas2", + execution: "worker", + animation: false, + maxIteration: 500, + barnesHut: true, + prune: true, + preventOverlap: true, + dissuadeHubs: false, + nodeSize: 22, + nodeSpacing: 4, + kr: 5, + kg: 1, + ks: 0.1, + ksmax: 10, + tao: 0.1, + mode: "normal", + }; return { format: VISUALIZATION_SPEC_FORMAT_V2, name: input.name, @@ -342,7 +354,9 @@ export function createDefaultChartSpec( kind: "chart", result: input.result, filters: copyFilters(input.filters), - renderer: input.renderer === "plotly" ? { id: "plotly" } : { ...DEFAULT_VISUALIZATION_POLICY.chart.renderer }, + renderer: input.renderer === "g2" + ? { id: "g2" } + : { ...DEFAULT_VISUALIZATION_POLICY.chart.renderer }, chart: { mark: input.mark, bindings: { @@ -382,6 +396,12 @@ export function createDefaultGeospatialSpec( if (input.source.type === "coordinates" && input.layers.some((layer) => layer.type !== "point")) { throw new Error("Coordinate geospatial sources support point layers only."); } + if (input.source.type === "links" && input.layers.some((layer) => layer.type !== "point" && layer.type !== "arc")) { + throw new Error("Link geospatial sources support point and arc layers only."); + } + if (input.source.type === "geojson" && input.layers.some((layer) => layer.type === "arc")) { + throw new Error("Arc layers require an explicit link coordinate source."); + } return { format: VISUALIZATION_SPEC_FORMAT_V2, name: input.name, @@ -390,7 +410,9 @@ export function createDefaultGeospatialSpec( filters: copyFilters(input.filters), renderer: { ...DEFAULT_VISUALIZATION_POLICY.geospatial.renderer }, geospatial: { - source: { ...input.source }, + source: input.source.type === "links" + ? { type: "links", source: { ...input.source.source }, target: { ...input.source.target } } + : { ...input.source }, sourceCrs: input.sourceCrs, projection: input.projection, aggregation: "none", @@ -667,6 +689,13 @@ function isGeospatialSource(value: unknown): value is GeospatialSourceV2 { if (value.type === "coordinates") { return onlyKeys(value, ["type", "longitudeField", "latitudeField"]) && isNonEmptyString(value.longitudeField) && isNonEmptyString(value.latitudeField); } + if (value.type === "links") { + const isCoordinateBinding = (binding: unknown): boolean => + isRecord(binding) && onlyKeys(binding, ["longitudeField", "latitudeField"]) && + isNonEmptyString(binding.longitudeField) && isNonEmptyString(binding.latitudeField); + return onlyKeys(value, ["type", "source", "target"]) && + isCoordinateBinding(value.source) && isCoordinateBinding(value.target); + } return value.type === "geojson" && onlyKeys(value, ["type", "geometryField"]) && isNonEmptyString(value.geometryField); } @@ -681,10 +710,12 @@ function isGeospatialV2(value: Record): boolean { isGeospatialSource(value.geospatial.source) && value.geospatial.sourceCrs === "EPSG:4326" && value.geospatial.projection === "EPSG:3857" && value.geospatial.aggregation === "none" && Array.isArray(value.geospatial.layers) && value.geospatial.layers.length > 0 && value.geospatial.layers.every((layer) => isRecord(layer) && onlyKeys(layer, ["id", "type", "colorField", "sizeField", "shapeField", "color", "opacity", "size"]) && isNonEmptyString(layer.id) && - isOneOf(layer.type, ["point", "line", "polygon"]) && isNonEmptyString(layer.color) && + isOneOf(layer.type, ["point", "line", "arc", "polygon"]) && isNonEmptyString(layer.color) && layer.colorField === null && layer.sizeField === null && layer.shapeField === null && isFiniteNumber(layer.opacity) && layer.opacity >= 0 && layer.opacity <= 1 && isFiniteNumber(layer.size) && layer.size >= 0) && (value.geospatial.source.type !== "coordinates" || value.geospatial.layers.every((layer) => isRecord(layer) && layer.type === "point")) && + (value.geospatial.source.type !== "links" || value.geospatial.layers.every((layer) => isRecord(layer) && (layer.type === "point" || layer.type === "arc"))) && + (value.geospatial.source.type !== "geojson" || value.geospatial.layers.every((layer) => isRecord(layer) && layer.type !== "arc")) && isRecord(basemap) && basemap.type === "blank" && onlyKeys(basemap, ["type"]) && isRecord(viewport) && onlyKeys(viewport, ["longitude", "latitude", "zoom", "bearing", "pitch", "bounds"]) && ["longitude", "latitude", "zoom", "bearing", "pitch"].every((key) => isFiniteNumber(viewport[key])) && diff --git a/src/test/agentContext.test.ts b/src/test/agentContext.test.ts index 87e9f22..d8bc042 100644 --- a/src/test/agentContext.test.ts +++ b/src/test/agentContext.test.ts @@ -15,10 +15,12 @@ suite("agent context", () => { fs.writeFileSync(path.join(root, "FORMAT"), PROJECT_FORMAT_BYTES); fs.writeFileSync(path.join(root, "AGENTS.md"), "# Agent contract\n"); fs.mkdirSync(path.join(root, "queries"), { recursive: true }); + fs.mkdirSync(path.join(root, "notebooks"), { recursive: true }); fs.mkdirSync(path.join(root, "results"), { recursive: true }); fs.mkdirSync(path.join(root, "visualizations"), { recursive: true }); fs.mkdirSync(path.join(root, "mutations"), { recursive: true }); fs.writeFileSync(path.join(root, "queries", "nodes.cypher"), "MATCH (n) RETURN n\n"); + fs.writeFileSync(path.join(root, "notebooks", "analysis.ipynb"), "{}\n"); fs.writeFileSync( path.join(root, "results", "query-result.json"), JSON.stringify({ columns: ["id"], rows: [{ id: "a" }], rowCount: 1 }), @@ -43,6 +45,12 @@ suite("agent context", () => { context.artifacts.queries[0]?.absolutePath, path.join(root, "queries", "nodes.cypher"), ); + assert.equal(context.directories.notebooks, path.join(root, "notebooks")); + assert.equal(context.artifacts.notebooks[0]?.path, "notebooks/analysis.ipynb"); + assert.equal( + context.artifacts.notebooks[0]?.absolutePath, + path.join(root, "notebooks", "analysis.ipynb"), + ); assert.equal(context.lastResult.inMemory, true); assert.equal(context.lastResult.exists, true); assert.equal( diff --git a/src/test/experienceMode.test.ts b/src/test/experienceMode.test.ts deleted file mode 100644 index 47f2106..0000000 --- a/src/test/experienceMode.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as assert from "node:assert/strict"; -import { - DEFAULT_EXPERIENCE_MODE, - EXPERIENCE_MODE_CARDS, - defaultsForExperienceMode, - isExperienceMode, - resolveExperienceMode, -} from "../session/experienceMode"; - -suite("experienceMode", () => { - test("default mode is guided", () => { - assert.equal(DEFAULT_EXPERIENCE_MODE, "guided"); - }); - - test("resolveExperienceMode falls back to guided for missing/stale/corrupt values", () => { - assert.equal(resolveExperienceMode(undefined), "guided"); - assert.equal(resolveExperienceMode(null), "guided"); - assert.equal(resolveExperienceMode(""), "guided"); - assert.equal(resolveExperienceMode("yolo"), "guided"); - assert.equal(resolveExperienceMode(42), "guided"); - }); - - test("resolveExperienceMode passes through valid values", () => { - assert.equal(resolveExperienceMode("guided"), "guided"); - assert.equal(resolveExperienceMode("autonomous"), "autonomous"); - }); - - test("isExperienceMode narrows correctly", () => { - assert.equal(isExperienceMode("guided"), true); - assert.equal(isExperienceMode("autonomous"), true); - assert.equal(isExperienceMode("high-autonomy"), false); - assert.equal(isExperienceMode(undefined), false); - }); - - test("guided defaults confirm before Initialize, keep Result Graph closed, and don't auto-open projects", () => { - const defaults = defaultsForExperienceMode("guided"); - assert.equal(defaults.confirmBeforeInitialize, true); - assert.equal(defaults.openResultGraphOnQuery, false); - assert.equal(defaults.autoOpenDetectedProject, false); - }); - - test("autonomous defaults skip the Initialize confirmation and auto-open results/projects", () => { - const defaults = defaultsForExperienceMode("autonomous"); - assert.equal(defaults.confirmBeforeInitialize, false); - assert.equal(defaults.openResultGraphOnQuery, true); - assert.equal(defaults.autoOpenDetectedProject, true); - }); - - test("Welcome shows exactly one card per mode, each with short bullets", () => { - assert.equal(EXPERIENCE_MODE_CARDS.length, 2); - const modes = EXPERIENCE_MODE_CARDS.map((c) => c.mode).sort(); - assert.deepEqual(modes, ["autonomous", "guided"]); - for (const card of EXPERIENCE_MODE_CARDS) { - assert.ok(card.title.length > 0); - assert.ok(card.tagline.length > 0 && card.tagline.length < 80); - assert.ok(card.bullets.length > 0); - for (const bullet of card.bullets) { - assert.ok(bullet.length < 80, `bullet too long for panel copy: ${bullet}`); - } - } - }); -}); diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 8cbd81a..266c8cd 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -22,6 +22,7 @@ import { const ALL_COMMAND_IDS = [ "graphforge.openProject", "graphforge.openSampleProject", + "graphforge.openSampleNotebook", "graphforge.closeProject", "graphforge.refreshExplorer", "graphforge.runQuery", @@ -44,7 +45,6 @@ const ALL_COMMAND_IDS = [ "graphforge.getStarted.showHub", "graphforge.getStarted.showQuery", "graphforge.getStarted.showVisualize", - "graphforge.chooseExperienceMode", "graphforge.openSettings", "graphforge.manageModules", "graphforge.installModuleFromFile", @@ -209,6 +209,9 @@ suite("GraphForge agent interop — safe commands (no binding, no project)", () assert.ok( context.commands.some((command) => command.id === "graphforge.runProjectQuery"), ); + assert.ok( + context.commands.some((command) => command.id === "graphforge.openSampleNotebook"), + ); assert.match(context.contracts.result, /columns/); }); @@ -498,12 +501,6 @@ suite("GraphForge agent interop — safe commands (no binding, no project)", () ); }); - test("chooseExperienceMode does not throw and does not prompt", async () => { - await assert.doesNotReject( - Promise.resolve(vscode.commands.executeCommand("graphforge.chooseExperienceMode")), - ); - }); - test("statusBarClick opens Get Started when no runtime is available", async () => { await assert.doesNotReject( Promise.resolve(vscode.commands.executeCommand("graphforge.statusBarClick")), diff --git a/src/test/geospatialModel.test.ts b/src/test/geospatialModel.test.ts new file mode 100644 index 0000000..d5ccb4d --- /dev/null +++ b/src/test/geospatialModel.test.ts @@ -0,0 +1,31 @@ +import * as assert from "node:assert/strict"; +import { + geospatialLinkEndpoints, + validGeospatialLinkRows, +} from "../webview/geospatialModel"; + +const source = { longitudeField: "sourceLongitude", latitudeField: "sourceLatitude" }; +const target = { longitudeField: "targetLongitude", latitudeField: "targetLatitude" }; + +suite("geospatial link model", () => { + test("keeps only rows with two valid geographic endpoints", () => { + const rows = [ + { sourceLongitude: -84.43, sourceLatitude: 33.64, targetLongitude: -97.67, targetLatitude: 30.2 }, + { sourceLongitude: -84.43, sourceLatitude: 33.64, targetLongitude: 400, targetLatitude: 30.2 }, + { sourceLongitude: -84.43, sourceLatitude: "33.64", targetLongitude: -97.67, targetLatitude: 30.2 }, + ]; + assert.deepEqual(validGeospatialLinkRows(rows, source, target), [rows[0]]); + }); + + test("materializes unique source and target airport nodes", () => { + const rows = [ + { sourceLongitude: -84.43, sourceLatitude: 33.64, targetLongitude: -97.67, targetLatitude: 30.2 }, + { sourceLongitude: -84.43, sourceLatitude: 33.64, targetLongitude: -71.01, targetLatitude: 42.36 }, + ]; + assert.deepEqual(geospatialLinkEndpoints(rows, source, target), [ + { longitude: -84.43, latitude: 33.64 }, + { longitude: -97.67, latitude: 30.2 }, + { longitude: -71.01, latitude: 42.36 }, + ]); + }); +}); diff --git a/src/test/getStartedContent.test.ts b/src/test/getStartedContent.test.ts index 7d0259b..eb01be2 100644 --- a/src/test/getStartedContent.test.ts +++ b/src/test/getStartedContent.test.ts @@ -1,194 +1,202 @@ import * as assert from "node:assert/strict"; -import { EXPERIENCE_MODE_CARDS } from "../session/experienceMode"; import { buildChecklistSteps, - buildWorkspaceModel, - renderModeCardsHtml, runtimeStepActions, + selectJourneyArtifacts, } from "../views/getStartedContent"; suite("getStartedContent", () => { - suite("buildChecklistSteps (#63)", () => { - const base = { - runtimeReady: true, - projectReady: true, - hasLastResult: false, - isSampleProject: true, - projectName: "graphforge-quickstart", - projectPath: "/tmp/graphforge-quickstart", - activeRuntime: "node" as const, - nodeLine: "Node binding ready", - pythonLine: "Python runtime not configured", - projectKind: "node" as const, - seenResultGraph: false, - seenFigure: false, - sampleQueryPath: "queries/templates/routes-overview.cypher", - sampleFigurePath: "visualizations/route-distances.gfviz.json", - }; + const base = { + runtimeReady: true, + projectReady: true, + hasLastResult: false, + hasResultArtifact: false, + hasSavedVisualization: false, + isSampleProject: true, + projectName: "graphforge-quickstart", + activeRuntime: "node" as const, + nodeLine: "Node binding ready", + pythonLine: "Python is optional for scripts and notebooks", + projectKind: "node" as const, + sampleQueryPath: "queries/templates/routes-overview.cypher", + }; - test("Try sample is project secondary when runtime ready and no project", () => { - const steps = buildChecklistSteps({ - ...base, - projectReady: false, - isSampleProject: false, - }); - const project = steps.find((s) => s.id === "project"); - assert.equal(project?.status, "current"); - assert.equal(project?.secondaryAction?.command, "graphforge.openSampleProject"); - assert.equal(project?.secondaryAction?.label, "Try sample project"); - }); + test("keeps one five-node journey visible in dependency order", () => { + const steps = buildChecklistSteps(base); + assert.deepEqual( + steps.map((step) => step.id), + ["environment", "project", "query", "result", "visualize"], + ); + assert.deepEqual( + steps.map((step) => step.status), + ["done", "done", "current", "pending", "pending"], + ); + }); - test("Try sample stays visible before runtime setup", () => { - const steps = buildChecklistSteps({ - ...base, - runtimeReady: false, - projectReady: false, - isSampleProject: false, - }); - const project = steps.find((s) => s.id === "project"); - assert.equal(project?.status, "pending"); - assert.equal(project?.secondaryAction?.command, "graphforge.openSampleProject"); + test("offers the sample only at the current project node", () => { + const steps = buildChecklistSteps({ + ...base, + projectReady: false, + isSampleProject: false, }); + const project = steps.find((step) => step.id === "project"); + assert.equal(project?.status, "current"); + assert.equal(project?.primaryAction?.command, "graphforge.openProject"); + assert.equal(project?.secondaryAction?.command, "graphforge.openSampleProject"); + assert.equal(project?.secondaryAction?.label, "Try the air-routes sample"); + }); - test("query step done on hasLastResult; see-results becomes current", () => { - const steps = buildChecklistSteps({ ...base, hasLastResult: true }); - assert.equal(steps.find((s) => s.id === "query")?.status, "done"); - const see = steps.find((s) => s.id === "see-results"); - assert.equal(see?.status, "current"); - assert.equal(see?.primaryAction?.command, "graphforge.showResultGraph"); - assert.equal(see?.secondaryAction?.command, "graphforge.openProjectVisualization"); - assert.deepEqual(see?.secondaryAction?.args, [ - { path: "visualizations/route-distances.gfviz.json" }, - ]); - }); + test("runs the sample's real project-owned query path", () => { + const query = buildChecklistSteps(base).find((step) => step.id === "query"); + assert.equal(query?.primaryAction?.command, "graphforge.runProjectQuery"); + assert.deepEqual(query?.primaryAction?.args, [ + { path: "queries/templates/routes-overview.cypher" }, + ]); + assert.equal(query?.secondaryAction?.label, "Open Python notebook"); + assert.equal(query?.secondaryAction?.command, "graphforge.openSampleNotebook"); + assert.equal(query?.artifact, "queries/templates/routes-overview.cypher"); + }); + + test("routes custom projects into the embedded query workspace", () => { + const query = buildChecklistSteps({ + ...base, + isSampleProject: false, + sampleQueryPath: undefined, + }).find((step) => step.id === "query"); + assert.equal(query?.primaryAction?.label, "Write and run query"); + assert.equal(query?.primaryAction?.command, "graphforge.getStarted.showQuery"); + assert.equal(query?.secondaryAction, undefined); + assert.equal(query?.artifact, "queries/first-query.cypher"); + }); - test("sample query CTA runs the project query by path", () => { - const steps = buildChecklistSteps(base); - const query = steps.find((s) => s.id === "query"); - assert.equal(query?.status, "current"); - assert.equal(query?.primaryAction?.command, "graphforge.runProjectQuery"); - assert.deepEqual(query?.primaryAction?.args, [ - { path: "queries/templates/routes-overview.cypher" }, - ]); + test("uses saved result and visualization artifacts as progress evidence", () => { + const steps = buildChecklistSteps({ + ...base, + hasResultArtifact: true, + hasSavedVisualization: true, + resultPath: "results/routes-overview.json", + visualizationPath: "visualizations/airports-map.gfviz.json", }); + assert.deepEqual(steps.map((step) => step.status), ["done", "done", "done", "done", "done"]); + const result = steps.find((step) => step.id === "result"); + const visualization = steps.find((step) => step.id === "visualize"); + assert.equal(result?.artifact, "results/routes-overview.json"); + assert.equal(visualization?.artifact, "visualizations/airports-map.gfviz.json"); + assert.equal( + visualization?.primaryAction?.command, + "graphforge.openProjectVisualization", + ); + assert.deepEqual(visualization?.primaryAction?.args, [ + { + path: "visualizations/airports-map.gfviz.json", + waitForReady: true, + timeoutMs: 60_000, + }, + ]); + }); - test("see-results done when both viz surfaces have been shown", () => { - const steps = buildChecklistSteps({ - ...base, - hasLastResult: true, - seenResultGraph: true, - seenFigure: true, - }); - assert.equal(steps.find((s) => s.id === "see-results")?.status, "done"); + test("opens a durable result before asking for a visualization", () => { + const steps = buildChecklistSteps({ + ...base, + hasResultArtifact: true, + resultPath: "results/routes-overview.json", }); + const result = steps.find((step) => step.id === "result"); + const visualization = steps.find((step) => step.id === "visualize"); + assert.equal(result?.status, "current"); + assert.equal(result?.primaryAction?.command, "graphforge.openProjectResult"); + assert.deepEqual(result?.primaryAction?.args, [ + { path: "results/routes-overview.json" }, + ]); + assert.equal(visualization?.status, "pending"); }); - suite("buildWorkspaceModel", () => { - test("guided onboarding prominently offers the starter space without a runtime", () => { - const workspace = buildWorkspaceModel({ - runtimeReady: false, - projectReady: false, - hasLastResult: false, - isSampleProject: false, - }); - assert.equal(workspace.layout, "guided"); - assert.equal(workspace.starter.title, "Starter space"); - assert.match(workspace.starter.detail, /after choosing a Node or Python runtime/); - assert.equal( - workspace.starter.actions[0]?.command, - "graphforge.openSampleProject", - ); - assert.equal(workspace.starter.actions[0]?.label, "Try sample project"); + test("does not advertise a quickstart visualization before its query result exists", () => { + const beforeQuery = selectJourneyArtifacts( + true, + [{ path: "results/route-activity.json" }], + [ + { + path: "visualizations/airports-map.gfviz.json", + result: "results/query-result.json", + kind: "geospatial", + format: "graphforge.visualization/v2", + renderer: "l7", + }, + { + path: "visualizations/route-activity-timeline.gfviz.json", + result: "results/route-activity.json", + kind: "temporal", + format: "graphforge.visualization/v2", + renderer: "g2", + }, + ], + ); + assert.deepEqual(beforeQuery, { + resultPath: undefined, + visualizationPath: undefined, }); - test("first result switches to a persistent control hub", () => { - const workspace = buildWorkspaceModel({ - runtimeReady: true, - projectReady: true, - hasLastResult: true, - isSampleProject: true, - projectName: "graphforge-quickstart", - }); - assert.equal(workspace.layout, "hub"); - const commands = workspace.controls.flatMap((control) => - control.actions.map((action) => action.command), - ); - for (const command of [ - "graphforge.openProject", - "graphforge.openSampleProject", - "graphforge.runQuery", - "graphforge.showResultsTable", - "graphforge.showResultGraph", - "graphforge.figureFromResult", - "graphforge.find", - "graphforge.showOntology", - ]) { - assert.ok(commands.includes(command), `missing hub command ${command}`); - } - const figure = workspace.controls - .flatMap((control) => control.actions) - .find((action) => action.command === "graphforge.figureFromResult"); - assert.equal(figure?.args, undefined); + const afterQuery = selectJourneyArtifacts( + true, + [ + { path: "results/query-result.json" }, + { path: "results/route-activity.json" }, + ], + [ + { + path: "visualizations/airports-map.gfviz.json", + result: "results/query-result.json", + kind: "geospatial", + format: "graphforge.visualization/v2", + renderer: "l7", + }, + { + path: "visualizations/routes-network-default.gfviz.json", + result: "results/query-result.json", + kind: "result-graph", + format: "graphforge.visualization/v2", + renderer: "cytoscape", + }, + { + path: "visualizations/routes-network-antv.gfviz.json", + result: "results/query-result.json", + kind: "result-graph", + format: "graphforge.visualization/v2", + renderer: "g6", + }, + { + path: "visualizations/routes-network.gfviz.json", + result: "results/query-result.json", + kind: "result-graph", + format: "graphforge.visualization/v1", + renderer: "cytoscape", + }, + ], + ); + assert.deepEqual(afterQuery, { + resultPath: "results/query-result.json", + visualizationPath: "visualizations/airports-map.gfviz.json", }); }); suite("runtimeStepActions", () => { - test("done step emits no setup actions (#29)", () => { - for (const kind of ["python", "node", "ambiguous"] as const) { - const actions = runtimeStepActions(true, kind); - assert.equal(actions.primaryAction, undefined, `primary leaked for ${kind}`); - assert.equal(actions.secondaryAction, undefined, `secondary leaked for ${kind}`); - } + test("a ready runtime has no setup action", () => { + assert.deepEqual(runtimeStepActions(true, "python"), {}); }); - test("Python-first workspace leads with Setup Python (#37)", () => { + test("Python-first workspaces lead with Python setup", () => { const actions = runtimeStepActions(false, "python"); - assert.equal(actions.primaryAction?.label, "Setup Python"); assert.equal(actions.primaryAction?.command, "graphforge.setupPythonBinding"); - assert.equal(actions.secondaryAction?.label, "Setup Native (Node)"); - assert.equal(actions.secondaryAction?.command, "graphforge.setupNativeBinding"); + assert.equal(actions.secondaryAction?.command, "graphforge.checkEnvironment"); }); - test("Node-ish and ambiguous workspaces keep the Node-first CTA", () => { + test("Node-ish and ambiguous workspaces lead with Node setup", () => { for (const kind of ["node", "ambiguous"] as const) { const actions = runtimeStepActions(false, kind); - assert.equal(actions.primaryAction?.label, "Setup Native (Node)"); assert.equal(actions.primaryAction?.command, "graphforge.setupNativeBinding"); - assert.equal(actions.secondaryAction?.label, "Setup Python"); - assert.equal(actions.secondaryAction?.command, "graphforge.setupPythonBinding"); - } - }); - }); - - suite("renderModeCardsHtml", () => { - test("renders one radio per card with ARIA semantics (#26)", () => { - const html = renderModeCardsHtml(EXPERIENCE_MODE_CARDS, "guided"); - const radios = html.match(/role="radio"/g) ?? []; - assert.equal(radios.length, EXPERIENCE_MODE_CARDS.length); - for (const card of EXPERIENCE_MODE_CARDS) { - assert.ok(html.includes(`data-mode="${card.mode}"`), `missing card for ${card.mode}`); - assert.ok( - html.includes(`aria-labelledby="mode-title-${card.mode}"`), - `missing accessible name for ${card.mode}`, - ); - assert.ok(html.includes(card.title)); - assert.ok(html.includes(card.tagline)); + assert.equal(actions.secondaryAction?.command, "graphforge.checkEnvironment"); } - // The hand-drawn radio circle is decoration only. - assert.ok(html.includes(`class="radio" aria-hidden="true"`)); - }); - - test("selected card is aria-checked and holds the roving tabindex", () => { - const html = renderModeCardsHtml(EXPERIENCE_MODE_CARDS, "autonomous"); - assert.ok( - html.includes(`aria-checked="true" tabindex="0" data-mode="autonomous"`), - "selected card should be checked and focusable", - ); - assert.ok( - html.includes(`aria-checked="false" tabindex="-1" data-mode="guided"`), - "unselected card should be unchecked and skipped by Tab", - ); - assert.equal((html.match(/tabindex="0"/g) ?? []).length, 1); }); }); }); diff --git a/src/test/paletteTitles.test.ts b/src/test/paletteTitles.test.ts index bc4a403..e833767 100644 --- a/src/test/paletteTitles.test.ts +++ b/src/test/paletteTitles.test.ts @@ -89,6 +89,7 @@ const ZERO_PROMPT_COMMAND_IDS = [ "graphforge.getStarted.showQuery", "graphforge.getStarted.showVisualize", "graphforge.openSampleProject", + "graphforge.openSampleNotebook", "graphforge.closeProject", ]; diff --git a/src/test/projectArtifacts.test.ts b/src/test/projectArtifacts.test.ts index e570c79..9db91f3 100644 --- a/src/test/projectArtifacts.test.ts +++ b/src/test/projectArtifacts.test.ts @@ -27,6 +27,8 @@ suite("project artifacts", () => { test("writes, scans, and reloads query and visualization files", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "gf-artifacts-")); fs.mkdirSync(path.join(root, "results"), { recursive: true }); + fs.mkdirSync(path.join(root, "notebooks"), { recursive: true }); + fs.writeFileSync(path.join(root, "notebooks", "routes.ipynb"), "{}\n"); fs.writeFileSync( path.join(root, "results", "routes.json"), JSON.stringify({ @@ -48,6 +50,7 @@ suite("project artifacts", () => { const artifacts = scanProjectArtifacts(root); assert.equal(artifacts.queries[0]?.path, queryPath); + assert.equal(artifacts.notebooks[0]?.path, "notebooks/routes.ipynb"); assert.equal(artifacts.results[0]?.rowCount, 1); assert.equal(artifacts.visualizations[0]?.path, visualizationPath); assert.equal(readProjectQuery(root, queryPath).cypher, "MATCH (n) RETURN n\n"); @@ -89,6 +92,19 @@ suite("project artifacts", () => { ); }); + test("keeps the canonical query result visible beside named result history", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "gf-artifact-results-")); + fs.mkdirSync(path.join(root, "results"), { recursive: true }); + const result = JSON.stringify({ columns: ["code"], rows: [{ code: "ATL" }], rowCount: 1 }); + fs.writeFileSync(path.join(root, "results", "query-result.json"), result); + fs.writeFileSync(path.join(root, "results", "route-activity.json"), result); + + assert.deepEqual( + scanProjectArtifacts(root).results.map((item) => item.path), + ["results/query-result.json", "results/route-activity.json"], + ); + }); + test("reads existing v1 visualization JSON exactly without migration", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "gf-artifact-v1-")); fs.mkdirSync(path.join(root, "visualizations"), { recursive: true }); diff --git a/src/test/quickstart.e2e.test.ts b/src/test/quickstart.e2e.test.ts index c91576f..96512d9 100644 --- a/src/test/quickstart.e2e.test.ts +++ b/src/test/quickstart.e2e.test.ts @@ -5,7 +5,6 @@ import * as path from "node:path"; import * as vscode from "vscode"; import { loadQuickstartDataset } from "../session/quickstartSample"; import { - readProjectVisualization, scanProjectArtifacts, } from "../session/projectArtifacts"; @@ -63,10 +62,12 @@ suite("Quickstart e2e (#63)", () => { const query = artifacts.queryTemplates.find( (item) => item.name === "routes-overview.cypher", ); - const graphSpec = artifacts.visualizations.find((item) => { - if (item.kind !== "result-graph") return false; - return readProjectVisualization(opened.path!, item.path).format === "graphforge.visualization/v2"; - }); + const graphSpec = artifacts.visualizations.find( + (item) => + item.kind === "result-graph" && + item.renderer === "cytoscape" && + item.format === "graphforge.visualization/v2", + ); const figureSpec = artifacts.visualizations.find((item) => item.kind === "plotly"); const v2CompanionSpecs = artifacts.visualizations.filter((item) => item.kind === "chart" || item.kind === "geospatial" || item.kind === "temporal", @@ -82,6 +83,14 @@ suite("Quickstart e2e (#63)", () => { artifacts.mutations.some((item) => item.name === "seed-air-routes.cypher"), "sample seed mutation must be a project file", ); + const notebook = await vscode.commands.executeCommand<{ + path?: string; + relativePath?: string; + error?: string; + }>("graphforge.openSampleNotebook"); + assert.ok(notebook && !notebook.error, `openSampleNotebook failed: ${JSON.stringify(notebook)}`); + assert.equal(notebook.relativePath, "notebooks/air-routes-analysis.ipynb"); + assert.ok(notebook.path && fs.existsSync(notebook.path)); const queryResult = await vscode.commands.executeCommand<{ rowCount?: number; @@ -97,8 +106,10 @@ suite("Quickstart e2e (#63)", () => { assert.ok(queryResult.columns?.includes("source")); assert.ok(queryResult.columns?.includes("dist")); assert.ok(queryResult.columns?.includes("region")); - assert.ok(queryResult.columns?.includes("longitude")); - assert.ok(queryResult.columns?.includes("latitude")); + assert.ok(queryResult.columns?.includes("sourceLongitude")); + assert.ok(queryResult.columns?.includes("sourceLatitude")); + assert.ok(queryResult.columns?.includes("targetLongitude")); + assert.ok(queryResult.columns?.includes("targetLatitude")); const persistedJson = path.join(opened.path!, "results", "query-result.json"); const persistedMarkdown = path.join(opened.path!, "results", "query-result.md"); assert.ok(fs.existsSync(persistedJson), "query JSON must persist inside the temp project"); @@ -193,7 +204,7 @@ suite("Quickstart e2e (#63)", () => { }>("graphforge.openProjectVisualization", { path: graphSpec.path, waitForReady: true, - timeoutMs: 30_000, + timeoutMs: 60_000, }); assert.ok(graph?.panel === "opened" || graph?.panel === "updated"); @@ -207,10 +218,10 @@ suite("Quickstart e2e (#63)", () => { ); assert.notEqual(graph?.styleMode, "demo"); const rendered = graph?.lifecycle; - assert.ok(rendered, "G6 terminal lifecycle was not returned."); + assert.ok(rendered, "Cytoscape terminal lifecycle was not returned."); assert.notEqual(rendered.type, "graphforge/renderFailed", `${rendered.code}: ${rendered.message}`); assert.equal(rendered.type, "graphforge/renderReady"); - assert.equal(rendered.renderer, "g6"); + assert.equal(rendered.renderer, "cytoscape"); assert.equal(rendered.backend, "canvas"); assert.ok((rendered.nodeCount ?? 0) >= minGraphNodes); assert.ok((rendered.edgeCount ?? 0) >= minRoutes); diff --git a/src/test/quickstartSample.test.ts b/src/test/quickstartSample.test.ts index adbb52e..dcfa081 100644 --- a/src/test/quickstartSample.test.ts +++ b/src/test/quickstartSample.test.ts @@ -10,6 +10,8 @@ import { materializeQuickstartProjectFiles, QUICKSTART_DIR_NAME, QUICKSTART_MARKER_BYTES, + QUICKSTART_NOTEBOOK_REL, + repairQuickstartProjectFiles, resolveQuickstartPath, writeQuickstartMarker, } from "../session/quickstartSample"; @@ -79,7 +81,7 @@ suite("quickstartSample (#63)", () => { assert.equal((seed.match(/:Airport \{/g) ?? []).length, dataset.airports.length); }); - test("materializes the six-version visualization matrix and supporting data", () => { + test("materializes the explicit visualization matrix and supporting data", () => { const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "gf-qs-files-")); const dataset = loadQuickstartDataset(REPO_ROOT); const { seedMutationPath } = materializeQuickstartProjectFiles(projectRoot, dataset); @@ -87,9 +89,33 @@ suite("quickstartSample (#63)", () => { assert.equal(artifacts.queries.length, 0); assert.equal(artifacts.queryTemplates.length, 1); - assert.equal(artifacts.visualizations.length, 6); + assert.deepEqual( + artifacts.notebooks.map((item) => item.path), + [QUICKSTART_NOTEBOOK_REL.split(path.sep).join("/")], + ); + assert.equal(artifacts.visualizations.length, 7); assert.ok(artifacts.mutations.some((item) => item.path === seedMutationPath)); assert.ok(fs.existsSync(path.join(projectRoot, "data", "air-routes", "airports.csv"))); + const notebookPath = path.join(projectRoot, QUICKSTART_NOTEBOOK_REL); + assert.ok(fs.existsSync(notebookPath)); + const notebook = JSON.parse(fs.readFileSync(notebookPath, "utf8")) as { + nbformat?: number; + cells?: Array<{ cell_type?: string; source?: string[]; outputs?: unknown[] }>; + metadata?: { kernelspec?: { language?: string } }; + }; + assert.equal(notebook.nbformat, 4); + assert.equal(notebook.metadata?.kernelspec?.language, "python"); + const cellIds = notebook.cells?.map((cell) => (cell as { id?: string }).id); + assert.ok(cellIds?.every((id) => typeof id === "string" && id.length > 0)); + assert.equal(new Set(cellIds).size, cellIds?.length); + assert.ok(notebook.cells?.some((cell) => + cell.cell_type === "code" && cell.source?.join("").includes("forge.rank(") + )); + assert.ok(notebook.cells?.some((cell) => + cell.cell_type === "code" && + cell.source?.join("").includes("results/python-airport-pagerank.json") + )); + assert.ok(notebook.cells?.every((cell) => (cell.outputs?.length ?? 0) === 0)); assert.match( fs.readFileSync(path.join(projectRoot, "AGENTS.md"), "utf8"), /graphforge\.agent\.getContext/, @@ -99,8 +125,10 @@ suite("quickstartSample (#63)", () => { assert.ok(query.cypher.includes("AS source")); assert.ok(query.cypher.includes("AS dist")); assert.ok(query.cypher.includes("AS region")); - assert.ok(query.cypher.includes("AS longitude")); - assert.ok(query.cypher.includes("AS latitude")); + assert.ok(query.cypher.includes("AS sourceLongitude")); + assert.ok(query.cypher.includes("AS sourceLatitude")); + assert.ok(query.cypher.includes("AS targetLongitude")); + assert.ok(query.cypher.includes("AS targetLatitude")); const legacyFiles = ["route-distances.gfviz.json", "routes-network.gfviz.json"]; for (const fileName of legacyFiles) { @@ -124,16 +152,16 @@ suite("quickstartSample (#63)", () => { readProjectVisualization(projectRoot, item.path), ); const v2 = specs.filter((spec) => spec.format === VISUALIZATION_SPEC_FORMAT_V2); - assert.equal(v2.length, 4); - const rendererByKind = new Map( - v2.map((spec) => [spec.kind, spec.renderer.id]), - ); - assert.deepEqual(Object.fromEntries(rendererByKind), { - geospatial: "l7", - temporal: "g2", - chart: "g2", - "result-graph": "g6", - }); + assert.equal(v2.length, 5); + assert.ok(v2.some((spec) => spec.kind === "result-graph" && spec.renderer.id === "cytoscape")); + assert.ok(v2.some((spec) => spec.kind === "result-graph" && spec.renderer.id === "g6")); + assert.ok(v2.some((spec) => spec.kind === "chart" && spec.renderer.id === "g2")); + assert.ok(v2.some((spec) => spec.kind === "geospatial" && spec.renderer.id === "l7")); + assert.ok(v2.some((spec) => spec.kind === "temporal" && spec.renderer.id === "g2")); + const geospatial = v2.find((spec) => spec.kind === "geospatial"); + assert.ok(geospatial); + assert.equal(geospatial.geospatial.source.type, "links"); + assert.deepEqual(geospatial.geospatial.layers.map((layer) => layer.type), ["arc", "point"]); const plotly = specs.find((spec) => spec.kind === "plotly"); assert.ok(plotly); @@ -150,4 +178,28 @@ suite("quickstartSample (#63)", () => { assert.equal(temporal.temporal.timestampField, "timestamp"); assert.equal(temporal.temporal.timezone, "UTC"); }); + + test("repairs newly shipped sample artifacts without overwriting analyst files", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "gf-qs-repair-")); + const dataset = loadQuickstartDataset(REPO_ROOT); + fs.mkdirSync(path.join(projectRoot, "visualizations"), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, "visualizations", "routes-network.gfviz.json"), + "analyst-owned\n", + ); + + const repaired = repairQuickstartProjectFiles(projectRoot, dataset); + + assert.ok(repaired.includes(QUICKSTART_NOTEBOOK_REL.split(path.sep).join("/"))); + assert.ok(repaired.includes("visualizations/airports-map.gfviz.json")); + assert.ok(repaired.includes("visualizations/routes-network-default.gfviz.json")); + assert.equal( + fs.readFileSync( + path.join(projectRoot, "visualizations", "routes-network.gfviz.json"), + "utf8", + ), + "analyst-owned\n", + ); + assert.deepEqual(repairQuickstartProjectFiles(projectRoot, dataset), []); + }); }); diff --git a/src/test/resultGraphModel.test.ts b/src/test/resultGraphModel.test.ts index 515ac62..b97473d 100644 --- a/src/test/resultGraphModel.test.ts +++ b/src/test/resultGraphModel.test.ts @@ -63,12 +63,12 @@ suite("Result Graph model (#65)", () => { assert.equal(layoutRuns, 1); }); - test("normalizes renderer setting with G6 as the creation default", () => { + test("normalizes renderer setting with Cytoscape as the creation default", () => { assert.equal(normalizeResultGraphRenderer("g6"), "g6"); assert.equal(normalizeResultGraphRenderer("sigma"), "sigma"); assert.equal(normalizeResultGraphRenderer("cytoscape"), "cytoscape"); - assert.equal(normalizeResultGraphRenderer("unknown"), "g6"); - assert.equal(normalizeResultGraphRenderer(undefined), "g6"); + assert.equal(normalizeResultGraphRenderer("unknown"), "cytoscape"); + assert.equal(normalizeResultGraphRenderer(undefined), "cytoscape"); }); test("materializes explicit G6 Canvas and ForceAtlas2 worker options", () => { diff --git a/src/test/viewContributions.test.ts b/src/test/viewContributions.test.ts new file mode 100644 index 0000000..dad90db --- /dev/null +++ b/src/test/viewContributions.test.ts @@ -0,0 +1,36 @@ +import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +interface ContributedView { + id: string; + visibility?: "visible" | "collapsed" | "hidden"; +} + +suite("GraphForge view contributions", () => { + const manifest = JSON.parse( + fs.readFileSync(path.join(__dirname, "..", "..", "package.json"), "utf8"), + ) as { + contributes: { + views: Record; + }; + }; + const graphforgeViews = manifest.contributes.views.graphforge ?? []; + const viewsById = new Map(graphforgeViews.map((view) => [view.id, view])); + + test("starts supporting views collapsed while keeping Get Started primary", () => { + for (const id of [ + "graphforge.projects", + "graphforge.ontology", + "graphforge.knowledge", + ]) { + assert.equal(viewsById.get(id)?.visibility, "collapsed", `${id} should start collapsed`); + } + + assert.equal( + viewsById.get("graphforge.getStarted")?.visibility, + undefined, + "Get Started should retain the default visible state", + ); + }); +}); diff --git a/src/test/visualizationLoadingModel.test.ts b/src/test/visualizationLoadingModel.test.ts new file mode 100644 index 0000000..24a0c1c --- /dev/null +++ b/src/test/visualizationLoadingModel.test.ts @@ -0,0 +1,48 @@ +import * as assert from "node:assert/strict"; +import { visualizationLoadingState } from "../webview/visualizationLoadingModel"; + +suite("visualization loading model", () => { + test("names truthful renderer-specific graph stages", () => { + const g6 = visualizationLoadingState({ + renderer: "g6", + phase: "layout", + nodeCount: 579, + edgeCount: 7_430, + }); + assert.equal(g6.rendererName, "AntV G6"); + assert.equal(g6.title, "Running ForceAtlas2 layout"); + assert.equal(g6.detail, "579 nodes · 7,430 edges"); + assert.deepEqual(g6.steps.map((step) => step.state), ["complete", "current", "pending"]); + + const cytoscape = visualizationLoadingState({ renderer: "cytoscape", phase: "layout" }); + assert.equal(cytoscape.title, "Running CoSE layout"); + assert.equal(cytoscape.steps[1]?.label, "CoSE layout"); + + const sigma = visualizationLoadingState({ renderer: "sigma", phase: "paint" }); + assert.equal(sigma.title, "Painting with WebGL"); + assert.equal(sigma.steps[2]?.label, "Paint WebGL"); + }); + + test("names chart and map pipelines without invented progress percentages", () => { + const chart = visualizationLoadingState({ renderer: "g2", phase: "layout", rowCount: 7_430 }); + assert.equal(chart.title, "Composing marks and scales"); + assert.equal(chart.detail, "7,430 result rows"); + assert.ok(!JSON.stringify(chart).includes("%")); + + const map = visualizationLoadingState({ renderer: "l7", phase: "paint", rowCount: 12 }); + assert.equal(map.title, "Painting map layers"); + assert.deepEqual(map.steps.map((step) => step.state), ["complete", "complete", "current"]); + }); + + test("keeps failure at the stage that actually failed", () => { + const failed = visualizationLoadingState({ + renderer: "g6", + phase: "failed", + failedAt: "paint", + message: "Canvas did not paint visible pixels.", + }); + assert.equal(failed.title, "AntV G6 stopped"); + assert.equal(failed.detail, "Canvas did not paint visible pixels."); + assert.deepEqual(failed.steps.map((step) => step.state), ["complete", "complete", "failed"]); + }); +}); diff --git a/src/test/visualizationRegistry.test.ts b/src/test/visualizationRegistry.test.ts index 6cd3632..80b782d 100644 --- a/src/test/visualizationRegistry.test.ts +++ b/src/test/visualizationRegistry.test.ts @@ -4,39 +4,50 @@ import { createDefaultGeospatialSpec, createDefaultResultGraphSpec, createDefaultTemporalSpec, + createResultGraphSpec, DEFAULT_VISUALIZATION_POLICY, isVisualizationSpecV2, } from "../session/visualizationRegistry"; suite("visualization registry", () => { - test("materializes the reversible AntV creation defaults", () => { + test("materializes the stable Cytoscape and Plotly creation defaults", () => { assert.deepEqual(DEFAULT_VISUALIZATION_POLICY.resultGraph.renderer, { - id: "g6", + id: "cytoscape", backend: "canvas", }); assert.deepEqual(DEFAULT_VISUALIZATION_POLICY.resultGraph.layout, { - type: "force-atlas2", - execution: "worker", + type: "cose", + execution: "main", animation: false, - maxIteration: 500, - barnesHut: true, - prune: true, - preventOverlap: true, - dissuadeHubs: false, - nodeSize: 22, - nodeSpacing: 4, - kr: 5, - kg: 1, - ks: 0.1, - ksmax: 10, - tao: 0.1, - mode: "normal", + maxIterations: 400, + gravity: 0.7, + nodeRepulsion: 180_000, + idealEdgeLength: 42, }); - assert.equal(DEFAULT_VISUALIZATION_POLICY.chart.renderer.id, "g2"); + assert.equal(DEFAULT_VISUALIZATION_POLICY.chart.renderer.id, "plotly"); assert.equal(DEFAULT_VISUALIZATION_POLICY.geospatial.renderer.id, "l7"); assert.equal(DEFAULT_VISUALIZATION_POLICY.temporal.renderer.id, "g2"); }); + test("keeps explicit G6 and G2 creation available", () => { + const graph = createResultGraphSpec({ + name: "G6 routes", + result: "results/routes.json", + renderer: "g6", + }); + const chart = createDefaultChartSpec({ + name: "G2 distances", + result: "results/routes.json", + mark: "bar", + x: "origin", + y: "distance", + renderer: "g2", + }); + assert.equal(graph.renderer.id, "g6"); + assert.equal(graph.graph.layout.type, "force-atlas2"); + assert.equal(chart.renderer.id, "g2"); + }); + test("creates complete strict specs for every v2 visualization kind", () => { const graph = createDefaultResultGraphSpec({ name: "Routes", @@ -108,6 +119,28 @@ suite("visualization registry", () => { legend: false, theme: "editor", }); + const routeMap = createDefaultGeospatialSpec({ + name: "Airport routes", + result: "results/routes.json", + source: { + type: "links", + source: { longitudeField: "sourceLon", latitudeField: "sourceLat" }, + target: { longitudeField: "targetLon", latitudeField: "targetLat" }, + }, + sourceCrs: "EPSG:4326", + projection: "EPSG:3857", + layers: [ + { ...geospatial.geospatial.layers[0], id: "routes", type: "arc" }, + { ...geospatial.geospatial.layers[0], id: "airports", type: "point" }, + ], + viewport: { longitude: 0, latitude: 0, zoom: 1, bearing: 0, pitch: 0, bounds: null }, + }); + assert.equal(isVisualizationSpecV2(routeMap), true); + assert.deepEqual(routeMap.geospatial.source, { + type: "links", + source: { longitudeField: "sourceLon", latitudeField: "sourceLat" }, + target: { longitudeField: "targetLon", latitudeField: "targetLat" }, + }); assert.deepEqual(temporal.temporal.playback, { enabled: false, step: null, @@ -208,6 +241,18 @@ suite("visualization registry", () => { () => createDefaultGeospatialSpec({ ...geoBase, layers: [{ ...layer, type: "line" as const }] }), /point layers only/, ); + const linkBase = { + ...geoBase, + source: { + type: "links" as const, + source: { longitudeField: "sourceLon", latitudeField: "sourceLat" }, + target: { longitudeField: "targetLon", latitudeField: "targetLat" }, + }, + }; + assert.throws( + () => createDefaultGeospatialSpec({ ...linkBase, layers: [{ ...layer, type: "polygon" as const }] }), + /point and arc layers only/, + ); assert.throws( () => createDefaultTemporalSpec({ name: "Timeline", diff --git a/src/views/getStartedContent.ts b/src/views/getStartedContent.ts index 18f96c6..cf7f38f 100644 --- a/src/views/getStartedContent.ts +++ b/src/views/getStartedContent.ts @@ -1,60 +1,43 @@ -import type { ExperienceMode, ExperienceModeCard } from "../session/experienceMode"; import type { ProjectKind } from "../session/projectKind"; -/** - * Pure Get Started view content (#26, #29, #37, #63), split out of - * `getStartedView.ts` so it can be unit tested directly under plain mocha - * without a `vscode` dependency (same convention as `runtimeSelection.ts` / - * `experienceMode.ts`). - */ +/** A command exposed by one node in the persistent startup journey. */ export interface GetStartedAction { label: string; command: string; - /** Optional `executeCommand` args (agent / canned sample bindings — #63). */ + /** Optional `executeCommand` args for project-owned artifacts. */ args?: unknown[]; } export type GetStartedStepStatus = "pending" | "done" | "current"; +/** One visible node in Environment → Project → Query → Result → Visualize. */ export interface GetStartedStepModel { id: string; title: string; detail: string; + /** A real artifact path, or the explicit path the step will create. */ + artifact?: string; status: GetStartedStepStatus; primaryAction?: GetStartedAction; secondaryAction?: GetStartedAction; } -export interface GetStartedControlModel { - id: string; - title: string; - detail: string; - actions: GetStartedAction[]; -} - -export interface GetStartedWorkspaceModel { - /** Guided milestones give way to the persistent workbench after the first result. */ - layout: "guided" | "hub"; - starter: GetStartedControlModel; - controls: GetStartedControlModel[]; -} - export interface ChecklistStepInput { runtimeReady: boolean; projectReady: boolean; hasLastResult: boolean; + hasResultArtifact: boolean; + hasSavedVisualization: boolean; isSampleProject: boolean; projectName?: string; - projectPath?: string; activeRuntime?: string; nodeLine: string; pythonLine: string; projectKind: ProjectKind; - seenResultGraph: boolean; - seenFigure: boolean; snapshotActive?: string; sampleQueryPath?: string; - sampleFigurePath?: string; + resultPath?: string; + visualizationPath?: string; } export interface RuntimeStepActions { @@ -62,106 +45,82 @@ export interface RuntimeStepActions { secondaryAction?: GetStartedAction; } -/** - * Persistent entry points for Get Started. - * - * The starter space is deliberately independent of checklist gating: users - * can always see what the sample is and invoke it. `openSampleProject` keeps - * its runtime guard, while this copy makes that prerequisite explicit before - * the user clicks. After the first result, these controls replace the - * one-time checklist so Get Started remains useful as a workbench. - */ -export function buildWorkspaceModel( - input: Pick< - ChecklistStepInput, - "runtimeReady" | "projectReady" | "hasLastResult" | "isSampleProject" | "projectName" - >, -): GetStartedWorkspaceModel { - const { - runtimeReady, - projectReady, - hasLastResult, - isSampleProject, - projectName, - } = input; - const sampleDetail = isSampleProject - ? "The air-routes starter space is open. Reopen it anytime to keep exploring the sample." - : runtimeReady - ? "Seed and open the US air-routes starter project, then run its ready-made query." - : "Try the US air-routes starter project after choosing a Node or Python runtime below."; - - const starter: GetStartedControlModel = { - id: "starter", - title: "Starter space", - detail: sampleDetail, - actions: [ - { - label: isSampleProject ? "Open sample project" : "Try sample project", - command: "graphforge.openSampleProject", - }, - { label: "Open your project", command: "graphforge.openProject" }, - ], - }; +export interface JourneyResultArtifact { + path: string; +} - const projectDetail = projectReady - ? `Current project: ${projectName ?? "GraphForge project"}.` - : "Open an existing GraphForge project or switch to the starter space."; - const resultDetail = hasLastResult - ? "Run another query, reopen the table, or move between graph, figure, and inspection." - : "Run a query first; result controls will guide you back if setup is incomplete."; +export interface JourneyVisualizationArtifact { + path: string; + result: string; + kind?: string; + format?: string; + renderer?: string; +} - return { - layout: hasLastResult ? "hub" : "guided", - starter, - controls: [ - { - id: "project-controls", - title: "Project", - detail: projectDetail, - actions: [ - { label: "Open Project", command: "graphforge.openProject" }, - { label: "Open Sample", command: "graphforge.openSampleProject" }, - ], - }, - { - id: "explore-controls", - title: "Query and results", - detail: resultDetail, - actions: [ - { label: "Run Query", command: "graphforge.runQuery" }, - { label: "Results Table", command: "graphforge.showResultsTable" }, - { label: "Result Graph", command: "graphforge.showResultGraph" }, - { - label: "Figure", - command: "graphforge.figureFromResult", - }, - { label: "Find / Inspect", command: "graphforge.find" }, - { label: "Ontology", command: "graphforge.showOntology" }, - ], - }, - ], - }; +/** + * Choose only a result/visualization pair that can actually be reopened. + * The quickstart's supporting temporal result must not pretend the saved + * routes query has run, and a visualization with a missing source is not a + * completed journey step. + */ +export function selectJourneyArtifacts( + isSampleProject: boolean, + results: readonly JourneyResultArtifact[], + visualizations: readonly JourneyVisualizationArtifact[], + preferredGraphRenderer = "cytoscape", +): { resultPath?: string; visualizationPath?: string } { + const resultPath = isSampleProject + ? results.find((item) => item.path === "results/query-result.json")?.path + : results[0]?.path; + const matchingVisualizations = resultPath + ? visualizations.filter((item) => item.result === resultPath) + : []; + const visualizationPath = + (isSampleProject + ? matchingVisualizations.find( + (item) => + item.kind === "geospatial" && + item.renderer === "l7" && + item.format === "graphforge.visualization/v2", + )?.path + : undefined) ?? + matchingVisualizations.find( + (item) => + item.kind === "result-graph" && + item.renderer === preferredGraphRenderer && + item.format === "graphforge.visualization/v2", + )?.path ?? + matchingVisualizations.find( + (item) => + item.kind === "result-graph" && + item.renderer === preferredGraphRenderer, + )?.path ?? + matchingVisualizations.find( + (item) => + item.kind === "result-graph" && + item.format === "graphforge.visualization/v2", + )?.path ?? + matchingVisualizations.find((item) => item.kind === "result-graph")?.path ?? + matchingVisualizations[0]?.path; + return { resultPath, visualizationPath }; } const SETUP_NODE_ACTION: GetStartedAction = { - label: "Setup Native (Node)", + label: "Set up Node", command: "graphforge.setupNativeBinding", }; const SETUP_PYTHON_ACTION: GetStartedAction = { - label: "Setup Python", + label: "Set up Python", command: "graphforge.setupPythonBinding", }; -/** - * Which setup CTAs the runtime checklist step shows. - * - * - A `done` step shows no setup actions at all (#29) — a checkmarked step - * with a dangling "Setup Python" button reads as unresolved. - * - While incomplete, the primary CTA mirrors `chooseRuntime`'s `auto` - * policy (FR-18, #37): Python-first workspaces lead with Setup Python; - * Node-ish and ambiguous workspaces keep the Node-first default. - */ +const CHECK_ENVIRONMENT_ACTION: GetStartedAction = { + label: "Check details", + command: "graphforge.checkEnvironment", +}; + +/** Lead with the runtime setup most likely to work in the current workspace. */ export function runtimeStepActions( runtimeReady: boolean, projectKind: ProjectKind, @@ -169,156 +128,158 @@ export function runtimeStepActions( if (runtimeReady) { return {}; } - if (projectKind === "python") { - return { primaryAction: SETUP_PYTHON_ACTION, secondaryAction: SETUP_NODE_ACTION }; - } - return { primaryAction: SETUP_NODE_ACTION, secondaryAction: SETUP_PYTHON_ACTION }; + return { + primaryAction: + projectKind === "python" ? SETUP_PYTHON_ACTION : SETUP_NODE_ACTION, + secondaryAction: CHECK_ENVIRONMENT_ACTION, + }; +} + +function stepStatus(done: boolean, available: boolean): GetStartedStepStatus { + if (done) return "done"; + return available ? "current" : "pending"; } /** - * Runtime → project → query → see-results checklist steps (#63). - * Pure so unit tests can lock done/current transitions without vscode. + * Build the persistent shortest path to user value. + * + * Status follows durable evidence wherever possible: project FORMAT, saved + * result JSON, and saved visualization JSON. Session state is used only when + * it proves the result table is currently usable. */ export function buildChecklistSteps(input: ChecklistStepInput): GetStartedStepModel[] { const { runtimeReady, projectReady, hasLastResult, + hasResultArtifact, + hasSavedVisualization, isSampleProject, projectName, - projectPath, activeRuntime, nodeLine, pythonLine, projectKind, - seenResultGraph, - seenFigure, snapshotActive, sampleQueryPath, - sampleFigurePath, + resultPath, + visualizationPath, } = input; const runtimeStep: GetStartedStepModel = { - id: "runtime", - title: "Set up a runtime", + id: "environment", + title: "Environment", detail: runtimeReady - ? `Active: ${activeRuntime ?? snapshotActive ?? "auto"}. ${nodeLine}; ${pythonLine}.` - : `${nodeLine}. ${pythonLine}. Link @curatelabs/graphforge or install graphforge with uv.`, + ? `${nodeLine}; ${pythonLine}.` + : `${nodeLine}. ${pythonLine}. Choose one runtime to continue.`, + artifact: runtimeReady + ? `Runtime: ${activeRuntime ?? snapshotActive ?? "auto"}` + : undefined, status: runtimeReady ? "done" : "current", ...runtimeStepActions(runtimeReady, projectKind), }; const projectStep: GetStartedStepModel = { id: "project", - title: "Open or create a project", + title: "Project", detail: projectReady - ? `Working in ${projectName ?? "project"}${projectPath ? ` (${projectPath})` : ""}.` - : "Pick a folder with a FORMAT marker, initialize an empty directory, or try the sample.", - status: projectReady ? "done" : runtimeReady ? "current" : "pending", - primaryAction: runtimeReady - ? { label: "Open Project", command: "graphforge.openProject" } - : undefined, - secondaryAction: projectReady - ? undefined - : { label: "Try sample project", command: "graphforge.openSampleProject" }, + ? `Working in ${projectName ?? "GraphForge project"}.` + : "Open a GraphForge project, initialize an empty folder, or use the sample.", + artifact: projectReady ? "FORMAT · graphforge-project/v1" : undefined, + status: stepStatus(projectReady, runtimeReady), + primaryAction: + runtimeReady && !projectReady + ? { label: "Open project", command: "graphforge.openProject" } + : undefined, + secondaryAction: + runtimeReady && !projectReady + ? { label: "Try the air-routes sample", command: "graphforge.openSampleProject" } + : undefined, }; - const queryDetail = !projectReady - ? "Available once a project is open and a runtime is active." - : hasLastResult - ? "Last query result is ready — open Result Graph or Chart below." - : isSampleProject - ? sampleQueryPath - ? `Ready from the project: ${sampleQueryPath}` - : "Open a query from the sample project's queries directory." - : "Open a .cypher file or run a query from the command palette."; - - let queryStatus: GetStartedStepStatus = "pending"; - if (hasLastResult) { - queryStatus = "done"; - } else if (projectReady && runtimeReady) { - queryStatus = "current"; - } - + const queryComplete = hasLastResult || hasResultArtifact; const queryStep: GetStartedStepModel = { id: "query", - title: "Run your first query", - detail: queryDetail, - status: queryStatus, + title: "Query", + detail: !projectReady + ? "Available after a project is open." + : queryComplete + ? "A saved query has produced a durable result." + : isSampleProject + ? "Run the saved routes query, or explore the same CSVs in Python." + : "Write and run a saved Cypher query.", + artifact: sampleQueryPath ?? (projectReady ? "queries/first-query.cypher" : undefined), + status: stepStatus(queryComplete, runtimeReady && projectReady), primaryAction: - projectReady && runtimeReady && !hasLastResult - ? isSampleProject + runtimeReady && projectReady && !queryComplete + ? isSampleProject && sampleQueryPath ? { label: "Run sample query", - command: sampleQueryPath - ? "graphforge.runProjectQuery" - : "graphforge.runQuery", - args: sampleQueryPath ? [{ path: sampleQueryPath }] : undefined, + command: "graphforge.runProjectQuery", + args: [{ path: sampleQueryPath }], } - : { label: "Run Query", command: "graphforge.runQuery" } + : { + label: "Write and run query", + command: "graphforge.getStarted.showQuery", + } + : undefined, + secondaryAction: + runtimeReady && projectReady && isSampleProject + ? { + label: "Open Python notebook", + command: "graphforge.openSampleNotebook", + } : undefined, }; - const seeBothDone = seenResultGraph && seenFigure; - let seeStatus: GetStartedStepStatus = "pending"; - if (seeBothDone) { - seeStatus = "done"; - } else if (hasLastResult) { - seeStatus = "current"; - } + const resultComplete = hasLastResult || hasSavedVisualization; + const resultAvailable = hasResultArtifact; + const resultStep: GetStartedStepModel = { + id: "result", + title: "Result", + detail: !resultAvailable && !hasLastResult + ? "Your query result will appear here and in the Results panel." + : resultComplete + ? "The result is ready to inspect and reuse." + : "Open the saved result in the Results panel.", + artifact: resultPath ?? (projectReady ? "results/query-result.json" : undefined), + status: stepStatus(resultComplete, resultAvailable), + primaryAction: + !resultComplete && resultAvailable && resultPath + ? { + label: "Inspect result", + command: "graphforge.openProjectResult", + args: [{ path: resultPath }], + } + : undefined, + }; - const seeStep: GetStartedStepModel = { - id: "see-results", - title: "See your results", - detail: !hasLastResult - ? "After a query, open the Result Graph and a Figure chart." - : seeBothDone - ? "Result Graph and Figure are open — keep exploring from the sidebar." - : "Open both views when you’re ready (nothing opens until you ask).", - status: seeStatus, - primaryAction: hasLastResult - ? { label: "Show Result Graph", command: "graphforge.showResultGraph" } - : undefined, - secondaryAction: hasLastResult + const visualizationAvailable = resultComplete; + const visualizationStep: GetStartedStepModel = { + id: "visualize", + title: "Visualize", + detail: hasSavedVisualization + ? "A reusable visualization is saved in the project." + : visualizationAvailable + ? "Create a graph, chart, map, or timeline from the saved result." + : "Available after the first result is saved.", + artifact: + visualizationPath ?? + (projectReady ? "visualizations/first-graph.gfviz.json" : undefined), + status: stepStatus(hasSavedVisualization, visualizationAvailable), + primaryAction: hasSavedVisualization && visualizationPath ? { - label: "Chart this result", - command: sampleFigurePath - ? "graphforge.openProjectVisualization" - : "graphforge.figureFromResult", - args: sampleFigurePath ? [{ path: sampleFigurePath }] : undefined, + label: "Open saved visualization", + command: "graphforge.openProjectVisualization", + args: [{ path: visualizationPath, waitForReady: true, timeoutMs: 60_000 }], } - : undefined, + : visualizationAvailable + ? { + label: "Create visualization", + command: "graphforge.getStarted.showVisualize", + } + : undefined, }; - return [runtimeStep, projectStep, queryStep, seeStep]; -} - -/** - * Welcome mode cards as an ARIA radio group (#26): `role="radio"` + - * `aria-checked` per card with a roving `tabindex`, so keyboard and - * screen-reader users can perceive and change the selection. The webview - * script only toggles these attributes; the markup is rendered host-side so - * it stays unit-testable. Card copy is trusted static content from - * `EXPERIENCE_MODE_CARDS`. - */ -export function renderModeCardsHtml( - cards: readonly ExperienceModeCard[], - selectedMode: ExperienceMode, -): string { - return cards - .map((card) => { - const selected = card.mode === selectedMode; - return ( - `` - ); - }) - .join(""); + return [runtimeStep, projectStep, queryStep, resultStep, visualizationStep]; } diff --git a/src/views/getStartedView.ts b/src/views/getStartedView.ts index bc857da..0c74e0d 100644 --- a/src/views/getStartedView.ts +++ b/src/views/getStartedView.ts @@ -1,17 +1,10 @@ +import * as crypto from "node:crypto"; import * as vscode from "vscode"; -import { - defaultsForExperienceMode, - EXPERIENCE_MODE_CARDS, - resolveExperienceMode, - type ExperienceMode, -} from "../session/experienceMode"; import type { GraphForgeSession } from "../session/graphForgeSession"; import type { HostToWebview, WebviewToHost } from "../webview/protocol"; import { buildChecklistSteps, - buildWorkspaceModel, - renderModeCardsHtml, - type GetStartedControlModel, + selectJourneyArtifacts, type GetStartedStepModel, } from "./getStartedContent"; import { isQuickstartSamplePath } from "../session/quickstartSample"; @@ -21,20 +14,14 @@ import { } from "../session/projectArtifacts"; export type GetStartedStepStatus = "pending" | "done" | "current"; -export type GetStartedScreen = "welcome" | "checklist"; export type GetStartedPage = "hub" | "query" | "visualize"; export type GetStartedStep = GetStartedStepModel; export interface GetStartedState { - screen: GetStartedScreen; headline: string; subhead: string; steps: GetStartedStep[]; - mode: ExperienceMode; - layout?: "guided" | "hub"; - starter?: GetStartedControlModel; - controls?: GetStartedControlModel[]; artifacts?: ProjectArtifactIndex; page: GetStartedPage; } @@ -58,28 +45,10 @@ export async function revealGetStartedPage( await provider.refresh(); } -/** Has the user ever completed (or skipped) the Welcome mode picker? */ -function experienceModeChosen(): boolean { - const inspected = vscode.workspace.getConfiguration("graphforge").inspect("experienceMode"); - return Boolean( - inspected?.globalValue !== undefined || - inspected?.workspaceValue !== undefined || - inspected?.workspaceFolderValue !== undefined, - ); -} - -function currentExperienceMode(): ExperienceMode { - return resolveExperienceMode( - vscode.workspace.getConfiguration("graphforge").get("experienceMode"), - ); -} - export class GetStartedViewProvider implements vscode.WebviewViewProvider { static instance: GetStartedViewProvider | undefined; private view: vscode.WebviewView | undefined; - /** Set by "Change mode" to re-show Welcome without waiting for a reload. */ - private forceWelcome = false; private page: GetStartedPage = "hub"; constructor( @@ -100,7 +69,10 @@ export class GetStartedViewProvider implements vscode.WebviewViewProvider { this.view = webviewView; webviewView.webview.options = { enableScripts: true, - localResourceRoots: [this.extensionUri], + localResourceRoots: [ + vscode.Uri.joinPath(this.extensionUri, "dist", "webview-ui"), + vscode.Uri.joinPath(this.extensionUri, "media"), + ], }; webviewView.webview.html = this.getHtml(webviewView.webview); webviewView.webview.onDidReceiveMessage((msg: WebviewToHost) => { @@ -108,60 +80,73 @@ export class GetStartedViewProvider implements vscode.WebviewViewProvider { void this.refresh(); } else if (msg.type === "graphforge/runCommand" && msg.command) { const args = Array.isArray(msg.args) ? msg.args : []; - void vscode.commands.executeCommand(msg.command, ...args); - } else if (msg.type === "graphforge/selectExperienceMode") { - void this.completeWelcome(msg.mode); + void (async () => { + try { + const outcome = await vscode.commands.executeCommand(msg.command, ...args); + const outcomeRecord = + outcome && typeof outcome === "object" + ? outcome as Record + : undefined; + const lifecycle = + outcomeRecord?.lifecycle && typeof outcomeRecord.lifecycle === "object" + ? outcomeRecord.lifecycle as Record + : undefined; + const error = outcomeRecord?.error ?? + (lifecycle?.type === "graphforge/renderFailed" + ? lifecycle.message ?? "The renderer failed before it became ready." + : undefined); + const rendererReady = lifecycle?.type === "graphforge/renderReady"; + await webviewView.webview.postMessage({ + type: "graphforge/commandStatus", + status: error ? "error" : "success", + message: error + ? String(error) + : rendererReady + ? `${String(lifecycle?.renderer ?? "Visualization")} renderer ready.` + : "Action complete.", + } satisfies HostToWebview); + if (!error) await this.refresh(); + } catch (error) { + await webviewView.webview.postMessage({ + type: "graphforge/commandStatus", + status: "error", + message: error instanceof Error ? error.message : String(error), + } satisfies HostToWebview); + } + })(); } }); void this.refresh(); } - /** Re-show the Welcome mode picker (e.g. from the checklist's "Change mode" link). */ - showWelcome(): void { - this.forceWelcome = true; - this.page = "hub"; - void this.refresh(); - } - showPage(page: GetStartedPage): void { this.page = page; void this.refresh(); } - private async completeWelcome(mode: ExperienceMode): Promise { - const config = vscode.workspace.getConfiguration("graphforge"); - const defaults = defaultsForExperienceMode(mode); - await config.update("experienceMode", mode, vscode.ConfigurationTarget.Global); - await config.update( - "openResultGraphOnQuery", - defaults.openResultGraphOnQuery, - vscode.ConfigurationTarget.Global, - ); - this.forceWelcome = false; - this.page = "hub"; - this.session.notifyChanged(); - await this.refresh(); - } - async refresh(): Promise { if (!this.view) { return; } - const screen: GetStartedScreen = - this.forceWelcome || !experienceModeChosen() ? "welcome" : "checklist"; - const state = await buildGetStartedState(this.session, screen, this.page); + const state = await buildGetStartedState(this.session, this.page); const msg: HostToWebview = { type: "graphforge/getStarted", state }; void this.view.webview.postMessage(msg); } private getHtml(webview: vscode.Webview): string { + const assetsRoot = vscode.Uri.joinPath(this.extensionUri, "dist", "webview-ui"); + const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(assetsRoot, "getStarted.js")); + const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(assetsRoot, "getStarted.css")); + const logoUri = webview.asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, "media", "graphforge.svg"), + ); + const nonce = crypto.randomBytes(16).toString("base64url"); const csp = [ `default-src 'none'`, `img-src ${webview.cspSource} data:`, - `style-src ${webview.cspSource} 'unsafe-inline'`, - `script-src ${webview.cspSource} 'unsafe-inline'`, + `style-src ${webview.cspSource}`, + `script-src 'nonce-${nonce}'`, ].join("; "); - const cardsHtml = renderModeCardsHtml(EXPERIENCE_MODE_CARDS, "guided"); return ` @@ -169,595 +154,12 @@ export class GetStartedViewProvider implements vscode.WebviewViewProvider { + Get Started - -
- -

Get started with GraphForge

-

-
-
- -
${cardsHtml}
-
-
-
-
-
- - - - +
+ `; } @@ -765,22 +167,8 @@ export class GetStartedViewProvider implements vscode.WebviewViewProvider { export async function buildGetStartedState( session: GraphForgeSession, - screen: GetStartedScreen = "checklist", page: GetStartedPage = "hub", ): Promise { - const mode = currentExperienceMode(); - - if (screen === "welcome") { - return { - screen, - headline: "Welcome to GraphForge", - subhead: "Choose how you want to work. You can change this anytime from Get Started.", - steps: [], - mode, - page: "hub", - }; - } - const snapshot = await session.environmentSnapshot(); const project = session.project; const runtimeReady = await session.hasUsableRuntime(); @@ -794,61 +182,56 @@ export async function buildGetStartedState( ); const artifacts = project?.rootPath ? scanProjectArtifacts(project.rootPath) - : { queries: [], queryTemplates: [], results: [], visualizations: [], mutations: [] }; + : { + queries: [], + queryTemplates: [], + notebooks: [], + results: [], + visualizations: [], + mutations: [], + }; const sampleQueryPath = isSampleProject ? (artifacts.queryTemplates[0] ?? artifacts.queries[0])?.path : undefined; - const sampleFigurePath = isSampleProject - ? artifacts.visualizations.find((item) => item.kind === "plotly")?.path - : undefined; + const { resultPath, visualizationPath } = selectJourneyArtifacts( + isSampleProject, + artifacts.results, + artifacts.visualizations, + vscode.workspace + .getConfiguration("graphforge") + .get("resultGraph.renderer", "cytoscape"), + ); const nodeLine = nodeAvailable ? "Node binding ready" : "Node binding not linked"; const pythonLine = pythonAvailable ? `Python ready${snapshot.python.graphforgeVersion ? ` (graphforge ${snapshot.python.graphforgeVersion})` : ""}` - : "Python runtime not configured"; + : "Python is optional for scripts and notebooks"; const steps = buildChecklistSteps({ runtimeReady, projectReady, hasLastResult, + hasResultArtifact: Boolean(resultPath), + hasSavedVisualization: Boolean(visualizationPath), isSampleProject, projectName: project?.name, - projectPath: project?.rootPath, activeRuntime, nodeLine, pythonLine, projectKind: snapshot.projectKind, - seenResultGraph: session.hasSeenResultGraph, - seenFigure: session.hasSeenFigure, snapshotActive: snapshot.active, sampleQueryPath, - sampleFigurePath, - }); - const workspace = buildWorkspaceModel({ - runtimeReady, - projectReady, - hasLastResult, - isSampleProject, - projectName: project?.name, + resultPath, + visualizationPath, }); - let headline = "Get started with GraphForge"; - let subhead = "Guided setup — no stack traces here. Details live in Check Environment."; - if (workspace.layout === "hub") { - headline = "GraphForge control hub"; - subhead = - "Open a project, run another query, and move between results without restarting setup."; - } else if (projectReady && runtimeReady) { - headline = "You're ready to explore"; - subhead = isSampleProject - ? "Run the sample query, then open Result Graph and Figure." - : "Run Cypher, analyst verbs, and browse ontology from the GraphForge sidebar."; - } else if (!runtimeReady) { - subhead = "Link a Node or Python runtime to begin. Full diagnostics are in Check Environment."; - } else if (!projectReady) { - subhead = "Runtime is ready — open a project or try the sample next."; - } - if (page === "query") { + let headline = "Build your first graph view"; + let subhead = + "Follow one visible path from environment to saved query, result, and visualization."; + if (page === "hub" && steps.length > 0 && steps.every((step) => step.status === "done")) { + headline = "Your GraphForge project is ready"; + subhead = "Open a saved view, inspect the durable result, or continue in Python."; + } else if (page === "query") { headline = "Query"; subhead = projectReady ? "Draft Cypher, save reusable project templates, and reopen result history." @@ -861,14 +244,9 @@ export async function buildGetStartedState( } return { - screen, headline, subhead, steps, - mode, - layout: workspace.layout, - starter: workspace.starter, - controls: workspace.controls, artifacts, page, }; diff --git a/src/webview/artifactVisualizationPanel.ts b/src/webview/artifactVisualizationPanel.ts index f9d196d..0863e02 100644 --- a/src/webview/artifactVisualizationPanel.ts +++ b/src/webview/artifactVisualizationPanel.ts @@ -250,6 +250,7 @@ export class ArtifactVisualizationPanel { const assetsRoot = vscode.Uri.joinPath(extensionUri, "dist", "webview-ui"); const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(assetsRoot, "artifactVisualization.js")); const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(assetsRoot, "artifactVisualization.css")); + const loadingStyleUri = webview.asWebviewUri(vscode.Uri.joinPath(assetsRoot, "visualizationLoading.css")); const nonce = crypto.randomBytes(16).toString("base64url"); const csp = [ `default-src 'none'`, @@ -267,12 +268,13 @@ export class ArtifactVisualizationPanel { + GraphForge Visualization
-

Visualization

+

Visualization

Waiting for visualization data
+

Waiting for graph data…

-
Colors are extension-owned. Epistemic when the knowledge ledger is resolvable, otherwise class-only.
+
Waiting for graph dataColors are extension-owned; styling is epistemic when the ledger is resolvable.
diff --git a/src/webview/settingsSchema.ts b/src/webview/settingsSchema.ts index 233b3cd..70932cc 100644 --- a/src/webview/settingsSchema.ts +++ b/src/webview/settingsSchema.ts @@ -6,7 +6,7 @@ * the Vite-built webview app (`webview-ui/src/settings/`), and is unit-tested * under plain mocha against `package.json#contributes.configuration`. * - * Categories follow issue #24's scope (Runtime / Experience / Advanced — + * Categories follow issue #24's scope (Runtime / Visualizations / Advanced — * "Project" is omitted until a project-scoped setting exists; a settings * category with no controls would be a stub UI). Copy is analyst-facing per * docs/DESIGN.md Voice: no infra jargon, always name the next action. @@ -88,39 +88,17 @@ export const SETTINGS_CATEGORIES: readonly SettingsCategory[] = [ ], }, { - id: "experience", - label: "Experience", - blurb: "How much GraphForge confirms with you vs. acts on its own.", + id: "visualizations", + label: "Visualizations", + blurb: "Defaults for opening and creating visualization artifacts.", settings: [ - { - key: "experienceMode", - label: "Experience mode", - description: - "Guided confirms before changes and keeps the checklist visible. Autonomous auto-opens detected projects and skips routine confirmations — it still fails closed on destructive operations.", - type: "enum", - default: "guided", - options: [ - { - value: "guided", - label: "Guided", - description: - "Confirms before Initialize; quieter auto-run; Result Graph stays closed until opened.", - }, - { - value: "autonomous", - label: "Autonomous", - description: - "Auto-opens detected projects and the Result Graph after queries; skips routine confirmations.", - }, - ], - }, { key: "openResultGraphOnQuery", label: "Open Result Graph after queries", description: "Show the Result Graph panel automatically after a successful Cypher query or analyst verb.", type: "boolean", - default: true, + default: false, }, { key: "resultGraph.renderer", @@ -128,19 +106,19 @@ export const SETTINGS_CATEGORIES: readonly SettingsCategory[] = [ description: "Choose the template used for new Result Graph artifacts. Saved artifacts keep their recorded renderer.", type: "enum", - default: "g6", + default: "cytoscape", options: [ { - value: "g6", - label: "AntV G6 (recommended)", + value: "cytoscape", + label: "Cytoscape (recommended)", description: - "Canvas rendering with an explicit worker ForceAtlas2 layout, pan, zoom, fit, and inspection.", + "Canvas rendering with force layout, pan, zoom, fit, and node or edge inspection.", }, { - value: "cytoscape", - label: "Cytoscape", + value: "g6", + label: "AntV G6", description: - "Canvas rendering with force layout, pan, zoom, fit, and node or edge inspection.", + "Canvas rendering with an explicit worker ForceAtlas2 layout, pan, zoom, fit, and inspection.", }, { value: "sigma", @@ -156,19 +134,19 @@ export const SETTINGS_CATEGORIES: readonly SettingsCategory[] = [ description: "Choose the template used for new chart artifacts. Saved artifacts keep their recorded renderer.", type: "enum", - default: "g2", + default: "plotly", options: [ { - value: "g2", - label: "AntV G2 (recommended)", + value: "plotly", + label: "Plotly (recommended)", description: - "Declarative charts generated from explicit project-artifact field bindings.", + "Retains the existing Plotly figure renderer and Python/JavaScript JSON interchange.", }, { - value: "plotly", - label: "Plotly", + value: "g2", + label: "AntV G2", description: - "Retains the existing Plotly figure renderer and Python/JavaScript JSON interchange.", + "Declarative charts generated from explicit project-artifact field bindings.", }, ], }, diff --git a/src/webview/visualizationLoadingModel.ts b/src/webview/visualizationLoadingModel.ts new file mode 100644 index 0000000..8f73f2d --- /dev/null +++ b/src/webview/visualizationLoadingModel.ts @@ -0,0 +1,148 @@ +export type VisualizationLoadingRenderer = + | "g6" + | "cytoscape" + | "sigma" + | "g2" + | "l7" + | "plotly"; + +export type VisualizationLoadingPhase = + | "prepare" + | "layout" + | "paint" + | "ready" + | "failed"; + +export interface VisualizationLoadingInput { + renderer: VisualizationLoadingRenderer; + phase: VisualizationLoadingPhase; + failedAt?: Exclude; + nodeCount?: number; + edgeCount?: number; + rowCount?: number; + durationMs?: number; + message?: string; +} + +export interface VisualizationLoadingStep { + label: string; + state: "complete" | "current" | "pending" | "failed"; +} + +export interface VisualizationLoadingState { + rendererName: string; + title: string; + detail: string; + steps: VisualizationLoadingStep[]; +} + +interface RendererProfile { + name: string; + steps: readonly [string, string, string]; + titles: Record, string>; +} + +const PROFILES: Record = { + g6: { + name: "AntV G6", + steps: ["Read graph", "ForceAtlas2", "Paint Canvas"], + titles: { + prepare: "Preparing graph data", + layout: "Running ForceAtlas2 layout", + paint: "Painting the Canvas", + ready: "Graph ready", + }, + }, + cytoscape: { + name: "Cytoscape", + steps: ["Read graph", "CoSE layout", "Paint Canvas"], + titles: { + prepare: "Preparing graph data", + layout: "Running CoSE layout", + paint: "Painting the Canvas", + ready: "Graph ready", + }, + }, + sigma: { + name: "Sigma", + steps: ["Read graph", "ForceAtlas2", "Paint WebGL"], + titles: { + prepare: "Preparing graph data", + layout: "Running ForceAtlas2 layout", + paint: "Painting with WebGL", + ready: "Graph ready", + }, + }, + g2: { + name: "AntV G2", + steps: ["Read rows", "Compose marks", "Paint chart"], + titles: { + prepare: "Preparing result rows", + layout: "Composing marks and scales", + paint: "Painting the chart", + ready: "Chart ready", + }, + }, + l7: { + name: "AntV L7", + steps: ["Read rows", "Build map scene", "Paint layers"], + titles: { + prepare: "Preparing geospatial rows", + layout: "Building the map scene", + paint: "Painting map layers", + ready: "Map ready", + }, + }, + plotly: { + name: "Plotly", + steps: ["Read rows", "Compose traces", "Paint chart"], + titles: { + prepare: "Preparing result rows", + layout: "Composing Plotly traces", + paint: "Painting the chart", + ready: "Chart ready", + }, + }, +}; + +function countDetail(input: VisualizationLoadingInput): string { + if (input.nodeCount !== undefined || input.edgeCount !== undefined) { + return `${(input.nodeCount ?? 0).toLocaleString("en-US")} nodes · ${(input.edgeCount ?? 0).toLocaleString("en-US")} edges`; + } + if (input.rowCount !== undefined) { + return `${input.rowCount.toLocaleString("en-US")} result rows`; + } + return "Local visualization pipeline"; +} + +function durationDetail(durationMs: number | undefined): string { + if (durationMs === undefined) return ""; + if (durationMs < 1_000) return ` · ${Math.round(durationMs)} ms`; + return ` · ${(durationMs / 1_000).toFixed(1)} s`; +} + +export function visualizationLoadingState( + input: VisualizationLoadingInput, +): VisualizationLoadingState { + const profile = PROFILES[input.renderer]; + const effectivePhase = input.phase === "failed" ? input.failedAt ?? "prepare" : input.phase; + const phaseIndex = effectivePhase === "prepare" ? 0 : effectivePhase === "layout" ? 1 : 2; + const steps = profile.steps.map((label, index): VisualizationLoadingStep => ({ + label, + state: input.phase === "ready" + ? "complete" + : index < phaseIndex + ? "complete" + : index > phaseIndex + ? "pending" + : input.phase === "failed" + ? "failed" + : "current", + })); + return { + rendererName: profile.name, + title: input.phase === "failed" ? `${profile.name} stopped` : profile.titles[input.phase], + detail: input.message ?? `${countDetail(input)}${durationDetail(input.durationMs)}`, + steps, + }; +} diff --git a/webview-ui/src/artifactVisualization/main.ts b/webview-ui/src/artifactVisualization/main.ts index 3c1a38a..1b9e3a3 100644 --- a/webview-ui/src/artifactVisualization/main.ts +++ b/webview-ui/src/artifactVisualization/main.ts @@ -12,11 +12,18 @@ import type { ArtifactVisualizationSpec, ArtifactVisualizationWebviewToHost, } from "../../../src/webview/artifactVisualizationProtocol"; +import { + geospatialLinkEndpoints, + validGeospatialLinkRows, +} from "../../../src/webview/geospatialModel"; +import { createVisualizationLoadingController } from "../shared/visualizationLoading"; +import "../shared/visualizationLoading.css"; import "./artifactVisualization.css"; const vscode = acquireVsCodeApi(); const titleElement = document.getElementById("title"); -const metaElement = document.getElementById("meta"); +const artifactIdentityElement = document.getElementById("artifact-identity"); +const renderSummaryElement = document.getElementById("render-summary"); const bannerElement = document.getElementById("banner"); const container = document.getElementById("visualization"); const saveButton = document.getElementById("save") as HTMLButtonElement | null; @@ -28,6 +35,7 @@ const playButton = document.getElementById("play") as HTMLButtonElement | null; const pauseButton = document.getElementById("pause") as HTMLButtonElement | null; const summaryElement = document.getElementById("summary"); const tableWrap = document.getElementById("table-wrap"); +const renderStatusElement = document.getElementById("render-status"); let currentPath = ""; let currentSpec: ArtifactVisualizationSpec | undefined; @@ -39,6 +47,31 @@ let playbackTimer: number | undefined; let playbackUpdateInProgress = false; let viewportTimer: number | undefined; let renderToken = 0; +let loadingPhase: "prepare" | "layout" | "paint" = "prepare"; +const loading = createVisualizationLoadingController( + renderStatusElement, + container, + renderSummaryElement, +); + +function updateArtifactLoading( + spec: ArtifactVisualizationSpec, + phase: "prepare" | "layout" | "paint" | "ready" | "failed", + rows: TableRow[], + options: { durationMs?: number; message?: string; failedAt?: "prepare" | "layout" | "paint" } = {}, +): void { + if (phase === "prepare" || phase === "layout" || phase === "paint") loadingPhase = phase; + loading.update({ + renderer: spec.renderer.id, + phase, + rowCount: rows.length, + ...options, + }); +} + +function nextPaintFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} const MAP_SCENE_LOAD_TIMEOUT_MS = 15_000; const VIEWPORT_SAVE_DEBOUNCE_MS = 200; @@ -56,8 +89,8 @@ function showBanner(message?: string): void { function setDirty(dirty: boolean): void { if (saveButton) saveButton.disabled = !dirty; if (revertButton) revertButton.disabled = !dirty; - if (metaElement && currentSpec) { - metaElement.textContent = `${currentSpec.kind} · ${currentSpec.renderer.id} · ${currentPath}${dirty ? " · unsaved changes" : ""}`; + if (artifactIdentityElement && currentSpec) { + artifactIdentityElement.textContent = `${currentSpec.kind} · ${currentSpec.renderer.id} · ${currentPath}${dirty ? " · unsaved changes" : ""}`; } } @@ -228,6 +261,7 @@ async function renderChart(spec: ChartVisualizationSpecV2, rows: TableRow[]): Pr if (spec.chart.title) options.title = spec.chart.title; chart = new Chart({ container, autoFit: true }); chart.options(options as never); + updateArtifactLoading(spec, "paint", rows); await chart.render(); } @@ -311,6 +345,7 @@ async function renderTemporal(spec: TemporalVisualizationSpecV2, rows: TableRow[ animate: temporal.animation, ...(temporal.title ? { title: temporal.title } : {}), } as never); + updateArtifactLoading(spec, "paint", rows); await chart.render(); if (rangeStart) rangeStart.value = temporal.range.start ?? ""; if (rangeEnd) rangeEnd.value = temporal.range.end ?? ""; @@ -366,16 +401,42 @@ async function renderGeospatial(spec: GeospatialVisualizationSpecV2, rows: Table nextScene.once("loaded", () => { if (settled) return; try { + updateArtifactLoading(spec, "paint", rows); for (const layerSpec of spec.geospatial.layers) { if (layerSpec.type === "point") { const layer = new PointLayer({ name: layerSpec.id }); if (spec.geospatial.source.type === "coordinates") { layer.source(rows, { parser: { type: "json", x: spec.geospatial.source.longitudeField, y: spec.geospatial.source.latitudeField } }); + } else if (spec.geospatial.source.type === "links") { + layer.source( + geospatialLinkEndpoints(rows, spec.geospatial.source.source, spec.geospatial.source.target), + { parser: { type: "json", x: "longitude", y: "latitude" } }, + ); } else { layer.source(featureCollection(spec, rows)); } layer.shape("circle").size(layerSpec.size).color(layerSpec.color).style({ opacity: layerSpec.opacity }); nextScene.addLayer(layer); + } else if (layerSpec.type === "arc") { + if (spec.geospatial.source.type !== "links") { + throw new Error("Arc layers require an explicit link coordinate source."); + } + const source = spec.geospatial.source; + const layer = new LineLayer({ name: layerSpec.id }) + .source(validGeospatialLinkRows(rows, source.source, source.target), { + parser: { + type: "json", + x: source.source.longitudeField, + y: source.source.latitudeField, + x1: source.target.longitudeField, + y1: source.target.latitudeField, + }, + }) + .shape("arc") + .size(layerSpec.size) + .color(layerSpec.color) + .style({ opacity: layerSpec.opacity }); + nextScene.addLayer(layer); } else if (layerSpec.type === "line") { const layer = new LineLayer({ name: layerSpec.id }) .source(featureCollection(spec, rows)) @@ -440,22 +501,32 @@ async function render(spec: ArtifactVisualizationSpec, rows: TableRow[]): Promis destroyRenderer(); showBanner(); if (temporalControls) temporalControls.hidden = spec.kind !== "temporal"; + updateArtifactLoading(spec, "prepare", rows); + await nextPaintFrame(); + if (token !== renderToken) return; + updateArtifactLoading(spec, "layout", rows); try { if (spec.kind === "chart") await renderChart(spec, rows); else if (spec.kind === "temporal") await renderTemporal(spec, rows); else await renderGeospatial(spec, rows); if (token !== renderToken) return; + const durationMs = performance.now() - started; + updateArtifactLoading(spec, "ready", rows, { durationMs }); post({ type: "graphforge/renderReady", kind: spec.kind, renderer: spec.renderer.id, rowCount: rows.length, - durationMs: performance.now() - started, + durationMs, }); } catch (error) { if (token !== renderToken) return; const message = error instanceof Error ? error.message : String(error); showBanner(`Could not render ${spec.kind}: ${message}`); + updateArtifactLoading(spec, "failed", rows, { + failedAt: loadingPhase, + message: `${spec.renderer.id.toUpperCase()}_RENDER_FAILED: ${message}`, + }); post({ type: "graphforge/renderFailed", kind: spec.kind, diff --git a/webview-ui/src/figure/figure.css b/webview-ui/src/figure/figure.css index 206b5cb..ca75d1f 100644 --- a/webview-ui/src/figure/figure.css +++ b/webview-ui/src/figure/figure.css @@ -12,6 +12,7 @@ body { } #app { + position: relative; display: grid; grid-template-rows: auto 1fr; height: 100%; diff --git a/webview-ui/src/figure/main.ts b/webview-ui/src/figure/main.ts index 979f5fd..600e6ac 100644 --- a/webview-ui/src/figure/main.ts +++ b/webview-ui/src/figure/main.ts @@ -9,6 +9,8 @@ import type { FigureWebviewToHost, PlotlyFigure, } from "../../../src/webview/figureSchema"; +import { createVisualizationLoadingController } from "../shared/visualizationLoading"; +import "../shared/visualizationLoading.css"; import "./figure.css"; const vscode = acquireVsCodeApi(); @@ -20,6 +22,12 @@ function post(message: FigureWebviewToHost): void { const root = document.getElementById("app"); const banner = document.getElementById("banner"); const plotEl = document.getElementById("plot"); +const renderStatusElement = document.getElementById("render-status"); +const loading = createVisualizationLoadingController(renderStatusElement, plotEl); + +function nextPaintFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} function showBanner(message: string | undefined): void { if (!banner) { @@ -54,12 +62,19 @@ async function renderFigure(figure: PlotlyFigure): Promise { if (!plotEl) { throw new Error("Plot container missing"); } + const startedAt = performance.now(); showBanner(undefined); + loading.update({ renderer: "plotly", phase: "prepare" }); + await nextPaintFrame(); const themed = themeLayout(figure); + loading.update({ renderer: "plotly", phase: "layout" }); + await nextPaintFrame(); + loading.update({ renderer: "plotly", phase: "paint" }); await Plotly.react(plotEl, themed.data, themed.layout ?? {}, { responsive: true, displaylogo: false, }); + loading.update({ renderer: "plotly", phase: "ready", durationMs: performance.now() - startedAt }); } window.addEventListener("message", (event: MessageEvent) => { @@ -69,12 +84,14 @@ window.addEventListener("message", (event: MessageEvent) => } if (msg.type === "graphforge/figureError") { showBanner(msg.message); + loading.update({ renderer: "plotly", phase: "failed", failedAt: "prepare", message: msg.message }); return; } if (msg.type === "graphforge/figure") { void renderFigure(msg.figure).catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); showBanner(`Could not render figure: ${message}`); + loading.update({ renderer: "plotly", phase: "failed", failedAt: "paint", message }); post({ type: "graphforge/renderFailed", message }); }); } diff --git a/webview-ui/src/getStarted/getStarted.css b/webview-ui/src/getStarted/getStarted.css new file mode 100644 index 0000000..7163616 --- /dev/null +++ b/webview-ui/src/getStarted/getStarted.css @@ -0,0 +1,194 @@ +:root { color-scheme: light dark; --gf-accent: #4c6ef5; } +body { + margin: 0; padding: 16px 14px 24px; + font-family: var(--vscode-font-family); + color: var(--vscode-foreground); + background: var(--vscode-sideBar-background); +} +.header { text-align: center; margin-bottom: 18px; } +.logo { + display: block; width: 40px; height: 40px; margin: 0 auto 10px; + filter: grayscale(1) brightness(2.4); + opacity: 0.88; +} +h1 { font-size: 15px; font-weight: 600; margin: 0 0 6px; letter-spacing: -0.01em; } +.subhead { + font-size: 12px; line-height: 1.45; + color: var(--vscode-descriptionForeground); margin: 0; +} +.page[hidden] { display: none; } +.journey-kicker { + display: flex; align-items: center; justify-content: center; gap: 6px; + margin: 0 0 18px; color: var(--vscode-descriptionForeground); + font-size: 11px; line-height: 1.35; +} +.journey-kicker::before { + content: ""; width: 7px; height: 7px; border: 1px solid currentColor; + border-radius: 50%; +} +.ready-summary { + margin: 0 0 14px; + padding: 12px; + border: 1px solid var(--vscode-panel-border, rgba(128,128,128,0.35)); + border-left: 3px solid var(--gf-accent); + border-radius: 6px; + background: var(--vscode-editor-background); +} +.ready-summary[hidden] { display: none; } +.ready-kicker { + margin: 0 0 5px; + color: var(--vscode-foreground); + font-size: 12px; + font-weight: 700; +} +.ready-copy, .muted { + margin: 0 0 10px; + color: var(--vscode-descriptionForeground); + font-size: 11px; + line-height: 1.45; +} +.compact-actions { margin-top: 7px; } +.journey-details, .artifact-section, .optional-section { + border: 0; +} +.journey-details > summary, .artifact-section > summary, .optional-section > summary { + cursor: pointer; + color: var(--vscode-descriptionForeground); + font-size: 11px; + font-weight: 600; +} +.journey-details > summary { margin-bottom: 12px; text-align: center; } +.artifact-section { margin-top: 14px; } +.artifact-section > summary { + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.optional-section { + padding: 7px 0 0; + border-top: 1px solid var(--vscode-panel-border, rgba(128,128,128,0.25)); +} +.optional-section > summary { margin-bottom: 7px; } +.optional-section label { display: block; margin-top: 7px; } +.journey-list { list-style: none; margin: 0; padding: 0; } +.journey-step { + position: relative; display: grid; grid-template-columns: 30px minmax(0, 1fr); + column-gap: 10px; min-height: 84px; padding: 0 0 12px; +} +.journey-step:not(:last-child)::before { + content: ""; position: absolute; top: 24px; bottom: -2px; left: 12px; + border-left: 2px solid var(--vscode-panel-border, rgba(128,128,128,0.45)); +} +.journey-step.done:not(:last-child)::before { border-left-color: var(--gf-accent); } +.journey-node { + position: relative; z-index: 1; box-sizing: border-box; + display: flex; align-items: center; justify-content: center; + width: 26px; height: 26px; border-radius: 50%; + border: 2px solid var(--vscode-panel-border, rgba(128,128,128,0.7)); + background: var(--vscode-sideBar-background); color: var(--vscode-descriptionForeground); + font-size: 10px; font-weight: 700; +} +.journey-step.done .journey-node { + border-color: var(--gf-accent); background: var(--gf-accent); color: #fff; +} +.journey-step.current .journey-node { + border-color: var(--gf-accent); color: var(--gf-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--gf-accent) 22%, transparent); +} +.journey-content { min-width: 0; padding-top: 2px; } +.journey-step.done .journey-content { opacity: 0.74; } +.step-title { font-size: 13px; font-weight: 600; margin: 0 0 4px; } +.journey-step.current .step-title { color: var(--vscode-textLink-foreground); } +.step-detail { + font-size: 11px; line-height: 1.42; + color: var(--vscode-descriptionForeground); margin: 0; +} +.step-artifact { + display: block; margin-top: 5px; color: var(--vscode-descriptionForeground); + font-family: var(--vscode-editor-font-family); font-size: 10px; line-height: 1.35; + overflow-wrap: anywhere; +} +.journey-step .actions { margin-top: 9px; } +.journey-step .primary { min-width: 126px; } +.journey-step .secondary { + padding-left: 0; padding-right: 0; border: 0; background: none; + color: var(--vscode-textLink-foreground); text-decoration: underline; +} +.sr-only { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; +} +.section-label { + margin: 16px 0 8px; font-size: 11px; font-weight: 600; + color: var(--vscode-descriptionForeground); text-transform: uppercase; + letter-spacing: 0.06em; +} +.actions { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 6px; } +button { + font-family: var(--vscode-font-family); + font-size: 11px; border: none; border-radius: 4px; + padding: 5px 10px; cursor: pointer; +} +button.primary { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} +button.primary:hover { background: var(--vscode-button-hoverBackground); } +button:disabled { + cursor: not-allowed; opacity: 0.58; +} +button.primary:disabled:hover { background: var(--vscode-button-background); } +button.primary.full { width: 100%; padding: 8px 10px; font-weight: 600; } +button.secondary { + background: var(--vscode-button-secondaryBackground, transparent); + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); + border: 1px solid var(--vscode-panel-border, rgba(128,128,128,0.35)); +} +.artifact-list { display: flex; flex-direction: column; gap: 6px; } +.artifact { + display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; + padding: 8px; border-radius: 6px; background: var(--vscode-editor-background); + border: 1px solid var(--vscode-panel-border, rgba(128,128,128,0.35)); +} +.artifact-name { font-size: 11px; font-weight: 600; word-break: break-word; } +.artifact-meta { + display: block; margin-top: 2px; font-size: 10px; + color: var(--vscode-descriptionForeground); word-break: break-all; +} +.artifact .actions { margin-top: 0; flex-shrink: 0; } +.form { + display: flex; flex-direction: column; gap: 8px; padding: 10px; + border-radius: 8px; background: var(--vscode-editor-background); + border: 1px solid var(--vscode-panel-border, rgba(128,128,128,0.35)); +} +label, legend { font-size: 10px; font-weight: 600; color: var(--vscode-descriptionForeground); } +input, select, textarea { + box-sizing: border-box; width: 100%; margin-top: 3px; padding: 5px 6px; + color: var(--vscode-input-foreground); background: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, transparent); border-radius: 3px; + font-family: var(--vscode-font-family); font-size: 11px; +} +textarea { min-height: 116px; resize: vertical; font-family: var(--vscode-editor-font-family); } +input:focus, select:focus, textarea:focus { + outline: 1px solid var(--vscode-focusBorder); outline-offset: -1px; +} +.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; } +.empty-copy { font-size: 11px; color: var(--vscode-descriptionForeground); } +.footer { + margin-top: 14px; text-align: center; +} +button.link { + background: none; color: var(--vscode-textLink-foreground); + text-decoration: underline; padding: 4px; +} +.action-status { + margin: 8px 0 0; padding: 7px 8px; border-radius: 4px; + font-size: 10px; line-height: 1.4; + color: var(--vscode-descriptionForeground); + background: var(--vscode-editor-background); + border: 1px solid var(--vscode-panel-border, rgba(128,128,128,0.35)); +} +.action-status.error { + color: var(--vscode-errorForeground); + border-color: var(--vscode-inputValidation-errorBorder, var(--vscode-errorForeground)); +} diff --git a/webview-ui/src/getStarted/main.js b/webview-ui/src/getStarted/main.js new file mode 100644 index 0000000..b43b49e --- /dev/null +++ b/webview-ui/src/getStarted/main.js @@ -0,0 +1,433 @@ +import "./getStarted.css"; + +const app = document.getElementById("app"); +if (!app) throw new Error("GraphForge Get Started root is missing."); +const logoUri = app.dataset.logoUri || ""; +document.body.innerHTML = ` +
+ +

Build your first graph view

+

+
+
+ +
+ Setup path +
    +
    +
    + + + + +`; + +const vscode = acquireVsCodeApi(); +let currentState; + +const byId = (id) => document.getElementById(id); +const escapeHtml = (value) => String(value) + .replaceAll('&', '&').replaceAll('<', '<') + .replaceAll('>', '>').replaceAll('"', '"'); +const runCommand = (command, args) => + vscode.postMessage({ type: 'graphforge/runCommand', command, args: args ? [args] : undefined }); + +function artifactRow(item, actions, meta) { + return '
    ' + + escapeHtml(item.name) + '' + + escapeHtml(meta || item.path) + '
    ' + + actions.map((action) => actionButton( + action.primary ? 'primary' : 'secondary', + action.disabled ? action : { ...action, args: [{ path: item.path }] }, + )).join('') + '
    '; +} + +function renderArtifacts(artifacts) { + const queries = artifacts?.queries || []; + const templates = artifacts?.queryTemplates || []; + const results = artifacts?.results || []; + const visualizations = artifacts?.visualizations || []; + const availableResults = new Set(results.map((item) => item.path)); + const visualizationPriority = (item) => { + if (item.kind === 'geospatial') return 0; + if (item.kind === 'result-graph' && item.renderer === 'cytoscape') return 1; + if (item.kind === 'result-graph') return 2; + if (item.kind === 'plotly') return 3; + if (item.kind === 'chart') return 4; + if (item.kind === 'temporal') return 5; + return 6; + }; + const readyVisualizations = visualizations + .filter((item) => availableResults.has(item.result)) + .slice() + .sort((left, right) => + visualizationPriority(left) - visualizationPriority(right) || + left.name.localeCompare(right.name) + ); + const hubVisualization = byId('hub-visualization'); + const previousHubPath = hubVisualization.value; + hubVisualization.innerHTML = readyVisualizations.map((item) => + '' + ).join(''); + if (readyVisualizations.some((item) => item.path === previousHubPath)) { + hubVisualization.value = previousHubPath; + } + byId('open-hub-visualization').disabled = readyVisualizations.length === 0; + const notebookAvailable = (artifacts?.notebooks || []).length > 0; + byId('open-hub-notebook').hidden = !notebookAvailable; + byId('open-hub-result').disabled = results.length === 0; + byId('template-list').innerHTML = templates.length + ? templates.map((item) => artifactRow(item, [ + { label: 'Run', command: 'graphforge.runProjectQuery', primary: true }, + { label: 'Open', command: 'graphforge.openProjectArtifact' }, + ])).join('') + : '

    Save a reusable query to create queries/templates/…

    '; + byId('query-list').innerHTML = queries.length + ? queries.map((item) => artifactRow(item, [ + { label: 'Run', command: 'graphforge.runProjectQuery', primary: true }, + { label: 'Open', command: 'graphforge.openProjectArtifact' }, + ])).join('') + : '

    No one-off queries in queries/.

    '; + byId('result-list').innerHTML = results.length + ? results.map((item) => artifactRow(item, [ + { label: 'Table', command: 'graphforge.openProjectResult', primary: true }, + { label: 'JSON', command: 'graphforge.openProjectArtifact' }, + ], item.rowCount + ' rows · ' + item.path)).join('') + : '

    Run a query to create durable result history.

    '; + byId('visualization-list').innerHTML = visualizations.length + ? visualizations.map((item) => { + const sourceReady = availableResults.has(item.result); + return artifactRow(item, [ + sourceReady + ? { label: 'Open', command: 'graphforge.openProjectVisualization', primary: true } + : { label: 'Needs result', disabled: 'Run the visualization source query first.', primary: true }, + { label: 'JSON', command: 'graphforge.openProjectArtifact' }, + ], item.kind + ' · ' + item.renderer + ' · ' + item.result + (sourceReady ? '' : ' · source result missing')); + }).join('') + : '

    Save a visualization above to create visualizations/…

    '; + [ + byId('template-list'), + byId('query-list'), + byId('result-list'), + byId('visualization-list'), + ].forEach(bindActions); + + const resultSelect = byId('viz-result'); + const previous = resultSelect.value; + resultSelect.innerHTML = results.map((item) => + '' + ).join(''); + if (results.some((item) => item.path === previous)) resultSelect.value = previous; + updateColumnOptions(); +} + +function selectedResult() { + return currentState?.artifacts?.results?.find((item) => item.path === byId('viz-result').value); +} + +function updateColumnOptions() { + const columns = selectedResult()?.columns || []; + ['viz-x', 'viz-y', 'viz-color', 'viz-longitude', 'viz-latitude', 'viz-target-longitude', 'viz-target-latitude', 'viz-timestamp', 'viz-temporal-value', 'viz-temporal-series', 'viz-filter-column'].forEach((id) => { + const select = byId(id); + const old = select.value; + const empty = id === 'viz-color' || id === 'viz-target-longitude' || id === 'viz-target-latitude' || id === 'viz-temporal-series' || id === 'viz-filter-column' + ? '' : ''; + select.innerHTML = empty + columns.map((column) => + '' + ).join(''); + if (columns.includes(old)) select.value = old; + }); +} + +byId('viz-result').addEventListener('change', updateColumnOptions); +byId('viz-kind').addEventListener('change', () => { + const kind = byId('viz-kind').value; + byId('graph-settings').hidden = kind !== 'result-graph'; + byId('chart-settings').hidden = kind !== 'chart'; + byId('geospatial-settings').hidden = kind !== 'geospatial'; + byId('temporal-settings').hidden = kind !== 'temporal'; +}); + +function currentQueryArgs() { + return { + name: byId('query-name').value, + cypher: byId('query-cypher').value, + resultName: byId('result-name').value, + }; +} +function saveTemplate(run) { + if (!byId('query-form').reportValidity()) return; + runCommand('graphforge.saveProjectQueryTemplate', { + ...currentQueryArgs(), + run, + }); +} +byId('query-form').addEventListener('submit', (event) => { + event.preventDefault(); + saveTemplate(true); +}); +byId('run-query').addEventListener('click', () => { + if (!byId('query-form').reportValidity()) return; + runCommand('graphforge.runQuery', currentQueryArgs()); +}); +byId('save-template').addEventListener('click', () => saveTemplate(false)); + +byId('visualization-form').addEventListener('submit', (event) => { + event.preventDefault(); + const kind = byId('viz-kind').value; + const filterValue = byId('viz-filter-value').value.trim(); + const filterColumn = byId('viz-filter-column').value; + const common = { + name: byId('viz-name').value, + kind, + result: byId('viz-result').value, + filter: filterValue && filterColumn ? { + column: filterColumn, + operator: byId('viz-filter-operator').value, + value: filterValue, + } : undefined, + }; + const args = kind === 'result-graph' ? { + ...common, + renderer: byId('viz-renderer').value || undefined, + } : kind === 'chart' ? { + ...common, + renderer: byId('viz-chart-renderer').value || undefined, + mark: byId('viz-chart-type').value, + x: byId('viz-x').value, + y: byId('viz-chart-type').value === 'histogram' ? undefined : byId('viz-y').value, + color: byId('viz-color').value || undefined, + } : kind === 'geospatial' ? { + ...common, + renderer: 'l7', + longitude: byId('viz-longitude').value, + latitude: byId('viz-latitude').value, + targetLongitude: byId('viz-target-longitude').value || undefined, + targetLatitude: byId('viz-target-latitude').value || undefined, + } : { + ...common, + renderer: byId('viz-temporal-renderer').value, + mark: 'line', + timestamp: byId('viz-timestamp').value, + y: byId('viz-temporal-value').value, + color: byId('viz-temporal-series').value || undefined, + timezone: byId('viz-timezone').value, + granularity: byId('viz-granularity').value, + }; + runCommand('graphforge.createProjectVisualization', { + ...args, + open: true, + }); +}); + +byId('open-hub-visualization').addEventListener('click', () => { + const selected = currentState?.artifacts?.visualizations?.find( + (item) => item.path === byId('hub-visualization').value, + ); + if (!selected) return; + runCommand('graphforge.openProjectVisualization', { + path: selected.path, + ...(selected.kind === 'result-graph' ? { waitForReady: true, timeoutMs: 60000 } : {}), + }); +}); +byId('open-hub-result').addEventListener('click', () => { + const results = currentState?.artifacts?.results || []; + const result = results.find((item) => item.path === 'results/query-result.json') || results[0]; + if (result) runCommand('graphforge.openProjectResult', { path: result.path }); +}); +byId('open-hub-notebook').addEventListener('click', () => + runCommand('graphforge.openSampleNotebook') +); + +document.getElementById('refresh').addEventListener('click', () => + vscode.postMessage({ type: 'graphforge/runCommand', command: 'graphforge.checkEnvironment' }) +); +document.getElementById('open-settings').addEventListener('click', () => + vscode.postMessage({ type: 'graphforge/runCommand', command: 'graphforge.openSettings' }) +); + +function actionButton(cls, action) { + if (action.disabled) { + return ''; + } + const argsAttr = action.args + ? ' data-args="' + encodeURIComponent(JSON.stringify(action.args)) + '"' + : ''; + return ''; +} + +function bindActions(root) { + root.querySelectorAll('[data-cmd]').forEach((btn) => { + btn.addEventListener('click', () => { + let args; + const raw = btn.getAttribute('data-args'); + if (raw) { + try { args = JSON.parse(decodeURIComponent(raw)); } catch (_) { args = undefined; } + } + vscode.postMessage({ + type: 'graphforge/runCommand', + command: btn.getAttribute('data-cmd'), + args: args, + }); + const status = byId('action-status'); + status.hidden = false; + status.className = 'action-status working'; + status.textContent = btn.textContent.trim() + '…'; + }); + }); +} + +function renderSteps(steps) { + const stepsEl = document.getElementById('steps'); + const doneCount = steps.filter((step) => step.status === 'done').length; + const current = steps.find((step) => step.status === 'current'); + const complete = steps.length > 0 && doneCount === steps.length; + byId('ready-summary').hidden = !complete; + byId('journey-details').open = !complete; + byId('journey-status').textContent = current + ? 'Shortest path · ' + doneCount + ' of ' + steps.length + ' complete' + : 'Setup path · ' + doneCount + ' of ' + steps.length + ' complete'; + stepsEl.innerHTML = steps.map((step, i) => { + const actions = []; + if (step.primaryAction) { + actions.push(actionButton('primary', step.primaryAction)); + } + if (step.secondaryAction) { + actions.push(actionButton('secondary', step.secondaryAction)); + } + const currentAttr = step.status === 'current' ? ' aria-current="step"' : ''; + const artifact = step.artifact + ? '' + escapeHtml(step.artifact) + '' + : ''; + return '
  1. ' + + '' + + '
    ' + + '' + escapeHtml(step.status) + ': ' + + '

    ' + escapeHtml(step.title) + '

    ' + + '

    ' + escapeHtml(step.detail) + artifact + '

    ' + + (actions.length ? '
    ' + actions.join('') + '
    ' : '') + + '
    ' + + '
  2. '; + }).join(''); + bindActions(stepsEl); +} + +function render(state) { + currentState = state; + document.getElementById('headline').textContent = state.headline; + document.getElementById('subhead').textContent = state.subhead; + const page = state.page || 'hub'; + ['hub', 'query', 'visualize'].forEach((name) => { + byId(name + '-page').hidden = name !== page; + }); + renderArtifacts(state.artifacts); + renderSteps(state.steps || []); +} + +window.addEventListener('message', (event) => { + const msg = event.data; + if (msg && msg.type === 'graphforge/getStarted') render(msg.state); + if (msg && msg.type === 'graphforge/commandStatus') { + const status = byId('action-status'); + status.hidden = false; + status.className = 'action-status ' + msg.status; + status.textContent = msg.status === 'error' ? 'Could not complete action: ' + msg.message : msg.message; + } +}); +vscode.postMessage({ type: 'graphforge/ready' }); diff --git a/webview-ui/src/resultGraph/main.ts b/webview-ui/src/resultGraph/main.ts index ecf198e..0cccc82 100644 --- a/webview-ui/src/resultGraph/main.ts +++ b/webview-ui/src/resultGraph/main.ts @@ -36,6 +36,8 @@ import type { ResultGraphLayoutWorkerResponse, } from "./layout.worker"; import InlineLayoutWorker from "./layout.worker?worker&inline"; +import { createVisualizationLoadingController } from "../shared/visualizationLoading"; +import "../shared/visualizationLoading.css"; import "./resultGraph.css"; interface RendererHandle { @@ -53,17 +55,26 @@ const rendererLabel = document.getElementById("renderer-label"); const statusLegend = document.getElementById("status-legend"); const typeLegend = document.getElementById("type-legend"); const banner = document.getElementById("banner"); -const footer = document.getElementById("footer"); +const styleSummaryElement = document.getElementById("style-summary"); const fitButton = document.getElementById("fit") as HTMLButtonElement | null; const relayoutButton = document.getElementById("relayout") as HTMLButtonElement | null; const saveArtifactButton = document.getElementById("save-artifact") as HTMLButtonElement | null; const revertArtifactButton = document.getElementById("revert-artifact") as HTMLButtonElement | null; +const renderStatusElement = document.getElementById("render-status"); +const renderSummaryElement = document.getElementById("render-summary"); let payload: GraphPayload | undefined; -let rendererKind: ResultGraphRenderer = "g6"; +let rendererKind: ResultGraphRenderer = "cytoscape"; let viewOptions: Omit = {}; let renderer: RendererHandle | undefined; let runtimeBanner: string | undefined; +let renderGeneration = 0; + +const loading = createVisualizationLoadingController( + renderStatusElement, + graphElement, + renderSummaryElement, +); function post(message: WebviewToHost): void { vscode.postMessage(message); @@ -112,6 +123,20 @@ function setEmpty(message: string | undefined): void { emptyElement.textContent = message ?? ""; } +function updateGraphLoading( + phase: "prepare" | "layout" | "paint" | "ready" | "failed", + options: { durationMs?: number; message?: string; failedAt?: "prepare" | "layout" | "paint" } = {}, +): void { + if (!payload) return; + loading.update({ + renderer: rendererKind, + phase, + nodeCount: payload.nodes.length, + edgeCount: payload.edges.length, + ...options, + }); +} + function showBanner(): void { if (!banner) { return; @@ -179,9 +204,7 @@ function renderChrome(current: GraphPayload): void { : current.styleMode === "demo" ? "demo styling" : "class-only styling"; - if (footer) { - footer.textContent = `${current.nodes.length} nodes · ${current.edges.length} edges · ${styleLabel}`; - } + if (styleSummaryElement) styleSummaryElement.textContent = styleLabel; } function initialPosition(index: number, count: number): { x: number; y: number } { @@ -883,6 +906,9 @@ function createG6(current: GraphPayload): RendererHandle { } activeLayout?.cancel(); const layoutStartedAt = performance.now(); + updateGraphLoading("layout"); + if (fitButton) fitButton.disabled = true; + if (relayoutButton) relayoutButton.disabled = true; post({ type: "graphforge/layoutStarted", renderer: "g6", @@ -926,6 +952,7 @@ function createG6(current: GraphPayload): RendererHandle { durationMs: performance.now() - layoutStartedAt, }); if (destroyed) return; + updateGraphLoading("paint"); g6.updateNodeData( result.positions.map((position) => ({ id: position.id, @@ -945,6 +972,10 @@ function createG6(current: GraphPayload): RendererHandle { } finally { renderInProgress = false; } + const durationMs = performance.now() - (hasRendered ? layoutStartedAt : renderStartedAt); + updateGraphLoading("ready", { durationMs }); + if (fitButton) fitButton.disabled = false; + if (relayoutButton) relayoutButton.disabled = false; if (!hasRendered) { hasRendered = true; post({ @@ -953,7 +984,7 @@ function createG6(current: GraphPayload): RendererHandle { backend: options.backend, nodeCount: current.nodes.length, edgeCount: renderedEdges.length, - durationMs: performance.now() - renderStartedAt, + durationMs, }); } }; @@ -1007,6 +1038,16 @@ function reportRenderFailure( message: failure.message, }); runtimeBanner = `${failedRenderer === "g6" ? "AntV G6" : failedRenderer === "sigma" ? "Sigma" : "Cytoscape"} failed during ${failure.phase} (${failure.code}): ${failure.message}`; + if (failure.phase !== "interaction") { + updateGraphLoading("failed", { + failedAt: failure.phase === "layout" + ? "layout" + : failure.phase === "render" + ? "paint" + : "prepare", + message: `${failure.code}: ${failure.message}`, + }); + } showBanner(); } @@ -1026,9 +1067,12 @@ function destroyRenderer(): void { } function render(): void { + const generation = ++renderGeneration; destroyRenderer(); runtimeBanner = undefined; if (!payload) { + loading.hide(); + if (renderSummaryElement) renderSummaryElement.textContent = "Waiting for graph data"; setEmpty("Waiting for graph data…"); if (fitButton) fitButton.disabled = true; if (relayoutButton) relayoutButton.disabled = true; @@ -1036,6 +1080,8 @@ function render(): void { } renderChrome(payload); if (payload.nodes.length === 0) { + loading.hide(); + if (renderSummaryElement) renderSummaryElement.textContent = "No graph nodes"; setEmpty("This result has no graph nodes."); if (fitButton) fitButton.disabled = true; if (relayoutButton) relayoutButton.disabled = true; @@ -1043,47 +1089,74 @@ function render(): void { } setEmpty(undefined); - if (fitButton) fitButton.disabled = false; - if (relayoutButton) relayoutButton.disabled = false; - try { - const renderStartedAt = performance.now(); - post({ - type: "graphforge/renderStarted", - renderer: rendererKind, - backend: viewOptions.backend, - layout: viewOptions.layout?.type, - nodeCount: payload.nodes.length, - edgeCount: payload.edges.length, - }); - const nextRenderer = - rendererKind === "g6" - ? createG6(payload) - : rendererKind === "sigma" - ? createSigma(payload) - : createCytoscape(payload); - bindRenderer(nextRenderer); - if (rendererKind !== "g6") { - requestAnimationFrame(() => { + if (fitButton) fitButton.disabled = true; + if (relayoutButton) relayoutButton.disabled = true; + updateGraphLoading("prepare"); + requestAnimationFrame(() => { + if (generation !== renderGeneration || !payload) return; + try { + const renderStartedAt = performance.now(); + post({ + type: "graphforge/renderStarted", + renderer: rendererKind, + backend: viewOptions.backend, + layout: viewOptions.layout?.type, + nodeCount: payload.nodes.length, + edgeCount: payload.edges.length, + }); + if (rendererKind !== "g6") updateGraphLoading("layout"); + const nextRenderer = + rendererKind === "g6" + ? createG6(payload) + : rendererKind === "sigma" + ? createSigma(payload) + : createCytoscape(payload); + bindRenderer(nextRenderer); + if (rendererKind !== "g6") { requestAnimationFrame(() => { - if (renderer === nextRenderer) { - post({ - type: "graphforge/renderReady", - renderer: rendererKind, - backend: viewOptions.backend, - nodeCount: payload?.nodes.length ?? 0, - edgeCount: payload?.edges.length ?? 0, - durationMs: performance.now() - renderStartedAt, - }); - } + if (renderer !== nextRenderer) return; + updateGraphLoading("paint"); + requestAnimationFrame(() => { + if (renderer === nextRenderer) { + const durationMs = performance.now() - renderStartedAt; + updateGraphLoading("ready", { durationMs }); + if (fitButton) fitButton.disabled = false; + if (relayoutButton) relayoutButton.disabled = false; + post({ + type: "graphforge/renderReady", + renderer: rendererKind, + backend: viewOptions.backend, + nodeCount: payload?.nodes.length ?? 0, + edgeCount: payload?.edges.length ?? 0, + durationMs, + }); + } + }); }); - }); + } + } catch (error) { + reportRenderFailure(rendererKind, error); + setEmpty("The selected graph renderer could not be started."); + if (fitButton) fitButton.disabled = true; + if (relayoutButton) relayoutButton.disabled = true; } - } catch (error) { - reportRenderFailure(rendererKind, error); - setEmpty("The selected graph renderer could not be started."); - if (fitButton) fitButton.disabled = true; - if (relayoutButton) relayoutButton.disabled = true; - } + }); +} + +let renderFrame: number | undefined; + +/** + * Renderer, options, and graph data are separate protocol messages so existing + * hosts remain compatible. Coalesce a burst into one render; rebuilding a dense + * Cytoscape graph for each message can otherwise run the same force layout + * several times before the first frame is painted. + */ +function scheduleRender(): void { + if (renderFrame !== undefined) return; + renderFrame = requestAnimationFrame(() => { + renderFrame = undefined; + render(); + }); } function runRendererAction( @@ -1105,15 +1178,48 @@ function runRendererAction( } } +function relayoutCurrentRenderer(): void { + if (!renderer || !payload) return; + if (rendererKind === "g6") { + renderer.relayout(); + return; + } + const activeRenderer = renderer; + const startedAt = performance.now(); + updateGraphLoading("layout"); + if (fitButton) fitButton.disabled = true; + if (relayoutButton) relayoutButton.disabled = true; + requestAnimationFrame(() => { + if (renderer !== activeRenderer) return; + try { + activeRenderer.relayout(); + updateGraphLoading("paint"); + requestAnimationFrame(() => { + if (renderer !== activeRenderer) return; + updateGraphLoading("ready", { durationMs: performance.now() - startedAt }); + if (fitButton) fitButton.disabled = false; + if (relayoutButton) relayoutButton.disabled = false; + }); + } catch (error) { + reportRenderFailure( + rendererKind, + new ResultGraphRuntimeError( + "layout", + "GF_RESULT_GRAPH_LAYOUT_FAILED", + error instanceof Error ? error.message : String(error), + ), + ); + } + }); +} + fitButton?.addEventListener("click", () => { runRendererAction("interaction", "GF_RESULT_GRAPH_FIT_FAILED", () => renderer?.fit(), ); }); relayoutButton?.addEventListener("click", () => { - runRendererAction("layout", "GF_RESULT_GRAPH_LAYOUT_FAILED", () => - renderer?.relayout(), - ); + relayoutCurrentRenderer(); }); saveArtifactButton?.addEventListener("click", () => { post({ type: "graphforge/saveGraphArtifactState" }); @@ -1129,12 +1235,12 @@ window.addEventListener("message", (event: MessageEvent) => { } if (message.type === "graphforge/graph") { payload = message.payload; - render(); + scheduleRender(); } else if (message.type === "graphforge/graphRenderer") { const changed = rendererKind !== message.renderer; rendererKind = message.renderer; - if (changed || !renderer) { - render(); + if (message.render !== false && (changed || !renderer)) { + scheduleRender(); } } else if (message.type === "graphforge/graphOptions") { viewOptions = { @@ -1145,8 +1251,8 @@ window.addEventListener("message", (event: MessageEvent) => { labels: message.labels, timebar: message.timebar, }; - if (renderer && payload) { - render(); + if (message.render !== false && renderer && payload) { + scheduleRender(); } } else if (message.type === "graphforge/graphArtifactState") { if (saveArtifactButton) { diff --git a/webview-ui/src/shared/visualizationLoading.css b/webview-ui/src/shared/visualizationLoading.css new file mode 100644 index 0000000..412efad --- /dev/null +++ b/webview-ui/src/shared/visualizationLoading.css @@ -0,0 +1,136 @@ +.render-status { + position: absolute; + z-index: 6; + inset: 0; + display: grid; + place-items: center; + padding: 24px; + background: + linear-gradient(135deg, color-mix(in srgb, var(--vscode-focusBorder, #4c6ef5) 7%, transparent), transparent 42%), + color-mix(in srgb, var(--vscode-editor-background) 97%, var(--vscode-panel-border, #444)); +} + +.render-status[hidden] { display: none; } + +.render-status-card { + width: min(460px, 100%); + padding: 18px 20px 16px; + border: 1px solid var(--vscode-panel-border, #444); + border-left: 3px solid var(--vscode-focusBorder, #4c6ef5); + border-radius: 3px; + background: var(--vscode-editorWidget-background, var(--vscode-editor-background)); + box-shadow: 0 10px 32px color-mix(in srgb, #000 24%, transparent); +} + +.render-status[data-phase="failed"] .render-status-card { + border-left-color: var(--vscode-errorForeground, #f14c4c); +} + +.render-status-renderer { + margin: 0 0 7px; + color: var(--vscode-descriptionForeground); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.render-status-title { + margin: 0; + font-size: 16px; + font-weight: 600; + line-height: 1.3; +} + +.render-status-detail { + margin: 5px 0 16px; + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +.render-status-steps { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.render-status-steps li { + position: relative; + display: grid; + grid-template-columns: 12px minmax(0, 1fr); + align-items: center; + gap: 6px; + min-width: 0; + color: var(--vscode-disabledForeground, var(--vscode-descriptionForeground)); + font-size: 11px; +} + +.render-status-steps li:not(:last-child)::after { + position: absolute; + z-index: 0; + top: 50%; + right: 5px; + left: 12px; + height: 1px; + background: var(--vscode-panel-border, #444); + content: ""; +} + +.render-status-steps li > span:last-child { + z-index: 1; + width: fit-content; + max-width: calc(100% - 4px); + padding-right: 5px; + overflow: hidden; + background: var(--vscode-editorWidget-background, var(--vscode-editor-background)); + text-overflow: ellipsis; + white-space: nowrap; +} + +.render-status-marker { + z-index: 1; + box-sizing: border-box; + width: 9px; + height: 9px; + border: 1px solid var(--vscode-panel-border, #666); + border-radius: 50%; + background: var(--vscode-editorWidget-background, var(--vscode-editor-background)); +} + +.render-status-steps li[data-state="complete"] { color: var(--vscode-foreground); } +.render-status-steps li[data-state="complete"] .render-status-marker { + border-color: var(--vscode-testing-iconPassed, #73c991); + background: var(--vscode-testing-iconPassed, #73c991); +} + +.render-status-steps li[data-state="current"] { color: var(--vscode-foreground); } +.render-status-steps li[data-state="current"] .render-status-marker { + border-color: var(--vscode-focusBorder, #4c6ef5); + background: var(--vscode-focusBorder, #4c6ef5); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--vscode-focusBorder, #4c6ef5) 22%, transparent); + animation: render-status-pulse 1.4s ease-in-out infinite; +} + +.render-status-steps li[data-state="failed"] { color: var(--vscode-errorForeground, #f14c4c); } +.render-status-steps li[data-state="failed"] .render-status-marker { + border-color: var(--vscode-errorForeground, #f14c4c); + background: var(--vscode-errorForeground, #f14c4c); +} + +@keyframes render-status-pulse { + 50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--vscode-focusBorder, #4c6ef5) 5%, transparent); } +} + +@media (prefers-reduced-motion: reduce) { + .render-status-steps li[data-state="current"] .render-status-marker { animation: none; } +} + +@media (max-width: 520px) { + .render-status { padding: 12px; } + .render-status-card { padding: 15px 14px 14px; } + .render-status-steps li { grid-template-columns: 10px minmax(0, 1fr); gap: 4px; font-size: 10px; } +} diff --git a/webview-ui/src/shared/visualizationLoading.ts b/webview-ui/src/shared/visualizationLoading.ts new file mode 100644 index 0000000..b9eff0d --- /dev/null +++ b/webview-ui/src/shared/visualizationLoading.ts @@ -0,0 +1,67 @@ +import { + visualizationLoadingState, + type VisualizationLoadingInput, +} from "../../../src/webview/visualizationLoadingModel"; + +export interface VisualizationLoadingController { + update(input: VisualizationLoadingInput): void; + hide(): void; +} + +export function createVisualizationLoadingController( + root: HTMLElement | null, + busyTarget: HTMLElement | null, + summaryTarget?: HTMLElement | null, +): VisualizationLoadingController { + let hideTimer: number | undefined; + const renderer = root?.querySelector("[data-render-status-renderer]"); + const title = root?.querySelector("[data-render-status-title]"); + const detail = root?.querySelector("[data-render-status-detail]"); + const steps = root?.querySelector("[data-render-status-steps]"); + + const hide = (): void => { + if (hideTimer !== undefined) window.clearTimeout(hideTimer); + hideTimer = undefined; + if (root) root.hidden = true; + busyTarget?.setAttribute("aria-busy", "false"); + }; + + return { + update: (input) => { + if (hideTimer !== undefined) window.clearTimeout(hideTimer); + hideTimer = undefined; + const state = visualizationLoadingState(input); + if (root) { + root.hidden = false; + root.dataset.phase = input.phase; + } + busyTarget?.setAttribute( + "aria-busy", + input.phase === "ready" || input.phase === "failed" ? "false" : "true", + ); + if (renderer) renderer.textContent = `Render pipeline · ${state.rendererName}`; + if (title) title.textContent = state.title; + if (detail) detail.textContent = state.detail; + if (steps) { + steps.replaceChildren(...state.steps.map((step) => { + const item = document.createElement("li"); + item.dataset.state = step.state; + const marker = document.createElement("span"); + marker.className = "render-status-marker"; + marker.setAttribute("aria-hidden", "true"); + const label = document.createElement("span"); + label.textContent = step.label; + item.append(marker, label); + return item; + })); + } + if (summaryTarget) { + summaryTarget.textContent = `${state.rendererName} · ${state.title} · ${state.detail}`; + } + if (input.phase === "ready") { + hideTimer = window.setTimeout(hide, 650); + } + }, + hide, + }; +} diff --git a/webview-ui/vite.config.mts b/webview-ui/vite.config.mts index c070cbf..0d0ef4a 100644 --- a/webview-ui/vite.config.mts +++ b/webview-ui/vite.config.mts @@ -23,6 +23,7 @@ export default defineConfig({ sourcemap: false, rollupOptions: { input: { + getStarted: resolve(here, "src/getStarted/main.js"), settings: resolve(here, "src/settings/main.ts"), figure: resolve(here, "src/figure/main.ts"), resultGraph: resolve(here, "src/resultGraph/main.ts"),