diff --git a/.gitignore b/.gitignore index 161357f41..819f78389 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ dist node_modules /*.vsix .claude -.tasks +.plans .vscode-test/ .DS_Store e2e-testing/.resources diff --git a/.vscode/settings.json b/.vscode/settings.json index cebd853da..6b687d9c3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,10 @@ "out": true // set this to false to include "out" folder in search results }, // Turn off tsc task auto detection since we have the necessary tasks as npm scripts - "typescript.tsc.autoDetect": "off", - "typescript.tsdk": "node_modules/typescript/lib" + "js/ts.tsc.autoDetect": "off", + // Unit tests are excluded from the primary tsconfig.json used by VS Code + // editor. Setting the implicit project target to match the tsconfig.json so + // that the editor shows proper language features for the test files. + "js/ts.implicitProjectConfig.target": "ES2023", + "js/ts.tsdk.path": "node_modules/typescript/lib" } diff --git a/__mocks__/vscode.ts b/__mocks__/vscode.ts index a06c62a0c..313bd61db 100644 --- a/__mocks__/vscode.ts +++ b/__mocks__/vscode.ts @@ -317,6 +317,7 @@ export const window = { .fn() .mockName('registerFileDecorationProvider'), showInputBox: vi.fn().mockName('showInputBox'), + showQuickPick: vi.fn().mockName('showQuickPick'), showTextDocument: vi.fn().mockName('showTextDocument'), showInformationMessage: vi.fn().mockName('showInformationMessage'), showErrorMessage: vi.fn().mockName('showErrorMessage'), diff --git a/docs/manual-smoke-coreplus-manager.md b/docs/manual-smoke-coreplus-manager.md new file mode 100644 index 000000000..1346cfa51 --- /dev/null +++ b/docs/manual-smoke-coreplus-manager.md @@ -0,0 +1,51 @@ +# Manual Smoke — Shared CorePlusManager Substrate + +These checks verify the `EnterpriseCorePlusManager` substrate added to +`DheService` (Phase 1) and the `QueryConfigTableService` (Phase 2). They require +a live Core+ (Deephaven Enterprise) server with the always-on `WebClientData` +system query running, and **cannot** be run in the devcontainer (the extension +UI does not run there). Run them from a full VS Code instance connected to a +real server. + +The automated gates (`npm run build:ts`, `npm run test:unit`, +`npm run test:lint`) are the developer bar; the items below are the +requester-run verification. + +## (a) Attach-to-workers flow is unchanged (regression) + +Worker-attach discovery still runs on the DHE config-event stream and is **not** +routed through the manager or the `QueryInfo` table (Decision 3). Re-run the +existing attach behavior with the manager now present in-tree and confirm each +case is unchanged: + +- [ ] **Create-on-empty** — connect to a DHE server with no running IC workers; + exactly one worker is created and attached. +- [ ] **Auto-attach-on-external-create** — with the server connected, create a + running IC worker from another client (e.g. the web UI). It is auto-attached + in the WORKERS tree within a few seconds. +- [ ] **No-delete-on-detach** — detach an *attached* (not extension-created) + worker; the server-side PQ keeps running. +- [ ] **Delete-owned-on-disconnect** — disconnect the DHE server; workers the + extension created are deleted, attached workers are left running. + +## (b) Manager is disposed on server disconnect + +- [ ] Disconnect the DHE server (or log out). Confirm the `CorePlusManager` is + disposed: no residual Core+ worker connections remain open, and no + post-dispose config-event handling / log spam occurs from the manager. +- [ ] Reconnect to the same server. A **new** manager is built from the fresh + client on next use (e.g. opening the PQ table), and only **one** + authenticated DHE session exists per server (verify via server-side session + count / logs — no second login is triggered by manager creation). + +## (c) QueryInfo table service returns and ticks + +Using a scratch consumer of `QueryConfigTableService.getQueryInfoTable(...)` +(or the PQ explorer once built): + +- [ ] The service returns a filtered, ticking `QueryInfo` table subscription. +- [ ] A PQ status change made from another client (e.g. stopping/starting a + query) is reflected in the subscription's `onDidUpdate` snapshots within + ~5 seconds. +- [ ] If `WebClientData` is stopped / not visible to the user, the service + throws a clear `WebClientDataUnavailableError` instead of hanging (Gotcha 7). diff --git a/docs/manual-smoke-pq-explorer.md b/docs/manual-smoke-pq-explorer.md new file mode 100644 index 000000000..52ade409e --- /dev/null +++ b/docs/manual-smoke-pq-explorer.md @@ -0,0 +1,59 @@ +# Manual Smoke — Persistent Queries Explorer + +These checks verify the **Persistent Queries** tree view (browse ACL-visible +non-InteractiveConsole PQs and open their objects read-only). They require a live +Core+ (Deephaven Enterprise) server with the always-on `WebClientData` system +query running, and **cannot** be run in the devcontainer (the extension UI does +not run there). Run them from a full VS Code instance connected to a real server. + +The automated gates (`npm run build:ts`, `npm run test:unit`, +`npm run test:lint`) are the developer bar; the items below are the +requester-run verification. + +## Headless plumbing check (optional, no UI) + +Proves the table filter + non-console object enumeration without the extension: + +``` +DHE_SERVER_URL=https://my-dhe:8123 \ +DHE_USERNAME=me DHE_PASSWORD=secret \ +node scripts/validate-pq-explorer.mjs +``` + +- [ ] Prints the filtered running non-InteractiveConsole PQ rows. +- [ ] Prints the exported objects of one running non-IC PQ. + (`NODE_EXTRA_CA_CERTS` may be needed for self-signed / internal-CA servers.) + +## UI smoke + +### List + +- [ ] Connect to a DHE server. The **Persistent Queries** view (in the Deephaven + activity-bar container, below **Panels**) shows the DHE server node. +- [ ] Expanding the server node lists **running non-InteractiveConsole** PQs + (owner shown as the node description). InteractiveConsole workers do **not** + appear here — they remain in the **Workers** tree. +- [ ] The list reflects server-side changes: start/stop a PQ from another client + (e.g. the web UI); the tree updates within a few seconds (table tick). Use the + title-bar refresh action to force a refresh. + +### Open objects + +- [ ] Expand a running PQ node. Its exported objects (tables, figures, widgets) + are listed as leaves with the correct type icons. +- [ ] Click a **Table** object. It opens read-only in a `dhPanel` webview and + renders data. +- [ ] Click a **Figure** object. It opens read-only in a `dhPanel` webview and + renders. +- [ ] No console session is created for the PQ: it does **not** appear in the + **Workers** tree, the server's worker/connection count is unchanged, and no + "Created Deephaven session" toast appears. + +### Ownership / lifecycle invariants + +- [ ] Close the object panels and collapse the PQ node — the server-side PQ keeps + running (it is never deleted by this view). +- [ ] Disconnect the DHE server — the Persistent Queries view empties for that + server and no residual browse connections leak; the PQ (owned by whoever + created it) is left running server-side. +- [ ] Reconnect — the PQ list repopulates and objects can be opened again. diff --git a/docs/panels.md b/docs/panels.md index b805cbf03..488310256 100644 --- a/docs/panels.md +++ b/docs/panels.md @@ -10,16 +10,18 @@ If the `deephaven-server` pip package is available in your local workspace, the ![Servers Panel](./assets/servers-panel.png) -## Connections +## Interactive Consoles -The `CONNECTIONS` panel shows all active connections + editors currently associated with them. Hovering over nodes will show additional contextual action icons. +The `INTERACTIVE CONSOLES` panel shows all active connections grouped under their server. Each worker node lists the editors currently associated with it, followed by the exported variables available on its session. Clicking a variable will open or refresh the respective output panel. Hovering over nodes will show additional contextual action icons. + +Worker names end in a generated id, so nodes show a shortened form of the name — hover a worker node to see its full name. ![Connections Panel](./assets/connections-panel.png) Editors can be dragged from one active connection to another. -## Panels +## Persistent Queries -The `PANELS` panel shows exported variables available on an active connection. Clicking a variable will open or refresh the respective output panel. +The `PERSISTENT QUERIES` panel shows the persistent queries visible to you on each connected enterprise server, listed directly beneath the server that owns them. Each query's icon shows its status: a filled circle for a running query, a slashed circle for a stopped or failed one, a hollow circle when the server reports no status, and a spinner while it is still in motion — starting up, or stopping. Expanding a running query lists the objects it exports; clicking one opens it read-only in a panel. -![Panels Panel](./assets/panels-panel.png) +The funnel in the panel title chooses which statuses are listed. It opens a menu with a checkable `Running` and `Stopped` entry: `Running` covers queries that are running or still in motion (including ones that are `Stopping`), and `Stopped` covers those that have finished, plus queries reporting no status at all. By default `Running` is checked and `Stopped` is not. `Filter by Status...` in the same menu opens a per-status checkbox list, with the number of queries currently in each status, for finer control. The funnel is filled while a filter is hiding something, and each affected server node gets a trailing `More (20,007)` entry after its queries; clicking that entry reopens the filter. diff --git a/e2e-testing/src/specs/statusBar.spec.ts b/e2e-testing/src/specs/statusBar.spec.ts index 8359892b1..89fafa794 100644 --- a/e2e-testing/src/specs/statusBar.spec.ts +++ b/e2e-testing/src/specs/statusBar.spec.ts @@ -90,7 +90,7 @@ describe('Status Bar Tests', () => { await step(4, 'Verify connection node', async stepLabel => { const simpleTickingEditor = await getSidebarViewItem( - VIEW_NAME.connections, + VIEW_NAME.workers, SIMPLE_TICKING3_PY.name ); diff --git a/e2e-testing/src/util/constants.ts b/e2e-testing/src/util/constants.ts index 53af58fd4..c305b901d 100644 --- a/e2e-testing/src/util/constants.ts +++ b/e2e-testing/src/util/constants.ts @@ -15,6 +15,5 @@ export const RETRY_SWITCH_IFRAME_ERRORS: ReadonlySet = new Set([ export const VIEW_NAME = { servers: 'Servers', - connections: 'Connections', - panels: 'Panels', + workers: 'Workers', } as const; diff --git a/package-lock.json b/package-lock.json index bc4679aea..13a433c31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,12 @@ "packages/*" ], "dependencies": { - "@deephaven-enterprise/auth-nodejs": "^1.20250219.134-beta", - "@deephaven-enterprise/query-utils": "^1.20250219.134-beta", - "@deephaven/jsapi-nodejs": "^1.15.0", + "@deephaven-enterprise/auth-nodejs": "^2026.1.48", + "@deephaven-enterprise/client-utils": "^2026.1.48", + "@deephaven-enterprise/jsapi-manager": "^2026.1.48", + "@deephaven-enterprise/jsapi-nodejs": "^2026.1.48", + "@deephaven-enterprise/query-utils": "^2026.1.48", + "@deephaven/jsapi-nodejs": "^1.28.0", "@deephaven/jsapi-utils": "^1.16.0", "@modelcontextprotocol/sdk": "^1.27.1", "archiver": "^7.0.1", @@ -23,7 +26,7 @@ }, "devDependencies": { "@cfworker/json-schema": "^4.1.1", - "@deephaven-enterprise/jsapi-types": "^1.20250219.134-beta", + "@deephaven-enterprise/jsapi-types": "^2026.1.48", "@deephaven/jsapi-types": "^41.2.0", "@react-types/shared": "^3.33.1", "@types/archiver": "^6.0.3", @@ -556,51 +559,112 @@ } }, "node_modules/@deephaven-enterprise/auth-nodejs": { - "version": "1.20250219.134-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/auth-nodejs/-/auth-nodejs-1.20250219.134-beta.tgz", - "integrity": "sha512-INkEml6Al6dQckHhx2gKy53uk2QMb+1IcpL0mu5Z7H7kfRcH7o54KfB9j8DsSYmFZyjdRd1wmUzgfAf8xtxqQA==", + "version": "2026.1.54", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/auth-nodejs/-/auth-nodejs-2026.1.54.tgz", + "integrity": "sha512-loTrwf3mzVHnvbnu60/caCPq6GnVKoJF0M3rWGkLrRa/FEcQtpO/PnoSNr7t9GptFzR21R7K5wOwsSHVod3BDw==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "@deephaven-enterprise/jsapi-types": "file:../jsapi-types", + "@deephaven-enterprise/client-utils": "2026.1.54", + "@deephaven-enterprise/jsapi-types": "2026.1.54", "@deephaven/utils": "^0.97.0" } }, - "node_modules/@deephaven-enterprise/auth-nodejs/node_modules/@deephaven-enterprise/jsapi-types": { - "resolved": "node_modules/@deephaven-enterprise/jsapi-types", - "link": true + "node_modules/@deephaven-enterprise/client-utils": { + "version": "2026.1.54", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/client-utils/-/client-utils-2026.1.54.tgz", + "integrity": "sha512-q08Bb5xYmLpLvS7FE0PJ20ViMiduFV3QbQUabF+TeBrProabQXjBi+uciIXbs4eByJ1oTWYuDYayBAhZfPp1GA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@deephaven-enterprise/jsapi-types": "2026.1.54", + "@deephaven/jsapi-types": "1.0.0-dev0.40.5", + "@deephaven/log": "^0.97.0", + "@deephaven/utils": "^0.97.0" + } + }, + "node_modules/@deephaven-enterprise/client-utils/node_modules/@deephaven/jsapi-types": { + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==", + "license": "Apache-2.0" + }, + "node_modules/@deephaven-enterprise/jsapi-manager": { + "version": "2026.1.54", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-manager/-/jsapi-manager-2026.1.54.tgz", + "integrity": "sha512-44pZLOSRs18syh6GonJsbuJ6vjQg6ZYdTl4RJxSse62G1/PZfF7N/AloNVCkuL//bUnXHQEeBjAcBJBWbjc1AQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@deephaven-enterprise/client-utils": "2026.1.54", + "@deephaven-enterprise/jsapi-types": "2026.1.54", + "@deephaven-enterprise/query-utils": "2026.1.54", + "@deephaven/jsapi-types": "1.0.0-dev0.40.5", + "@deephaven/log": "^0.97.0", + "@deephaven/utils": "^0.97.0", + "@internationalized/date": "^3.12.2" + } + }, + "node_modules/@deephaven-enterprise/jsapi-manager/node_modules/@deephaven/jsapi-types": { + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==", + "license": "Apache-2.0" + }, + "node_modules/@deephaven-enterprise/jsapi-nodejs": { + "version": "2026.1.54", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-nodejs/-/jsapi-nodejs-2026.1.54.tgz", + "integrity": "sha512-4P1y0Kc3oX/1UNkwGpvT11u8lxfyiMDEIYDhqHwKYJwgWzIhmI2VuISzBEX0Vh2otb3VB0U3aBADPozWpX736g==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@deephaven-enterprise/auth-nodejs": "2026.1.54", + "@deephaven-enterprise/client-utils": "2026.1.54", + "@deephaven-enterprise/jsapi-manager": "2026.1.54", + "@deephaven-enterprise/jsapi-types": "2026.1.54", + "@deephaven-enterprise/query-utils": "2026.1.54", + "@deephaven/jsapi-nodejs": "^1.15.0", + "@deephaven/jsapi-types": "1.0.0-dev0.40.5", + "@deephaven/utils": "^0.97.0" + } + }, + "node_modules/@deephaven-enterprise/jsapi-nodejs/node_modules/@deephaven/jsapi-types": { + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==", + "license": "Apache-2.0" }, "node_modules/@deephaven-enterprise/jsapi-types": { - "version": "1.20250219.134-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-1.20250219.134-beta.tgz", - "integrity": "sha512-PRb6Z2J4msFDgooPTcGinml4xcoTc/YYkxK5PVOYV8ax71JXeicsGl4ndIcmgDZwdaciJhvVwhtuY5lMJkmTMg==", + "version": "2026.1.54", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-2026.1.54.tgz", + "integrity": "sha512-BAIzBTHa0xuz29y3n3/ZPfLhDvsRQ+OeDLFYaLOV2DD6B2MtxzxpWvdSHN6BqM+6ni/FFnEy/OQNlIWiTq0/HA==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "@deephaven/jsapi-types": "^1.0.0-dev0.36.1" + "@deephaven/jsapi-types": "1.0.0-dev0.40.5" } }, "node_modules/@deephaven-enterprise/jsapi-types/node_modules/@deephaven/jsapi-types": { - "version": "1.0.0-dev0.40.9", - "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.9.tgz", - "integrity": "sha512-NwMxFmNCnRV4/2+MdN/8vUGiEtXFgL1K/+iXTKKvi+Brje5JHOSCn2miCKR9tAn0LNb/UdmJq+DSIZqvz8cU/Q==", + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==", "license": "Apache-2.0" }, "node_modules/@deephaven-enterprise/query-utils": { - "version": "1.20250219.134-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-1.20250219.134-beta.tgz", - "integrity": "sha512-zkrzFGRgWScDvMA1/tLBE/LtwFDKEoZYSiOdxtgih2+xzP6mgn+VnjWevtcBxPTDJCf6+n7+R+YfGW7TqJtXcw==", + "version": "2026.1.54", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-2026.1.54.tgz", + "integrity": "sha512-FjmoE+W+mOoEjensCD2ujKIfvXcLAC43oAeze5FqUjbwvPD6DLBcVdgqkYbdX2OclLS7bzI+QFX/8GMex3Xj3A==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "@deephaven/jsapi-types": "^1.0.0-dev0.36.1", + "@deephaven-enterprise/client-utils": "2026.1.54", + "@deephaven-enterprise/jsapi-types": "2026.1.54", + "@deephaven/jsapi-types": "1.0.0-dev0.40.5", "@deephaven/log": "^0.97.0", "@deephaven/utils": "^0.97.0", - "@internationalized/date": "^3.5.5", - "nanoid": "^5.1.6" + "@internationalized/date": "^3.12.2", + "nanoid": "^5.1.6", + "shell-quote": "^1.8.4" } }, "node_modules/@deephaven-enterprise/query-utils/node_modules/@deephaven/jsapi-types": { - "version": "1.0.0-dev0.40.9", - "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.9.tgz", - "integrity": "sha512-NwMxFmNCnRV4/2+MdN/8vUGiEtXFgL1K/+iXTKKvi+Brje5JHOSCn2miCKR9tAn0LNb/UdmJq+DSIZqvz8cU/Q==", + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==", "license": "Apache-2.0" }, "node_modules/@deephaven/filters": { @@ -613,9 +677,9 @@ } }, "node_modules/@deephaven/jsapi-nodejs": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@deephaven/jsapi-nodejs/-/jsapi-nodejs-1.15.0.tgz", - "integrity": "sha512-41er+fe0hvhaVjuz8/FGctMee0XZUXbnBCjidoveA5o5LeEfYE5yLTxDd3kRqIlwu6g26VKn4xq2W8HYnK4n3w==", + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-nodejs/-/jsapi-nodejs-1.28.0.tgz", + "integrity": "sha512-RQ12ClUSOVlsI4KvlaGpKXVZ+W0Kl8/eCwdZcmUtGE4Xw+jEvC/AhlHlpD/AbXS7878YeYXJADFrh7rIi9Z1mg==", "license": "Apache-2.0", "dependencies": { "@deephaven/jsapi-types": "^1.0.0-dev0.40.4", @@ -1364,9 +1428,10 @@ "license": "BSD-3-Clause" }, "node_modules/@internationalized/date": { - "version": "3.5.5", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.5.5.tgz", - "integrity": "sha512-H+CfYvOZ0LTJeeLOqm19E3uj/4YjrmOFtBufDHPfvtI80hFAMqtrp7oCACpe4Cil5l8S0Qu/9dYfZc/5lY8WQQ==", + "version": "3.12.3", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.3.tgz", + "integrity": "sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==", + "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0" } @@ -2297,11 +2362,12 @@ "license": "MIT" }, "node_modules/@swc/helpers": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz", - "integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", "dependencies": { - "tslib": "^2.4.0" + "tslib": "^2.8.0" } }, "node_modules/@textlint/ast-node-types": { @@ -11726,10 +11792,13 @@ } }, "node_modules/shell-quote": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", - "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", - "dev": true, + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -12978,9 +13047,10 @@ } }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/ttf2eot": { "version": "3.1.0", @@ -15000,60 +15070,110 @@ "dev": true }, "@deephaven-enterprise/auth-nodejs": { - "version": "1.20250219.134-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/auth-nodejs/-/auth-nodejs-1.20250219.134-beta.tgz", - "integrity": "sha512-INkEml6Al6dQckHhx2gKy53uk2QMb+1IcpL0mu5Z7H7kfRcH7o54KfB9j8DsSYmFZyjdRd1wmUzgfAf8xtxqQA==", + "version": "2026.1.54", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/auth-nodejs/-/auth-nodejs-2026.1.54.tgz", + "integrity": "sha512-loTrwf3mzVHnvbnu60/caCPq6GnVKoJF0M3rWGkLrRa/FEcQtpO/PnoSNr7t9GptFzR21R7K5wOwsSHVod3BDw==", "requires": { - "@deephaven-enterprise/jsapi-types": "file:../jsapi-types", + "@deephaven-enterprise/client-utils": "2026.1.54", + "@deephaven-enterprise/jsapi-types": "2026.1.54", + "@deephaven/utils": "^0.97.0" + } + }, + "@deephaven-enterprise/client-utils": { + "version": "2026.1.54", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/client-utils/-/client-utils-2026.1.54.tgz", + "integrity": "sha512-q08Bb5xYmLpLvS7FE0PJ20ViMiduFV3QbQUabF+TeBrProabQXjBi+uciIXbs4eByJ1oTWYuDYayBAhZfPp1GA==", + "requires": { + "@deephaven-enterprise/jsapi-types": "2026.1.54", + "@deephaven/jsapi-types": "1.0.0-dev0.40.5", + "@deephaven/log": "^0.97.0", "@deephaven/utils": "^0.97.0" }, "dependencies": { - "@deephaven-enterprise/jsapi-types": { - "version": "file:node_modules/@deephaven-enterprise/jsapi-types", - "requires": { - "@deephaven/jsapi-types": "^1.0.0-dev0.36.1" - }, - "dependencies": { - "@deephaven/jsapi-types": { - "version": "1.0.0-dev0.40.9", - "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.9.tgz", - "integrity": "sha512-NwMxFmNCnRV4/2+MdN/8vUGiEtXFgL1K/+iXTKKvi+Brje5JHOSCn2miCKR9tAn0LNb/UdmJq+DSIZqvz8cU/Q==" - } - } + "@deephaven/jsapi-types": { + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==" + } + } + }, + "@deephaven-enterprise/jsapi-manager": { + "version": "2026.1.54", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-manager/-/jsapi-manager-2026.1.54.tgz", + "integrity": "sha512-44pZLOSRs18syh6GonJsbuJ6vjQg6ZYdTl4RJxSse62G1/PZfF7N/AloNVCkuL//bUnXHQEeBjAcBJBWbjc1AQ==", + "requires": { + "@deephaven-enterprise/client-utils": "2026.1.54", + "@deephaven-enterprise/jsapi-types": "2026.1.54", + "@deephaven-enterprise/query-utils": "2026.1.54", + "@deephaven/jsapi-types": "1.0.0-dev0.40.5", + "@deephaven/log": "^0.97.0", + "@deephaven/utils": "^0.97.0", + "@internationalized/date": "^3.12.2" + }, + "dependencies": { + "@deephaven/jsapi-types": { + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==" + } + } + }, + "@deephaven-enterprise/jsapi-nodejs": { + "version": "2026.1.54", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-nodejs/-/jsapi-nodejs-2026.1.54.tgz", + "integrity": "sha512-4P1y0Kc3oX/1UNkwGpvT11u8lxfyiMDEIYDhqHwKYJwgWzIhmI2VuISzBEX0Vh2otb3VB0U3aBADPozWpX736g==", + "requires": { + "@deephaven-enterprise/auth-nodejs": "2026.1.54", + "@deephaven-enterprise/client-utils": "2026.1.54", + "@deephaven-enterprise/jsapi-manager": "2026.1.54", + "@deephaven-enterprise/jsapi-types": "2026.1.54", + "@deephaven-enterprise/query-utils": "2026.1.54", + "@deephaven/jsapi-nodejs": "^1.15.0", + "@deephaven/jsapi-types": "1.0.0-dev0.40.5", + "@deephaven/utils": "^0.97.0" + }, + "dependencies": { + "@deephaven/jsapi-types": { + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==" } } }, "@deephaven-enterprise/jsapi-types": { - "version": "1.20250219.134-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-1.20250219.134-beta.tgz", - "integrity": "sha512-PRb6Z2J4msFDgooPTcGinml4xcoTc/YYkxK5PVOYV8ax71JXeicsGl4ndIcmgDZwdaciJhvVwhtuY5lMJkmTMg==", + "version": "2026.1.54", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-2026.1.54.tgz", + "integrity": "sha512-BAIzBTHa0xuz29y3n3/ZPfLhDvsRQ+OeDLFYaLOV2DD6B2MtxzxpWvdSHN6BqM+6ni/FFnEy/OQNlIWiTq0/HA==", "requires": { - "@deephaven/jsapi-types": "^1.0.0-dev0.36.1" + "@deephaven/jsapi-types": "1.0.0-dev0.40.5" }, "dependencies": { "@deephaven/jsapi-types": { - "version": "1.0.0-dev0.40.9", - "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.9.tgz", - "integrity": "sha512-NwMxFmNCnRV4/2+MdN/8vUGiEtXFgL1K/+iXTKKvi+Brje5JHOSCn2miCKR9tAn0LNb/UdmJq+DSIZqvz8cU/Q==" + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==" } } }, "@deephaven-enterprise/query-utils": { - "version": "1.20250219.134-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-1.20250219.134-beta.tgz", - "integrity": "sha512-zkrzFGRgWScDvMA1/tLBE/LtwFDKEoZYSiOdxtgih2+xzP6mgn+VnjWevtcBxPTDJCf6+n7+R+YfGW7TqJtXcw==", + "version": "2026.1.54", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-2026.1.54.tgz", + "integrity": "sha512-FjmoE+W+mOoEjensCD2ujKIfvXcLAC43oAeze5FqUjbwvPD6DLBcVdgqkYbdX2OclLS7bzI+QFX/8GMex3Xj3A==", "requires": { - "@deephaven/jsapi-types": "^1.0.0-dev0.36.1", + "@deephaven-enterprise/client-utils": "2026.1.54", + "@deephaven-enterprise/jsapi-types": "2026.1.54", + "@deephaven/jsapi-types": "1.0.0-dev0.40.5", "@deephaven/log": "^0.97.0", "@deephaven/utils": "^0.97.0", - "@internationalized/date": "^3.5.5", - "nanoid": "^5.1.6" + "@internationalized/date": "^3.12.2", + "nanoid": "^5.1.6", + "shell-quote": "^1.8.4" }, "dependencies": { "@deephaven/jsapi-types": { - "version": "1.0.0-dev0.40.9", - "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.9.tgz", - "integrity": "sha512-NwMxFmNCnRV4/2+MdN/8vUGiEtXFgL1K/+iXTKKvi+Brje5JHOSCn2miCKR9tAn0LNb/UdmJq+DSIZqvz8cU/Q==" + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==" } } }, @@ -15063,9 +15183,9 @@ "integrity": "sha512-SMWKYT8aFtZ/CyVUtUiF1/RPQGl4Y6dvNG43KvmIMKKMXYqhliKD291aynYSl7C8IafkHNzdDtdAZPgPXLoOWA==" }, "@deephaven/jsapi-nodejs": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@deephaven/jsapi-nodejs/-/jsapi-nodejs-1.15.0.tgz", - "integrity": "sha512-41er+fe0hvhaVjuz8/FGctMee0XZUXbnBCjidoveA5o5LeEfYE5yLTxDd3kRqIlwu6g26VKn4xq2W8HYnK4n3w==", + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-nodejs/-/jsapi-nodejs-1.28.0.tgz", + "integrity": "sha512-RQ12ClUSOVlsI4KvlaGpKXVZ+W0Kl8/eCwdZcmUtGE4Xw+jEvC/AhlHlpD/AbXS7878YeYXJADFrh7rIi9Z1mg==", "requires": { "@deephaven/jsapi-types": "^1.0.0-dev0.40.4", "@deephaven/log": "^1.8.0", @@ -15480,9 +15600,9 @@ "dev": true }, "@internationalized/date": { - "version": "3.5.5", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.5.5.tgz", - "integrity": "sha512-H+CfYvOZ0LTJeeLOqm19E3uj/4YjrmOFtBufDHPfvtI80hFAMqtrp7oCACpe4Cil5l8S0Qu/9dYfZc/5lY8WQQ==", + "version": "3.12.3", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.3.tgz", + "integrity": "sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==", "requires": { "@swc/helpers": "^0.5.0" } @@ -16080,11 +16200,11 @@ "dev": true }, "@swc/helpers": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz", - "integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", "requires": { - "tslib": "^2.4.0" + "tslib": "^2.8.0" } }, "@textlint/ast-node-types": { @@ -22668,10 +22788,9 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" }, "shell-quote": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", - "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", - "dev": true + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==" }, "side-channel": { "version": "1.1.0", @@ -23559,9 +23678,9 @@ } }, "tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "ttf2eot": { "version": "3.1.0", diff --git a/package.json b/package.json index 1e7374e9b..caf6e6340 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "docs:start": "./scripts/startDocs", "docs:format": "./scripts/formatDocs", "docs:validate": "./scripts/validateDocs", + "format": "prettier --write \"src/**/*.{ts,js}\"", "icon:gen:dh": "node icons/generate.mjs dh", "icon:gen:ext": "node icons/generate.mjs dh-ext icons/src", "package:dev": "./scripts/package-dev.sh", @@ -244,6 +245,11 @@ "title": "Connect to Server as Another User", "icon": "$(account)" }, + { + "command": "vscode-deephaven.createWorker", + "title": "Create Worker", + "icon": "$(add)" + }, { "command": "vscode-deephaven.createNewTextDoc", "title": "New File", @@ -259,6 +265,11 @@ "title": "Disconnect from Server", "icon": "$(debug-disconnect)" }, + { + "command": "vscode-deephaven.disconnectFromWorker", + "title": "Disconnect from Worker", + "icon": "$(debug-disconnect)" + }, { "command": "vscode-deephaven.generateDHEKeyPair", "title": "Generate DHE Key Pair", @@ -294,8 +305,8 @@ "icon": "$(refresh)" }, { - "command": "vscode-deephaven.refreshPanelsTree", - "title": "Refresh Panels Tree", + "command": "vscode-deephaven.refreshPersistentQueryTree", + "title": "Refresh Persistent Queries", "icon": "$(refresh)" }, { @@ -317,10 +328,30 @@ "icon": "$(search)" }, { - "command": "vscode-deephaven.searchPanels", - "title": "Search Panels", + "command": "vscode-deephaven.searchPersistentQueries", + "title": "Search Persistent Queries", "icon": "$(search)" }, + { + "command": "vscode-deephaven.hideRunningQueries", + "title": "✓ Running" + }, + { + "command": "vscode-deephaven.showRunningQueries", + "title": "Running" + }, + { + "command": "vscode-deephaven.hideStoppedQueries", + "title": "✓ Stopped" + }, + { + "command": "vscode-deephaven.showStoppedQueries", + "title": "Stopped" + }, + { + "command": "vscode-deephaven.filterPersistentQueries", + "title": "Filter by Status..." + }, { "command": "vscode-deephaven.startServer", "title": "Deephaven: Start Server", @@ -788,6 +819,18 @@ "label": "Deephaven VS Code MCP Server" } ], + "submenus": [ + { + "id": "vscode-deephaven.persistentQueryFilterMenu", + "label": "Filter Persistent Queries", + "icon": "$(filter)" + }, + { + "id": "vscode-deephaven.persistentQueryFilterMenuActive", + "label": "Filter Persistent Queries", + "icon": "$(filter-filled)" + } + ], "menus": { "commandPalette": [ { @@ -834,6 +877,10 @@ "command": "vscode-deephaven.connectToServerOperateAs", "when": "false" }, + { + "command": "vscode-deephaven.createWorker", + "when": "false" + }, { "command": "vscode-deephaven.disconnectEditor", "when": "false" @@ -842,6 +889,10 @@ "command": "vscode-deephaven.disconnectFromServer", "when": "false" }, + { + "command": "vscode-deephaven.disconnectFromWorker", + "when": "false" + }, { "command": "vscode-deephaven.generateDHEKeyPair", "when:": "false" @@ -862,10 +913,6 @@ "command": "vscode-deephaven.refreshServerConnectionTree", "when": "false" }, - { - "command": "vscode-deephaven.refreshPanelsTree", - "when": "false" - }, { "command": "vscode-deephaven.refreshRemoteImportSourceTree", "when": "false" @@ -879,7 +926,27 @@ "when": "false" }, { - "command": "vscode-deephaven.searchPanels", + "command": "vscode-deephaven.searchPersistentQueries", + "when": "false" + }, + { + "command": "vscode-deephaven.hideRunningQueries", + "when": "false" + }, + { + "command": "vscode-deephaven.showRunningQueries", + "when": "false" + }, + { + "command": "vscode-deephaven.hideStoppedQueries", + "when": "false" + }, + { + "command": "vscode-deephaven.showStoppedQueries", + "when": "false" + }, + { + "command": "vscode-deephaven.filterPersistentQueries", "when": "false" }, { @@ -964,33 +1031,43 @@ "view/title": [ { "command": "vscode-deephaven.refreshServerTree", - "group": "navigation", + "group": "navigation@99", "when": "view == vscode-deephaven.view.serverTree" }, { "command": "vscode-deephaven.refreshServerConnectionTree", - "group": "navigation", + "group": "navigation@99", "when": "view == vscode-deephaven.view.serverConnectionTree" }, { "command": "vscode-deephaven.refreshRemoteImportSourceTree", - "group": "navigation@1", + "group": "navigation@99", "when": "view == vscode-deephaven.view.remoteImportSourceTree" }, { - "command": "vscode-deephaven.refreshPanelsTree", - "group": "navigation", - "when": "view == vscode-deephaven.view.serverConnectionPanelTree" + "submenu": "vscode-deephaven.persistentQueryFilterMenu", + "group": "navigation@1", + "when": "view == vscode-deephaven.view.persistentQueryTree && !vscode-deephaven.view.persistentQueryTree.isFiltered" + }, + { + "submenu": "vscode-deephaven.persistentQueryFilterMenuActive", + "group": "navigation@1", + "when": "view == vscode-deephaven.view.persistentQueryTree && vscode-deephaven.view.persistentQueryTree.isFiltered" + }, + { + "command": "vscode-deephaven.refreshPersistentQueryTree", + "group": "navigation@99", + "when": "view == vscode-deephaven.view.persistentQueryTree" }, { "command": "vscode-deephaven.searchConnections", - "group": "navigation", + "group": "navigation@2", "when": "view == vscode-deephaven.view.serverConnectionTree" }, { - "command": "vscode-deephaven.searchPanels", - "group": "navigation", - "when": "view == vscode-deephaven.view.serverConnectionPanelTree" + "command": "vscode-deephaven.searchPersistentQueries", + "group": "navigation@2", + "when": "view == vscode-deephaven.view.persistentQueryTree" }, { "command": "vscode-deephaven.view.createQuery.close", @@ -998,6 +1075,58 @@ "when": "view == vscode-deephaven.view.createQuery" } ], + "vscode-deephaven.persistentQueryFilterMenu": [ + { + "command": "vscode-deephaven.hideRunningQueries", + "group": "1_sections@1", + "when": "vscode-deephaven.view.persistentQueryTree.runningVisible" + }, + { + "command": "vscode-deephaven.showRunningQueries", + "group": "1_sections@1", + "when": "!vscode-deephaven.view.persistentQueryTree.runningVisible" + }, + { + "command": "vscode-deephaven.hideStoppedQueries", + "group": "1_sections@2", + "when": "vscode-deephaven.view.persistentQueryTree.stoppedVisible" + }, + { + "command": "vscode-deephaven.showStoppedQueries", + "group": "1_sections@2", + "when": "!vscode-deephaven.view.persistentQueryTree.stoppedVisible" + }, + { + "command": "vscode-deephaven.filterPersistentQueries", + "group": "2_more@1" + } + ], + "vscode-deephaven.persistentQueryFilterMenuActive": [ + { + "command": "vscode-deephaven.hideRunningQueries", + "group": "1_sections@1", + "when": "vscode-deephaven.view.persistentQueryTree.runningVisible" + }, + { + "command": "vscode-deephaven.showRunningQueries", + "group": "1_sections@1", + "when": "!vscode-deephaven.view.persistentQueryTree.runningVisible" + }, + { + "command": "vscode-deephaven.hideStoppedQueries", + "group": "1_sections@2", + "when": "vscode-deephaven.view.persistentQueryTree.stoppedVisible" + }, + { + "command": "vscode-deephaven.showStoppedQueries", + "group": "1_sections@2", + "when": "!vscode-deephaven.view.persistentQueryTree.stoppedVisible" + }, + { + "command": "vscode-deephaven.filterPersistentQueries", + "group": "2_more@1" + } + ], "view/item/context": [ { "command": "vscode-deephaven.connectToServerOperateAs", @@ -1005,9 +1134,14 @@ }, { "command": "vscode-deephaven.createNewTextDoc", - "when": "view == vscode-deephaven.view.serverConnectionTree && viewItem == isConnectionConnected", + "when": "view == vscode-deephaven.view.serverConnectionTree && (viewItem == isConnectionConnected || viewItem == isConnectionConnectedRemovable)", "group": "inline@1" }, + { + "command": "vscode-deephaven.createWorker", + "when": "view == vscode-deephaven.view.serverConnectionTree && viewItem == isDHEServerConnectionParent", + "group": "inline" + }, { "command": "vscode-deephaven.disconnectEditor", "when": "view == vscode-deephaven.view.serverConnectionTree && viewItem == isUri", @@ -1015,7 +1149,12 @@ }, { "command": "vscode-deephaven.disconnectFromServer", - "when": "(view == vscode-deephaven.view.serverTree && (viewItem == isServerRunningConnected || viewItem == isDHEServerRunningConnected)) || (view == vscode-deephaven.view.serverConnectionTree && (viewItem == isConnectionConnected || viewItem == isConnectionConnecting))", + "when": "view == vscode-deephaven.view.serverTree && (viewItem == isServerRunningConnected || viewItem == isDHEServerRunningConnected)", + "group": "inline@2" + }, + { + "command": "vscode-deephaven.disconnectFromWorker", + "when": "view == vscode-deephaven.view.serverConnectionTree && (viewItem == isConnectionConnectedRemovable || viewItem == isConnectionConnectingRemovable)", "group": "inline@2" }, { @@ -1024,7 +1163,7 @@ }, { "command": "vscode-deephaven.generateRequirementsTxt", - "when": "view == vscode-deephaven.view.serverConnectionTree && viewItem == isConnectionConnected" + "when": "view == vscode-deephaven.view.serverConnectionTree && (viewItem == isConnectionConnected || viewItem == isConnectionConnectedRemovable)" }, { "command": "vscode-deephaven.openInBrowser", @@ -1043,7 +1182,7 @@ }, { "command": "vscode-deephaven.deleteVariable", - "when": "view == vscode-deephaven.view.serverConnectionPanelTree && viewItem == canDeleteDeephavenVariable", + "when": "view == vscode-deephaven.view.serverConnectionTree && viewItem == canDeleteDeephavenVariable", "group": "inline@2" }, { @@ -1096,13 +1235,13 @@ }, { "id": "vscode-deephaven.view.serverConnectionTree", - "name": "Connections", + "name": "Interactive Consoles", "type": "tree", "icon": "images/dh-logo-28.svg" }, { - "id": "vscode-deephaven.view.serverConnectionPanelTree", - "name": "Panels", + "id": "vscode-deephaven.view.persistentQueryTree", + "name": "Persistent Queries", "type": "tree", "icon": "images/dh-logo-28.svg" }, @@ -1125,9 +1264,12 @@ } }, "dependencies": { - "@deephaven-enterprise/auth-nodejs": "^1.20250219.134-beta", - "@deephaven-enterprise/query-utils": "^1.20250219.134-beta", - "@deephaven/jsapi-nodejs": "^1.15.0", + "@deephaven-enterprise/auth-nodejs": "^2026.1.48", + "@deephaven-enterprise/client-utils": "^2026.1.48", + "@deephaven-enterprise/jsapi-manager": "^2026.1.48", + "@deephaven-enterprise/jsapi-nodejs": "^2026.1.48", + "@deephaven-enterprise/query-utils": "^2026.1.48", + "@deephaven/jsapi-nodejs": "^1.28.0", "@deephaven/jsapi-utils": "^1.16.0", "@modelcontextprotocol/sdk": "^1.27.1", "archiver": "^7.0.1", @@ -1137,7 +1279,7 @@ }, "devDependencies": { "@cfworker/json-schema": "^4.1.1", - "@deephaven-enterprise/jsapi-types": "^1.20250219.134-beta", + "@deephaven-enterprise/jsapi-types": "^2026.1.48", "@deephaven/jsapi-types": "^41.2.0", "@react-types/shared": "^3.33.1", "@types/archiver": "^6.0.3", diff --git a/scripts/validate-pq-explorer.mjs b/scripts/validate-pq-explorer.mjs new file mode 100644 index 000000000..719d31320 --- /dev/null +++ b/scripts/validate-pq-explorer.mjs @@ -0,0 +1,235 @@ +// @ts-check +/** + * Headless validation for the Persistent Queries explorer plumbing. + * + * Proves — WITHOUT the extension UI — that against a live Core+ (Deephaven + * Enterprise) server we can: + * 1. build a `CorePlusManager`, + * 2. fetch the ticking `QueryInfo` table, + * 3. apply the PQ-explorer server-side filter (non-helper types, any status), + * 4. print the filtered PQ rows from a full table subscription, and confirm + * every row's serial resolves to a known config, + * 5. pick one running non-InteractiveConsole PQ and enumerate its exported + * objects (from `queryInfo.designated.objects`, the same source the tree + * provider uses — no console session / no `initSession`). + * + * This is DEFERRED to the requester: it cannot run in the devcontainer (no live + * server). Run it from a machine with network access to a Core+ server. + * + * Usage: + * DHE_SERVER_URL=https://my-dhe:8123 \ + * DHE_USERNAME=me DHE_PASSWORD=secret \ + * node scripts/validate-pq-explorer.mjs + * + * Requires the same `@deephaven-enterprise/*` packages the extension depends on + * (already installed). `NODE_EXTRA_CA_CERTS` may be needed for self-signed / + * internal-CA servers (see the jsapi-nodejs README). + */ + +import { initCorePlusManager } from '@deephaven-enterprise/jsapi-nodejs'; +import { createPasswordCredentials } from '@deephaven-enterprise/auth-nodejs'; +import { + makeFactoryServiceTablePromise, + QueryColumns, + QUERY_CONFIG_TABLE, + EXCLUDED_QUERY_TYPES, + WEB_CLIENT_DATA_CORE_QUERY, +} from '@deephaven-enterprise/query-utils'; + +const INTERACTIVE_CONSOLE_QUERY_TYPE = 'InteractiveConsole'; + +function requireEnv(name) { + const value = process.env[name]; + if (value == null || value === '') { + console.error(`Missing required env var: ${name}`); + process.exit(2); + } + return value; +} + +async function main() { + const serverUrl = new URL(requireEnv('DHE_SERVER_URL')); + const username = requireEnv('DHE_USERNAME'); + const password = requireEnv('DHE_PASSWORD'); + + console.log(`Connecting to ${serverUrl} as ${username}...`); + + const manager = await initCorePlusManager({ + serverUrl, + credentials: createPasswordCredentials(username, password), + }); + + try { + const dheClient = manager.dheClient; + const dhe = manager.dhe; + const userInfo = await dheClient.getUserInfo(); + + // Guard: WebClientData must be running + visible (Gotcha 7). + const hasWebClientData = dheClient + .getKnownConfigs() + .some( + qi => + qi.name === WEB_CLIENT_DATA_CORE_QUERY && + qi.designated?.status === 'Running' + ); + if (!hasWebClientData) { + console.error( + `'${WEB_CLIENT_DATA_CORE_QUERY}' is not running/visible — cannot load the QueryInfo table.` + ); + process.exit(1); + } + + console.log(`Fetching '${QUERY_CONFIG_TABLE}' table...`); + const webClientData = dheClient + .getKnownConfigs() + .find( + qi => + qi.name === WEB_CLIENT_DATA_CORE_QUERY && + qi.designated?.status === 'Running' + ); + + const table = await makeFactoryServiceTablePromise({ + client: dheClient, + corePlusManager: manager, + dh: dhe, + userInfo, + tableName: QUERY_CONFIG_TABLE, + }); + + // The table is created by the WebClientData worker's *community* API, so + // filter values must be built from that same API — a FilterValue from the + // enterprise `dhe` API throws java.lang.ClassCastException when the table + // casts it. `getApi` returns the cached instance the factory used above. + const coreApi = await manager.getApi( + webClientData.workerKind, + webClientData.designated.jsApiUrl + ); + + // Dump the actual runtime column types so we can see how Status/QueryType + // are really typed on the server (QueryColumns metadata is the new-table + // schema, not necessarily the runtime QueryInfo table type). + console.log('\nColumns (name: type):'); + for (const col of table.columns) { + console.log(` ${col.name}: ${col.type}`); + } + console.log(); + + // Server-side filter: exclude InteractiveConsole + other helper/system + // types. Deliberately NOT filtered by status — stopped/failed PQs list too, + // mirroring `PersistentQueryService`. Filter values come from `coreApi` (the + // table's own API), not `dhe`. + const excludeTypeFilter = table + .findColumn(QueryColumns.QUERY_TYPE.name) + .filter() + .in([...EXCLUDED_QUERY_TYPES].map(t => coreApi.FilterValue.ofString(t))) + .not(); + + console.log( + `Unfiltered '${QUERY_CONFIG_TABLE}' size (pre-settle): ${table.size}` + ); + + // Poll until the (unfiltered) size settles, so we can distinguish "table + // never populates" (server-side/ACL/factory problem) from "viewport never + // covered the rows" (client-side bug). + for (let i = 0; i < 20 && table.size === 0; i++) { + await new Promise(resolve => setTimeout(resolve, 250)); + } + console.log( + `Unfiltered '${QUERY_CONFIG_TABLE}' size (settled): ${table.size}` + ); + + table.applyFilter([excludeTypeFilter]); + + const serialColumn = table.findColumn(QueryColumns.SERIAL.name); + const nameColumn = table.findColumn(QueryColumns.NAME.name); + const statusColumn = table.findColumn(QueryColumns.STATUS.name); + const parentColumn = table.findColumn(QueryColumns.PARENT_ID.name); + + // Subscribe to the whole filtered table rather than tracking a viewport: + // `getViewportData()` only returns the snapshot that has arrived so far, so + // rows ticking in later were silently dropped. Mirrors + // `QueryConfigTableService`. + const tableSubscription = table.subscribe([ + serialColumn, + nameColumn, + statusColumn, + parentColumn, + ]); + + /** @type {{serial: string, name: string, status: string}[]} */ + let queries = []; + tableSubscription.addEventListener( + coreApi.Table.EVENT_UPDATED, + ({ detail }) => { + queries = detail.rows + .filter(row => { + const parent = row.get(parentColumn); + return parent == null || String(parent).length === 0; + }) + .map(row => ({ + serial: String(row.get(serialColumn)), + name: String(row.get(nameColumn)), + status: String(row.get(statusColumn)), + })); + } + ); + + // Give the subscription a moment to deliver its first tick. + for (let i = 0; i < 20 && queries.length === 0; i++) { + await new Promise(resolve => setTimeout(resolve, 250)); + } + console.log(`Filtered '${QUERY_CONFIG_TABLE}' size: ${table.size}`); + + console.log(`\nFiltered non-helper PQs, any status (${queries.length}):`); + const serials = []; + for (const { serial, name, status } of queries) { + serials.push(serial); + console.log(` - ${name} [${status}] [serial ${serial}]`); + } + + // Every table serial must resolve to a known config — an unresolved serial + // is a PQ that would silently vanish from the tree. + const knownSerials = new Set( + dheClient.getKnownConfigs().map(qi => String(qi.serial)) + ); + const unresolved = serials.filter(serial => !knownSerials.has(serial)); + console.log( + `\nTable serials with no known config (expected 0): ${unresolved.length}` + + (unresolved.length > 0 ? ` — ${unresolved.join(', ')}` : '') + ); + + // Resolve one PQ's full QueryInfo and enumerate its objects. + const pq = dheClient + .getKnownConfigs() + .find( + qi => + qi.serial != null && + serials.includes(String(qi.serial)) && + qi.type !== INTERACTIVE_CONSOLE_QUERY_TYPE && + qi.designated?.status === 'Running' + ); + + if (pq == null) { + console.log('\nNo running non-IC PQ available to enumerate objects for.'); + } else { + const objects = pq.designated?.objects ?? []; + console.log( + `\nObjects for PQ '${pq.name}' (worker ${pq.designated?.jsApiUrl}):` + ); + for (const obj of objects) { + console.log(` - ${obj.title} (${obj.type})`); + } + } + + tableSubscription.close(); + table.close(); + console.log('\nDone.'); + } finally { + await manager.dispose(); + } +} + +main().catch(err => { + console.error('Validation failed:', err); + process.exit(1); +}); diff --git a/src/common/commands.ts b/src/common/commands.ts index ee2042f83..cdf66a98c 100644 --- a/src/common/commands.ts +++ b/src/common/commands.ts @@ -26,6 +26,11 @@ export type ConnectToServerCmdArgs = [ operateAsAnotherUser?: boolean, ]; +/** Arguments passed to `CREATE_WORKER_CMD` handler */ +export type CreateWorkerCmdArgs = [ + serverState: Pick, +]; + /** Arguments passed to `OPEN_VARIABLE_PANELS_CMD` handler */ export type OpenVariablePanelsCmdArgs = [ serverUrl: URL, @@ -97,10 +102,32 @@ export const CREATE_DHE_AUTHENTICATED_CLIENT_CMD = cmd( 'createDHEAuthenticatedClient' ); export const CREATE_NEW_TEXT_DOC_CMD = cmd('createNewTextDoc'); +export const CREATE_WORKER_CMD = cmd('createWorker'); export const DELETE_VARIABLE_CMD = cmd('deleteVariable'); export const DISCONNECT_EDITOR_CMD = cmd('disconnectEditor'); export const DISCONNECT_FROM_SERVER_CMD = cmd('disconnectFromServer'); +export const DISCONNECT_FROM_WORKER_CMD = cmd('disconnectFromWorker'); export const DOWNLOAD_LOGS_CMD = cmd('downloadLogs'); +// The Persistent Queries status filter is one handler behind two command ids: +// VS Code takes a view-title action's icon from the command, so the only way to +// show a filled funnel while a filter is active is to contribute a second +// command and swap between them on a `when` clause. +export const FILTER_PERSISTENT_QUERIES_CMD = cmd('filterPersistentQueries'); +/** + * The four rows of the Persistent Queries filter menu: one pair per section, + * gated on complementary `when` clauses so exactly one of each pair shows. The + * "checked" variant carries a literal U+2713 in its title — extension menus + * cannot render a real checked state, and a `$(check)` codicon does not render + * in a menu at all. + */ +export const SHOW_RUNNING_QUERIES_CMD = cmd('showRunningQueries'); +export const HIDE_RUNNING_QUERIES_CMD = cmd('hideRunningQueries'); +export const SHOW_STOPPED_QUERIES_CMD = cmd('showStoppedQueries'); +export const HIDE_STOPPED_QUERIES_CMD = cmd('hideStoppedQueries'); + +export const FILTER_PERSISTENT_QUERIES_ACTIVE_CMD = cmd( + 'filterPersistentQueriesActive' +); export const GENERATE_DHE_KEY_PAIR_CMD = cmd('generateDHEKeyPair'); export const GENERATE_REQUIREMENTS_TXT_CMD = cmd('generateRequirementsTxt'); export const OPEN_IN_BROWSER_CMD = cmd('openInBrowser'); @@ -112,7 +139,9 @@ export const REFRESH_SERVER_TREE_CMD = cmd('refreshServerTree'); export const REFRESH_SERVER_CONNECTION_TREE_CMD = cmd( 'refreshServerConnectionTree' ); -export const REFRESH_PANELS_TREE_CMD = cmd('refreshPanelsTree'); +export const REFRESH_PERSISTENT_QUERY_TREE_CMD = cmd( + 'refreshPersistentQueryTree' +); export const REFRESH_VARIABLE_PANELS_CMD = cmd('refreshVariablePanels'); export const REMOVE_GROOVY_REMOTE_FILE_SOURCE_CMD = cmd( 'removeGroovyRemoteFileSource' @@ -125,7 +154,7 @@ export const RUN_CODE_COMMAND = cmd('runCode'); export const RUN_MARKDOWN_CODEBLOCK_CMD = cmd('runMarkdownCodeBlock'); export const RUN_SELECTION_COMMAND = cmd('runSelection'); export const SEARCH_CONNECTIONS_CMD = cmd('searchConnections'); -export const SEARCH_PANELS_CMD = cmd('searchPanels'); +export const SEARCH_PERSISTENT_QUERIES_CMD = cmd('searchPersistentQueries'); export const SELECT_CONNECTION_COMMAND = cmd('selectConnection'); export const SHOW_MCP_QUICK_PICK_CMD = cmd('showMcpQuickPick'); export const START_SERVER_CMD = cmd('startServer'); diff --git a/src/common/constants.ts b/src/common/constants.ts index 3506aa0ce..dc9641bab 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -47,6 +47,16 @@ export const MCP_DOCS_SERVER_NAME = 'Deephaven Documentation' as const; export const MCP_DOCS_SERVER_URL = 'https://deephaven-mcp-docs-prod.dhc-demo.deephaven.io/mcp' as const; +/** + * Minimum milliseconds between `QueryInfo` table update notifications. The table + * ticks on every row add/remove and status transition, and a server can hold + * tens of thousands of queries, so an unthrottled tick would refresh the whole + * Persistent Queries tree continuously — expensive, and it lands mid-interaction + * with the tree's find widget. The serial set behind `getQuerySerials` is still + * updated on every tick; only the notification is rate limited. + */ +export const QUERY_INFO_UPDATE_INTERVAL_MS = 250; + export const PIP_SERVER_STATUS_CHECK_INTERVAL = 3000; export const PIP_SERVER_STATUS_CHECK_TIMEOUT = 30000; @@ -88,6 +98,99 @@ export function isTerminalQueryStatus( ); } +/** + * The canonical hidden-set key for "no status". A PQ can report its status as + * `null`, `undefined`, or `''` (the JS API maps a null status to an empty + * string), so all three normalise to this single entry. + */ +export const UNSET_QUERY_STATUS = '' as const; + +/** + * The statuses the Persistent Queries view treats as *completely stopped* — the + * unset one (a stopped PQ can report no status at all) followed by the terminal + * statuses minus `Stopping`. A query that is `Stopping` is still in motion, so + * it belongs with the live ones where someone watching the view can see it wind + * down; everything here has finished moving. + * + * The order is the filter picker's row order for its "Stopped" section, with the + * unset row leading because "reported nothing at all" is the vaguest case and + * reads best before the specific ones. + * + * Deliberately derived from — but not equal to — {@link TERMINAL_QUERY_STATUSES}. + * That set drives worker-lifecycle teardown in `DheService` / `dhe.ts`, where + * `Stopping` must keep counting as terminal or a worker on its way out would + * never be detached. Widening the terminal set automatically widens this one. + */ +export const STOPPED_QUERY_STATUSES: readonly string[] = [ + UNSET_QUERY_STATUS, + ...[...TERMINAL_QUERY_STATUSES].filter(status => status !== 'Stopping'), +]; + +/** + * Statuses hidden by the Persistent Queries status filter on first run — exactly + * {@link STOPPED_QUERY_STATUSES}, so the view opens showing every query that is + * running or still in motion. The filter stores what to HIDE rather than what to + * show, so a status this extension has never heard of (a new one from a future + * DHE release) stays visible instead of silently disappearing. + */ +/** + * The statuses the Persistent Queries view treats as *live* — running, or still + * in motion on the way to or from it. The complement of + * {@link STOPPED_QUERY_STATUSES} over the vocabulary this extension knows, and + * the order the filter picker lists them in. + * + * Spelled out rather than derived from `QueryStatus` (the enterprise + * `query-utils` class) so `constants` stays free of that runtime import, and so + * the picker's row order is stable regardless of what `QueryStatus` gains later: + * anything new simply lands in the picker's "unrecognized" rows and is visible + * by default. + */ +export const LIVE_QUERY_STATUSES: readonly string[] = [ + 'Running', + 'Uninitialized', + 'Connecting', + 'Authenticating', + 'AcquiringWorker', + 'FindingDispatcher', + 'Initializing', + 'Executing', + 'Stopping', +]; + +/** The two halves the Persistent Queries status filter can toggle at once. */ +export type QueryStatusSection = 'Running' | 'Stopped'; + +/** + * The statuses making up a {@link QueryStatusSection}. + * @param section The section whose statuses to list. + */ +export function getQueryStatusSectionStatuses( + section: QueryStatusSection +): readonly string[] { + return section === 'Running' ? LIVE_QUERY_STATUSES : STOPPED_QUERY_STATUSES; +} + +const STOPPED_QUERY_STATUS_SET: ReadonlySet = new Set( + STOPPED_QUERY_STATUSES +); + +/** + * Whether a status means the query has finished moving — see + * {@link STOPPED_QUERY_STATUSES}. Note this is NOT + * {@link isTerminalQueryStatus}: `Stopping` is terminal for worker-teardown + * purposes but is still in motion as far as the user is concerned. + * @param status The status to check. + */ +export function isStoppedQueryStatus( + status: string | null | undefined +): boolean { + return STOPPED_QUERY_STATUS_SET.has(status ?? UNSET_QUERY_STATUS); +} + +export const DEFAULT_HIDDEN_QUERY_STATUSES: readonly string[] = [ + ...STOPPED_QUERY_STATUSES, +]; + export const PIP_SERVER_SUPPORTED_PLATFORMS = new Set([ 'darwin', 'linux', @@ -113,7 +216,7 @@ export const VIEW_ID = { remoteImportSourceTree: `${VIEW_ID_PREFIX}remoteImportSourceTree`, serverTree: `${VIEW_ID_PREFIX}serverTree`, serverConnectionTree: `${VIEW_ID_PREFIX}serverConnectionTree`, - serverConnectionPanelTree: `${VIEW_ID_PREFIX}serverConnectionPanelTree`, + persistentQueryTree: `${VIEW_ID_PREFIX}persistentQueryTree`, variablePanel: `${VIEW_ID_PREFIX}variablePanel`, } as const; @@ -124,6 +227,8 @@ export const ICON_ID = { connected: 'vm-connect', connecting: 'sync~spin', disconnected: 'plug', + /** Trailing node standing in for the queries a filter is hiding. */ + hidden: 'ellipsis', groovy: 'coffee', python: 'dh-python', runAll: 'run-all', @@ -138,8 +243,55 @@ export const ICON_ID = { varElement: 'preview', varPandas: 'dh-pandas', varTable: 'dh-table', + /** + * Fallback icon for a worker node (connection / persistent query) whose script + * language isn't known. Deliberately not `connected` (`vm-connect`) — that's + * the parent server node's icon, and a worker should never look like its + * server. + */ + worker: 'remote', } as const; +/** + * Variable types that can open as a Deephaven panel. Anything not listed is + * hidden from panel lists rather than opening a panel that never renders. + * + * This is an allow-list because a worker's exported objects include plenty of + * things that are not panels at all — DHE service objects (`AclService` and + * friends), dashboards, and legacy widget types — and nothing on the object says + * so. The web UI decides by looking the type up in its widget-plugin registry + * (`WidgetLoaderPlugin.handlePanelOpen` silently returns when no plugin claims + * the type), a registry assembled at runtime from the plugins a given worker + * kind serves and therefore not inspectable from the extension host. + * + * The entries mirror the types the bundled web plugins claim — Grid + * (`Table`/`TreeTable`/`HierarchicalTable`/`PartitionedTable`), Chart (`Figure`), + * Pandas (`pandas.DataFrame`) — plus the two first-party plugin widgets + * (`deephaven.ui.Element`, `deephaven.plot.express.DeephavenFigure`). + * + * Deliberately absent: + * - `deephaven.ui.Dashboard`: a dashboard, not a panel (the web client excludes + * it from its own Panels menu — iris `QueryListUtils.filterPanelWidgets`). + * - `TableMap` / `Treemap`: legacy `dh.VariableType` members no plugin claims + * (`TableMap` was superseded by `PartitionedTable`). + * - `OtherWidget`: the server's catch-all for a widget with no specific type; + * no bundled plugin claims it. + * + * TRADEOFF: a server-side JS plugin defining its own widget type will be hidden + * until its type is added here. That is the cost of the allow-list — the + * alternative surfaces unopenable service objects. + */ +export const OPENABLE_PANEL_VARIABLE_TYPES: ReadonlySet = new Set([ + 'deephaven.plot.express.DeephavenFigure', + 'deephaven.ui.Element', + 'Figure', + 'HierarchicalTable', + 'pandas.DataFrame', + 'PartitionedTable', + 'Table', + 'TreeTable', +]); + /* eslint-disable @typescript-eslint/naming-convention */ export const VARIABLE_UNICODE_ICONS = { 'deephaven.plot.express.DeephavenFigure': '📈', @@ -157,11 +309,23 @@ export const VARIABLE_UNICODE_ICONS = { /* eslint-enable @typescript-eslint/naming-convention */ export const CONNECTION_TREE_ITEM_CONTEXT = { - isConnectionConnected: 'isConnectionConnected', - isConnectionConnecting: 'isConnectionConnecting', + isConnectionConnected: (isOwned: boolean) => + `isConnectionConnected${isOwned ? 'Removable' : ''}`, + isConnectionConnecting: (isOwned: boolean) => + `isConnectionConnecting${isOwned ? 'Removable' : ''}`, + isDHEServerConnectionParent: 'isDHEServerConnectionParent', isUri: 'isUri', } as const; +export const PERSISTENT_QUERY_TREE_ITEM_CONTEXT = { + /** A DHE server grouping its persistent queries. */ + isPersistentQueryServer: 'isPersistentQueryServer', + /** A (non-InteractiveConsole) persistent query node. */ + isPersistentQuery: 'isPersistentQuery', + /** The trailing "N hidden" node under a filtered server. */ + isPersistentQueryHidden: 'isPersistentQueryHidden', +} as const; + export const PIP_SERVER_STATUS_DIRECTORY = 'pip-server-status'; export const SERVER_TREE_ITEM_CONTEXT = { @@ -171,6 +335,7 @@ export const SERVER_TREE_ITEM_CONTEXT = { isManagedServerConnected: 'isManagedServerConnected', isManagedServerConnecting: 'isManagedServerConnecting', isManagedServerDisconnected: 'isManagedServerDisconnected', + isServerConnecting: 'isServerConnecting', isServerRunningConnected: 'isServerRunningConnected', isServerRunningDisconnected: 'isServerRunningDisconnected', isServerStopped: 'isServerStopped', @@ -211,6 +376,14 @@ export const AUTH_CONFIG_SAML_LOGIN_URL = export const CREATE_QUERY_SETTINGS_STORAGE_KEY = 'createQuerySettings' as const; +/** + * `globalState` key for the Persistent Queries status filter (the set of + * statuses to hide). Global rather than workspace scoped so the filter follows + * the user across workspaces, matching `CREATE_QUERY_SETTINGS_STORAGE_KEY`. + */ +export const PERSISTENT_QUERY_HIDDEN_STATUSES_STORAGE_KEY = + 'persistentQueryHiddenStatuses' as const; + export const DH_SAML_AUTH_PROVIDER_TYPE = 'dhsaml' as const; export const DH_SAML_SERVER_URL_SCOPE_KEY = 'deephaven.samlServerUrl' as const; export const DH_SAML_LOGIN_URL_SCOPE_KEY = 'deephaven.samlLoginUrl' as const; diff --git a/src/controllers/ConnectionController.ts b/src/controllers/ConnectionController.ts index bef0cfaff..05a314c29 100644 --- a/src/controllers/ConnectionController.ts +++ b/src/controllers/ConnectionController.ts @@ -24,8 +24,11 @@ import { CONNECT_TO_SERVER_CMD, CONNECT_TO_SERVER_OPERATE_AS_CMD, ConnectToServerCmdArgs, + CREATE_WORKER_CMD, + CreateWorkerCmdArgs, DISCONNECT_EDITOR_CMD, DISCONNECT_FROM_SERVER_CMD, + DISCONNECT_FROM_WORKER_CMD, SELECT_CONNECTION_COMMAND, UnsupportedConsoleTypeError, } from '../common'; @@ -66,6 +69,9 @@ export class ConnectionController this.onConnectToServerOperateAs ); + /** Create a new worker on a DHE server */ + this.registerCommand(CREATE_WORKER_CMD, this.onCreateWorker); + /** Disconnect editor */ this.registerCommand(DISCONNECT_EDITOR_CMD, this.onDisconnectEditor); @@ -75,6 +81,12 @@ export class ConnectionController this.onDisconnectFromServer ); + /** Disconnect from worker (per-worker action on connection nodes) */ + this.registerCommand( + DISCONNECT_FROM_WORKER_CMD, + this.onDisconnectFromServer + ); + /** Select connection to run scripts against */ this.registerCommand( SELECT_CONNECTION_COMMAND, @@ -185,6 +197,10 @@ export class ConnectionController ); if (cn == null) { + updateConnectionStatusBarItem( + this._connectStatusBarItem, + 'disconnected' + ); return; } @@ -327,13 +343,35 @@ export class ConnectionController const workerConsoleType = serverState.type === 'DHE' ? getConsoleType(languageId) : undefined; + // Plain "connect to server" — attach to existing workers and let + // persistent queries populate, but do not auto-create a worker. Worker + // creation is explicit (the "+" action) or on-demand when running code. await this._serverManager?.connectToServer( serverState.url, workerConsoleType, - operateAsAnotherUser + operateAsAnotherUser, + false ); }; + /** + * Handle explicitly creating a new worker on a DHE server (the "+" action). + */ + onCreateWorker = async ( + ...[serverState]: CreateWorkerCmdArgs | [undefined] + ): Promise => { + // Sometimes view/item/context commands pass undefined instead of a value. + // Just ignore. microsoft/vscode#283655 + if (serverState == null) { + return; + } + + const languageId = vscode.window.activeTextEditor?.document.languageId; + const workerConsoleType = getConsoleType(languageId); + + await this._serverManager?.createWorker(serverState.url, workerConsoleType); + }; + /** * Handle connecting to a server as another user. * @param serverState @@ -473,10 +511,11 @@ export class ConnectionController try { selectedCnResult = await createConnectionQuickPick( - createConnectionQuickPickOptions( + await createConnectionQuickPickOptions( [...runningDHCServersWithoutConnections, ...runningDHEServers], connectionsForConsoleType, languageId, + this._serverManager, editorActiveConnectionUrl ) ); diff --git a/src/controllers/ExtensionController.ts b/src/controllers/ExtensionController.ts index 00e66c7d1..1f02105aa 100644 --- a/src/controllers/ExtensionController.ts +++ b/src/controllers/ExtensionController.ts @@ -21,18 +21,23 @@ import { DOWNLOAD_LOGS_CMD, GENERATE_REQUIREMENTS_TXT_CMD, OPEN_IN_BROWSER_CMD, - REFRESH_PANELS_TREE_CMD, + REFRESH_PERSISTENT_QUERY_TREE_CMD, REFRESH_REMOTE_IMPORT_SOURCE_TREE_CMD, REFRESH_SERVER_CONNECTION_TREE_CMD, REFRESH_SERVER_TREE_CMD, REMOVE_GROOVY_REMOTE_FILE_SOURCE_CMD, REMOVE_PYTHON_REMOTE_FILE_SOURCE_CMD, + FILTER_PERSISTENT_QUERIES_CMD, + HIDE_RUNNING_QUERIES_CMD, + HIDE_STOPPED_QUERIES_CMD, + SHOW_RUNNING_QUERIES_CMD, + SHOW_STOPPED_QUERIES_CMD, REVEAL_IN_EXPLORER_CMD, RUN_CODE_COMMAND, RUN_MARKDOWN_CODEBLOCK_CMD, RUN_SELECTION_COMMAND, SEARCH_CONNECTIONS_CMD, - SEARCH_PANELS_CMD, + SEARCH_PERSISTENT_QUERIES_CMD, START_SERVER_CMD, STOP_SERVER_CMD, VIEW_ID, @@ -40,6 +45,7 @@ import { type RemoveRemoteFileSourceCmdArgs, type RunCodeCmdArgs, type RunMarkdownCodeblockCmdArgs, + type QueryStatusSection, type RunSelectionCmdArgs, type ViewID, } from '../common'; @@ -49,6 +55,7 @@ import { getEditorForUri, getGroovyTopLevelPackageName, getPythonTopLevelModuleFullname, + getSharedTransportFactory, getTempDir, isInstanceOf, isSerializedRange, @@ -57,9 +64,12 @@ import { Logger, OutputChannelWithHistory, parseMarkdownCodeblocks, + promptForQueryStatusFilter, sanitizeGRPCLogMessageArgs, saveLogFiles, serializeRefreshToken, + setViewIsFiltered, + setViewSectionIsVisible, Toaster, URLMap, withResolvers, @@ -69,7 +79,7 @@ import { RunCommandCodeLensProvider, ServerTreeProvider, ServerConnectionTreeProvider, - ServerConnectionPanelTreeProvider, + PersistentQueryTreeProvider, runSelectedLinesHoverProvider, RunMarkdownCodeBlockCodeLensProvider, SamlAuthProvider, @@ -87,6 +97,8 @@ import { RemoteFileSourceService, PanelService, ParsedDocumentCache, + PersistentQueryService, + PersistentQueryStatusFilterService, PYTHON_FILE_PATTERN, SecretService, ServerManager, @@ -102,11 +114,14 @@ import type { IDhcService, IDhcServiceFactory, IPanelService, + IPersistentQueryService, + IPersistentQueryStatusFilterService, ISecretService, IServerManager, IToastService, - ServerConnectionPanelNode, - ServerConnectionPanelTreeView, + ServerConnectionNode, + PersistentQueryTreeNode, + PersistentQueryTreeView, ServerConnectionTreeView, ServerState, ServerTreeView, @@ -194,6 +209,9 @@ export class ExtensionController implements IDisposable { private _logFileHandler: LogFileHandler | null = null; private _panelController: PanelController | null = null; private _panelService: IPanelService | null = null; + private _persistentQueryService: IPersistentQueryService | null = null; + private _persistentQueryStatusFilterService: IPersistentQueryStatusFilterService | null = + null; private _pipServerController: PipServerController | null = null; private _dhcServiceFactory: IDhcServiceFactory | null = null; private _dheJsApiCache: IAsyncCacheService | null = null; @@ -211,7 +229,7 @@ export class ExtensionController implements IDisposable { private _serverTreeProvider: ServerTreeProvider | null = null; private _serverConnectionTreeProvider: ServerConnectionTreeProvider | null = null; - private _serverConnectionPanelTreeProvider: ServerConnectionPanelTreeProvider | null = + private _persistentQueryTreeProvider: PersistentQueryTreeProvider | null = null; private _remoteImportSourceTreeProvider: RemoteImportSourceTreeProvider | null = null; @@ -219,8 +237,7 @@ export class ExtensionController implements IDisposable { // Tree views private _serverTreeView: ServerTreeView | null = null; private _serverConnectionTreeView: ServerConnectionTreeView | null = null; - private _serverConnectionPanelTreeView: ServerConnectionPanelTreeView | null = - null; + private _persistentQueryTreeView: PersistentQueryTreeView | null = null; private _remoteImportSourceTreeView: RemoteImportSourceTreeView | null = null; // Web views @@ -593,7 +610,7 @@ export class ExtensionController implements IDisposable { // so that we can provide a working NodeJS version of http2. transportFactory: isElectronFetchEnabled ? undefined - : NodeHttp2gRPCTransport.factory, + : getSharedTransportFactory(), }; if (envoyPrefix != null) { @@ -622,6 +639,7 @@ export class ExtensionController implements IDisposable { url: URL ): Promise => { assertDefined(this._dheJsApiCache, 'dheJsApiCache'); + const dhe = await this._dheJsApiCache.get(url); const client: DheUnauthenticatedClient = await createDheClient( @@ -634,7 +652,9 @@ export class ExtensionController implements IDisposable { // should just be ignored if provided. We don't really have a good way // to determine if the server supports it or not, and gplus and beyond // require it on non-envoy servers, so we just always provide it. - transportFactory: NodeHttp2gRPCTransport.factory, + // + // The shared factory carries the HTTP/2 window sizes. + transportFactory: getSharedTransportFactory(), } ); @@ -736,6 +756,32 @@ export class ExtensionController implements IDisposable { ); this._context.subscriptions.push(this._serverManager); + // Shared PQ source for every view that lists persistent queries (the + // Persistent Queries tree and the Panels tree). + this._persistentQueryService = new PersistentQueryService( + this._serverManager, + this._dheServiceCache, + this._dheJsApiCache + ); + this._context.subscriptions.push(this._persistentQueryService); + + // The Persistent Queries status filter. Owned here rather than by the tree + // provider so the `isFiltered` context key can be seeded from the persisted + // set at startup, before any command has run. + this._persistentQueryStatusFilterService = + new PersistentQueryStatusFilterService(this._context); + this._context.subscriptions.push(this._persistentQueryStatusFilterService); + + this.syncPersistentQueryFilterContext(); + + this._persistentQueryStatusFilterService.onDidUpdate( + () => { + this.syncPersistentQueryFilterContext(); + }, + undefined, + this._context.subscriptions + ); + this._serverManager.onDidDisconnect( serverUrl => { this._panelService?.clearServerData(serverUrl); @@ -823,7 +869,34 @@ export class ExtensionController implements IDisposable { ); /** Refresh variable panels tree */ - this.registerCommand(REFRESH_PANELS_TREE_CMD, this.onRefreshServerStatus); + + /** Refresh persistent query tree */ + this.registerCommand( + REFRESH_PERSISTENT_QUERY_TREE_CMD, + this.onRefreshPersistentQueryTree + ); + + /** + * Filter persistent queries by status. Two command ids share one handler so + * the view-title action can render a filled funnel while a filter is active + * (VS Code reads an action's icon from its command). + */ + this.registerCommand( + FILTER_PERSISTENT_QUERIES_CMD, + this.onFilterPersistentQueries + ); + this.registerCommand(SHOW_RUNNING_QUERIES_CMD, () => + this.onSetQuerySectionVisible('Running', true) + ); + this.registerCommand(HIDE_RUNNING_QUERIES_CMD, () => + this.onSetQuerySectionVisible('Running', false) + ); + this.registerCommand(SHOW_STOPPED_QUERIES_CMD, () => + this.onSetQuerySectionVisible('Stopped', true) + ); + this.registerCommand(HIDE_STOPPED_QUERIES_CMD, () => + this.onSetQuerySectionVisible('Stopped', false) + ); /** Remote import source tree */ this.registerCommand( @@ -854,11 +927,11 @@ export class ExtensionController implements IDisposable { VIEW_ID.serverConnectionTree ); - /** Search variable panels */ + /** Search persistent queries */ this.registerCommand( - SEARCH_PANELS_CMD, + SEARCH_PERSISTENT_QUERIES_CMD, this.onSearchTree, - VIEW_ID.serverConnectionPanelTree + VIEW_ID.persistentQueryTree ); this.registerCommand(DELETE_VARIABLE_CMD, this.onDeleteVariable); @@ -875,6 +948,8 @@ export class ExtensionController implements IDisposable { */ initializeWebViews = (): void => { assertDefined(this._dheClientCache, 'dheClientCache'); + assertDefined(this._dheJsApiCache, 'dheJsApiCache'); + assertDefined(this._dheServiceCache, 'dheServiceCache'); assertDefined(this._groovyWorkspace, 'groovyWorkspace'); assertDefined(this._pythonWorkspace, 'pythonWorkspace'); assertDefined(this._panelService, 'panelService'); @@ -889,19 +964,21 @@ export class ExtensionController implements IDisposable { // Connection tree this._serverConnectionTreeProvider = new ServerConnectionTreeProvider( - this._serverManager + this._serverManager, + this._panelService ); const serverConnectionTreeDragAndDropController = new ServerConnectionTreeDragAndDropController(this._serverManager); - this._serverConnectionTreeView = vscode.window.createTreeView( - VIEW_ID.serverConnectionTree, - { - dragAndDropController: serverConnectionTreeDragAndDropController, - showCollapseAll: true, - treeDataProvider: this._serverConnectionTreeProvider, - } - ); + this._serverConnectionTreeView = + vscode.window.createTreeView( + VIEW_ID.serverConnectionTree, + { + dragAndDropController: serverConnectionTreeDragAndDropController, + showCollapseAll: true, + treeDataProvider: this._serverConnectionTreeProvider, + } + ); // Create Query View this._createQueryViewProvider = new CreateQueryViewProvider( @@ -920,18 +997,23 @@ export class ExtensionController implements IDisposable { ) ); - // Connection Panel tree - this._serverConnectionPanelTreeProvider = - new ServerConnectionPanelTreeProvider( - this._serverManager, - this._panelService - ); - this._serverConnectionPanelTreeView = - vscode.window.createTreeView( - VIEW_ID.serverConnectionPanelTree, + // Persistent Queries tree + assertDefined(this._persistentQueryService, 'persistentQueryService'); + assertDefined( + this._persistentQueryStatusFilterService, + 'persistentQueryStatusFilterService' + ); + this._persistentQueryTreeProvider = new PersistentQueryTreeProvider( + this._serverManager, + this._persistentQueryService, + this._persistentQueryStatusFilterService + ); + this._persistentQueryTreeView = + vscode.window.createTreeView( + VIEW_ID.persistentQueryTree, { showCollapseAll: true, - treeDataProvider: this._serverConnectionPanelTreeProvider, + treeDataProvider: this._persistentQueryTreeProvider, } ); @@ -952,11 +1034,11 @@ export class ExtensionController implements IDisposable { this._context.subscriptions.push( this._serverTreeView, this._serverConnectionTreeView, - this._serverConnectionPanelTreeView, + this._persistentQueryTreeView, this._remoteImportSourceTreeView, this._serverTreeProvider, this._serverConnectionTreeProvider, - this._serverConnectionPanelTreeProvider, + this._persistentQueryTreeProvider, this._remoteImportSourceTreeProvider ); }; @@ -1213,6 +1295,80 @@ export class ExtensionController implements IDisposable { await this._serverManager?.updateStatus(); }; + onRefreshPersistentQueryTree = async (): Promise => { + this._persistentQueryTreeProvider?.refresh(); + }; + + /** + * Prompt for the persistent-query statuses to show. Shared by both filter + * command ids. A dismissed picker resolves `undefined` and must leave the + * filter untouched — treating it as an empty selection would hide everything. + */ + /** + * Push the filter state into context keys the Persistent Queries menu reads: + * which submenu icon to show (hollow / filled funnel), and which of each + * checked / unchecked row pair to render. Driven off the filter service's + * event rather than the commands, so the keys are correct at startup too, + * where no command has run. + */ + syncPersistentQueryFilterContext = (): void => { + const filterService = this._persistentQueryStatusFilterService; + if (filterService == null) { + return; + } + + setViewIsFiltered( + VIEW_ID.persistentQueryTree, + filterService.getHiddenStatuses().size > 0 + ); + + for (const section of ['Running', 'Stopped'] as const) { + setViewSectionIsVisible( + VIEW_ID.persistentQueryTree, + section, + filterService.isSectionVisible(section) + ); + } + }; + + /** + * Show or hide a whole status section from the filter menu's checkbox rows. + * @param section The section to toggle. + * @param isVisible Whether its queries should be listed. + */ + onSetQuerySectionVisible = async ( + section: QueryStatusSection, + isVisible: boolean + ): Promise => { + await this._persistentQueryStatusFilterService?.setSectionVisible( + section, + isVisible + ); + }; + + onFilterPersistentQueries = async (): Promise => { + if ( + this._persistentQueryTreeProvider == null || + this._persistentQueryStatusFilterService == null + ) { + return; + } + + const statusCounts = + await this._persistentQueryTreeProvider.getStatusCounts(); + + const hidden = await promptForQueryStatusFilter( + statusCounts, + this._persistentQueryStatusFilterService.getHiddenStatuses() + ); + + if (hidden == null) { + return; + } + + await this._persistentQueryStatusFilterService.setHiddenStatuses(hidden); + }; + onRevealInExplorer = async ( uriOrHasUri: vscode.Uri | { uri: vscode.Uri } | undefined ): Promise => { diff --git a/src/controllers/ServerConnectionTreeDragAndDropController.ts b/src/controllers/ServerConnectionTreeDragAndDropController.ts index 9ed518904..d1da4f79f 100644 --- a/src/controllers/ServerConnectionTreeDragAndDropController.ts +++ b/src/controllers/ServerConnectionTreeDragAndDropController.ts @@ -1,5 +1,5 @@ import { MIME_TYPE } from '../common'; -import { getEditorForUri } from '../util'; +import { getEditorForUri, isServerStateNode } from '../util'; import type { IServerManager } from '../types'; import type { ServerConnectionNode } from '../types/treeViewTypes'; @@ -21,8 +21,14 @@ export class ServerConnectionTreeDragAndDropController dataTransfer: vscode.DataTransfer, _token: vscode.CancellationToken ): Promise => { - // Only target connection nodes - if (target == null || target instanceof vscode.Uri) { + // Only target connection nodes. DHE server group nodes, uri leaf nodes, and + // panel leaf nodes are not valid editor-connection targets. + if ( + target == null || + Array.isArray(target) || + target instanceof vscode.Uri || + isServerStateNode(target) + ) { return; } diff --git a/src/controllers/UserLoginController.ts b/src/controllers/UserLoginController.ts index 33d397610..8125350c1 100644 --- a/src/controllers/UserLoginController.ts +++ b/src/controllers/UserLoginController.ts @@ -297,6 +297,7 @@ export class UserLoginController extends ControllerBase { operateAsAnotherUser: boolean ): Promise => { const dheClient = await this.dheClientFactory(serverUrl); + const authConfig = await getDheAuthConfig(dheClient.client, logger); if (isNoAuthConfig(authConfig)) { @@ -370,7 +371,11 @@ export class UserLoginController extends ControllerBase { }); } - if (!(await hasInteractivePermission(authenticatedClient.client))) { + const isInteractive = await hasInteractivePermission( + authenticatedClient.client + ); + + if (!isInteractive) { throw new Error('User does not have interactive permissions.'); } diff --git a/src/dh/ambientModules.d.ts b/src/dh/ambientModules.d.ts new file mode 100644 index 000000000..490547c8f --- /dev/null +++ b/src/dh/ambientModules.d.ts @@ -0,0 +1,19 @@ +/** + * Ambient module declarations for packages that are referenced by the type + * declarations of our dependencies but are not installed in this project. + * + * IMPORTANT: This file must stay a global *script* — do NOT add any top-level + * `import` or `export`. A `.d.ts` with a top-level import/export becomes a + * *module*, and inside a module `declare module 'x'` is treated as + * *augmentation* of an already-existing module. These modules are not + * installed, so there is nothing to augment; only a script-file *ambient module + * declaration* actually creates the module and lets the import resolve. + */ + +// `@deephaven-enterprise/query-utils@2026.x` ships a `.d.ts` (QueryUtils.d.ts) +// that does `import type { UriVariableDescriptor } from '@deephaven/jsapi-bootstrap'`, +// but does not declare `@deephaven/jsapi-bootstrap` as a dependency and it is +// not installed. Declare a minimal stub so the type import resolves (TS2307). +declare module '@deephaven/jsapi-bootstrap' { + type UriVariableDescriptor = string; +} diff --git a/src/dh/dhe.ts b/src/dh/dhe.ts index 82c828e45..bea64696e 100644 --- a/src/dh/dhe.ts +++ b/src/dh/dhe.ts @@ -42,7 +42,7 @@ import { UnsupportedFeatureQueryError, } from '../common'; import { withResolvers } from '../util'; -import type { QuerySerial } from '../shared'; +import { type QuerySerial } from '../shared'; export type IDraftQuery = EditableQueryInfo & { isClientSide: boolean; @@ -50,9 +50,11 @@ export type IDraftQuery = EditableQueryInfo & { }; declare global { - // This gets added by the DHE jsapi. - // eslint-disable-next-line no-unused-vars - const iris: DheType; + // This gets added by the DHE jsapi. Declared as `var` (not `const`) so it + // merges with the ambient `var iris` global declared by + // `@deephaven-enterprise/jsapi-nodejs` rather than conflicting with it. + // eslint-disable-next-line no-unused-vars, no-var + var iris: DheType; } /** @@ -220,7 +222,7 @@ export async function loginClientWrapper( * @param consoleType The type of console to create. * @returns A promise that resolves to the serial of the created query. Note * that this will resolve before the query is actually ready to use. Use - * `getWorkerInfoFromQuery` to get the worker info when the query is ready. + * `getWorkerInfoFromQuerySerial` to get the worker info when the query is ready. */ export async function createInteractiveConsoleQuery( tagId: UniqueID, @@ -411,6 +413,84 @@ export async function getDheAuthConfig( return authConfig; } +/** + * Determine if a given query info represents an attachable IC worker for the + * current effective user. An attachable worker is an InteractiveConsole type, + * owned by `operateAs`, and currently Running. + * @param queryInfo Query info to check. + * @param operateAs The effective user to match ownership against. + * @returns True if the query is attachable, false otherwise. + */ +export function isAttachableWorker( + queryInfo: QueryInfo, + operateAs: string | null +): boolean { + return ( + queryInfo.type === INTERACTIVE_CONSOLE_QUERY_TYPE && + queryInfo.owner === operateAs && + queryInfo.designated?.status === 'Running' + ); +} + +/** + * List all running InteractiveConsole workers owned by the current effective + * user. + * @param dheClient DHE client to use. + * @param exclude Iterable of query serials to exclude from the results. + * @returns A promise resolving to the filtered QueryInfo array. + */ +export async function listAttachableWorkers( + dheClient: DheAuthenticatedClient, + exclude: Iterable +): Promise { + const userInfo = await dheClient.getUserInfo(); + const operateAs = userInfo.operateAs; + const excludeSet = new Set(exclude); + + return dheClient + .getKnownConfigs() + .filter( + qi => + isAttachableWorker(qi, operateAs) && + !excludeSet.has(qi.serial as QuerySerial) + ); +} + +/** + * Convert a QueryInfo object to a WorkerInfo object without any I/O or + * side effects. Returns undefined when `queryInfo.designated` is null. + * @param tagId Unique tag id to associate with the worker. + * @param queryInfo The running query info. + * @returns WorkerInfo or undefined. + */ +export function getWorkerInfoFromQueryInfo( + tagId: UniqueID, + queryInfo: QueryInfo +): WorkerInfo | undefined { + if (queryInfo.designated == null || queryInfo.designated.ideUrl == null) { + return; + } + + const { envoyPrefix, grpcUrl, ideUrl, jsApiUrl, processInfoId, workerName } = + queryInfo.designated; + + const workerUrl = new URL(jsApiUrl) as WorkerURL; + workerUrl.pathname = workerUrl.pathname.replace(/jsapi\/dh-core.js$/, ''); + + return { + tagId, + serial: queryInfo.serial as QuerySerial, + envoyPrefix, + grpcUrl: new URL(grpcUrl) as GrpcURL, + ideUrl: new URL(ideUrl) as IdeURL, + jsapiUrl: new URL(jsApiUrl) as JsapiURL, + name: queryInfo.name, + processInfoId, + workerName, + workerUrl, + }; +} + /** * Search existing queries for a query with the given tag id and return its serial. * @param tagId Unique tag id to search for. @@ -436,7 +516,7 @@ export function getSerialFromTagId( * @param querySerial Serial of the query to get worker info for. * @returns A promise that resolves to the worker info when the worker is ready. */ -export async function getWorkerInfoFromQuery( +export async function getWorkerInfoFromQuerySerial( tagId: UniqueID, dhe: DheType, dheClient: DheAuthenticatedClient, @@ -498,27 +578,7 @@ export async function getWorkerInfoFromQuery( } } - if (queryInfo.designated == null) { - return; - } - - const { envoyPrefix, grpcUrl, ideUrl, jsApiUrl, processInfoId, workerName } = - queryInfo.designated; - - const workerUrl = new URL(jsApiUrl) as WorkerURL; - workerUrl.pathname = workerUrl.pathname.replace(/jsapi\/dh-core.js$/, ''); - - return { - tagId, - serial: querySerial, - envoyPrefix, - grpcUrl: new URL(grpcUrl) as GrpcURL, - ideUrl: new URL(ideUrl) as IdeURL, - jsapiUrl: new URL(jsApiUrl) as JsapiURL, - processInfoId, - workerName, - workerUrl, - }; + return getWorkerInfoFromQueryInfo(tagId, queryInfo); } /** diff --git a/src/dh/modules.d.ts b/src/dh/modules.d.ts index d7de75a7b..ea6f96fce 100644 --- a/src/dh/modules.d.ts +++ b/src/dh/modules.d.ts @@ -1,12 +1,3 @@ -/** - * Augment types that are missing in current jsapi-types. - */ -declare module '@deephaven-enterprise/jsapi-types' { - interface EnterpriseClient { - deleteQueries(querySerials: string[]): Promise; - } -} - export {}; /** diff --git a/src/mcp/tools/listConnections.spec.ts b/src/mcp/tools/listConnections.spec.ts index 69b4fd704..5a8bcbab1 100644 --- a/src/mcp/tools/listConnections.spec.ts +++ b/src/mcp/tools/listConnections.spec.ts @@ -18,6 +18,7 @@ vi.mock('vscode'); const MOCK_CONNECTION_1: ConnectionState = { serverUrl: MOCK_DHC_URL, + label: 'Connection 1', isConnected: true, isRunningCode: false, tagId: 'conn1' as UniqueID, @@ -25,6 +26,7 @@ const MOCK_CONNECTION_1: ConnectionState = { const MOCK_CONNECTION_2: ConnectionState = { serverUrl: new URL('http://localhost:10001'), + label: 'Connection 2', isConnected: true, isRunningCode: true, tagId: 'conn2' as UniqueID, diff --git a/src/mcp/tools/listConnections.ts b/src/mcp/tools/listConnections.ts index 734d4890b..2c471359d 100644 --- a/src/mcp/tools/listConnections.ts +++ b/src/mcp/tools/listConnections.ts @@ -26,6 +26,7 @@ const spec = { .array( z.object({ serverUrl: z.string(), + label: z.string(), isConnected: z.boolean(), isRunningCode: z.boolean().optional(), querySerial: z.string().optional(), @@ -66,7 +67,7 @@ export function createListConnectionsTool({ const connections = await Promise.all( rawConnections.map( - async ({ serverUrl, isConnected, isRunningCode, tagId }) => { + async ({ serverUrl, label, isConnected, isRunningCode, tagId }) => { // Get worker info to retrieve querySerial for DHE connections const workerInfo = await serverManager.getWorkerInfo( serverUrl as WorkerURL @@ -74,6 +75,7 @@ export function createListConnectionsTool({ return { serverUrl: serverUrl.toString(), + label, isConnected, isRunningCode, tagId, diff --git a/src/mcp/utils/serverUtils.spec.ts b/src/mcp/utils/serverUtils.spec.ts index b63df4b8b..64f5a20c5 100644 --- a/src/mcp/utils/serverUtils.spec.ts +++ b/src/mcp/utils/serverUtils.spec.ts @@ -42,11 +42,13 @@ describe('serverUtils', () => { isRunningCode, serverUrl, tagId, + label: 'mock connection', }); expect(resultWithoutTag, 'without tag').toEqual({ isConnected, isRunningCode, + label: 'mock connection', serverUrl: serverUrl.toString(), tagId, }); @@ -389,6 +391,7 @@ describe('serverUtils', () => { const MOCK_CONNECTION: ConnectionState = { isConnected: true, isRunningCode: true, + label: 'mock connection', serverUrl, tagId, } as ConnectionState; diff --git a/src/mcp/utils/serverUtils.ts b/src/mcp/utils/serverUtils.ts index 26e5561ac..8e060d876 100644 --- a/src/mcp/utils/serverUtils.ts +++ b/src/mcp/utils/serverUtils.ts @@ -14,6 +14,7 @@ import { createConnectionNotFoundHint } from './runCodeUtils'; export const connectionResultSchema = z.object({ isConnected: z.boolean(), isRunningCode: z.boolean().optional(), + label: z.string(), serverUrl: z.string(), tagId: z.string().optional(), }); @@ -57,12 +58,14 @@ export type GetFirstConnectionOrCreateResult = export function connectionToResult({ isConnected, isRunningCode, + label, serverUrl, tagId, }: ConnectionState): ConnectionResult { return { isConnected, isRunningCode, + label, serverUrl: serverUrl.toString(), tagId, }; diff --git a/src/providers/PersistentQueryTreeProvider.spec.ts b/src/providers/PersistentQueryTreeProvider.spec.ts new file mode 100644 index 000000000..a1e1346f1 --- /dev/null +++ b/src/providers/PersistentQueryTreeProvider.spec.ts @@ -0,0 +1,376 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type * as vscode from 'vscode'; +import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; +import { PersistentQueryTreeProvider } from './PersistentQueryTreeProvider'; +import type { + IPersistentQueryService, + IPersistentQueryStatusFilterService, + IServerManager, + PersistentQueryNode, + PersistentQueryTreeNode, + ServerState, + VariableDefintion, + WorkerInfo, +} from '../types'; +import { + FILTER_PERSISTENT_QUERIES_CMD, + OPEN_VARIABLE_PANELS_CMD, + UNSET_QUERY_STATUS, +} from '../common'; + +// See __mocks__/vscode.ts for the mock implementation +vi.mock('vscode'); + +const DHE_URL = new URL('https://dhe.example.com:8123/'); +const WORKER_URL = new URL('https://dhe.example.com:8123/worker/1/'); + +/** Build a minimal `QueryInfo` for tests. */ +function makeQueryInfo(overrides: Partial = {}): QueryInfo { + return { + serial: 'serial-1', + name: 'My PQ', + owner: 'alice', + type: 'DeephavenCommunity', + designated: { + status: 'Running', + jsApiUrl: `${WORKER_URL.href}jsapi/dh-core.js`, + ideUrl: `${WORKER_URL.href}ide`, + objects: [ + { title: 'my_table', name: 'my_table', type: 'Table', id: 'v1' }, + { title: 'my_figure', name: 'my_figure', type: 'Figure', id: 'v2' }, + ], + }, + ...overrides, + } as unknown as QueryInfo; +} + +function makeServerState(overrides: Partial = {}): ServerState { + return { + type: 'DHE', + url: DHE_URL, + label: 'DHE', + isConnected: true, + isRunning: true, + connectionCount: 0, + ...overrides, + } as ServerState; +} + +describe('PersistentQueryTreeProvider', () => { + let serverManager: IServerManager; + let persistentQueryService: IPersistentQueryService; + let statusFilterService: IPersistentQueryStatusFilterService; + let hiddenStatuses: Set; + let onFilterDidUpdate: (() => void) | undefined; + let provider: PersistentQueryTreeProvider; + + beforeEach(() => { + vi.clearAllMocks(); + onFilterDidUpdate = undefined; + + // Default filter: hide the completely-stopped statuses. `Stopping` is not + // one of them — it is still winding down, so it stays visible. + hiddenStatuses = new Set([ + 'Stopped', + 'Failed', + 'Error', + 'Disconnected', + 'Completed', + UNSET_QUERY_STATUS, + ]); + + statusFilterService = { + onDidUpdate: vi.fn((listener: () => void) => { + onFilterDidUpdate = listener; + return vi.fn(); + }), + isVisible: vi.fn( + (status?: string | null) => !hiddenStatuses.has(status ?? '') + ), + getHiddenStatuses: vi.fn(() => hiddenStatuses), + setHiddenStatuses: vi.fn(), + } as unknown as IPersistentQueryStatusFilterService; + + persistentQueryService = { + onDidUpdate: vi.fn(() => vi.fn()), + getPersistentQueries: vi.fn(async () => [makeQueryInfo()]), + } as unknown as IPersistentQueryService; + + serverManager = { + onDidUpdate: vi.fn(() => vi.fn()), + onDidDisconnect: vi.fn(() => vi.fn()), + getServers: vi.fn(() => [makeServerState()]), + registerBrowseConnection: vi.fn( + async (): Promise => + ({ workerUrl: WORKER_URL, name: 'My PQ' }) as WorkerInfo + ), + } as unknown as IServerManager; + + provider = new PersistentQueryTreeProvider( + serverManager, + persistentQueryService, + statusFilterService + ); + }); + + describe('getChildren (root)', () => { + it('returns only connected DHE servers', async () => { + (serverManager.getServers as ReturnType).mockReturnValue([ + makeServerState({ isConnected: true }), + makeServerState({ + url: new URL('https://other.example.com/'), + isConnected: false, + }), + ]); + + const children = (await provider.getChildren()) as ServerState[]; + expect(children).toHaveLength(1); + expect(children[0].isConnected).toBe(true); + expect(serverManager.getServers).toHaveBeenCalledWith({ type: 'DHE' }); + }); + }); + + describe('getChildren (server -> persistent queries)', () => { + beforeEach(() => { + ( + persistentQueryService.getPersistentQueries as ReturnType + ).mockResolvedValue([ + makeQueryInfo({ serial: 'serial-1', name: 'Zeta PQ' }), + makeQueryInfo({ + serial: 'serial-2', + name: 'Terminated PQ', + designated: { status: 'Failed' }, + } as unknown as Partial), + makeQueryInfo({ serial: 'serial-3', name: 'Alpha PQ' }), + makeQueryInfo({ + serial: 'serial-4', + name: 'No Status PQ', + designated: undefined, + } as Partial), + ]); + }); + + it('lists the visible queries directly under the server, alphabetized', async () => { + const server = makeServerState(); + const children = (await provider.getChildren( + server + )) as PersistentQueryTreeNode[]; + + expect(persistentQueryService.getPersistentQueries).toHaveBeenCalledWith( + server.url + ); + // Two visible queries, then the trailing hidden-count node. + expect( + children + .slice(0, -1) + .map(c => (c as PersistentQueryNode).queryInfo.name) + ).toEqual(['Alpha PQ', 'Zeta PQ']); + expect(children.at(-1)).toEqual({ + dheServerUrl: server.url, + hiddenCount: 2, + }); + }); + + it('excludes the hidden statuses', async () => { + const children = (await provider.getChildren( + makeServerState() + )) as PersistentQueryTreeNode[]; + + const names = children.map( + c => (c as PersistentQueryNode).queryInfo?.name + ); + expect(names).not.toContain('Terminated PQ'); + expect(names).not.toContain('No Status PQ'); + }); + + it('shows a status it does not recognize (not in the hidden set)', async () => { + ( + persistentQueryService.getPersistentQueries as ReturnType + ).mockResolvedValue([ + makeQueryInfo({ + name: 'Future PQ', + designated: { status: 'Hibernating' }, + } as unknown as Partial), + ]); + + const children = (await provider.getChildren( + makeServerState() + )) as PersistentQueryNode[]; + + expect(children.map(c => c.queryInfo.name)).toEqual(['Future PQ']); + }); + + it('returns an empty list when the service reports none', async () => { + ( + persistentQueryService.getPersistentQueries as ReturnType + ).mockResolvedValue([]); + + const children = await provider.getChildren(makeServerState()); + expect(children).toEqual([]); + }); + }); + + describe('getChildren (PQ -> object leaves)', () => { + it('registers a browse connection and returns object leaves opened via OPEN_VARIABLE_PANELS_CMD', async () => { + const node: PersistentQueryNode = { + dheServerUrl: DHE_URL, + queryInfo: makeQueryInfo(), + }; + + const leaves = (await provider.getChildren(node)) as [ + URL, + VariableDefintion, + ][]; + + expect(serverManager.registerBrowseConnection).toHaveBeenCalledWith( + DHE_URL, + node.queryInfo + ); + + expect(leaves).toHaveLength(2); + expect(leaves.map(([, v]) => v.title)).toEqual(['my_table', 'my_figure']); + // Leaves are keyed by the worker URL (from the browse connection). + leaves.forEach(([url]) => expect(url.href).toBe(WORKER_URL.href)); + + // Verify the open command wiring via the tree item. + const item = await provider.getTreeItem(leaves[0]); + expect(item.command?.command).toBe(OPEN_VARIABLE_PANELS_CMD); + expect(item.command?.arguments?.[0]).toBe(leaves[0][0]); + }); + + it('returns no leaves when the browse connection cannot be registered', async () => { + ( + serverManager.registerBrowseConnection as ReturnType + ).mockResolvedValue(null); + + const node: PersistentQueryNode = { + dheServerUrl: DHE_URL, + queryInfo: makeQueryInfo(), + }; + + const leaves = await provider.getChildren(node); + expect(leaves).toEqual([]); + }); + }); + + describe('getTreeItem', () => { + it('renders a DHE server node with no count description', async () => { + ( + persistentQueryService.getPersistentQueries as ReturnType + ).mockResolvedValue([ + makeQueryInfo({ serial: 'serial-1', name: 'Alpha PQ' }), + ]); + hiddenStatuses.clear(); + + const item = await provider.getTreeItem(makeServerState()); + expect(item.label).toBe('DHE'); + expect(item.description).toBeUndefined(); + }); + + it('renders the hidden-count node with the filter command', async () => { + const item = await provider.getTreeItem({ + dheServerUrl: DHE_URL, + hiddenCount: 20007, + }); + + expect(item.label).toBe('More (20,007)'); + // Fixed id: the label's count changes on every table tick, and a + // label-generated id would make this a different node each time. + expect(item.id).toBe(`pq:${DHE_URL.href}:more`); + expect(item.command?.command).toBe(FILTER_PERSISTENT_QUERIES_CMD); + expect(item.contextValue).toBe('isPersistentQueryHidden'); + expect((item.iconPath as vscode.ThemeIcon).id).toBe('ellipsis'); + }); + + it('singularizes the hidden-count tooltip', async () => { + const one = await provider.getTreeItem({ + dheServerUrl: DHE_URL, + hiddenCount: 1, + }); + const many = await provider.getTreeItem({ + dheServerUrl: DHE_URL, + hiddenCount: 2, + }); + + expect(one.tooltip).toContain('1 query is hidden'); + expect(many.tooltip).toContain('2 queries are hidden'); + }); + + it('renders a persistent-query node with its status circle + name', async () => { + const node: PersistentQueryNode = { + dheServerUrl: DHE_URL, + queryInfo: makeQueryInfo({ name: 'My PQ' }), + }; + const item = await provider.getTreeItem(node); + expect(item.label).toBe('My PQ'); + expect(item.contextValue).toBe('isPersistentQuery'); + expect((item.iconPath as vscode.ThemeIcon).id).toBe( + 'circle-large-filled' + ); + }); + + it('renders an object leaf via the shared panel renderer', async () => { + const leaf: PersistentQueryTreeNode = [ + WORKER_URL, + { title: 'my_table', name: 'my_table', type: 'Table', id: 'v1' }, + ] as [URL, VariableDefintion]; + const item = await provider.getTreeItem(leaf); + expect(item.label).toBe('my_table'); + expect(item.command?.command).toBe(OPEN_VARIABLE_PANELS_CMD); + }); + }); + + describe('getStatusCounts', () => { + it('sums across servers and buckets an unset status under the empty string', async () => { + const otherUrl = new URL('https://other.example.com/'); + (serverManager.getServers as ReturnType).mockReturnValue([ + makeServerState(), + makeServerState({ url: otherUrl }), + makeServerState({ + url: new URL('https://disconnected.example.com/'), + isConnected: false, + }), + ]); + + ( + persistentQueryService.getPersistentQueries as ReturnType + ).mockImplementation(async (url: URL) => + url === otherUrl + ? [ + makeQueryInfo({ name: 'Other Running' }), + makeQueryInfo({ + name: 'Other Unset', + designated: undefined, + } as Partial), + ] + : [ + makeQueryInfo({ name: 'Running' }), + makeQueryInfo({ + name: 'Stopped', + designated: { status: 'Stopped' }, + } as unknown as Partial), + ] + ); + + const counts = await provider.getStatusCounts(); + + expect(counts.get('Running')).toBe(2); + expect(counts.get('Stopped')).toBe(1); + expect(counts.get(UNSET_QUERY_STATUS)).toBe(1); + // Disconnected servers contribute nothing. + expect([...counts.values()].reduce((a, b) => a + b, 0)).toBe(4); + }); + }); + + describe('filter updates', () => { + it('refreshes the tree when the filter service updates', () => { + const onDidChangeTreeData = vi.fn(); + provider.onDidChangeTreeData(onDidChangeTreeData); + + expect(onFilterDidUpdate).toBeDefined(); + onFilterDidUpdate?.(); + + expect(onDidChangeTreeData).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/providers/PersistentQueryTreeProvider.ts b/src/providers/PersistentQueryTreeProvider.ts new file mode 100644 index 000000000..db5a8415a --- /dev/null +++ b/src/providers/PersistentQueryTreeProvider.ts @@ -0,0 +1,193 @@ +import type * as vscode from 'vscode'; +import type { + IPersistentQueryService, + IPersistentQueryStatusFilterService, + IServerManager, + PersistentQueryNode, + PersistentQueryTreeNode, +} from '../types'; +import { UNSET_QUERY_STATUS } from '../common'; +import { ServerTreeProviderBase } from './ServerTreeProviderBase'; +import { + getConnectionServerLabel, + getPanelVariableTreeItem, + getPersistentQueryHiddenTreeItem, + getPersistentQueryObjectLeaves, + getPersistentQueryServerTreeItem, + getPersistentQueryStatus, + getPersistentQueryTreeItem, + isPersistentQueryHiddenNode, + isPersistentQueryNode, +} from '../util'; + +/** + * Tree data provider backing the **Persistent Queries** view. Lists the + * ACL-visible non-InteractiveConsole persistent queries of each connected DHE + * server (from the shared `IPersistentQueryService`), each expandable to its + * exported objects. Selecting an object opens it read-only via + * `OPEN_VARIABLE_PANELS_CMD` — the same panel path the Panels tree uses. + * Browse-only: no console session, no "attach", and the server-side PQ is never + * deleted. + * + * Which queries are listed is governed by the shared status filter + * (`IPersistentQueryStatusFilterService`); the server node's description states + * what the filter is doing, since a filter with no visible effect reads as a + * missing query. + * + * Tree shape: + * root → DHE `ServerState[]` + * server → `PersistentQueryNode[]` (alphabetized, status-filtered) + * PQ → `[URL, VariableDefintion][]` (object leaves, opened on click) + */ +export class PersistentQueryTreeProvider extends ServerTreeProviderBase { + constructor( + serverManager: IServerManager, + persistentQueryService: IPersistentQueryService, + statusFilterService: IPersistentQueryStatusFilterService + ) { + super(serverManager); + this._persistentQueryService = persistentQueryService; + this._statusFilterService = statusFilterService; + + // Refresh whenever the PQ set ticks. + this.disposables.add( + this._persistentQueryService.onDidUpdate(() => { + this._onDidChangeTreeData.fire(); + }) + ); + + // ...or whenever the user changes which statuses are listed. + this.disposables.add( + this._statusFilterService.onDidUpdate(() => { + this._onDidChangeTreeData.fire(); + }) + ); + } + + private readonly _persistentQueryService: IPersistentQueryService; + private readonly _statusFilterService: IPersistentQueryStatusFilterService; + + getTreeItem = async ( + node: PersistentQueryTreeNode + ): Promise => { + // Object leaf node (worker URL + variable). Reuse the Panels tree renderer + // so the click command is `OPEN_VARIABLE_PANELS_CMD` verbatim. Browse-only: + // this view never deletes anything from a PQ. + if (Array.isArray(node)) { + return getPanelVariableTreeItem(node, false); + } + + // Persistent-query node, drawn with its status circle. + if (isPersistentQueryNode(node)) { + return getPersistentQueryTreeItem(node); + } + + // The trailing node standing in for whatever the filter is hiding. + if (isPersistentQueryHiddenNode(node)) { + return getPersistentQueryHiddenTreeItem(node); + } + + // DHE server node grouping its persistent queries. + return getPersistentQueryServerTreeItem(node); + }; + + /** + * How many queries carry each status, summed across every connected DHE + * server — the counts shown beside each row of the filter picker. Unset + * statuses bucket under {@link UNSET_QUERY_STATUS}, matching the filter's own + * normalisation. Counts are of *all* queries, not just the visible ones: + * a hidden status still needs its count so the user can see what unhiding it + * would bring back. + */ + getStatusCounts = async (): Promise> => { + const servers = this.serverManager + .getServers({ type: 'DHE' }) + .filter(server => server.isConnected); + + const counts = new Map(); + + for (const server of servers) { + const queries = await this._persistentQueryService.getPersistentQueries( + server.url + ); + + for (const queryInfo of queries) { + const status = + getPersistentQueryStatus(queryInfo) ?? UNSET_QUERY_STATUS; + counts.set(status, (counts.get(status) ?? 0) + 1); + } + } + + return counts; + }; + + getChildren = async ( + elementOrRoot?: PersistentQueryTreeNode + ): Promise => { + // Root: one node per connected DHE server. + if (elementOrRoot == null) { + return this.serverManager + .getServers({ type: 'DHE' }) + .filter(server => server.isConnected) + .sort((a, b) => + getConnectionServerLabel(a).localeCompare(getConnectionServerLabel(b)) + ); + } + + // The hidden-count node is a leaf. + if (isPersistentQueryHiddenNode(elementOrRoot)) { + return []; + } + + // Object leaves have no children. + if (Array.isArray(elementOrRoot)) { + return []; + } + + // PQ node → its exported object leaves. Objects come straight off the + // running PQ's `designated.objects` (no console session, no connect-on- + // expand needed — the QueryInfo already carries them). The worker URL is + // registered as a browse connection so the embed panel can authenticate. + if (isPersistentQueryNode(elementOrRoot)) { + const { dheServerUrl, queryInfo } = elementOrRoot; + + const workerInfo = await this.serverManager.registerBrowseConnection( + dheServerUrl, + queryInfo + ); + + if (workerInfo == null) { + return []; + } + + return getPersistentQueryObjectLeaves( + new URL(workerInfo.workerUrl), + queryInfo + ); + } + + // Server node → its persistent queries, filtered by status and sorted by + // name, with a trailing node accounting for whatever the filter hid. + const dheServerUrl = elementOrRoot.url; + const queries = + await this._persistentQueryService.getPersistentQueries(dheServerUrl); + + const visible = queries.filter(queryInfo => + this._statusFilterService.isVisible(getPersistentQueryStatus(queryInfo)) + ); + + const children: PersistentQueryTreeNode[] = visible + .sort((a, b) => a.name.localeCompare(b.name)) + .map((queryInfo): PersistentQueryNode => ({ dheServerUrl, queryInfo })); + + const hiddenCount = queries.length - visible.length; + + // Always last, and only when something is actually hidden — a filter that + // hides nothing needs no footnote. + if (hiddenCount > 0) { + children.push({ dheServerUrl, hiddenCount }); + } + + return children; + }; +} diff --git a/src/providers/ServerConnectionPanelTreeProvider.ts b/src/providers/ServerConnectionPanelTreeProvider.ts deleted file mode 100644 index 6f602be36..000000000 --- a/src/providers/ServerConnectionPanelTreeProvider.ts +++ /dev/null @@ -1,61 +0,0 @@ -import * as vscode from 'vscode'; -import type { - IPanelService, - IServerManager, - ConnectionState, - ServerConnectionPanelNode, -} from '../types'; -import { ServerTreeProviderBase } from './ServerTreeProviderBase'; -import { - getPanelConnectionTreeItem, - getPanelVariableTreeItem, - sortByStringProp, -} from '../util'; -import { getFirstSupportedConsoleType } from '../services'; -import { getServerMatchPortIfLocalHost } from '../mcp/utils'; - -export class ServerConnectionPanelTreeProvider extends ServerTreeProviderBase { - constructor(serverManager: IServerManager, panelService: IPanelService) { - super(serverManager); - this._panelService = panelService; - - this._panelService.onDidUpdate(() => { - this._onDidChangeTreeData.fire(); - }); - } - - private readonly _panelService: IPanelService; - - getTreeItem = async ( - connectionOrVariable: ServerConnectionPanelNode - ): Promise => { - if (Array.isArray(connectionOrVariable)) { - return getPanelVariableTreeItem(connectionOrVariable); - } - - const serverLabel = getServerMatchPortIfLocalHost( - this.serverManager, - connectionOrVariable.serverUrl - )?.label; - - return getPanelConnectionTreeItem( - connectionOrVariable, - getFirstSupportedConsoleType, - serverLabel - ); - }; - - getChildren = ( - connectionOrRoot?: ConnectionState - ): vscode.ProviderResult => { - if (connectionOrRoot == null) { - return this.serverManager - .getConnections() - .sort(sortByStringProp('serverUrl')); - } - - return [...this._panelService.getVariables(connectionOrRoot.serverUrl)] - .sort(sortByStringProp('title')) - .map(variable => [connectionOrRoot.serverUrl, variable]); - }; -} diff --git a/src/providers/ServerConnectionTreeProvider.spec.ts b/src/providers/ServerConnectionTreeProvider.spec.ts new file mode 100644 index 000000000..2693848e2 --- /dev/null +++ b/src/providers/ServerConnectionTreeProvider.spec.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as vscode from 'vscode'; +import { ServerConnectionTreeProvider } from './ServerConnectionTreeProvider'; +import type { + ConnectionState, + IPanelService, + IServerManager, + ServerConnectionNode, + ServerState, + VariableDefintion, +} from '../types'; +import { OPEN_VARIABLE_PANELS_CMD } from '../common'; + +// See __mocks__/vscode.ts for the mock implementation +vi.mock('vscode'); + +const DHE_URL = new URL('https://dhe.example.com:8123/'); +const WORKER_URL = new URL('https://dhe.example.com:8123/worker/ic/'); + +const WORKER_LABEL = 'IC - VS Code - KM7lskRLXCESeQpTBPSTB'; + +function makeServerState(overrides: Partial = {}): ServerState { + return { + type: 'DHE', + url: DHE_URL, + label: 'DHE', + isConnected: true, + isRunning: true, + connectionCount: 1, + ...overrides, + } as ServerState; +} + +function makeVariable(title: string, id: string): VariableDefintion { + return { id, title, name: title, type: 'Table' } as VariableDefintion; +} + +describe('ServerConnectionTreeProvider', () => { + let serverManager: IServerManager; + let panelService: IPanelService; + let provider: ServerConnectionTreeProvider; + let connection: ConnectionState; + let uris: vscode.Uri[]; + let variables: VariableDefintion[]; + + beforeEach(() => { + vi.clearAllMocks(); + + connection = { + label: WORKER_LABEL, + isConnected: true, + serverUrl: WORKER_URL, + } as ConnectionState; + + uris = []; + variables = []; + + panelService = { + onDidUpdate: vi.fn(() => vi.fn()), + getVariables: vi.fn(() => variables), + } as unknown as IPanelService; + + serverManager = { + onDidUpdate: vi.fn(() => vi.fn()), + getConnections: vi.fn(() => [connection]), + getConnection: vi.fn(() => connection), + getConnectionUris: vi.fn(() => uris), + hasConnectionUris: vi.fn(() => uris.length > 0), + getServers: vi.fn(() => [makeServerState()]), + getServerForConnection: vi.fn(() => makeServerState()), + getUriConnection: vi.fn(() => connection), + } as unknown as IServerManager; + + provider = new ServerConnectionTreeProvider(serverManager, panelService); + }); + + describe('getChildren', () => { + it('returns one server node per server at the root', async () => { + const children = (await provider.getChildren()) as ServerState[]; + expect(children).toHaveLength(1); + expect(children[0].url).toBe(DHE_URL); + }); + + it('returns a server node`s worker connections', async () => { + const children = (await provider.getChildren( + makeServerState() + )) as ConnectionState[]; + expect(children).toEqual([connection]); + }); + + it('lists files alphabetized, then panels alphabetized', async () => { + uris = [ + vscode.Uri.parse('file:///workspace/z_first.py'), + vscode.Uri.parse('file:///workspace/nested/a_second.py'), + ]; + variables = [ + makeVariable('zebra', 'v1'), + makeVariable('apple', 'v2'), + // Not an openable panel type — must be dropped. + { + id: 'v3', + title: 'acl', + name: 'acl', + type: 'AclService', + } as unknown as VariableDefintion, + ]; + + const children = (await provider.getChildren( + connection + )) as ServerConnectionNode[]; + + expect(children).toHaveLength(4); + expect((children[0] as vscode.Uri).path).toBe( + '/workspace/nested/a_second.py' + ); + expect((children[1] as vscode.Uri).path).toBe('/workspace/z_first.py'); + expect((children[2] as [URL, VariableDefintion])[1].title).toBe('apple'); + expect((children[3] as [URL, VariableDefintion])[1].title).toBe('zebra'); + // Panel leaves are keyed by the worker url. + expect((children[2] as [URL, VariableDefintion])[0]).toBe(WORKER_URL); + }); + + it('returns no children for leaf nodes', async () => { + expect( + await provider.getChildren(vscode.Uri.parse('file:///workspace/a.py')) + ).toEqual([]); + expect( + await provider.getChildren([WORKER_URL, makeVariable('t1', 'v1')]) + ).toEqual([]); + }); + }); + + describe('getTreeItem', () => { + it('shortens the worker label and keeps the full name on hover', async () => { + const item = await provider.getTreeItem(connection); + expect(item.label).toBe('IC - VS Code - KM7lsk'); + expect(item.tooltip).toBe(WORKER_LABEL); + }); + + it('has no expander when the worker has no files or panels', async () => { + const item = await provider.getTreeItem(connection); + expect(item.collapsibleState).toBeUndefined(); + }); + + it('expands a worker that only has panels', async () => { + variables = [makeVariable('t1', 'v1')]; + const item = await provider.getTreeItem(connection); + expect(item.collapsibleState).toBe( + vscode.TreeItemCollapsibleState.Expanded + ); + }); + + it('expands a worker that only has files', async () => { + uris = [vscode.Uri.parse('file:///workspace/a.py')]; + const item = await provider.getTreeItem(connection); + expect(item.collapsibleState).toBe( + vscode.TreeItemCollapsibleState.Expanded + ); + }); + + it('renders a panel leaf with the open command + delete action', async () => { + const item = await provider.getTreeItem([ + WORKER_URL, + makeVariable('t1', 'v1'), + ]); + expect(item.label).toBe('t1'); + expect(item.command?.command).toBe(OPEN_VARIABLE_PANELS_CMD); + // Console-session variables can be deleted (PQ objects cannot). + expect(item.contextValue).toBe('canDeleteDeephavenVariable'); + }); + + it('renders a uri leaf', async () => { + const uri = vscode.Uri.parse('file:///workspace/a.py'); + const item = await provider.getTreeItem(uri); + expect(item.contextValue).toBe('isUri'); + expect(item.resourceUri).toBe(uri); + }); + }); + + describe('getParent', () => { + it('resolves a panel leaf to its connection', () => { + expect(provider.getParent([WORKER_URL, makeVariable('t1', 'v1')])).toBe( + connection + ); + expect(serverManager.getConnection).toHaveBeenCalledWith(WORKER_URL); + }); + + it('resolves a uri leaf to its connection', () => { + const uri = vscode.Uri.parse('file:///workspace/a.py'); + expect(provider.getParent(uri)).toBe(connection); + }); + + it('resolves a connection to its server', () => { + expect((provider.getParent(connection) as ServerState).url).toBe(DHE_URL); + }); + + it('resolves a server to the root', () => { + expect(provider.getParent(makeServerState())).toBeNull(); + }); + }); + + it('refreshes when the panel service updates', () => { + const listener = (panelService.onDidUpdate as ReturnType).mock + .calls[0][0] as () => void; + + const onDidChangeTreeData = vi.fn(); + provider.onDidChangeTreeData(onDidChangeTreeData); + + listener(); + + expect(onDidChangeTreeData).toHaveBeenCalled(); + }); +}); diff --git a/src/providers/ServerConnectionTreeProvider.ts b/src/providers/ServerConnectionTreeProvider.ts index 3815937b6..d57c1baef 100644 --- a/src/providers/ServerConnectionTreeProvider.ts +++ b/src/providers/ServerConnectionTreeProvider.ts @@ -1,91 +1,155 @@ import * as vscode from 'vscode'; +import * as path from 'node:path'; import { ServerTreeProviderBase } from './ServerTreeProviderBase'; import { CONNECTION_TREE_ITEM_CONTEXT, ICON_ID } from '../common'; import type { - IDhcService, ConnectionState, + IPanelService, + IServerManager, ServerConnectionNode, } from '../types'; -import { isInstanceOf, sortByStringProp } from '../util'; -import { DhcService } from '../services'; -import { getServerMatchPortIfLocalHost } from '../mcp/utils'; +import { + getConnectionServerTreeItem, + getConnectionTreeRootNodes, + getConsoleTypeIconId, + getPanelVariableLeaves, + getPanelVariableTreeItem, + getWorkerNodeLabel, + isInstanceOf, + isServerStateNode, + sortByStringProp, +} from '../util'; +import { DhcService, getFirstSupportedConsoleType } from '../services'; /** - * Provider for the server connection tree view. + * Provider for the server connection tree view. Each server node lists its + * console worker connections, and each worker lists the editor files associated + * with it followed by the panels open on its session. */ export class ServerConnectionTreeProvider extends ServerTreeProviderBase { + constructor(serverManager: IServerManager, panelService: IPanelService) { + super(serverManager); + this._panelService = panelService; + + // Refresh whenever a worker's variables change (panel opened / closed, code + // run), since worker nodes now list them. + this.disposables.add( + this._panelService.onDidUpdate(() => { + this._onDidChangeTreeData.fire(); + }) + ); + } + + private readonly _panelService: IPanelService; + + /** + * The editor uris associated with a connection, alphabetized by the file name + * the node shows (the full path is only the node description). + * @param connection The worker connection. + */ + private _getSortedUris = (connection: ConnectionState): vscode.Uri[] => + this.serverManager + .getConnectionUris(connection) + .sort( + (a, b) => + path.posix + .basename(a.path) + .localeCompare(path.posix.basename(b.path)) || + a.path.localeCompare(b.path) + ); + getTreeItem = async ( - connectionOrUri: ServerConnectionNode + node: ServerConnectionNode ): Promise => { + // Panel leaf node associated with a parent connection node. Worker + // connections always own a console session (`getConnections` excludes + // browse connections), so their variables can be deleted. + if (Array.isArray(node)) { + return getPanelVariableTreeItem(node, true); + } + // Uri node associated with a parent connection node - if (connectionOrUri instanceof vscode.Uri) { + if (node instanceof vscode.Uri) { return { - description: connectionOrUri.path, + description: node.path, contextValue: CONNECTION_TREE_ITEM_CONTEXT.isUri, command: { command: 'vscode.open', title: 'Open Uri', - arguments: [connectionOrUri], + arguments: [node], }, - resourceUri: connectionOrUri, + resourceUri: node, }; } - const descriptionTokens: string[] = []; - - if ( - isInstanceOf(connectionOrUri, DhcService) && - connectionOrUri.isInitialized - ) { - const [consoleType] = await connectionOrUri.getConsoleTypes(); - if (consoleType) { - descriptionTokens.push(consoleType); - } - } - - if (connectionOrUri.tagId) { - descriptionTokens.push(connectionOrUri.tagId); + // DHE server node grouping its worker connections. + if (isServerStateNode(node)) { + return getConnectionServerTreeItem(node); } - const hasUris = this.serverManager.hasConnectionUris(connectionOrUri); + // Console type (language) drives the node icon rather than the description. + const consoleType = await getFirstSupportedConsoleType(node); - const serverLabel = getServerMatchPortIfLocalHost( - this.serverManager, - connectionOrUri.serverUrl - )?.label; + const hasChildren = + this.serverManager.hasConnectionUris(node) || + getPanelVariableLeaves(this._panelService, node.serverUrl).length > 0; - const label = serverLabel ?? connectionOrUri.serverUrl.host; + // Identify connections created by the extension or in-flight placeholders + const isOwned = isInstanceOf(node, DhcService) ? node.isOwned : true; // Connection node return { - label, - description: descriptionTokens.join(' - '), - contextValue: connectionOrUri.isConnected - ? CONNECTION_TREE_ITEM_CONTEXT.isConnectionConnected - : CONNECTION_TREE_ITEM_CONTEXT.isConnectionConnecting, - collapsibleState: hasUris + // Worker names end in a long generated id, so the node shows a shortened + // form and keeps the full name on hover. + label: getWorkerNodeLabel(node.label), + tooltip: node.label, + contextValue: node.isConnected + ? CONNECTION_TREE_ITEM_CONTEXT.isConnectionConnected(isOwned) + : CONNECTION_TREE_ITEM_CONTEXT.isConnectionConnecting(isOwned), + collapsibleState: hasChildren ? vscode.TreeItemCollapsibleState.Expanded : undefined, + // Show the language (Python/Groovy) icon when idle/connected; show the + // spinner while busy (connecting or running code). iconPath: new vscode.ThemeIcon( - connectionOrUri.isRunningCode - ? ICON_ID.runningCode - : connectionOrUri.isConnected - ? ICON_ID.connected - : ICON_ID.connecting + node.isRunningCode || !node.isConnected + ? ICON_ID.connecting + : getConsoleTypeIconId(consoleType) ), }; }; getChildren = ( - elementOrRoot?: IDhcService + elementOrRoot?: ServerConnectionNode ): vscode.ProviderResult => { + // Root: one server node per server that has connections. if (elementOrRoot == null) { + return getConnectionTreeRootNodes(this.serverManager); + } + + // Panel leaf nodes have no children. + if (Array.isArray(elementOrRoot)) { + return []; + } + + // Uri leaf nodes have no children. + if (elementOrRoot instanceof vscode.Uri) { + return []; + } + + // Server node -> its worker connections. + if (isServerStateNode(elementOrRoot)) { return this.serverManager - .getConnections() + .getConnections(elementOrRoot.url) .sort(sortByStringProp('serverUrl')); } - return this.serverManager.getConnectionUris(elementOrRoot); + // Connection node -> its editor uris, then its open panels. Files sort to + // the top since they are the association the user manages; panels follow. + return [ + ...this._getSortedUris(elementOrRoot), + ...getPanelVariableLeaves(this._panelService, elementOrRoot.serverUrl), + ]; }; /** @@ -93,11 +157,22 @@ export class ServerConnectionTreeProvider extends ServerTreeProviderBase { + getParent = (element: ServerConnectionNode): ServerConnectionNode | null => { + // Panel leaf node -> the connection hosting the variable. + if (Array.isArray(element)) { + const [workerUrl] = element; + return this.serverManager.getConnection(workerUrl) ?? null; + } + if (element instanceof vscode.Uri) { return this.serverManager.getUriConnection(element); } - return null; + if (isServerStateNode(element)) { + return null; + } + + // Connection node -> its parent server. + return this.serverManager.getServerForConnection(element) ?? null; }; } diff --git a/src/providers/ServerTreeProvider.ts b/src/providers/ServerTreeProvider.ts index 629382f53..8eafaaad3 100644 --- a/src/providers/ServerTreeProvider.ts +++ b/src/providers/ServerTreeProvider.ts @@ -21,7 +21,10 @@ export class ServerTreeProvider extends ServerTreeProviderBase { return getServerGroupTreeItem(element, this.serverManager.canStartServer); } - return getServerTreeItem(element); + return getServerTreeItem( + element, + this.serverManager.isServerConnecting(element.url) + ); }; getChildren(elementOrRoot?: ServerNode): vscode.ProviderResult { diff --git a/src/providers/index.ts b/src/providers/index.ts index 7846bf92c..3fc92f99c 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,5 +1,6 @@ export * from './CreateQueryViewProvider'; export * from './McpServerDefinitionProvider'; +export * from './PersistentQueryTreeProvider'; export * from './RemoteImportSourceTreeProvider'; export * from './RunCommandCodeLensProvider'; export * from './RunMarkdownCodeBlockCodeLensProvider'; @@ -8,5 +9,4 @@ export * from './RunSelectedLinesHoverProvider'; export * from './SamlAuthProvider'; export * from './ServerConnectionTreeProvider'; export * from './ServerTreeProvider'; -export * from './ServerConnectionPanelTreeProvider'; export * from './TreeDataProviderBase'; diff --git a/src/services/DhcService.ts b/src/services/DhcService.ts index 3c4002451..3922db73f 100644 --- a/src/services/DhcService.ts +++ b/src/services/DhcService.ts @@ -6,6 +6,7 @@ import { formatTimestamp, getCombinedRangeLinesText, isNonEmptyArray, + isOpenablePanelVariable, Logger, saveRequirementsTxt, type URLMap, @@ -72,9 +73,16 @@ export class DhcService extends DisposableBase implements IDhcService { toaster: IToastService ): IDhcServiceFactory => { return { - create: (serverUrl: URL, tagId?: UniqueID): IDhcService => { + create: ( + label: string, + serverUrl: URL, + isOwned: boolean, + tagId?: UniqueID + ): IDhcService => { return new DhcService( + label, serverUrl, + isOwned, coreClientCache, groovyDiagnosticsCollection, diagnosticsCollection, @@ -94,7 +102,9 @@ export class DhcService extends DisposableBase implements IDhcService { * mechanism for instantiating. */ private constructor( + label: string, serverUrl: URL, + isOwned: boolean, coreClientCache: URLMap, groovyDiagnosticsCollection: vscode.DiagnosticCollection, diagnosticsCollection: vscode.DiagnosticCollection, @@ -108,6 +118,8 @@ export class DhcService extends DisposableBase implements IDhcService { super(); this.coreClientCache = coreClientCache; + this.isOwned = isOwned; + this.label = label; this.groovyDiagnosticsCollection = groovyDiagnosticsCollection; this.diagnosticsCollection = diagnosticsCollection; this.remoteFileSourceService = remoteFileSourceService; @@ -132,6 +144,8 @@ export class DhcService extends DisposableBase implements IDhcService { private readonly _onDidDisconnect = new vscode.EventEmitter(); readonly onDidDisconnect = this._onDidDisconnect.event; + public readonly isOwned: boolean; + public readonly label: string; public readonly serverUrl: URL; public readonly tagId?: UniqueID; @@ -658,7 +672,12 @@ export class DhcService extends DisposableBase implements IDhcService { this.outputChannel.appendLine(`${icon} ${title}`); }); - const showVariables = changed.filter(v => !v.title.startsWith('_')); + // Everything created is logged above; only open panels for variables that + // can actually render in one (a `deephaven.ui.Dashboard`, for example, would + // open a blank panel). + const showVariables = changed.filter( + v => !v.title.startsWith('_') && isOpenablePanelVariable(v) + ); if (isNonEmptyArray(showVariables)) { logger.debug( diff --git a/src/services/DheService.ts b/src/services/DheService.ts index 79b504668..e27e347d9 100644 --- a/src/services/DheService.ts +++ b/src/services/DheService.ts @@ -3,6 +3,12 @@ import type { EnterpriseDhType as DheType, QueryInfo, } from '@deephaven-enterprise/jsapi-types'; +import type { + AuthenticatedEnterpriseClient, + CorePlusManager, +} from '@deephaven-enterprise/client-utils'; +import { EnterpriseCorePlusManager } from '@deephaven-enterprise/jsapi-manager'; +import { createJsApiFactories } from '@deephaven-enterprise/jsapi-nodejs'; import { type ConsoleType, type DheAuthenticatedClientWrapper, @@ -18,14 +24,24 @@ import { type WorkerInfo, type WorkerURL, } from '../types'; -import { Logger, URLMap } from '../util'; import { + getSharedTransportFactory, + getTempDir, + Logger, + uniqueId, + URLMap, + urlToDirectoryName, +} from '../util'; +import { + getWorkerInfoFromQueryInfo, createInteractiveConsoleQuery, createQueryName, deleteQueries, getDheFeatures, getSerialFromTagId, - getWorkerInfoFromQuery, + getWorkerInfoFromQuerySerial, + isAttachableWorker, + listAttachableWorkers, } from '../dh/dhe'; import { CLOSE_CREATE_QUERY_VIEW_CMD, @@ -61,15 +77,21 @@ export class DheService implements IDheService { toaster: IToastService ): IDheServiceFactory => { return { - create: (serverUrl: URL): IDheService => - new DheService( + create: (serverUrl: URL): IDheService => { + const serverConfig = configService + .getEnterpriseServers() + .find(server => server.url.href === serverUrl.href); + + return new DheService( + serverConfig?.label ?? serverUrl.href, serverUrl, configService, dheClientCache, dheJsApiCache, interactiveConsoleQueryFactory, toaster - ), + ); + }, }; }; @@ -78,6 +100,7 @@ export class DheService implements IDheService { * mechanism for instantiating. */ private constructor( + label: string, serverUrl: URL, configService: IConfigService, dheClientCache: URLMap, @@ -85,6 +108,7 @@ export class DheService implements IDheService { interactiveConsoleQueryFactory: IInteractiveConsoleQueryFactory, toaster: IToastService ) { + this.label = label; this.serverUrl = serverUrl; this._config = configService; this._dheClientCache = dheClientCache; @@ -100,19 +124,29 @@ export class DheService implements IDheService { private _clientPromise: Promise | null = null; + private _corePlusManagerPromise: Promise | null = + null; private _isConnected: boolean = false; + private _operateAs: string | null = null; + private _removeConfigListeners: (() => void) | null = null; + private readonly _config: IConfigService; private readonly _dheClientCache: URLMap; private readonly _dheJsApiCache: IAsyncCacheService; private readonly _dheServerFeaturesCache: URLMap; + private readonly _pendingQueryTagIds = new Set(); private readonly _querySerialSet: Set; private readonly _interactiveConsoleQueryFactory: IInteractiveConsoleQueryFactory; private readonly _toaster: IToastService; private readonly _workerInfoMap: URLMap; - private readonly _onDidWorkerTerminate = new vscode.EventEmitter(); - readonly onDidWorkerTerminate = this._onDidWorkerTerminate.event; + private readonly _onWorkerAttachable = new vscode.EventEmitter(); + readonly onWorkerAttachable = this._onWorkerAttachable.event; + private readonly _onWorkerRemoved = new vscode.EventEmitter(); + readonly onWorkerRemoved = this._onWorkerRemoved.event; + + readonly label: string; readonly serverUrl: URL; /** @@ -141,7 +175,11 @@ export class DheService implements IDheService { const maybeClient = await this._dheClientCache.get(this.serverUrl); if (maybeClient != null) { - this._subscribeToWorkerTermination(maybeClient); + const userInfo = await maybeClient.client.getUserInfo(); + + this._operateAs = userInfo.operateAs; + + await this._subscribeToWorkerEvents(maybeClient); } if (!this._dheServerFeaturesCache.has(this.serverUrl)) { @@ -177,44 +215,122 @@ export class DheService implements IDheService { }; private _onDidDheClientCacheInvalidate = (url: URL): void => { - if (url.toString() === this.serverUrl.toString()) { + // Only reset when the client was actually removed from the cache (logout / + // disconnect / failed login). The cache also fires `onDidChange` on `set`, + // which happens during a successful login from inside `_initClient` itself. + // Resetting on that `set` would null out the in-flight `_clientPromise` we + // are currently awaiting, so a subsequent `getClient(false)` (e.g. from + // `listAttachableWorkers`) would see `null` and short-circuit — silently + // skipping the attach path. Guard on the client being absent so we only + // reset for genuine invalidations. + if ( + url.toString() === this.serverUrl.toString() && + !this._dheClientCache.has(this.serverUrl) + ) { // Reset the client promise so that the next call to `getClient` can // reinitialize it if necessary. this._clientPromise = null; + + // The CorePlusManager is bound to the specific (now stale) client + // instance, so dispose + null it. The next `getCorePlusManager` call will + // rebuild one from the fresh client. + this._disposeCorePlusManager(); + } + }; + + /** + * Dispose the current CorePlusManager (if any) and reset the cached promise so + * a subsequent `getCorePlusManager` rebuilds one from the current client. + */ + private _disposeCorePlusManager = (): void => { + const managerPromise = this._corePlusManagerPromise; + this._corePlusManagerPromise = null; + + if (managerPromise == null) { + return; } + + void managerPromise + .then(manager => manager?.dispose()) + .catch(err => { + logger.error('Failed to dispose CorePlusManager', err); + }); }; /** - * Subscribe to DHE config updates to detect when workers enter a terminal state. + * Subscribe to DHE config added/updated/removed events. Emits + * `onDidWorkerAttachable` when an IC worker becomes attachable, and + * `onDidWorkerRemoved` when a tracked worker is removed or enters a terminal + * state. Replaces any previous subscription. * @param dheClient DHE client to use. */ - private _subscribeToWorkerTermination = async ( + private _subscribeToWorkerEvents = async ( dheClient: DheAuthenticatedClientWrapper ): Promise => { + // Remove any previous listeners before re-registering. + this._removeConfigListeners?.(); + const dhe = await this._dheJsApiCache.get(this.serverUrl); - dheClient.client.addEventListener( - dhe.Client.EVENT_CONFIG_UPDATED, - ({ detail: queryInfo }: CustomEvent) => { - const status = queryInfo.designated?.status; - if (!isTerminalQueryStatus(status)) { - return; + const onConfigAddedOrUpdated = ({ + detail: queryInfo, + }: CustomEvent): void => { + const status = queryInfo.designated?.status; + + if (isTerminalQueryStatus(status)) { + // Terminal status on a tracked worker, fire _onWorkerRemoved + if (this._isQueryTracked(queryInfo)) { + logger.info('Worker entered terminal state:', queryInfo.serial); + this._onWorkerRemoved.fire(queryInfo.serial as QuerySerial); } - const workerInfo = [...this._workerInfoMap.values()].find( - w => w.serial === queryInfo.serial - ); + return; + } - if (workerInfo == null) { - return; - } + if ( + !this._isQueryOwned(queryInfo) && + isAttachableWorker(queryInfo, this._operateAs) + ) { + this._onWorkerAttachable.fire(queryInfo); + } + }; - logger.info( - 'Worker entered terminal state:', - workerInfo.workerUrl.href - ); - this._onDidWorkerTerminate.fire(workerInfo.workerUrl); + const onConfigRemoved = ({ + detail: queryInfo, + }: CustomEvent): void => { + if (!this._isQueryTracked(queryInfo)) { + return; + } + + logger.info('Worker removed:', queryInfo.serial); + this._onWorkerRemoved.fire(queryInfo.serial as QuerySerial); + }; + + const removeConfigListeners: (() => void)[] = []; + this._removeConfigListeners = (): void => { + for (const remove of removeConfigListeners) { + remove(); } + removeConfigListeners.length = 0; + }; + + removeConfigListeners.push( + dheClient.client.addEventListener( + dhe.Client.EVENT_CONFIG_ADDED, + onConfigAddedOrUpdated + ) + ); + removeConfigListeners.push( + dheClient.client.addEventListener( + dhe.Client.EVENT_CONFIG_UPDATED, + onConfigAddedOrUpdated + ) + ); + removeConfigListeners.push( + dheClient.client.addEventListener( + dhe.Client.EVENT_CONFIG_REMOVED, + onConfigRemoved + ) ); }; @@ -273,6 +389,96 @@ export class DheService implements IDheService { return dheClient; } + /** + * Lazily create the `EnterpriseCorePlusManager` for this DHE server, built + * from the client this service already authenticates (Decision 1 — no second + * login). Returns null when no client is available. The manager is cached on + * the instance and disposed with the service / on client-cache invalidation. + * @returns The CorePlusManager or null if the client is not available. + */ + getCorePlusManager = async (): Promise => { + if (this._corePlusManagerPromise == null) { + this._corePlusManagerPromise = this._initCorePlusManager(); + } + + const manager = await this._corePlusManagerPromise; + + // If construction failed, clear the cached rejection/null so a later call + // can retry once the client is available. + if (manager == null) { + this._corePlusManagerPromise = null; + } + + return manager; + }; + + /** + * Build the `EnterpriseCorePlusManager` from the already-authenticated DHE + * client (Decision 1). Replicates `initCorePlusManager`'s assembly minus the + * login so the manager reuses the extension's existing session. Reuses the + * extension's jsapi storage dir (Gotcha 6) and `NodeHttp2gRPCTransport`. + * @returns The CorePlusManager or null if the client is not available. + */ + private _initCorePlusManager = async (): Promise => { + const dheClient = await this.getClient(false); + if (dheClient == null) { + return null; + } + + const dhe = await this._dheJsApiCache.get(this.serverUrl); + const { workerKinds } = await dheClient.client.getServerConfigValues(); + + // Only the `loadCorePlusApi` (worker api loader) and `createCoreClient` + // (construct-only, un-logged-in — Gotcha 2) hooks are needed. The manager + // owns worker login via an auth token off the DHE client. + const { loadCorePlusApi, createCoreClient } = createJsApiFactories({ + storageDir: getTempDir({ + subDirectory: urlToDirectoryName(this.serverUrl), + }), + transportFactory: getSharedTransportFactory(), + }); + + return new EnterpriseCorePlusManager( + dhe, + // `DheAuthenticatedClientWrapper.client` is branded + // `AuthenticatedClient` (auth-nodejs); the manager expects the + // structurally-identical `AuthenticatedEnterpriseClient` brand + // (client-utils). Same underlying `EnterpriseClient`. + dheClient.client as unknown as AuthenticatedEnterpriseClient, + workerKinds, + loadCorePlusApi, + createCoreClient + ); + }; + + private _isQueryOwned = ( + querySerialOrInfo: QuerySerial | QueryInfo + ): boolean => { + const { name, serial } = + typeof querySerialOrInfo === 'object' + ? querySerialOrInfo + : { serial: querySerialOrInfo }; + + if (name != null) { + for (const tagId of this._pendingQueryTagIds) { + // Created workers are named `IC - VS Code - ` (the iframe create + // flow may append a `_vN` version suffix), so a prefix match against + // in-flight tagIds covers both forms + const namePrefix = createQueryName(tagId); + + if (name.startsWith(namePrefix)) { + return true; + } + } + } + + return this._querySerialSet.has(serial as QuerySerial); + }; + + private _isQueryTracked = ({ serial }: QueryInfo): boolean => { + return [...this._workerInfoMap.values()].some(w => w.serial === serial); + }; + /** * Create an InteractiveConsole query and get worker info from it. * @param tagId Unique tag id to include in the worker info. @@ -300,6 +506,15 @@ export class DheService implements IDheService { let startupFailureStatus: string | null = null; const queryName = createQueryName(tagId); + + // Suppress auto-attach for this worker while it is being created. The + // config event that flips it to `Running` would otherwise race the create + // path and auto-attach it (see `onDidWorkerAttachable` guard). Cleared in + // the `finally` below, by which point its serial is in `_querySerialSet` + // (added synchronously right after, with no `await` in between), so the + // serial-based guard takes over without a gap. + this._pendingQueryTagIds.add(tagId); + const removeStartupFailureListener = dheClient.client.addEventListener( dhe.Client.EVENT_CONFIG_UPDATED, ({ detail: queryInfo }: CustomEvent) => { @@ -348,6 +563,7 @@ export class DheService implements IDheService { throw err; } finally { removeStartupFailureListener(); + this._pendingQueryTagIds.delete(tagId); } if (querySerial == null) { @@ -355,7 +571,7 @@ export class DheService implements IDheService { } this._querySerialSet.add(querySerial); - const workerInfo = await getWorkerInfoFromQuery( + const workerInfo = await getWorkerInfoFromQuerySerial( tagId, dhe, dheClient.client, @@ -371,7 +587,46 @@ export class DheService implements IDheService { }; /** - * Delete a worker. + * Register a pre-existing Running worker by building WorkerInfo from the + * given QueryInfo. Does NOT add the serial to `_querySerialSet` — registered + * workers are never owned and must never be deleted by the extension. + * @param queryInfo The already-Running QueryInfo for the worker to register. + * @returns WorkerInfo for the registered worker. + */ + registerWorkerInfo = (queryInfo: QueryInfo): WorkerInfo => { + const tagId = uniqueId(); + const workerInfo = getWorkerInfoFromQueryInfo(tagId, queryInfo); + if (workerInfo == null) { + throw new Error( + `Cannot register worker for query info: ${queryInfo.serial}` + ); + } + + this._workerInfoMap.set(workerInfo.workerUrl, workerInfo); + + return workerInfo; + }; + + /** + * List all running InteractiveConsole workers owned by the current effective + * user. + * @param exclude Iterable of query serials to exclude from the results. + * @returns A promise resolving to the filtered QueryInfo array. + */ + listAttachableWorkers = async ( + exclude: Iterable + ): Promise => { + const dheClient = await this.getClient(false); + if (dheClient == null) { + return []; + } + return listAttachableWorkers(dheClient.client, exclude); + }; + + /** + * Delete a worker. Only deletes the server-side PQ when the worker is owned + * by this extension (serial is in `_querySerialSet`). Attached workers are + * removed from `_workerInfoMap` but the PQ is left running. * @param workerUrl Worker URL to delete. */ deleteWorker = async (workerUrl: WorkerURL): Promise => { @@ -380,10 +635,12 @@ export class DheService implements IDheService { return; } - this._querySerialSet.delete(workerInfo.serial); this._workerInfoMap.delete(workerUrl); - await this._disposeQueries([workerInfo.serial]); + if (this._isQueryOwned(workerInfo.serial)) { + this._querySerialSet.delete(workerInfo.serial); + await this._disposeQueries([workerInfo.serial]); + } }; getQuerySerialFromTag = async ( @@ -405,13 +662,23 @@ export class DheService implements IDheService { dispose = async (): Promise => { const querySerials = [...this._querySerialSet]; + const managerPromise = this._corePlusManagerPromise; + this._corePlusManagerPromise = null; this._querySerialSet.clear(); - this._onDidWorkerTerminate.dispose(); + this._removeConfigListeners?.(); + this._removeConfigListeners = null; + this._onWorkerAttachable.dispose(); + this._onWorkerRemoved.dispose(); await Promise.all([ this._workerInfoMap.dispose(), this._disposeQueries(querySerials), + managerPromise + ?.then(manager => manager?.dispose()) + .catch(err => { + logger.error('Failed to dispose CorePlusManager', err); + }), ]); }; } diff --git a/src/services/PersistentQueryService.spec.ts b/src/services/PersistentQueryService.spec.ts new file mode 100644 index 000000000..8eef21480 --- /dev/null +++ b/src/services/PersistentQueryService.spec.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; +import { PersistentQueryService } from './PersistentQueryService'; +import { getQueryInfoTableMock } from '../testUtils'; +import type { IAsyncCacheService, IDheService, IServerManager } from '../types'; + +// See __mocks__/vscode.ts for the mock implementation +vi.mock('vscode'); + +// Control the QueryConfigTableService the service constructs internally. +const getQueryInfoTable = vi.fn(); +vi.mock('./QueryConfigTableService', async importActual => { + const actual = + await importActual(); + return { + ...actual, + // eslint-disable-next-line @typescript-eslint/naming-convention + QueryConfigTableService: class { + getQueryInfoTable = getQueryInfoTable; + dispose = vi.fn(async () => {}); + }, + }; +}); + +const DHE_URL = new URL('https://dhe.example.com:8123/'); + +function makeQueryInfo(overrides: Partial = {}): QueryInfo { + return { + serial: 'serial-1', + name: 'My PQ', + type: 'DeephavenCommunity', + designated: { status: 'Running', objects: [] }, + ...overrides, + } as unknown as QueryInfo; +} + +/** A `QueryInfoTableSubscription` mock over the given serials. */ +const makeSubscription = (serials: string[]): unknown => + getQueryInfoTableMock({ serials }); + +describe('PersistentQueryService', () => { + let serverManager: IServerManager; + let dheServiceCache: IAsyncCacheService; + let dheJsApiCache: IAsyncCacheService; + let service: PersistentQueryService; + let knownConfigs: QueryInfo[]; + + beforeEach(() => { + vi.clearAllMocks(); + + knownConfigs = [makeQueryInfo()]; + + const dheService = { + getClient: vi.fn(async () => ({ + client: { getKnownConfigs: vi.fn(() => knownConfigs) }, + })), + } as unknown as IDheService; + + dheServiceCache = { + get: vi.fn(async () => dheService), + has: vi.fn(() => true), + } as unknown as IAsyncCacheService; + + dheJsApiCache = { + get: vi.fn(async () => ({}) as never), + has: vi.fn(() => true), + } as unknown as IAsyncCacheService; + + serverManager = { + onDidDisconnect: vi.fn(() => vi.fn()), + } as unknown as IServerManager; + + service = new PersistentQueryService( + serverManager, + dheServiceCache, + dheJsApiCache + ); + }); + + it('applies an exclude-helper-types filter with no status filter', async () => { + getQueryInfoTable.mockResolvedValue(makeSubscription(['serial-1'])); + + await service.getPersistentQueries(DHE_URL); + + expect(getQueryInfoTable).toHaveBeenCalledWith({ + excludeHelperTypes: true, + }); + }); + + it('resolves the ticking serials to known configs, sorted by name', async () => { + knownConfigs = [ + makeQueryInfo({ serial: 'serial-1', name: 'Zeta PQ' }), + makeQueryInfo({ serial: 'serial-2', name: 'Alpha PQ' }), + // InteractiveConsole — belongs to the Workers tree, never listed here. + makeQueryInfo({ serial: 'serial-ic', type: 'InteractiveConsole' }), + // Not in the filtered table — excluded. + makeQueryInfo({ serial: 'serial-hidden', name: 'Hidden' }), + ]; + + getQueryInfoTable.mockResolvedValue( + makeSubscription(['serial-1', 'serial-2', 'serial-ic']) + ); + + const queries = await service.getPersistentQueries(DHE_URL); + expect(queries.map(q => q.name)).toEqual(['Alpha PQ', 'Zeta PQ']); + }); + + it('includes stopped queries (no status filter, no designated worker)', async () => { + knownConfigs = [ + makeQueryInfo({ + serial: 'serial-stopped', + name: 'Stopped PQ', + designated: undefined, + } as Partial), + ]; + + getQueryInfoTable.mockResolvedValue(makeSubscription(['serial-stopped'])); + + const queries = await service.getPersistentQueries(DHE_URL); + expect(queries.map(q => q.name)).toEqual(['Stopped PQ']); + }); + + it('returns an empty list when the table fails to load', async () => { + getQueryInfoTable.mockRejectedValue(new Error('WebClientData down')); + + expect(await service.getPersistentQueries(DHE_URL)).toEqual([]); + }); + + it('reuses one subscription per server across calls', async () => { + getQueryInfoTable.mockResolvedValue(makeSubscription(['serial-1'])); + + await service.getPersistentQueries(DHE_URL); + await service.getPersistentQueries(DHE_URL); + + expect(getQueryInfoTable).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/services/PersistentQueryService.ts b/src/services/PersistentQueryService.ts new file mode 100644 index 000000000..ee8fcbe3c --- /dev/null +++ b/src/services/PersistentQueryService.ts @@ -0,0 +1,220 @@ +import * as vscode from 'vscode'; +import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; +import type { EnterpriseDhType as DheType } from '@deephaven-enterprise/jsapi-types'; +import { INTERACTIVE_CONSOLE_QUERY_TYPE } from '../common'; +import type { + IAsyncCacheService, + IDheService, + IPersistentQueryService, + IServerManager, +} from '../types'; +import { Logger } from '../util'; +import { DisposableBase } from './DisposableBase'; +import { + QueryConfigTableService, + type QueryInfoTableSubscription, +} from './QueryConfigTableService'; + +const logger = new Logger('PersistentQueryService'); + +/** + * Per-DHE-server source of the ACL-visible persistent queries, shared by every + * view that lists PQs (the Persistent Queries tree and the Panels tree). + * + * Serials come from the ticking `QueryInfo` table (server-side filtered to + * non-InteractiveConsole / non-helper types, any status) and are resolved to + * full `QueryInfo` objects via `getKnownConfigs()` — the table rows carry no + * `designated` block, which is where a PQ's status and exported objects live. + * `onDidUpdate` fires on every table tick so consumers can refresh. + */ +export class PersistentQueryService + extends DisposableBase + implements IPersistentQueryService +{ + /** + * @param serverManager Server manager (for disconnect teardown). + * @param dheServiceCache Cache providing the DHE service per server URL. + * @param dheJsApiCache Cache providing the DHE JS API per server URL. + */ + constructor( + serverManager: IServerManager, + dheServiceCache: IAsyncCacheService, + dheJsApiCache: IAsyncCacheService + ) { + super(); + this._dheServiceCache = dheServiceCache; + this._dheJsApiCache = dheJsApiCache; + + // Tear down per-server table subscriptions for any server that goes away + // (disconnect / config change removing the server). + this.disposables.add( + serverManager.onDidDisconnect(url => { + this._disposeServer(url); + }) + ); + } + + private readonly _dheServiceCache: IAsyncCacheService; + private readonly _dheJsApiCache: IAsyncCacheService; + + private readonly _onDidUpdate = new vscode.EventEmitter(); + readonly onDidUpdate = this._onDidUpdate.event; + + /** One `QueryConfigTableService` per DHE server URL. */ + private readonly _tableServiceMap = new Map< + string, + QueryConfigTableService + >(); + /** The ticking `QueryInfo` table subscription per DHE server URL. */ + private readonly _subscriptionMap = new Map< + string, + Promise + >(); + + /** + * Get (creating if needed) the ticking `QueryInfo` table subscription for a + * DHE server. Cached per server; every tick re-fires `onDidUpdate`. + * @param serverUrl The DHE server URL. + */ + private _getSubscription = ( + serverUrl: URL + ): Promise => { + const key = serverUrl.toString(); + + let subscriptionPromise = this._subscriptionMap.get(key); + if (subscriptionPromise != null) { + return subscriptionPromise; + } + + subscriptionPromise = (async (): Promise => { + const dheService = await this._dheServiceCache.get(serverUrl); + + let tableService = this._tableServiceMap.get(key); + if (tableService == null) { + tableService = new QueryConfigTableService( + serverUrl, + dheService, + this._dheJsApiCache + ); + this._tableServiceMap.set(key, tableService); + this.disposables.add(tableService); + } + + // Server-side filter: exclude InteractiveConsole + other helper/system + // query types. Deliberately unfiltered by status so stopped / failed PQs + // still list (their state is rendered on the node). + const subscription = await tableService.getQueryInfoTable({ + excludeHelperTypes: true, + }); + + this.disposables.add( + subscription.onDidUpdate(() => { + this._onDidUpdate.fire(); + }) + ); + + return subscription; + })(); + + this._subscriptionMap.set(key, subscriptionPromise); + + // If the table fails to load (e.g. WebClientData unavailable), drop the + // cached rejection so a later refresh can retry. + subscriptionPromise.catch(err => { + logger.error(`Failed to load QueryInfo table for ${serverUrl}:`, err); + if (this._subscriptionMap.get(key) === subscriptionPromise) { + this._subscriptionMap.delete(key); + } + }); + + return subscriptionPromise; + }; + + /** + * Dispose the table subscription + service for a server (on disconnect). + * @param serverUrl The DHE server URL. + */ + private _disposeServer = (serverUrl: URL): void => { + const key = serverUrl.toString(); + + const subscriptionPromise = this._subscriptionMap.get(key); + this._subscriptionMap.delete(key); + if (subscriptionPromise != null) { + void subscriptionPromise + .then(subscription => subscription.dispose()) + .catch(() => { + // Ignore — already logged / disposed. + }); + } + + const tableService = this._tableServiceMap.get(key); + this._tableServiceMap.delete(key); + if (tableService != null) { + this.disposables.delete(tableService); + void tableService.dispose(); + } + + this._onDidUpdate.fire(); + }; + + /** + * The persistent queries visible on a DHE server, sorted by name. Any status + * (running, stopped, failed) — InteractiveConsole workers are never included; + * those belong to the Workers tree. + * @param serverUrl The DHE server URL. + */ + getPersistentQueries = async (serverUrl: URL): Promise => { + const dheService = await this._dheServiceCache.get(serverUrl); + const dheClient = await dheService.getClient(false); + if (dheClient == null) { + return []; + } + + let subscription: QueryInfoTableSubscription; + try { + subscription = await this._getSubscription(serverUrl); + } catch { + // Error already logged in `_getSubscription`; show an empty PQ list. + return []; + } + + const serials = subscription.getQuerySerials(); + + const knownConfigs = dheClient.client.getKnownConfigs(); + const knownSerials = new Set( + knownConfigs.map(queryInfo => String(queryInfo.serial)) + ); + + const queries = knownConfigs.filter( + queryInfo => + queryInfo.serial != null && + serials.has(String(queryInfo.serial)) && + // Defense in depth: never list InteractiveConsole workers here (they + // live in the WORKERS tree). + queryInfo.type !== INTERACTIVE_CONSOLE_QUERY_TYPE + ); + + // A serial in the table with no matching config would silently vanish from + // the tree, so surface it rather than dropping it quietly. + const unresolved = [...serials].filter(serial => !knownSerials.has(serial)); + if (unresolved.length > 0) { + // Capped: this fires on every tree refresh, and a server can hold tens of + // thousands of queries — stringifying the whole array each time would cost + // more than the lookup it is reporting on. + logger.debug( + `Table serials with no known config for ${serverUrl.href}: ${unresolved.length} total,`, + unresolved.slice(0, 20) + ); + } + + logger.debug( + `PQs for ${serverUrl.href}: table serials=${serials.size}, known configs=${knownConfigs.length}, resolved=${queries.length}` + ); + + return queries.sort((a, b) => a.name.localeCompare(b.name)); + }; + + protected override async onDisposing(): Promise { + this._onDidUpdate.dispose(); + } +} diff --git a/src/services/PersistentQueryStatusFilterService.spec.ts b/src/services/PersistentQueryStatusFilterService.spec.ts new file mode 100644 index 000000000..1364d9059 --- /dev/null +++ b/src/services/PersistentQueryStatusFilterService.spec.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type * as vscode from 'vscode'; +import { PersistentQueryStatusFilterService } from './PersistentQueryStatusFilterService'; +import { + DEFAULT_HIDDEN_QUERY_STATUSES, + LIVE_QUERY_STATUSES, + PERSISTENT_QUERY_HIDDEN_STATUSES_STORAGE_KEY, + UNSET_QUERY_STATUS, +} from '../common'; + +// See __mocks__/vscode.ts for the mock implementation +vi.mock('vscode'); + +/** + * Build an extension context whose `globalState` returns `stored` for the + * filter key. `undefined` models "never set", which must behave differently + * from a stored empty array. + */ +function makeContext(stored?: unknown): vscode.ExtensionContext { + return { + globalState: { + get: vi.fn((key: string) => + key === PERSISTENT_QUERY_HIDDEN_STATUSES_STORAGE_KEY + ? stored + : undefined + ), + update: vi.fn(async () => undefined), + }, + } as unknown as vscode.ExtensionContext; +} + +describe('PersistentQueryStatusFilterService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('initial state', () => { + it('hides the completely-stopped statuses on first run', () => { + const service = new PersistentQueryStatusFilterService(makeContext()); + + expect([...service.getHiddenStatuses()].sort()).toEqual( + [...DEFAULT_HIDDEN_QUERY_STATUSES].sort() + ); + expect(service.isVisible('Running')).toBe(true); + expect(service.isVisible('Initializing')).toBe(true); + // Still winding down, so still worth seeing. + expect(service.isVisible('Stopping')).toBe(true); + expect(service.isVisible('Stopped')).toBe(false); + expect(service.isVisible(null)).toBe(false); + }); + + it('treats a stored empty array as "show everything", not as unset', () => { + const service = new PersistentQueryStatusFilterService(makeContext([])); + + expect(service.getHiddenStatuses().size).toBe(0); + expect(service.isVisible('Stopped')).toBe(true); + expect(service.isVisible(null)).toBe(true); + }); + + it('restores a persisted hidden set', () => { + const service = new PersistentQueryStatusFilterService( + makeContext(['Running']) + ); + + expect([...service.getHiddenStatuses()]).toEqual(['Running']); + expect(service.isVisible('Running')).toBe(false); + expect(service.isVisible('Stopped')).toBe(true); + }); + + it.each([ + ['a non-array', { hidden: ['Stopped'] }], + ['an array of non-strings', ['Stopped', 42]], + ['a string', 'Stopped'], + ['null', null], + ])('falls back to the default for a malformed value: %s', (_l, stored) => { + const service = new PersistentQueryStatusFilterService( + makeContext(stored) + ); + + expect([...service.getHiddenStatuses()].sort()).toEqual( + [...DEFAULT_HIDDEN_QUERY_STATUSES].sort() + ); + }); + }); + + describe('isVisible', () => { + it.each([[null], [undefined], ['']])( + 'normalises an unset status to the same entry: %s', + status => { + const service = new PersistentQueryStatusFilterService( + makeContext([UNSET_QUERY_STATUS]) + ); + + expect(service.isVisible(status as string | null)).toBe(false); + } + ); + + it('shows a status it does not recognize when it is not hidden', () => { + const service = new PersistentQueryStatusFilterService(makeContext()); + + expect(service.isVisible('Hibernating')).toBe(true); + }); + }); + + describe('setHiddenStatuses', () => { + it('persists the new set and fires onDidUpdate once', async () => { + const context = makeContext(); + const service = new PersistentQueryStatusFilterService(context); + + const onDidUpdate = vi.fn(); + service.onDidUpdate(onDidUpdate); + + await service.setHiddenStatuses(['Running', 'Running', null as never]); + + expect(context.globalState.update).toHaveBeenCalledWith( + PERSISTENT_QUERY_HIDDEN_STATUSES_STORAGE_KEY, + ['Running', UNSET_QUERY_STATUS] + ); + expect([...service.getHiddenStatuses()]).toEqual([ + 'Running', + UNSET_QUERY_STATUS, + ]); + expect(onDidUpdate).toHaveBeenCalledTimes(1); + }); + + it('persists an empty set (hide nothing)', async () => { + const context = makeContext(); + const service = new PersistentQueryStatusFilterService(context); + + await service.setHiddenStatuses([]); + + expect(context.globalState.update).toHaveBeenCalledWith( + PERSISTENT_QUERY_HIDDEN_STATUSES_STORAGE_KEY, + [] + ); + expect(service.getHiddenStatuses().size).toBe(0); + }); + + it('does not persist or fire when the set is unchanged', async () => { + const context = makeContext(); + const service = new PersistentQueryStatusFilterService(context); + + const onDidUpdate = vi.fn(); + service.onDidUpdate(onDidUpdate); + + await service.setHiddenStatuses([...DEFAULT_HIDDEN_QUERY_STATUSES]); + + expect(context.globalState.update).not.toHaveBeenCalled(); + expect(onDidUpdate).not.toHaveBeenCalled(); + }); + }); +}); + +describe('status sections', () => { + it('reports the default filter as Running shown, Stopped hidden', () => { + const service = new PersistentQueryStatusFilterService(makeContext()); + + expect(service.isSectionVisible('Running')).toBe(true); + expect(service.isSectionVisible('Stopped')).toBe(false); + }); + + it('counts a partly hidden section as still shown', async () => { + const service = new PersistentQueryStatusFilterService(makeContext([])); + + // Hide all of Running except `Running` itself. + await service.setHiddenStatuses( + LIVE_QUERY_STATUSES.filter(status => status !== 'Running') + ); + + // The checkbox answers "am I seeing any of these?", so unchecking it can + // hide the remainder rather than doing nothing. + expect(service.isSectionVisible('Running')).toBe(true); + }); + + it('hides every status in a section', async () => { + const service = new PersistentQueryStatusFilterService(makeContext([])); + + await service.setSectionVisible('Running', false); + + expect(service.isSectionVisible('Running')).toBe(false); + for (const status of LIVE_QUERY_STATUSES) { + expect(service.isVisible(status)).toBe(false); + } + // The other section is untouched. + expect(service.isSectionVisible('Stopped')).toBe(true); + }); + + it('shows every status in a section, including the unset one', async () => { + const service = new PersistentQueryStatusFilterService(makeContext()); + + await service.setSectionVisible('Stopped', true); + + expect(service.isSectionVisible('Stopped')).toBe(true); + expect(service.isVisible('Stopped')).toBe(true); + expect(service.isVisible(null)).toBe(true); + }); + + it('leaves an unrecognized status alone when toggling a section', async () => { + const service = new PersistentQueryStatusFilterService(makeContext([])); + + await service.setSectionVisible('Running', false); + + // Not part of either section's status list, so the toggle cannot hide it. + expect(service.isVisible('Hibernating')).toBe(true); + }); + + it('persists a section toggle', async () => { + const context = makeContext([]); + const service = new PersistentQueryStatusFilterService(context); + + await service.setSectionVisible('Stopped', false); + + expect(context.globalState.update).toHaveBeenCalledWith( + PERSISTENT_QUERY_HIDDEN_STATUSES_STORAGE_KEY, + expect.arrayContaining(['Stopped', UNSET_QUERY_STATUS]) + ); + }); +}); diff --git a/src/services/PersistentQueryStatusFilterService.ts b/src/services/PersistentQueryStatusFilterService.ts new file mode 100644 index 000000000..f044e7379 --- /dev/null +++ b/src/services/PersistentQueryStatusFilterService.ts @@ -0,0 +1,168 @@ +import * as vscode from 'vscode'; +import { + DEFAULT_HIDDEN_QUERY_STATUSES, + PERSISTENT_QUERY_HIDDEN_STATUSES_STORAGE_KEY, + getQueryStatusSectionStatuses, + type QueryStatusSection, + UNSET_QUERY_STATUS, +} from '../common'; +import type { IPersistentQueryStatusFilterService } from '../types'; +import { Logger } from '../util'; +import { DisposableBase } from './DisposableBase'; + +const logger = new Logger('PersistentQueryStatusFilterService'); + +/** + * Backs the Persistent Queries view's status filter. + * + * The persisted state is the set of statuses to HIDE, not the set to show. An + * inclusion set would make any status this extension has never heard of — a new + * one from a future DHE release — invisible by default and unreachable from the + * picker; with an exclusion set an unknown status is visible until the user + * hides it, and a filter persisted today stays meaningful as the status + * vocabulary grows. + * + * The set lives in `globalState` so one filter applies across every workspace. + */ +export class PersistentQueryStatusFilterService + extends DisposableBase + implements IPersistentQueryStatusFilterService +{ + /** + * @param context Extension context providing the `globalState` the filter is + * persisted in. + */ + constructor(context: vscode.ExtensionContext) { + super(); + this._context = context; + this._hiddenStatuses = new Set( + readHiddenStatuses(context).map(normalizeQueryStatus) + ); + } + + private readonly _context: vscode.ExtensionContext; + private _hiddenStatuses: Set; + + private readonly _onDidUpdate = new vscode.EventEmitter(); + readonly onDidUpdate = this._onDidUpdate.event; + + /** + * Whether a query with the given status should be listed. + * @param status The PQ status (`null` / `undefined` / `''` all mean unset). + */ + isVisible = (status: string | null | undefined): boolean => { + return !this._hiddenStatuses.has(normalizeQueryStatus(status)); + }; + + /** + * Whether any status in the section is currently listed. A section the user + * has partly hidden through the per-status picker still counts as shown: the + * checkbox answers "am I seeing any of these?", so unchecking it hides the + * rest rather than doing nothing. + * @param section The section to check. + */ + isSectionVisible = (section: QueryStatusSection): boolean => { + return getQueryStatusSectionStatuses(section).some(status => + this.isVisible(status) + ); + }; + + /** + * Show or hide every status in a section at once. + * @param section The section to toggle. + * @param isVisible Whether its statuses should be listed. + */ + setSectionVisible = async ( + section: QueryStatusSection, + isVisible: boolean + ): Promise => { + const hidden = new Set(this._hiddenStatuses); + + for (const status of getQueryStatusSectionStatuses(section)) { + if (isVisible) { + hidden.delete(status); + } else { + hidden.add(status); + } + } + + await this.setHiddenStatuses(hidden); + }; + + /** The statuses currently hidden (normalised; unset is `''`). */ + getHiddenStatuses = (): ReadonlySet => { + return this._hiddenStatuses; + }; + + /** + * Replace the hidden set and persist it. `onDidUpdate` only fires when the + * set actually changed, so a picker dismissed on the same selection doesn't + * churn the tree. + * @param hidden The statuses to hide. + */ + setHiddenStatuses = async (hidden: Iterable): Promise => { + const next = new Set([...hidden].map(normalizeQueryStatus)); + + if ( + next.size === this._hiddenStatuses.size && + [...next].every(status => this._hiddenStatuses.has(status)) + ) { + return; + } + + this._hiddenStatuses = next; + + await this._context.globalState.update( + PERSISTENT_QUERY_HIDDEN_STATUSES_STORAGE_KEY, + [...next] + ); + + this._onDidUpdate.fire(); + }; + + protected override async onDisposing(): Promise { + this._onDidUpdate.dispose(); + } +} + +/** + * Normalise a PQ status to its hidden-set key. `null`, `undefined`, and `''` + * are all ways the server / JS API report "no status", so they collapse to one + * entry. + * @param status The status to normalise. + */ +function normalizeQueryStatus(status: string | null | undefined): string { + return status == null ? UNSET_QUERY_STATUS : status; +} + +/** + * Read the persisted hidden set, falling back to the default only when nothing + * has ever been stored. An empty stored array means the user deliberately unhid + * everything, so it must be distinguished from "never set" (`undefined`) — + * treating it as falsy would spring the default filter back on every reload. A + * value that isn't an array of strings (a hand-edited or stale state file) is + * discarded in favour of the default rather than throwing on startup. + * @param context Extension context holding the `globalState`. + */ +function readHiddenStatuses(context: vscode.ExtensionContext): string[] { + const stored = context.globalState.get( + PERSISTENT_QUERY_HIDDEN_STATUSES_STORAGE_KEY + ); + + if (stored === undefined) { + return [...DEFAULT_HIDDEN_QUERY_STATUSES]; + } + + if ( + !Array.isArray(stored) || + stored.some(status => typeof status !== 'string') + ) { + logger.debug( + 'Discarding malformed persisted PQ status filter:', + JSON.stringify(stored) + ); + return [...DEFAULT_HIDDEN_QUERY_STATUSES]; + } + + return stored as string[]; +} diff --git a/src/services/QueryConfigTableService.spec.ts b/src/services/QueryConfigTableService.spec.ts new file mode 100644 index 000000000..8211c6594 --- /dev/null +++ b/src/services/QueryConfigTableService.spec.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { dh as DhcType } from '@deephaven/jsapi-types'; +import { getQueryTableFilters } from './QueryConfigTableService'; + +/** The API-object parameter type of `getQueryTableFilters`. */ +type QueryFilterApi = Parameters[0]; + +// See __mocks__/vscode.ts for the mock implementation +vi.mock('vscode'); + +/** A recorded filter condition produced by the mocked filter builder. */ +interface RecordedCondition { + column: string; + op: string; + terms?: unknown[]; + term?: unknown; + not?: () => RecordedCondition; +} + +/** Mocked filter builder returned by `column.filter()`. */ +interface MockFilterValue { + in: (terms: unknown[]) => RecordedCondition; + containsIgnoreCase: (term: unknown) => RecordedCondition; +} + +function makeFilterValue(name: string): MockFilterValue { + return { + // `.in(...)` supports chaining `.not()` for the helper-type exclusion branch. + in: (terms: unknown[]): RecordedCondition => ({ + column: name, + op: 'in', + terms, + not: (): RecordedCondition => ({ column: name, op: 'notIn', terms }), + }), + containsIgnoreCase: (term: unknown): RecordedCondition => ({ + column: name, + op: 'containsIgnoreCase', + term, + }), + }; +} + +/** + * Build a mocked `dh.Table` whose `findColumn(name).filter()` returns a filter + * builder that records the operations invoked on it. Each produced + * `FilterCondition` records the column it came from and the operation applied, + * so tests can assert the correct server-side filters were constructed. + */ +function createMockTable(): { + table: DhcType.Table; + findColumn: ReturnType; +} { + const findColumn = vi.fn((name: string) => ({ + name, + filter: (): MockFilterValue => makeFilterValue(name), + })); + + return { + table: { findColumn } as unknown as DhcType.Table, + findColumn, + }; +} + +const dh = { + // eslint-disable-next-line @typescript-eslint/naming-convention + FilterValue: { + ofString: (value: string): { value: string } => ({ value }), + }, +} as unknown as QueryFilterApi; + +describe('getQueryTableFilters', () => { + let mock: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mock = createMockTable(); + }); + + it('returns no conditions for empty filters', () => { + const conditions = getQueryTableFilters(dh, mock.table, {}); + expect(conditions).toHaveLength(0); + expect(mock.findColumn).not.toHaveBeenCalled(); + }); + + it('builds an `in` filter on Owner', () => { + const conditions = getQueryTableFilters(dh, mock.table, { + owners: ['alice', 'bob'], + }); + expect(conditions).toHaveLength(1); + expect(conditions[0]).toMatchObject({ + column: 'Owner', + op: 'in', + terms: [{ value: 'alice' }, { value: 'bob' }], + }); + }); + + it('builds an `in` filter on QueryType', () => { + const conditions = getQueryTableFilters(dh, mock.table, { + types: ['InteractiveConsole'], + }); + expect(conditions).toHaveLength(1); + expect(conditions[0]).toMatchObject({ + column: 'QueryType', + op: 'in', + terms: [{ value: 'InteractiveConsole' }], + }); + }); + + it('builds an `in` filter on Status', () => { + const conditions = getQueryTableFilters(dh, mock.table, { + statuses: ['Running'], + }); + expect(conditions).toHaveLength(1); + expect(conditions[0]).toMatchObject({ + column: 'Status', + op: 'in', + terms: [{ value: 'Running' }], + }); + }); + + it('builds a case-insensitive contains filter on Name for search', () => { + const conditions = getQueryTableFilters(dh, mock.table, { + search: 'my-query', + }); + expect(conditions).toEqual([ + { + column: 'Name', + op: 'containsIgnoreCase', + term: { value: 'my-query' }, + }, + ]); + }); + + it('ignores an empty search string', () => { + const conditions = getQueryTableFilters(dh, mock.table, { search: '' }); + expect(conditions).toHaveLength(0); + }); + + it('ignores empty owner/type/status arrays', () => { + const conditions = getQueryTableFilters(dh, mock.table, { + owners: [], + types: [], + statuses: [], + }); + expect(conditions).toHaveLength(0); + }); + + it('excludes helper query types via `not in` when excludeHelperTypes is set', () => { + const conditions = getQueryTableFilters(dh, mock.table, { + excludeHelperTypes: true, + }); + expect(conditions).toHaveLength(1); + expect(conditions[0]).toMatchObject({ + column: 'QueryType', + op: 'notIn', + }); + }); + + it('prefers an explicit type allow-list over excludeHelperTypes', () => { + const conditions = getQueryTableFilters(dh, mock.table, { + types: ['InteractiveConsole'], + excludeHelperTypes: true, + }); + expect(conditions).toHaveLength(1); + expect(conditions[0]).toMatchObject({ + column: 'QueryType', + op: 'in', + terms: [{ value: 'InteractiveConsole' }], + }); + }); + + it('ANDs multiple filters together in order', () => { + const conditions = getQueryTableFilters(dh, mock.table, { + owners: ['alice'], + types: ['InteractiveConsole'], + statuses: ['Running'], + search: 'foo', + }); + expect( + conditions.map(c => (c as unknown as { column: string }).column) + ).toEqual(['Owner', 'QueryType', 'Status', 'Name']); + }); +}); diff --git a/src/services/QueryConfigTableService.ts b/src/services/QueryConfigTableService.ts new file mode 100644 index 000000000..a72fbbb03 --- /dev/null +++ b/src/services/QueryConfigTableService.ts @@ -0,0 +1,335 @@ +import * as vscode from 'vscode'; +import type { dh as DhcType } from '@deephaven/jsapi-types'; +import type { EnterpriseDhType as DheType } from '@deephaven-enterprise/jsapi-types'; +import type { CorePlusManager } from '@deephaven-enterprise/client-utils'; +import { + EXCLUDED_QUERY_TYPES, + makeFactoryServiceTablePromise, + QueryColumns, + QUERY_CONFIG_TABLE, + WEB_CLIENT_DATA_CORE_QUERY, +} from '@deephaven-enterprise/query-utils'; +import { QUERY_INFO_UPDATE_INTERVAL_MS } from '../common'; +import type { IAsyncCacheService, IDheService, IDisposable } from '../types'; +import { createThrottledTrigger, Logger } from '../util'; +import { DisposableBase } from './DisposableBase'; + +const logger = new Logger('QueryConfigTableService'); + +/** + * The community (Core) JS API object returned by `CorePlusManager.getApi`. The + * `QueryInfo` table is created by the WebClientData worker's community API, so + * server-side filter values must be built from *this* API — a `FilterValue` + * from any other API instance (e.g. the enterprise `dhe`) throws a + * `java.lang.ClassCastException` when the table tries to cast it. + */ +type CoreApi = Awaited>; + +/** + * Error thrown when the `WebClientData` Core+ system query required to fetch the + * `QueryInfo` table is unavailable (not visible to the current user or not + * running). This surfaces a clear failure instead of hanging (Gotcha 7). + */ +export class WebClientDataUnavailableError extends Error { + constructor(serverUrl: URL) { + super( + `The '${WEB_CLIENT_DATA_CORE_QUERY}' system query is unavailable on ${serverUrl}. ` + + `The Persistent Queries table cannot be loaded until it is running and visible to you.` + ); + this.name = 'WebClientDataUnavailableError'; + } +} + +/** + * Server-side filters to apply to the `QueryInfo` table. All fields are + * optional; only provided fields are applied. Multiple fields are AND'd + * together. Mirrors iris `PQExplorerPanel.getQueryTableFilters`. + */ +export interface QueryTableFilters { + /** Restrict to queries owned by these owners (OR'd). */ + owners?: readonly string[]; + /** Restrict to these query types (OR'd), e.g. `InteractiveConsole`. */ + types?: readonly string[]; + /** Restrict to these statuses (OR'd), e.g. `Running`. */ + statuses?: readonly string[]; + /** Case-insensitive substring match against the query name. */ + search?: string; + /** + * When true, exclude the query types the PQ explorer never lists (helper / + * system queries) via `EXCLUDED_QUERY_TYPES`. Defaults to false. + */ + excludeHelperTypes?: boolean; +} + +/** + * Build the server-side `FilterCondition[]` for the `QueryInfo` table from the + * given filters. Pure — no I/O or subscription side effects, so it can be unit + * tested against a mocked table. Mirrors `PQExplorerPanel.getQueryTableFilters`. + * @param dh The community DH API that created `table`, providing `FilterValue`. + * Must be the table's own API (see {@link CoreApi}). + * @param table The `QueryInfo` table to build columns/filters from. + * @param filters The filters to apply. + * @returns An array of `FilterCondition` to pass to `table.applyFilter`. + */ +export function getQueryTableFilters( + dh: CoreApi, + table: DhcType.Table, + filters: QueryTableFilters +): DhcType.FilterCondition[] { + const conditions: DhcType.FilterCondition[] = []; + + const inColumn = ( + columnName: string, + values: readonly string[] + ): DhcType.FilterCondition | null => { + if (values.length === 0) { + return null; + } + + const filterValue = table.findColumn(columnName).filter(); + return filterValue.in(values.map(value => dh.FilterValue.ofString(value))); + }; + + const pushIfNonEmpty = (condition: DhcType.FilterCondition | null): void => { + if (condition != null) { + conditions.push(condition); + } + }; + + if (filters.owners != null) { + pushIfNonEmpty(inColumn(QueryColumns.OWNER.name, filters.owners)); + } + + if (filters.types != null) { + // Explicit type allow-list takes precedence over the helper-type exclusion. + pushIfNonEmpty(inColumn(QueryColumns.QUERY_TYPE.name, filters.types)); + } else if (filters.excludeHelperTypes === true) { + // Exclude the helper/system query types the PQ explorer never lists. + const excluded = [...EXCLUDED_QUERY_TYPES]; + if (excluded.length > 0) { + conditions.push( + table + .findColumn(QueryColumns.QUERY_TYPE.name) + .filter() + .in(excluded.map(value => dh.FilterValue.ofString(value))) + .not() + ); + } + } + + if (filters.statuses != null) { + pushIfNonEmpty(inColumn(QueryColumns.STATUS.name, filters.statuses)); + } + + if (filters.search != null && filters.search.length > 0) { + const nameFilter = table.findColumn(QueryColumns.NAME.name).filter(); + conditions.push( + nameFilter.containsIgnoreCase(dh.FilterValue.ofString(filters.search)) + ); + } + + return conditions; +} + +/** + * A subscription over a filtered `QueryInfo` table. Ticks on every server + * update (`EVENT_UPDATED`) until disposed, keeping {@link getQuerySerials} in + * sync with the filtered row set. + */ +export interface QueryInfoTableSubscription extends IDisposable { + /** The underlying (filtered) `QueryInfo` table. */ + readonly table: DhcType.Table; + /** Fires on every tick of the filtered row set. */ + readonly onDidUpdate: vscode.Event; + /** + * Serials of the current filtered rows, excluding child-replica rows (rows + * with `Parent` set). Reflects the most recent tick — empty until the first + * one arrives, so consumers must refresh on {@link onDidUpdate} rather than + * treating an empty set as "no queries". + */ + getQuerySerials: () => ReadonlySet; +} + +/** + * Reusable service exposing a server-side-filtered, ticking subscription over + * the Core+ `QueryInfo` table (built on the `EnterpriseCorePlusManager` + * substrate). Consumed by the Persistent Queries explorer feature. + */ +export class QueryConfigTableService extends DisposableBase { + /** + * @param serverUrl The DHE server URL this service is scoped to. + * @param dheService The DHE service providing the authenticated client and + * the `CorePlusManager`. + * @param dheJsApiCache Cache providing the DHE JS API for `serverUrl`. + */ + constructor( + serverUrl: URL, + dheService: IDheService, + dheJsApiCache: IAsyncCacheService + ) { + super(); + this._serverUrl = serverUrl; + this._dheService = dheService; + this._dheJsApiCache = dheJsApiCache; + } + + private readonly _serverUrl: URL; + private readonly _dheService: IDheService; + private readonly _dheJsApiCache: IAsyncCacheService; + + /** + * Fetch the (unfiltered) `QueryInfo` table via the WebClientData factory + * service, along with the community API that created it. Throws a clear error + * when `WebClientData` is unavailable (Gotcha 7) rather than hanging on the + * upstream widget-message path. + * @returns The `QueryInfo` table and the community API that created it. Filter + * values must be built from that API (see {@link CoreApi}). + */ + private async _fetchQueryInfoTable(): Promise<{ + table: DhcType.Table; + coreApi: CoreApi; + }> { + const dheClient = await this._dheService.getClient(false); + if (dheClient == null) { + throw new Error( + `Cannot fetch '${QUERY_CONFIG_TABLE}' table: DHE client is not available for ${this._serverUrl}.` + ); + } + + const corePlusManager = await this._dheService.getCorePlusManager(); + if (corePlusManager == null) { + throw new Error( + `Cannot fetch '${QUERY_CONFIG_TABLE}' table: CorePlusManager is not available for ${this._serverUrl}.` + ); + } + + // Pre-check that the always-on WebClientData system query is visible + + // running. `makeFactoryServiceTablePromise` routes through a widget-message + // helper whose rejection is not reliably propagated (upstream DH-20345), so + // guard here to surface a clear error instead of hanging (Gotcha 7). + const webClientData = dheClient.client + .getKnownConfigs() + .find( + qi => + qi.name === WEB_CLIENT_DATA_CORE_QUERY && + qi.designated?.status === 'Running' + ); + + if (webClientData?.designated == null) { + throw new WebClientDataUnavailableError(this._serverUrl); + } + + const dhe = await this._dheJsApiCache.get(this._serverUrl); + const userInfo = await dheClient.client.getUserInfo(); + + const table = await makeFactoryServiceTablePromise({ + client: dheClient.client, + corePlusManager, + dh: dhe, + userInfo, + tableName: QUERY_CONFIG_TABLE, + }); + + // The table is served by the WebClientData worker's *community* API. Grab + // that same (cached) API instance so filter values are castable by the + // table — `getApi` returns the instance the factory used above. + const coreApi = await corePlusManager.getApi( + webClientData.workerKind, + webClientData.designated.jsApiUrl + ); + + return { table, coreApi }; + } + + /** + * Get a filtered, ticking `QueryInfo` table subscription. The returned + * subscription emits a viewport snapshot on every update and must be disposed + * by the caller (or with this service). + * @param filters Server-side filters to apply. + * @returns The subscription. + */ + getQueryInfoTable = async ( + filters: QueryTableFilters = {} + ): Promise => { + const { table, coreApi } = await this._fetchQueryInfoTable(); + + const conditions = getQueryTableFilters(coreApi, table, filters); + table.applyFilter(conditions); + + const onDidUpdateEmitter = new vscode.EventEmitter(); + + const serialColumn = table.findColumn(QueryColumns.SERIAL.name); + const parentColumn = table.findColumn(QueryColumns.PARENT_ID.name); + const statusColumn = table.findColumn(QueryColumns.STATUS.name); + + let querySerials: ReadonlySet = new Set(); + + // Subscribe to the whole filtered table rather than tracking a viewport. + // A viewport has to be re-pinned as the filtered size changes, and + // `getViewportData()` only returns the snapshot that has arrived so far, so + // rows that tick in later were silently dropped (PQs missing from the + // tree). A full subscription's `EVENT_UPDATED` carries every row the client + // has, so each tick is a complete picture of the filtered set. + // + // Only the columns consumers act on are subscribed, so the table ticks on + // row add/remove and status transitions but not on churn like heap usage. + // `Status` earns its place even though the value is unused here: a PQ going + // Running → Stopped must re-render, and the resolved `QueryInfo` carries the + // new status. + const tableSubscription = table.subscribe([ + serialColumn, + parentColumn, + statusColumn, + ]); + + const throttledUpdate = createThrottledTrigger( + () => onDidUpdateEmitter.fire(), + QUERY_INFO_UPDATE_INTERVAL_MS + ); + + const removeUpdateListener = + tableSubscription.addEventListener( + coreApi.Table.EVENT_UPDATED, + ({ detail }) => { + const serials = new Set(); + + for (const row of detail.rows) { + // Skip child-replica rows; the parent row represents the query. + const parent = row.get(parentColumn); + if (parent != null && String(parent).length > 0) { + continue; + } + serials.add(String(row.get(serialColumn))); + } + + // The serial set is updated on every tick so `getQuerySerials` is + // never stale; only the notification is rate limited. + querySerials = serials; + throttledUpdate.trigger(); + } + ); + + const subscription: QueryInfoTableSubscription = { + table, + onDidUpdate: onDidUpdateEmitter.event, + getQuerySerials: () => querySerials, + dispose: async (): Promise => { + removeUpdateListener(); + // Before the emitter, so a pending trailing fire cannot land on a + // disposed emitter. + throttledUpdate.dispose(); + onDidUpdateEmitter.dispose(); + try { + tableSubscription.close(); + table.close(); + } catch (err) { + logger.debug('Error closing QueryInfo table', err); + } + }, + }; + + this.disposables.add(subscription); + + return subscription; + }; +} diff --git a/src/services/ServerManager.spec.ts b/src/services/ServerManager.spec.ts index 8ae55c9a4..d8e933143 100644 --- a/src/services/ServerManager.spec.ts +++ b/src/services/ServerManager.spec.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ServerManager } from './ServerManager'; -import { URLMap, withResolvers } from '../util'; +import { URLMap, withResolvers, type PromiseWithResolvers } from '../util'; import type { ConnectionState, IAsyncCacheService, @@ -36,8 +36,20 @@ vi.mock('../dh/dhe', () => ({ type TestServerManager = PublicOf & { _serverMap: URLMap; _connectionMap: URLMap; - _pendingConnectionMap: URLMap>; - _doConnectToServer: ReturnType; + _pendingConnectionMap: URLMap>; + _pendingServerConnections: URLMap>; + _dhcServiceFactory: { create: ReturnType }; + _dheServiceCache: { + has: ReturnType; + get: ReturnType; + }; + _resolvePendingServerConnection: (serverUrl: URL) => void; + _createOrAttachToWorkers: ( + dheService: IDheService, + workerConsoleType?: unknown, + createWorkerIfNone?: boolean + ) => Promise; + _createWorker: ReturnType; }; /** Build a `ServerManager` with minimal mocked dependencies. */ @@ -71,7 +83,7 @@ function createServerManager(): TestServerManager { } function mockConnectionState(url: URL): ConnectionState { - return { isConnected: true, serverUrl: url }; + return { label: 'Mock connection state', isConnected: true, serverUrl: url }; } function mockServerState({ @@ -107,21 +119,13 @@ const dheServer0 = mockServerState({ type: 'DHE', }); const cn1 = mockConnectionState(serverUrl); -const cn2 = mockConnectionState(serverUrl); describe('ServerManager.connectToServer', () => { let manager: TestServerManager; - let promise: Promise; - let resolve: (value: ConnectionState | null) => void; - beforeEach(() => { vi.clearAllMocks(); manager = createServerManager(); - - ({ promise, resolve } = withResolvers()); - - manager._doConnectToServer = vi.fn().mockReturnValue(promise); }); it('throws when the server is not found', async () => { @@ -137,50 +141,158 @@ describe('ServerManager.connectToServer', () => { const result = await manager.connectToServer(serverUrl); expect(result).toBe(cn1); - expect(manager._doConnectToServer).not.toHaveBeenCalled(); + expect(manager._dhcServiceFactory.create).not.toHaveBeenCalled(); }); it('only connects once when called concurrently for the same DHC server', async () => { manager._serverMap.set(dhcServer0.url, dhcServer0); + // Hold the client handshake open so the connection stays in flight. + const { promise: clientPromise, resolve: resolveClient } = + withResolvers(); + const connection = { + getClient: vi.fn().mockReturnValue(clientPromise), + initSession: vi.fn().mockResolvedValue(true), + onDidDisconnect: vi.fn(), + onDidChangeRunningCodeStatus: vi.fn(), + }; + manager._dhcServiceFactory.create.mockReturnValue(connection); + const first = manager.connectToServer(serverUrl); const second = manager.connectToServer(serverUrl); - // The in-progress connection is reused rather than starting a new one. - expect(manager._doConnectToServer).toHaveBeenCalledTimes(1); + // The second call dedupes against the in-flight connection rather than + // creating a new one. + expect(manager._dhcServiceFactory.create).toHaveBeenCalledTimes(1); + expect(manager.isServerConnecting(serverUrl)).toBe(true); - resolve(cn1); + resolveClient({}); - expect(await first).toBe(cn1); - expect(await second).toBe(cn1); + expect(await first).toBe(connection); + expect(await second).toBe(connection); }); - it('clears the pending connection once it resolves so later calls reconnect', async () => { - manager._serverMap.set(dhcServer0.url, dhcServer0); + it('dedups concurrent client connections for DHE servers', () => { + manager._serverMap.set(dheServer0.url, dheServer0); - manager._doConnectToServer.mockResolvedValue(cn1); - const first = manager.connectToServer(serverUrl); - expect(manager._pendingConnectionMap.has(serverUrl)).toBe(true); - expect(await first).toBe(cn1); + // Leave the DHE service acquisition pending so the client connection stays + // in flight. + const { promise } = withResolvers(); + manager._dheServiceCache.get.mockReturnValue(promise); - expect(manager._pendingConnectionMap.has(serverUrl)).toBe(false); + void manager.connectToServer(serverUrl); + void manager.connectToServer(serverUrl); - // A subsequent connect attempt is not blocked by the resolved pending entry. - manager._doConnectToServer.mockResolvedValue(cn2); - const second = manager.connectToServer(serverUrl); - expect(await second).toBe(cn2); + // The DHE client connection is singular, so concurrent attempts reuse the + // in-flight connection rather than starting a second one (multiple workers + // are created later, off the single client connection). + expect(manager._dheServiceCache.get).toHaveBeenCalledTimes(1); + expect(manager.isServerConnecting(serverUrl)).toBe(true); + }); +}); - expect(manager._doConnectToServer).toHaveBeenCalledTimes(2); +describe('ServerManager._createOrAttachToWorkers', () => { + let manager: TestServerManager; + + beforeEach(() => { + vi.clearAllMocks(); + manager = createServerManager(); + // Isolate the create-vs-attach decision from the placeholder/attach + // plumbing. + manager._createWorker = vi.fn().mockResolvedValue(null); }); - it('does not track pending connections for DHE servers', async () => { - manager._serverMap.set(dheServer0.url, dheServer0); + function mockDheServiceWithNoWorkers(): IDheService { + return { + listAttachableWorkers: vi.fn().mockResolvedValue([]), + registerWorkerInfo: vi.fn(), + } as unknown as IDheService; + } - void manager.connectToServer(serverUrl); - void manager.connectToServer(serverUrl); + it('does not create a worker when none are attachable and createWorkerIfNone is false', async () => { + const dheService = mockDheServiceWithNoWorkers(); + + const result = await manager._createOrAttachToWorkers( + dheService, + undefined, + false + ); + + expect(result).toEqual([]); + expect(manager._createWorker).not.toHaveBeenCalled(); + }); + + it('creates a worker when none are attachable and createWorkerIfNone defaults to true', async () => { + const dheService = mockDheServiceWithNoWorkers(); + + await manager._createOrAttachToWorkers(dheService); + + expect(manager._createWorker).toHaveBeenCalledTimes(1); + }); +}); + +describe('ServerManager.isServerConnecting', () => { + let manager: TestServerManager; + + beforeEach(() => { + vi.clearAllMocks(); + manager = createServerManager(); + }); + + it('reflects a pending server connection and fires onDidUpdate when it resolves', () => { + const onDidUpdate = vi.fn(); + manager.onDidUpdate(onDidUpdate); + + expect(manager.isServerConnecting(serverUrl)).toBe(false); + + manager._pendingServerConnections.set(serverUrl, withResolvers()); + expect(manager.isServerConnecting(serverUrl)).toBe(true); + + manager._resolvePendingServerConnection(serverUrl); + expect(manager.isServerConnecting(serverUrl)).toBe(false); + expect(onDidUpdate).toHaveBeenCalledTimes(1); + }); + + it('resolves idempotently and does not fire when there is no pending entry', () => { + const onDidUpdate = vi.fn(); + manager.onDidUpdate(onDidUpdate); + + // Resolving when not connecting is a no-op (no fire). + manager._resolvePendingServerConnection(serverUrl); + expect(onDidUpdate).not.toHaveBeenCalled(); + + manager._pendingServerConnections.set(serverUrl, withResolvers()); + manager._resolvePendingServerConnection(serverUrl); + expect(onDidUpdate).toHaveBeenCalledTimes(1); + + // Resolving again is a no-op (no extra fire). + manager._resolvePendingServerConnection(serverUrl); + expect(onDidUpdate).toHaveBeenCalledTimes(1); + }); + + it('clears the pending entry when a connect settles, so a retry is not blocked', async () => { + manager._serverMap.set(dhcServer0.url, dhcServer0); - // DHE supports multiple connections, so concurrent calls each start one. - expect(manager._doConnectToServer).toHaveBeenCalledTimes(2); - expect(manager._pendingConnectionMap.has(serverUrl)).toBe(false); + // First attempt fails to get a client; `_doConnectToServer`'s `finally` + // must clear the pending entry (no stale entry left behind). + const failConnection = { getClient: vi.fn().mockResolvedValue(null) }; + const okConnection = { + getClient: vi.fn().mockResolvedValue({}), + initSession: vi.fn().mockResolvedValue(true), + onDidDisconnect: vi.fn(), + onDidChangeRunningCodeStatus: vi.fn(), + }; + manager._dhcServiceFactory.create + .mockReturnValueOnce(failConnection) + .mockReturnValueOnce(okConnection); + + expect(await manager.connectToServer(serverUrl)).toBeNull(); + expect(manager.isServerConnecting(serverUrl)).toBe(false); + + // The retry is not blocked by a stale pending entry — it starts a new + // connection rather than deduping against the failed one. + expect(await manager.connectToServer(serverUrl)).toBe(okConnection); + expect(manager._dhcServiceFactory.create).toHaveBeenCalledTimes(2); + expect(manager.isServerConnecting(serverUrl)).toBe(false); }); }); diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 334b5029c..690eee688 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { randomUUID } from 'node:crypto'; import type { dh as DhcType } from '@deephaven/jsapi-types'; +import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; import { QueryCreationCancelledError, QueryStartupFailureError, @@ -32,9 +33,12 @@ import { uniqueId, URIMap, URLMap, + withResolvers, + type PromiseWithResolvers, } from '../util'; import { DhcService } from './DhcService'; import { getWorkerCredentials, isDheServerRunning } from '../dh/dhe'; +import type { QuerySerial } from '../shared'; import { isDhcServerRunning } from '../dh/dhc'; const logger = new Logger('ServerManager'); @@ -52,7 +56,10 @@ export class ServerManager implements IServerManager { ) { this._configService = configService; this._connectionMap = new URLMap(); - this._pendingConnectionMap = new URLMap>(); + this._pendingConnectionMap = new URLMap< + PromiseWithResolvers + >(); + this._pendingServerConnections = new URLMap>(); this._coreClientCache = coreClientCache; this._dhcServiceFactory = dhcServiceFactory; this._dheClientCache = dheClientCache; @@ -69,10 +76,15 @@ export class ServerManager implements IServerManager { void this.loadServerConfig(); } + private readonly _attachedWorkerSerials: Map = + new Map(); private readonly _configService: IConfigService; private readonly _connectionMap: URLMap; private readonly _pendingConnectionMap: URLMap< - Promise + PromiseWithResolvers + >; + private readonly _pendingServerConnections: URLMap< + PromiseWithResolvers >; private readonly _coreClientCache: URLMap; private readonly _dhcServiceFactory: IDhcServiceFactory; @@ -104,6 +116,28 @@ export class ServerManager implements IServerManager { private readonly _onDidUpdate = new vscode.EventEmitter(); readonly onDidUpdate = this._onDidUpdate.event; + private _resolvePendingConnection = ( + serverUrl: URL, + connectionState: ConnectionState | null + ): void => { + if (this._pendingConnectionMap.has(serverUrl)) { + this._pendingConnectionMap.getOrThrow(serverUrl).resolve(connectionState); + this._pendingConnectionMap.delete(serverUrl); + this._onDidUpdate.fire(); + } + }; + + private _resolvePendingServerConnection = (serverUrl: URL): void => { + if (this._pendingServerConnections.has(serverUrl)) { + this._pendingServerConnections.getOrThrow(serverUrl).resolve(); + this._pendingServerConnections.delete(serverUrl); + this._onDidUpdate.fire(); + } + }; + + isServerConnecting = (serverUrl: URL): boolean => + this._pendingServerConnections.has(serverUrl); + private _lastServerRunningStatus = new URLMap(); private _hasEverUpdatedStatus = false; @@ -197,10 +231,25 @@ export class ServerManager implements IServerManager { this._onDidLoadConfig.fire(); }; + /** + * Connect to a server and attach to any Core / Core+ workers available for + * the server. For DHE, if no workers are available and `createWorkerIfNone` + * is `true`, creates one and attaches to it; if `false`, the server connects + * (letting persistent queries populate) without provisioning a worker. + * @param serverUrl + * @param workerConsoleType + * @param operateAsAnotherUser + * @param createWorkerIfNone When no attachable DHE worker exists, whether to + * auto-create one. Defaults to `true` (e.g. the run-code flow needs a live + * console). The plain "connect to server" action passes `false`. + * @returns The connection state of the attached worker, or `null` if the + * connection or worker creation/attachment failed (or none was created). + */ connectToServer = async ( serverUrl: URL, workerConsoleType?: ConsoleType, - operateAsAnotherUser: boolean = false + operateAsAnotherUser: boolean = false, + createWorkerIfNone: boolean = true ): Promise => { const serverState = this._serverMap.get(serverUrl); @@ -208,7 +257,7 @@ export class ServerManager implements IServerManager { throw new Error(`Server with URL '${serverUrl}' not found.`); } - // We only support 1 connection for DHC servers in the extension + // We only support 1 connection to a DHC server if (serverState.type === 'DHC' && serverState.connectionCount > 0) { logger.info('Already connected to server:', serverUrl.href); return this._connectionMap.getOrThrow(serverUrl); @@ -216,144 +265,138 @@ export class ServerManager implements IServerManager { if (this._pendingConnectionMap.has(serverUrl)) { logger.debug('Connection already in progress:', serverUrl.href); - return this._pendingConnectionMap.getOrThrow(serverUrl); + return this._pendingConnectionMap.getOrThrow(serverUrl).promise; } - const connectionPromise = this._doConnectToServer( + return this._doConnectToServer( serverState, workerConsoleType, - operateAsAnotherUser + operateAsAnotherUser, + createWorkerIfNone ); - - // We only support 1 connection for DHC servers in the extension, but the - // count doesn't get updated until the connection is established, so we need - // to mark pending connections to prevent multiple simultaneous connection - // attempts to the same DHC server. - if (serverState.type === 'DHC') { - this._pendingConnectionMap.set( - serverUrl, - connectionPromise.then(result => { - this._pendingConnectionMap.delete(serverUrl); - return result; - }) - ); - } - - return connectionPromise; }; private _doConnectToServer = async ( serverState: ServerState, - workerConsoleType?: ConsoleType, - operateAsAnotherUser: boolean = false + workerConsoleType: ConsoleType | undefined = undefined, + operateAsAnotherUser: boolean, + createWorkerIfNone: boolean ): Promise => { - let serverUrl = serverState.url; + const serverUrl = serverState.url; logger.debug('Connecting to server:', serverUrl.href); - let tagId: UniqueID | undefined; - - let placeholderUrl: URL | undefined; - - if (serverState.type === 'DHE') { - const isNewDheService = !this._dheServiceCache.has(serverUrl); - const dheService = await this._dheServiceCache.get(serverUrl); + // Mark server and worker connection as pending + this._pendingServerConnections.set(serverUrl, withResolvers()); + this._pendingConnectionMap.set(serverUrl, withResolvers()); + this._onDidUpdate.fire(); - // Get client. Client will be initialized if it doesn't exist (including - // prompting user for login). - if (!(await dheService.getClient(true, operateAsAnotherUser))) { - return null; - } + let firstConnection: ConnectionState | null = null; - // Handle workers stopped externally (i.e. web-client Query Monitor) - if (isNewDheService) { - dheService.onDidWorkerTerminate(workerUrl => { - this.disconnectFromServer(workerUrl); - }); - } + try { + if (serverState.type === 'DHC') { + // DHC attach to Core worker + firstConnection = await this._attachToWorker('Core', serverUrl, true); - // The `serverUrl` in this block is for the DHE server but gets set to the - // newly created worker url before leaving the block, so we need to update - // the connection count while we still have the reference. - this.updateConnectionCount(serverUrl, 1); - tagId = uniqueId(); + this._resolvePendingServerConnection(serverUrl); + } else { + const dheService = await this._connectToDheServer( + serverUrl, + operateAsAnotherUser + ); - // Put a placeholder connection in place until the worker is ready. - placeholderUrl = this.addWorkerPlaceholderConnection(serverUrl, tagId); + // server connection is done + this._resolvePendingServerConnection(serverUrl); - let workerInfo: WorkerInfo; - try { - workerInfo = await dheService.createWorker(tagId, workerConsoleType); - - // If the worker finished creating, but there is no placeholder connection, - // this indicates that the user cancelled the creation before it was ready. - // In this case, dispose of the worker. - if (!this._connectionMap.has(placeholderUrl)) { - dheService.deleteWorker(workerInfo.workerUrl); - this._onDidUpdate.fire(); + if (dheService == null) { return null; } - } catch (err) { - if (err instanceof QueryCreationCancelledError) { - logger.info(err); - const msg = 'Connection cancelled.'; - this._outputChannel.appendLine(msg); - this._toaster.info(msg); - } else { - const msg = - err instanceof QueryStartupFailureError - ? err.message - : 'Failed to create worker.'; - logger.error(err); - this._outputChannel.appendLine(msg); - this._toaster.error(msg); - } - this.updateConnectionCount(serverUrl, -1); - this._connectionMap.delete(placeholderUrl); - return null; + [firstConnection = null] = await this._createOrAttachToWorkers( + dheService, + workerConsoleType, + createWorkerIfNone + ); + + return firstConnection; } + } finally { + this._resolvePendingConnection(serverUrl, firstConnection); + } - // Map the worker URL to the server URL to make things easier to dispose - // later. - this._workerURLToServerURLMap.set( - new URL(workerInfo.workerUrl), - serverUrl - ); + return firstConnection; + }; + + /** + * Create a Core+ JS API connection to an existing worker. Populates + * `_workerURLToServerURLMap` for auth lookup and `_attachedWorkerSerials` + * for idempotency/teardown. Used by both the create path and the attach path. + * Does NOT touch placeholder connections — that is the create path's concern. + * @param label The connection label to show in the UI for this worker. + * @param serverUrl The DHE server this worker belongs to. + * @param workerInfo Worker info built from the query. + * @returns The new connection state, or null on failure. + */ + private _attachToWorker = async ( + label: string, + serverUrl: URL, + isOwned: boolean, + workerInfo?: WorkerInfo + ): Promise => { + const workerUrl = + workerInfo == null ? serverUrl : new URL(workerInfo.workerUrl); + + if (workerInfo != null) { + // Idempotency gate: reserve the serial synchronously, before any `await`, + // so concurrent attach attempts for the same worker — e.g. the initial + // enumeration racing a streaming config event, or two clicks on the same + // server — cannot both connect and double-count. A later failure rolls the + // reservation back so the worker can be retried. + if (this._attachedWorkerSerials.has(workerInfo.serial)) { + return this._connectionMap.get(workerUrl) ?? null; + } + this._attachedWorkerSerials.set(workerInfo.serial, workerInfo.workerUrl); - // Update the server URL to the worker url to be used below with core - // connection creation. - serverUrl = new URL(workerInfo.workerUrl); + // Map the worker URL to its DHE server so the auth flow can resolve creds. + this._workerURLToServerURLMap.set(workerUrl, serverUrl); } - const connection = this._dhcServiceFactory.create(serverUrl, tagId); + const connection = this._dhcServiceFactory.create( + label, + workerUrl, + isOwned, + workerInfo?.tagId + ); - // Initialize client + prompt for login if necessary + // Initialize client (includes auth flow). const coreClient = await connection.getClient(); - // Cleanup placeholder connection if one exists - if (placeholderUrl) { - this.removeWorkerPlaceholderConnection(placeholderUrl); - } - if (coreClient == null) { + if (workerInfo != null) { + this._attachedWorkerSerials.delete(workerInfo.serial); + } + return null; } - this._connectionMap.set(serverUrl, connection); + this._connectionMap.set(workerUrl, connection); this._onDidUpdate.fire(); if (!(await connection.initSession())) { - this._coreClientCache.delete(serverUrl); + if (workerInfo != null) { + this._attachedWorkerSerials.delete(workerInfo.serial); + } + + this._coreClientCache.delete(workerUrl); connection.dispose(); - this._connectionMap.delete(serverUrl); + this._connectionMap.delete(workerUrl); return null; } connection.onDidDisconnect(() => { - logger.debug('onDidDisconnect fired for:', serverUrl.href); - this.disconnectFromServer(serverUrl); + logger.debug('onDidDisconnect fired for:', workerUrl.href); + this.disconnectFromServer(workerUrl); }); connection.onDidChangeRunningCodeStatus?.(() => { @@ -362,19 +405,339 @@ export class ServerManager implements IServerManager { this.updateConnectionCount(serverUrl, 1); - this._onDidConnect.fire(serverUrl); + this._onDidConnect.fire(workerUrl); this._onDidUpdate.fire(); - return this._connectionMap.get(serverUrl) ?? null; + return this._connectionMap.get(workerUrl) ?? null; + }; + + private _connectToDheServer = async ( + serverUrl: URL, + operateAsAnotherUser: boolean + ): Promise => { + const dheServerUrl = serverUrl; + const isNewDheService = !this._dheServiceCache.has(dheServerUrl); + const dheService = await this._dheServiceCache.get(dheServerUrl); + + // Get client. Client will be initialized if it doesn't exist (including + // prompting user for login). + if (!(await dheService.getClient(true, operateAsAnotherUser))) { + return null; + } + + // Mark the DHE server as connected now that we have a live client. + // (connectionCount stays 0 until workers attach; isConnected reflects + // the server-level connection, which is now established.) + const currentServerState = this._serverMap.get(dheServerUrl); + if (currentServerState != null && !currentServerState.isConnected) { + this._serverMap.set(dheServerUrl, { + ...currentServerState, + isConnected: true, + }); + this._onDidUpdate.fire(); + } + + // Wire the config-event subscription exactly once per DHE service + // instance, BEFORE snapshotting, so any worker that appears or disappears + // between the snapshot and now is not missed. `_connectWorker` reserves + // each serial synchronously (before any `await`), so an event-driven + // attach and the snapshot batch below can never double-connect the same + // worker — whichever reaches `_connectWorker` first wins and the other is + // a no-op. + if (isNewDheService) { + dheService.onWorkerAttachable(qi => + this._reconcileAttach(dheServerUrl, dheService, qi) + ); + dheService.onWorkerRemoved(serial => this._reconcileDetach(serial)); + } + + return dheService; + }; + + private _createOrAttachToWorkers = async ( + dheService: IDheService, + workerConsoleType: ConsoleType | undefined = undefined, + createWorkerIfNone: boolean = true + ): Promise => { + const attachableWorkers = await dheService.listAttachableWorkers( + this._attachedWorkerSerials.keys() + ); + + let workerInfos: [boolean, WorkerInfo][]; + + // If no attachable workers exist, optionally create a new one. When + // `createWorkerIfNone` is false (the plain "connect to server" action), the + // server stays connected with no worker so persistent queries can populate + // without forcing worker creation. + if (attachableWorkers.length === 0) { + if (!createWorkerIfNone) { + return []; + } + + const workerInfo = await this._createWorker( + dheService, + workerConsoleType + ); + + if (workerInfo == null) { + return []; + } + + workerInfos = [[true, workerInfo]]; + } + // register attachable workers + else { + workerInfos = []; + for (const queryInfo of attachableWorkers) { + try { + workerInfos.push([false, dheService.registerWorkerInfo(queryInfo)]); + } catch (err) { + logger.error( + 'Failed to register attachable worker; skipping:', + queryInfo.serial, + err + ); + } + } + } + + // Connect to workers in parallel + const connections = ( + await Promise.all( + workerInfos.map(([isOwned, workerInfo]) => + this._attachToWorker( + workerInfo.name, + dheService.serverUrl, + isOwned, + workerInfo + ) + ) + ) + ).filter(c => c != null); + + if (attachableWorkers.length > 1) { + this._toaster.info(`Attached to ${connections.length} worker(s).`); + } + + return connections; + }; + + private _createWorker = async ( + dheService: IDheService, + workerConsoleType?: ConsoleType + ): Promise => { + const tagId = uniqueId(); + const placeholderUrl = this.addWorkerPlaceholderConnection( + dheService.label, + dheService.serverUrl, + tagId + ); + + try { + const workerInfo = await dheService.createWorker( + tagId, + workerConsoleType + ); + + // If the worker finished creating but there is no placeholder + // connection, the user cancelled before it was ready. + if (!this._connectionMap.has(placeholderUrl)) { + dheService.deleteWorker(workerInfo.workerUrl); + this._onDidUpdate.fire(); + return null; + } + + this.removeWorkerPlaceholderConnection(placeholderUrl); + return workerInfo; + } catch (err) { + if (err instanceof QueryCreationCancelledError) { + logger.info(err); + const msg = 'Connection cancelled.'; + this._outputChannel.appendLine(msg); + this._toaster.info(msg); + } else { + const msg = + err instanceof QueryStartupFailureError + ? err.message + : 'Failed to create worker.'; + logger.error(err); + this._outputChannel.appendLine(msg); + this._toaster.error(msg); + } + + this.removeWorkerPlaceholderConnection(placeholderUrl); + return null; + } + }; + + /** + * Explicitly create a new worker on a DHE server and attach to it. Unlike + * `connectToServer` (which only auto-creates a worker when none are + * attachable), this always creates a worker — it backs the "+" create-worker + * action on DHE server nodes. + * @param dheServerUrl The DHE server to create the worker on. + * @param workerConsoleType Optional console type for the new worker. + * @returns The new connection state, or `null` if client/worker + * creation/attachment failed. + */ + createWorker = async ( + dheServerUrl: URL, + workerConsoleType?: ConsoleType + ): Promise => { + const dheService = await this._dheServiceCache.get(dheServerUrl); + + // Ensure a live client (the server is already connected when the "+" action + // is visible, but guard anyway — this also covers a stale/expired client). + if (!(await dheService.getClient(true, false))) { + return null; + } + + const workerInfo = await this._createWorker(dheService, workerConsoleType); + if (workerInfo == null) { + return null; + } + + return this._attachToWorker( + workerInfo.name, + dheServerUrl, + true, + workerInfo + ); + }; + + /** + * Attach a single worker when a config event indicates it became attachable. + * Idempotent: no-ops if the serial is already connected. + */ + private _reconcileAttach = async ( + dheServerUrl: URL, + dheService: IDheService, + queryInfo: QueryInfo + ): Promise => { + if (this._attachedWorkerSerials.has(queryInfo.serial as QuerySerial)) { + return; + } + try { + const workerInfo = dheService.registerWorkerInfo(queryInfo); + await this._attachToWorker( + workerInfo.name, + dheServerUrl, + false, + workerInfo + ); + } catch (err) { + logger.error('Failed to attach worker:', queryInfo.serial, err); + } + }; + + /** + * Detach a worker when a config event indicates it was removed or died. + * No-ops if the serial is not tracked. + */ + private _reconcileDetach = async (serial: QuerySerial): Promise => { + const workerUrl = this._attachedWorkerSerials.get(serial); + if (workerUrl == null) { + return; + } + await this.disconnectFromServer(workerUrl); + }; + + /** + * Register a lightweight, NON-console "browse" connection for a persistent + * query's worker so the DH embed panel (`OPEN_VARIABLE_PANELS_CMD`) can open + * its objects read-only. This wires up exactly the three lookups the panel + * path needs — `getConnection`, `getWorkerInfo`, `getWorkerCredentials` — all + * keyed by the worker URL, WITHOUT creating a console session. + * + * Browse connections are a view layer over someone else's PQ. They are + * deliberately NOT worker connections: no `initSession`, no + * `connectionCount` increment, no `_attachedWorkerSerials` entry, and the + * server-side PQ is NEVER deleted on teardown (`unregisterBrowseConnection`). + * Idempotent — re-registering the same worker URL is a no-op that returns the + * existing worker info. + * @param dheServerUrl The DHE server the PQ belongs to (for auth resolution). + * @param queryInfo The running PQ whose worker to register. + * @returns The registered `WorkerInfo`, or `null` if the DHE service/worker + * info could not be resolved. + */ + registerBrowseConnection = async ( + dheServerUrl: URL, + queryInfo: QueryInfo + ): Promise => { + if (!this._dheServiceCache.has(dheServerUrl)) { + return null; + } + + const dheService = await this._dheServiceCache.get(dheServerUrl); + + let workerInfo: WorkerInfo; + try { + workerInfo = dheService.registerWorkerInfo(queryInfo); + } catch (err) { + logger.error( + 'Failed to register browse worker info:', + queryInfo.serial, + err + ); + return null; + } + + const workerUrl = new URL(workerInfo.workerUrl); + + // Map the worker URL to its DHE server so the panel auth flow + // (`getWorkerCredentials` / `getWorkerInfo`) can resolve creds + info. + this._workerURLToServerURLMap.set(workerUrl, dheServerUrl); + + // Register a lightweight non-console ConnectionState so + // `PanelController._onRefreshPanelsContent`'s `getConnection` assertion is + // satisfied. This is NOT a `DhcService` (so no PSK path, no session) and is + // never counted as a worker connection. + if (!this._connectionMap.has(workerUrl)) { + this._connectionMap.set(workerUrl, { + // Flagged so `getConnections` can exclude it: this is an auth/panel + // shim, not a worker connection the user can select or run code on. + isBrowseConnection: true, + label: workerInfo.name, + isConnected: true, + isRunningCode: false, + serverUrl: workerUrl, + }); + this._onDidUpdate.fire(); + } + + return workerInfo; + }; + + /** + * Tear down a browse connection previously registered with + * `registerBrowseConnection`. Removes the lightweight connection + auth + * mapping ONLY — it never deletes the server-side PQ and never touches + * `connectionCount` / `_attachedWorkerSerials` (browse connections never + * contributed to those). No-op if the worker URL is not a browse connection. + * @param workerUrl The worker URL to unregister. + */ + unregisterBrowseConnection = (workerUrl: URL): void => { + const hadConnection = this._connectionMap.has(workerUrl); + this._connectionMap.delete(workerUrl); + this._workerURLToServerURLMap.delete(workerUrl); + + if (hadConnection) { + this._onDidUpdate.fire(); + } }; /** * Add a placeholder connection to represent a pending DHE Core+ woker creation. + * @param label The connection label to show in the UI for this pending worker. * @param serverUrl The DHE server URL the pending worker is associated with. * @param tagId The tag ID of the worker. * @returns The placeholder URL. */ - addWorkerPlaceholderConnection = (serverUrl: URL, tagId: UniqueID): URL => { + addWorkerPlaceholderConnection = ( + label: string, + serverUrl: URL, + tagId: UniqueID + ): URL => { // simple way to keep placeholder urls unique by just adding a tagId as the pathname const placeholderUrl = new URL(serverUrl); placeholderUrl.pathname = tagId; @@ -382,6 +745,7 @@ export class ServerManager implements IServerManager { this._workerURLToServerURLMap.set(placeholderUrl, serverUrl); this._connectionMap.set(placeholderUrl, { + label, isConnected: false, isRunningCode: false, serverUrl: placeholderUrl, @@ -459,6 +823,15 @@ export class ServerManager implements IServerManager { await dheService.deleteWorker(serverOrWorkerUrl as WorkerURL); } + // Clear the idempotency/teardown gate so a later reconnect can re-attach. + const urlStr = serverOrWorkerUrl.toString(); + for (const [serial, url] of this._attachedWorkerSerials.entries()) { + if (url.toString() === urlStr) { + this._attachedWorkerSerials.delete(serial); + break; + } + } + const connection = this._connectionMap.get(serverOrWorkerUrl); if (connection == null) { return; @@ -560,17 +933,25 @@ export class ServerManager implements IServerManager { }; /** - * Get all connections. Optionally filter connections by server or worker URL. + * Get all worker connections. Optionally filter connections by server or + * worker URL. PQ browse connections are always excluded — they are panel-auth + * shims, not worker connections, so they must not show up in the Workers tree, + * the connection picker, or any "run code here" target list. Use + * `getConnection` to look one up directly by worker URL. * @param serverOrWorkerUrl The server or worker URL to filter connections by. - * @returns An array of all connections. + * @returns An array of all worker connections. */ getConnections = (serverOrWorkerUrl?: URL): ConnectionState[] => { + const isWorkerConnection = (connection: ConnectionState): boolean => + connection.isBrowseConnection !== true; + if (serverOrWorkerUrl == null) { - return [...this._connectionMap.values()]; + return [...this._connectionMap.values()].filter(isWorkerConnection); } if (this._connectionMap.has(serverOrWorkerUrl)) { - return [this._connectionMap.getOrThrow(serverOrWorkerUrl)]; + const connection = this._connectionMap.getOrThrow(serverOrWorkerUrl); + return isWorkerConnection(connection) ? [connection] : []; } const server = this.getServer(serverOrWorkerUrl); @@ -580,11 +961,16 @@ export class ServerManager implements IServerManager { if (server.type === 'DHC') { const connection = this._connectionMap.get(serverOrWorkerUrl); - return connection == null ? [] : [connection]; + return connection == null || !isWorkerConnection(connection) + ? [] + : [connection]; } // For DHE, return all connections associated with the server URL return [...this._connectionMap.values()].filter(connection => { + if (!isWorkerConnection(connection)) { + return false; + } const dheServerUrl = this._workerURLToServerURLMap.get(connection.serverUrl) ?? connection.serverUrl; @@ -592,6 +978,23 @@ export class ServerManager implements IServerManager { }); }; + /** + * Get the parent server for a connection. For a DHE worker, resolves the DHE + * server via the worker→server map; for a DHC connection, the connection's + * `serverUrl` is itself the server URL. Returns `undefined` only when no + * matching server is registered. + * @param connection The connection to get the parent server for. + * @returns The parent server state, or `undefined`. + */ + getServerForConnection = ( + connection: ConnectionState + ): ServerState | undefined => { + const serverUrl = + this._workerURLToServerURLMap.get(connection.serverUrl) ?? + connection.serverUrl; + return this.getServer(serverUrl); + }; + /** * Get all URIs associated with a connection. * @param connection diff --git a/src/services/consoleTypeUtils.ts b/src/services/consoleTypeUtils.ts index f062ef9d0..601f27a7b 100644 --- a/src/services/consoleTypeUtils.ts +++ b/src/services/consoleTypeUtils.ts @@ -1,8 +1,4 @@ -import type { - ConnectionState, - ConsoleType, - ServerConnectionPanelNode, -} from '../types'; +import type { ConnectionState, ConsoleType } from '../types'; import { isInstanceOf } from '../util'; import DhcService from './DhcService'; @@ -29,12 +25,11 @@ export async function getConnectionsForConsoleType( } export async function getFirstSupportedConsoleType( - connectionOrVariable: ServerConnectionPanelNode + connection: ConnectionState ): Promise { const [consoleType] = - isInstanceOf(connectionOrVariable, DhcService) && - connectionOrVariable.isInitialized - ? await connectionOrVariable.getConsoleTypes() + isInstanceOf(connection, DhcService) && connection.isInitialized + ? await connection.getConsoleTypes() : []; return consoleType; diff --git a/src/services/index.ts b/src/services/index.ts index c24d34584..d2cf0839d 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -7,7 +7,10 @@ export * from './DisposableBase'; export * from './FilteredWorkspace'; export * from './PanelService'; export * from './ParseDocumentCache'; +export * from './PersistentQueryService'; +export * from './PersistentQueryStatusFilterService'; export * from './PollingService'; +export * from './QueryConfigTableService'; export * from './RemoteFileSourceService'; export * from './SecretService'; export * from './ServerManager'; diff --git a/src/testUtils.ts b/src/testUtils.ts index 3f03829c6..65dfd2208 100644 --- a/src/testUtils.ts +++ b/src/testUtils.ts @@ -40,6 +40,26 @@ export function getLastEventListener< return calls.at(-1)![1]; } +/** + * Build a mock `QueryInfoTableSubscription` reporting the given query serials. + * Used to test consumers of the ticking `QueryInfo` table without a live server. + * @param options.serials The serials the latest tick reported (child-replica + * rows are already excluded by the real subscription). + * @returns A mocked subscription. + */ +export function getQueryInfoTableMock({ + serials, +}: { + serials: string[]; +}): unknown { + return { + table: {}, + onDidUpdate: vi.fn(() => () => {}), + getQuerySerials: () => new Set(serials), + dispose: vi.fn(async () => {}), + }; +} + /** * Return last registered event handler for 'message' event. Assumes that * `window.addEventListener` has been mocked or spied on. diff --git a/src/types/commonTypes.d.ts b/src/types/commonTypes.d.ts index de54d9051..130bd269a 100644 --- a/src/types/commonTypes.d.ts +++ b/src/types/commonTypes.d.ts @@ -168,8 +168,18 @@ export interface WorkerConfig { } export interface ConnectionState { + /** + * True for a lightweight PQ browse connection: an auth/panel shim registered + * by `ServerManager.registerBrowseConnection`, with no console session behind + * it. These are excluded from `getConnections` — they are not worker + * connections and must never appear in the Workers tree or the connection + * picker. PQs surface as their own nodes, sourced from + * `IPersistentQueryService`. + */ + readonly isBrowseConnection?: boolean; readonly isConnected: boolean; readonly isRunningCode?: boolean; + readonly label: string; readonly serverUrl: URL; readonly tagId?: UniqueID; } @@ -185,6 +195,8 @@ export interface WorkerInfo { grpcUrl: GrpcURL; ideUrl: IdeURL; jsapiUrl: JsapiURL; + /** Persistent query name (`queryInfo.name`), as shown in the Query Monitor. */ + name: string; processInfoId: string | null; serial: QuerySerial; workerName: string | null; diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index 3e7f2890b..4a8d5602c 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -1,5 +1,8 @@ import * as vscode from 'vscode'; import type { dh as DhcType } from '@deephaven/jsapi-types'; +import type { QueryStatusSection } from '../common'; +import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; +import type { CorePlusManager } from '@deephaven-enterprise/client-utils'; import type { ConsoleType, CoreConnectionConfig, @@ -57,6 +60,7 @@ export interface IConfigService { export interface IDhcService extends IDisposable, ConnectionState { readonly isInitialized: boolean; readonly isConnected: boolean; + readonly isOwned: boolean; isRunningCode: boolean; readonly onDidDisconnect: vscode.Event; @@ -80,7 +84,8 @@ export interface IDhcService extends IDisposable, ConnectionState { } export interface IDheService extends ConnectionState, IDisposable { - readonly onDidWorkerTerminate: vscode.Event; + readonly onWorkerAttachable: vscode.Event; + readonly onWorkerRemoved: vscode.Event; getClient( initializeIfNull: false @@ -89,9 +94,14 @@ export interface IDheService extends ConnectionState, IDisposable { initializeIfNull: true, operateAsAnotherUser: boolean ): Promise; + getCorePlusManager(): Promise; getQuerySerialFromTag(tagId: UniqueID): Promise; getServerFeatures(): DheServerFeatures | undefined; getWorkerInfo: (workerUrl: WorkerURL) => WorkerInfo | undefined; + registerWorkerInfo: (queryInfo: QueryInfo) => WorkerInfo; + listAttachableWorkers: ( + exclude: Iterable + ) => Promise; createWorker: ( tagId: UniqueID, consoleType?: ConsoleType @@ -112,7 +122,7 @@ export type ICoreClientFactory = ( */ export type IDhcServiceFactory = IFactory< IDhcService, - [serverUrl: URL, tagId?: UniqueID] + [label: string, serverUrl: URL, isOwned: boolean, tagId?: UniqueID] >; export type IDheClientFactory = ( serverUrl: URL @@ -144,6 +154,45 @@ export interface IPanelService extends IDisposable { updateVariables: (url: URL, changes: VariableChanges) => void; } +/** + * Source of the persistent queries visible on a DHE server, shared by every view + * that lists PQs. `onDidUpdate` fires whenever the underlying (ticking) + * `QueryInfo` table changes. + */ +export interface IPersistentQueryService extends IDisposable { + readonly onDidUpdate: vscode.Event; + + getPersistentQueries: (serverUrl: URL) => Promise; +} + +/** + * The Persistent Queries view's status filter. Stores the set of statuses to + * HIDE rather than the set to show: an inclusion set would make any status this + * extension has never heard of (a new one from a future DHE release) invisible + * by default and unreachable, whereas an exclusion set keeps an unknown status + * visible until the user hides it. `onDidUpdate` fires whenever the set changes. + */ +export interface IPersistentQueryStatusFilterService extends IDisposable { + readonly onDidUpdate: vscode.Event; + /** Whether a query with this status should be listed. */ + isVisible: (status: string | null | undefined) => boolean; + /** The statuses currently hidden (normalised; unset is ''). */ + getHiddenStatuses: () => ReadonlySet; + /** Replace the hidden set, persist it, and fire `onDidUpdate`. */ + setHiddenStatuses: (hidden: Iterable) => Promise; + /** + * Whether *any* status in the section is currently listed. A section the user + * has partly hidden (via the per-status picker) still counts as shown — the + * checkbox answers "am I seeing any of these?". + */ + isSectionVisible: (section: QueryStatusSection) => boolean; + /** Show or hide every status in the section at once. */ + setSectionVisible: ( + section: QueryStatusSection, + isVisible: boolean + ) => Promise; +} + /** * Secret service interface. */ @@ -174,7 +223,12 @@ export interface IServerManager extends IDisposable { connectToServer: ( serverUrl: URL, workerConsoleType?: ConsoleType, - operateAsAnotherUser?: boolean + operateAsAnotherUser?: boolean, + createWorkerIfNone?: boolean + ) => Promise; + createWorker: ( + dheServerUrl: URL, + workerConsoleType?: ConsoleType ) => Promise; disconnectEditor: (uri: vscode.Uri) => void; disconnectFromDHEServer: (dheServerUrl: URL) => Promise; @@ -183,15 +237,38 @@ export interface IServerManager extends IDisposable { hasConnectionUris: (connection: ConnectionState) => boolean; + /** Whether a client connection to the given server is currently being established. */ + isServerConnecting: (serverUrl: URL) => boolean; + getConnection: (serverUrl: URL) => ConnectionState | undefined; getConnections: (serverOrWorkerUrl?: URL) => ConnectionState[]; getConnectionUris: (connection: ConnectionState) => vscode.Uri[]; + /** + * Get the parent server for a connection. Resolves the DHE server for a DHE + * worker, or the DHC server for a plain DHC connection. Returns `undefined` + * only when no matching server is registered. + */ + getServerForConnection: ( + connection: ConnectionState + ) => ServerState | undefined; getDheServiceForWorker: (maybeWorkerUrl: URL) => Promise; getEditorConnection: (uri: vscode.Uri) => Promise; getWorkerCredentials: ( serverOrWorkerUrl: URL | WorkerURL ) => Promise; getWorkerInfo: (maybeWorkerUrl: URL) => Promise; + /** + * Register a lightweight, non-console "browse" connection for a persistent + * query's worker so the DH embed panel can open its objects read-only. Never + * creates a console session, never increments `connectionCount`, and never + * deletes the server-side PQ. + */ + registerBrowseConnection: ( + dheServerUrl: URL, + queryInfo: QueryInfo + ) => Promise; + /** Tear down a browse connection (never deletes the server-side PQ). */ + unregisterBrowseConnection: (workerUrl: URL) => void; setEditorConnection: ( uri: vscode.Uri, languageId: string, diff --git a/src/types/treeViewTypes.d.ts b/src/types/treeViewTypes.d.ts index 7f437f3c4..ce26fd5ef 100644 --- a/src/types/treeViewTypes.d.ts +++ b/src/types/treeViewTypes.d.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; import type { ConnectionState, ServerState, @@ -9,14 +10,55 @@ export type ServerGroupState = 'Managed' | 'Running' | 'Stopped'; export type ServerNode = ServerGroupState | ServerState; export interface ServerTreeView extends vscode.TreeView {} -export type ServerConnectionNode = ConnectionState | vscode.Uri; +/** + * A node in the Interactive Consoles tree: + * - `ServerState`: a server grouping its console workers. + * - `ConnectionState`: a console worker connection. + * - `vscode.Uri`: an editor file associated with the worker. + * - `[URL, VariableDefintion]`: a panel leaf (worker URL + variable). + */ +export type ServerConnectionNode = + | ServerState + | ConnectionState + | vscode.Uri + | [URL, VariableDefintion]; export interface ServerConnectionTreeView extends vscode.TreeView {} -export type ServerConnectionPanelNode = - | ConnectionState +/** + * A persistent-query node in the Persistent Queries tree. Pairs the owning DHE + * server URL with the `QueryInfo` so the provider can resolve the PQ's worker + * (via `getKnownConfigs()`) and its exported objects on expand. + */ +export type PersistentQueryNode = { + readonly dheServerUrl: URL; + readonly queryInfo: QueryInfo; +}; + +/** + * The trailing node under a server whose status filter is hiding queries. It + * states how many are hidden and opens the filter picker when clicked, so the + * hidden ones are never a silent omission. + */ +export type PersistentQueryHiddenNode = { + readonly dheServerUrl: URL; + readonly hiddenCount: number; +}; + +/** + * A node in the Persistent Queries tree: + * - `ServerState`: a DHE server grouping its persistent queries. + * - `PersistentQueryNode`: a non-InteractiveConsole PQ (expandable to objects). + * - `PersistentQueryHiddenNode`: the trailing "N hidden" node, when filtered. + * - `[URL, VariableDefintion]`: an exported object leaf (worker URL + variable), + * rendered/opened exactly like the Panels tree's object leaves. + */ +export type PersistentQueryTreeNode = + | ServerState + | PersistentQueryNode + | PersistentQueryHiddenNode | [URL, VariableDefintion]; -export interface ServerConnectionPanelTreeView - extends vscode.TreeView {} +export interface PersistentQueryTreeView + extends vscode.TreeView {} diff --git a/src/util/__snapshots__/treeViewUtils.spec.ts.snap b/src/util/__snapshots__/treeViewUtils.spec.ts.snap index 48a720433..bbeeaad35 100644 --- a/src/util/__snapshots__/treeViewUtils.spec.ts.snap +++ b/src/util/__snapshots__/treeViewUtils.spec.ts.snap @@ -1,50 +1,26 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:false 1`] = ` +exports[`getConnectionServerTreeItem > should fall back to the url host when there is no label 1`] = ` { "collapsibleState": 2, - "description": undefined, - "iconPath": ThemeIcon { - "color": undefined, - "id": "sync~spin", - }, - "label": "localhost:10000", -} -`; - -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:true 1`] = ` -{ - "collapsibleState": 2, - "description": "python", - "iconPath": ThemeIcon { - "color": undefined, - "id": "sync~spin", - }, - "label": "localhost:10000", -} -`; - -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:false 1`] = ` -{ - "collapsibleState": 2, - "description": undefined, + "contextValue": "isDHEServerConnectionParent", "iconPath": ThemeIcon { "color": undefined, "id": "vm-connect", }, - "label": "localhost:10000", + "label": "my-dhe-server:8123", } `; -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:true 1`] = ` +exports[`getConnectionServerTreeItem > should return a labeled server tree item 1`] = ` { "collapsibleState": 2, - "description": "python", + "contextValue": "isDHEServerConnectionParent", "iconPath": ThemeIcon { "color": undefined, "id": "vm-connect", }, - "label": "localhost:10000", + "label": "My DHE Server", } `; @@ -312,21 +288,37 @@ exports[`getPanelVariableTreeItem > should return panel variable tree item: type } `; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isManaged=false, isRunning=false 1`] = `"isServerStopped"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=false, isManaged=false, isRunning=false 1`] = `"isServerStopped"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=false, isManaged=false, isRunning=true 1`] = `"isServerRunningDisconnected"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=false, isManaged=true, isRunning=false 1`] = `"isManagedServerConnecting"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=false, isManaged=true, isRunning=true 1`] = `"isManagedServerDisconnected"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=true, isManaged=false, isRunning=false 1`] = `"isServerConnecting"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=true, isManaged=false, isRunning=true 1`] = `"isServerConnecting"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isManaged=false, isRunning=true 1`] = `"isServerRunningDisconnected"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=true, isManaged=true, isRunning=false 1`] = `"isServerConnecting"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isManaged=true, isRunning=false 1`] = `"isManagedServerConnecting"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=true, isManaged=true, isRunning=true 1`] = `"isServerConnecting"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isManaged=true, isRunning=true 1`] = `"isManagedServerDisconnected"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=false, isManaged=false, isRunning=false 1`] = `"isServerStopped"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isManaged=false, isRunning=false 1`] = `"isServerStopped"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=false, isManaged=false, isRunning=true 1`] = `"isServerRunningConnected"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isManaged=false, isRunning=true 1`] = `"isServerRunningConnected"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=false, isManaged=true, isRunning=false 1`] = `"isManagedServerConnected"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isManaged=true, isRunning=false 1`] = `"isManagedServerConnected"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=false, isManaged=true, isRunning=true 1`] = `"isManagedServerConnected"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isManaged=true, isRunning=true 1`] = `"isManagedServerConnected"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=true, isManaged=false, isRunning=false 1`] = `"isServerConnecting"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=true, isManaged=false, isRunning=true 1`] = `"isServerConnecting"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=true, isManaged=true, isRunning=false 1`] = `"isServerConnecting"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=true, isManaged=true, isRunning=true 1`] = `"isServerConnecting"`; exports[`getServerDescription > should return server description based on parameters: connectionCount=0, isManaged=false, label=some label 1`] = `"some label"`; @@ -400,23 +392,39 @@ exports[`getServerGroupTreeItem > should return server group tree item: group=Ru } `; -exports[`getServerIconID > should return icon id based on server state: isConnected=false, isManaged=false, isRunning=false 1`] = `"circle-slash"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=false, isManaged=false, isRunning=false 1`] = `"circle-slash"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=false, isManaged=false, isRunning=true 1`] = `"circle-large-outline"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=false, isManaged=true, isRunning=false 1`] = `"sync~spin"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=false, isManaged=true, isRunning=true 1`] = `"circle-large-outline"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=true, isManaged=false, isRunning=false 1`] = `"sync~spin"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=false, isManaged=false, isRunning=true 1`] = `"circle-large-outline"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=true, isManaged=false, isRunning=true 1`] = `"sync~spin"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=false, isManaged=true, isRunning=false 1`] = `"sync~spin"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=true, isManaged=true, isRunning=false 1`] = `"sync~spin"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=false, isManaged=true, isRunning=true 1`] = `"circle-large-outline"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=true, isManaged=true, isRunning=true 1`] = `"sync~spin"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=true, isManaged=false, isRunning=false 1`] = `"circle-slash"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=false, isManaged=false, isRunning=false 1`] = `"circle-slash"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=true, isManaged=false, isRunning=true 1`] = `"circle-large-filled"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=false, isManaged=false, isRunning=true 1`] = `"circle-large-filled"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=true, isManaged=true, isRunning=false 1`] = `"sync~spin"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=false, isManaged=true, isRunning=false 1`] = `"sync~spin"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=true, isManaged=true, isRunning=true 1`] = `"circle-large-filled"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=false, isManaged=true, isRunning=true 1`] = `"circle-large-filled"`; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isManaged=false, isRunning=false 1`] = ` +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=true, isManaged=false, isRunning=false 1`] = `"sync~spin"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=true, isManaged=false, isRunning=true 1`] = `"sync~spin"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=true, isManaged=true, isRunning=false 1`] = `"sync~spin"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=true, isManaged=true, isRunning=true 1`] = `"sync~spin"`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=false, isManaged=false, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isServerStopped", @@ -430,7 +438,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isManaged=false, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=false, isManaged=false, isRunning=true 1`] = ` { "command": { "arguments": [ @@ -457,7 +465,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isManaged=true, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=false, isManaged=true, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isManagedServerConnecting", @@ -471,7 +479,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isManaged=true, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=false, isManaged=true, isRunning=true 1`] = ` { "command": { "arguments": [ @@ -499,7 +507,63 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isManaged=false, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=true, isManaged=false, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=true, isManaged=false, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=true, isManaged=true, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=true, isManaged=true, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=false, isManaged=false, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isServerStopped", @@ -513,7 +577,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isManaged=false, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=false, isManaged=false, isRunning=true 1`] = ` { "command": undefined, "contextValue": "isServerRunningConnected", @@ -527,7 +591,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isManaged=true, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=false, isManaged=true, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isManagedServerConnected", @@ -541,7 +605,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isManaged=true, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=false, isManaged=true, isRunning=true 1`] = ` { "command": undefined, "contextValue": "isManagedServerConnected", @@ -555,7 +619,63 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isManaged=false, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=true, isManaged=false, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "(1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=true, isManaged=false, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "(1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=true, isManaged=true, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip (1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=true, isManaged=true, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip (1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=false, isManaged=false, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isServerStopped", @@ -569,7 +689,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isManaged=false, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=false, isManaged=false, isRunning=true 1`] = ` { "command": { "arguments": [ @@ -596,7 +716,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isManaged=true, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=false, isManaged=true, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isManagedServerConnecting", @@ -610,7 +730,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isManaged=true, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=false, isManaged=true, isRunning=true 1`] = ` { "command": { "arguments": [ @@ -638,7 +758,63 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isManaged=false, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=true, isManaged=false, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=true, isManaged=false, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=true, isManaged=true, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=true, isManaged=true, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=false, isManaged=false, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isServerStopped", @@ -652,7 +828,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isManaged=false, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=false, isManaged=false, isRunning=true 1`] = ` { "command": { "arguments": [ @@ -679,7 +855,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isManaged=true, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=false, isManaged=true, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isManagedServerConnected", @@ -693,7 +869,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isManaged=true, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=false, isManaged=true, isRunning=true 1`] = ` { "command": undefined, "contextValue": "isManagedServerConnected", @@ -707,6 +883,62 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=true, isManaged=false, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "(1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=true, isManaged=false, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "(1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=true, isManaged=true, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip (1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=true, isManaged=true, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip (1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + exports[`getVariableIconPath > should return icon path for variableType 1`] = ` [ [ diff --git a/src/util/__snapshots__/uiUtils.spec.ts.snap b/src/util/__snapshots__/uiUtils.spec.ts.snap index 9c7e875cf..7c067cc81 100644 --- a/src/util/__snapshots__/uiUtils.spec.ts.snap +++ b/src/util/__snapshots__/uiUtils.spec.ts.snap @@ -31,27 +31,29 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed { "data": { "isConnected": true, + "label": "ServerA Connection", "serverUrl": "http://localhost:10000/", }, - "description": "python (current)", + "description": "localhost:10000 (current)", "iconPath": ThemeIcon { "color": undefined, - "id": "vm-connect", + "id": "dh-python", }, - "label": "http://localhost:10000/", + "label": "ServerA Connection", "type": "connection", }, { "data": { "isConnected": true, + "label": "ServerC Connection", "serverUrl": "http://localhost:10002/", }, - "description": "python", + "description": "localhost:10002", "iconPath": ThemeIcon { "color": undefined, - "id": "vm-connect", + "id": "dh-python", }, - "label": "http://localhost:10002/", + "label": "ServerC Connection", "type": "connection", }, { @@ -102,27 +104,29 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed { "data": { "isConnected": true, + "label": "ServerA Connection", "serverUrl": "http://localhost:10000/", }, - "description": "python", + "description": "localhost:10000", "iconPath": ThemeIcon { "color": undefined, - "id": "vm-connect", + "id": "dh-python", }, - "label": "http://localhost:10000/", + "label": "ServerA Connection", "type": "connection", }, { "data": { "isConnected": true, + "label": "ServerC Connection", "serverUrl": "http://localhost:10002/", }, - "description": "python", + "description": "localhost:10002", "iconPath": ThemeIcon { "color": undefined, - "id": "vm-connect", + "id": "dh-python", }, - "label": "http://localhost:10002/", + "label": "ServerC Connection", "type": "connection", }, { diff --git a/src/util/dataUtils.spec.ts b/src/util/dataUtils.spec.ts index 3b23c952e..4ab95e60d 100644 --- a/src/util/dataUtils.spec.ts +++ b/src/util/dataUtils.spec.ts @@ -1,6 +1,10 @@ import { describe, it, expect, vi } from 'vitest'; import type { dh as DhType } from '@deephaven/jsapi-types'; -import { parseSamlScopes, serializeRefreshToken } from './dataUtils'; +import { + formatCount, + parseSamlScopes, + serializeRefreshToken, +} from './dataUtils'; import { DH_SAML_LOGIN_URL_SCOPE_KEY, DH_SAML_SERVER_URL_SCOPE_KEY, @@ -68,3 +72,17 @@ describe('serializeRefreshToken', () => { } ); }); + +describe('formatCount', () => { + it.each([ + [0, '0'], + [7, '7'], + [999, '999'], + [1000, '1,000'], + [20007, '20,007'], + [20001, '20,001'], + [1234567, '1,234,567'], + ])('formats %s with thousands separators', (count, expected) => { + expect(formatCount(count)).toBe(expected); + }); +}); diff --git a/src/util/dataUtils.ts b/src/util/dataUtils.ts index 835e18445..96305701b 100644 --- a/src/util/dataUtils.ts +++ b/src/util/dataUtils.ts @@ -205,3 +205,17 @@ export function sortByStringProp( return String(a[propName]).localeCompare(String(b[propName])); }; } + +/** + * Format a count for display with thousands separators, e.g. `20007` -> + * `'20,007'`. A DHE server can host tens of thousands of queries, and an + * unseparated run of digits is hard to size up at a glance. + * + * The locale is pinned to `en-US` rather than left to the host so the separator + * is always a comma. Drop the argument if these counts should follow the user's + * locale instead. + * @param count The number to format. + */ +export function formatCount(count: number): string { + return count.toLocaleString('en-US'); +} diff --git a/src/util/index.ts b/src/util/index.ts index 29043971e..9d8d98916 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -21,6 +21,7 @@ export * from './selectionUtils'; export * from './serverUtils'; export * from './sets'; export * from './tmpUtils'; +export * from './transportUtils'; export * from './treeViewUtils'; export * from './Toaster'; export * from './uiUtils'; diff --git a/src/util/panelUtils.spec.ts b/src/util/panelUtils.spec.ts new file mode 100644 index 000000000..a44eecbfb --- /dev/null +++ b/src/util/panelUtils.spec.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from 'vitest'; +import { isOpenablePanelVariable } from './panelUtils'; + +// See __mocks__/vscode.ts for the mock implementation +vi.mock('vscode'); + +describe('isOpenablePanelVariable', () => { + it.each([ + // Grid plugin. + ['Table'], + ['TreeTable'], + ['HierarchicalTable'], + ['PartitionedTable'], + // Chart / Pandas plugins. + ['Figure'], + ['pandas.DataFrame'], + // First-party plugin widgets — deephaven.ui panels are openable. + ['deephaven.ui.Element'], + ['deephaven.plot.express.DeephavenFigure'], + ])('is true for an openable type: %s', type => { + expect(isOpenablePanelVariable({ title: 't', type })).toBe(true); + }); + + it.each([ + // A dashboard, not a panel. + ['deephaven.ui.Dashboard'], + // Legacy types no plugin claims. + ['TableMap'], + ['Treemap'], + // The server's catch-all widget type. + ['OtherWidget'], + // DHE service objects exported by a worker. + ['AclService'], + // An unknown server plugin type — hidden until added to the allow-list. + ['some.server.PluginWidget'], + ])('is false for a type that is not an openable panel: %s', type => { + expect(isOpenablePanelVariable({ title: 't', type })).toBe(false); + }); + + it.each([ + [{ title: '', type: 'Table' }], + [{ title: null, type: 'Table' }], + [{ type: 'Table' }], + [{ title: 't', type: '' }], + [{ title: 't', type: null }], + [{ title: 't' }], + [{}], + ])('is false without both a title and an openable type: %s', variable => { + expect(isOpenablePanelVariable(variable)).toBe(false); + }); +}); diff --git a/src/util/panelUtils.ts b/src/util/panelUtils.ts index d3c4846d4..30751d2fd 100644 --- a/src/util/panelUtils.ts +++ b/src/util/panelUtils.ts @@ -1,6 +1,10 @@ import * as vscode from 'vscode'; import type { dh as DhcType } from '@deephaven/jsapi-types'; -import { DH_PANEL_VIEW_TYPE, VIEW_ID } from '../common'; +import { + DH_PANEL_VIEW_TYPE, + OPENABLE_PANEL_VARIABLE_TYPES, + VIEW_ID, +} from '../common'; import type { WorkerInfo } from '../types'; import { VSCODE_POST_MSG, @@ -9,6 +13,27 @@ import { } from '../shared'; import { getWebViewHtml } from './webViewUtils'; +/** + * Whether a variable can be opened as a Deephaven panel: its type must be one we + * know renders ({@link OPENABLE_PANEL_VARIABLE_TYPES}), and it must carry the + * `title` the embed widget URL addresses it by. Everything else — service objects + * like `AclService`, dashboards, legacy widget types — is not a panel. + * @param variable The variable (or PQ exported object) to check. + */ +export function isOpenablePanelVariable(variable: { + title?: string | null; + type?: string | null; +}): boolean { + const { title, type } = variable; + + return ( + title != null && + title !== '' && + type != null && + OPENABLE_PANEL_VARIABLE_TYPES.has(type) + ); +} + /** * Create response for login options `postMessage` request from Deephaven iframe. * @param id The id of the request. diff --git a/src/util/promiseUtils.spec.ts b/src/util/promiseUtils.spec.ts index 63fcd75ec..f2d9864dd 100644 --- a/src/util/promiseUtils.spec.ts +++ b/src/util/promiseUtils.spec.ts @@ -1,5 +1,14 @@ -import { beforeEach, describe, it, expect, vi, afterAll } from 'vitest'; import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { + createThrottledTrigger, rejectAfterTimeout, waitFor, waitForEvent, @@ -121,3 +130,75 @@ describe('withResolvers', () => { expect(rejected).toHaveBeenCalledWith('Some Error'); }); }); + +describe('createThrottledTrigger', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('runs immediately on the leading edge', () => { + const callback = vi.fn(); + const throttled = createThrottledTrigger(callback, 250); + + throttled.trigger(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('coalesces a burst into one trailing run', () => { + const callback = vi.fn(); + const throttled = createThrottledTrigger(callback, 250); + + throttled.trigger(); + throttled.trigger(); + throttled.trigger(); + throttled.trigger(); + expect(callback).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(250); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('does not run again when nothing arrived during the window', () => { + const callback = vi.fn(); + const throttled = createThrottledTrigger(callback, 250); + + throttled.trigger(); + vi.advanceTimersByTime(1000); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('keeps running under a source that never goes quiet', () => { + // The reason this is a throttle and not a debounce: a debounce timer would + // be reset forever here and the callback would never run at all. + const callback = vi.fn(); + const throttled = createThrottledTrigger(callback, 250); + + for (let i = 0; i < 100; ++i) { + throttled.trigger(); + vi.advanceTimersByTime(100); + } + + // ~10,000ms of ticks every 100ms, rate limited to one run per 250ms. + expect(callback.mock.calls.length).toBeGreaterThan(30); + expect(callback.mock.calls.length).toBeLessThan(45); + }); + + it('drops a pending trailing run when disposed', () => { + const callback = vi.fn(); + const throttled = createThrottledTrigger(callback, 250); + + throttled.trigger(); + throttled.trigger(); + throttled.dispose(); + + vi.advanceTimersByTime(1000); + + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/util/promiseUtils.ts b/src/util/promiseUtils.ts index 8c4cf8b13..9f02ca9aa 100644 --- a/src/util/promiseUtils.ts +++ b/src/util/promiseUtils.ts @@ -86,3 +86,58 @@ export function withResolvers(): PromiseWithResolvers { reject, }; } + +export interface ThrottledTrigger { + /** Request a run: immediate when idle, coalesced while cooling down. */ + trigger: () => void; + /** Drop any pending trailing run. */ + dispose: () => void; +} + +/** + * Wrap a callback so it runs at most once per `intervalMs`: immediately on the + * leading edge of a burst, then once more at the end of the window if further + * triggers arrived during it. + * + * Deliberately a throttle and not a plain debounce. A source that never goes + * quiet — a ticking table on a busy server — would reset a debounce timer + * forever and the callback would never run at all. + * @param callback The callback to rate limit. + * @param intervalMs Minimum milliseconds between runs. + */ +export function createThrottledTrigger( + callback: () => void, + intervalMs: number +): ThrottledTrigger { + let timeout: ReturnType | undefined; + let isPending = false; + + const run = (): void => { + callback(); + + timeout = setTimeout(() => { + timeout = undefined; + + if (isPending) { + isPending = false; + run(); + } + }, intervalMs); + }; + + return { + trigger: (): void => { + if (timeout == null) { + run(); + return; + } + + isPending = true; + }, + dispose: (): void => { + clearTimeout(timeout); + timeout = undefined; + isPending = false; + }, + }; +} diff --git a/src/util/transportUtils.spec.ts b/src/util/transportUtils.spec.ts new file mode 100644 index 000000000..4dee89aed --- /dev/null +++ b/src/util/transportUtils.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from 'vitest'; +import { NodeHttp2gRPCTransport } from '@deephaven/jsapi-nodejs'; +import { getSharedTransportFactory } from './transportUtils'; + +// See __mocks__/vscode.ts for the mock implementation +vi.mock('vscode'); + +vi.mock('@deephaven/jsapi-nodejs', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + NodeHttp2gRPCTransport: { + createFactory: vi.fn(() => ({ create: vi.fn() })), + }, +})); + +const createFactoryMock = vi.mocked(NodeHttp2gRPCTransport.createFactory); + +// The factory is memoized at module scope, so these assertions are deliberately +// made against a single shared creation rather than reset between tests. +describe('getSharedTransportFactory', () => { + it('should create the factory exactly once and reuse it', () => { + const first = getSharedTransportFactory(); + const second = getSharedTransportFactory(); + const third = getSharedTransportFactory(); + + // Each `createFactory` call allocates its own session map, so calling it per + // site would open a TCP connection per origin *per site* rather than sharing + // them, which is what the deprecated static `factory` did for free. + expect(first).toBe(second); + expect(second).toBe(third); + expect(createFactoryMock).toHaveBeenCalledTimes(1); + }); + + it('should configure both HTTP/2 window sizes', () => { + getSharedTransportFactory(); + + // The per-stream window is an `http2.connect` setting; the connection window + // is not (RFC 9113 6.9.2) and is applied via `setLocalWindowSize` upstream. + expect(createFactoryMock.mock.calls[0]?.[0]).toEqual({ + sessionLocalWindowSize: 8 * 1024 * 1024, + sessionOptions: { + settings: { + initialWindowSize: 4 * 1024 * 1024, + }, + }, + }); + }); + + it('should not configure a connection window below the stream window', () => { + getSharedTransportFactory(); + + // Upstream throws on this, since the connection window caps every stream. + const config = createFactoryMock.mock.calls[0]?.[0]; + + expect(config?.sessionLocalWindowSize).toBeGreaterThanOrEqual( + config?.sessionOptions?.settings?.initialWindowSize ?? 0 + ); + }); +}); diff --git a/src/util/transportUtils.ts b/src/util/transportUtils.ts new file mode 100644 index 000000000..0afa7eeb7 --- /dev/null +++ b/src/util/transportUtils.ts @@ -0,0 +1,56 @@ +import type { dh as DhcType } from '@deephaven/jsapi-types'; +import { NodeHttp2gRPCTransport } from '@deephaven/jsapi-nodejs'; + +/** + * Per-stream HTTP/2 receive window (SETTINGS_INITIAL_WINDOW_SIZE), passed + * through to `http2.connect`. + * + * Node defaults both receive windows to 64KB and, unlike browsers, never grows + * them, which caps a single stream at roughly `window / RTT`. Against a DHE + * server holding 20k persistent queries that capped the controller's + * query-config stream at ~425 KB/s and made a connect take 31.4s; 4MiB took the + * same connect to 8.4s. + */ +const INITIAL_WINDOW_SIZE = 4 * 1024 * 1024; + +/** + * Connection-level HTTP/2 receive window. Shared by every stream on a session, + * so it is set to 2x {@link INITIAL_WINDOW_SIZE} rather than left to default + * (which would derive it as equal): the controller stream runs alongside + * concurrent unary calls (`getToken`, `ping`, `getGroupsForUser`) on the same + * session, and 4MiB/8MiB is the pair measured end to end. + */ +const SESSION_LOCAL_WINDOW_SIZE = 8 * 1024 * 1024; + +let sharedFactory: DhcType.grpc.GrpcTransportFactory | null = null; + +/** + * The gRPC transport factory shared by every Deephaven connection this + * extension makes — Core/DHC clients, the DHE client, and the Core+ manager + * behind worker connections. + * + * There is deliberately exactly one instance. `createFactory` allocates a + * session map per call, so calling it per site would open one TCP connection per + * origin *per site* instead of sharing them, which is what the deprecated static + * `NodeHttp2gRPCTransport.factory` used to do for free. + * @returns The shared transport factory. + */ +export function getSharedTransportFactory(): DhcType.grpc.GrpcTransportFactory { + if (sharedFactory != null) { + return sharedFactory; + } + + sharedFactory = NodeHttp2gRPCTransport.createFactory({ + // Not an `http2.connect` option: per RFC 9113 6.9.2 the connection window + // can only change via a WINDOW_UPDATE on stream 0, so upstream applies this + // with `session.setLocalWindowSize()` on connect. + sessionLocalWindowSize: SESSION_LOCAL_WINDOW_SIZE, + sessionOptions: { + settings: { + initialWindowSize: INITIAL_WINDOW_SIZE, + }, + }, + }); + + return sharedFactory; +} diff --git a/src/util/treeViewUtils.spec.ts b/src/util/treeViewUtils.spec.ts index 7630e67e7..d9077f970 100644 --- a/src/util/treeViewUtils.spec.ts +++ b/src/util/treeViewUtils.spec.ts @@ -1,8 +1,19 @@ import { describe, it, expect, vi } from 'vitest'; +import type * as vscode from 'vscode'; import { bitValues, boolValues, matrix } from '../testUtils'; import { - getPanelConnectionTreeItem, + canBrowsePersistentQueryObjects, + getConnectionServerTreeItem, + getPanelVariableLeaves, getPanelVariableTreeItem, + getPersistentQueryIconId, + getPersistentQueryObjectLeaves, + getPersistentQueryServerTreeItem, + getPersistentQueryStatus, + getPersistentQueryTreeItem, + isPersistentQueryNode, + getWorkerNodeLabel, + persistentQueryHasTables, getServerContextValue, getServerDescription, getServerGroupContextValue, @@ -13,18 +24,16 @@ import { groupServers, } from './treeViewUtils'; import type { - ConsoleType, - IDhcService, + IPanelService, Psk, ServerState, VariableDefintion, VariableType, } from '../types'; -import { isInstanceOf } from './isInstanceOf'; +import { DH_PROTECTED_VARIABLE_NAMES } from '../common'; // See __mocks__/vscode.ts for the mock implementation vi.mock('vscode'); -vi.mock('../util/isInstanceOf.ts'); const variableTypes: readonly VariableType[] = [ 'deephaven.plot.express.DeephavenFigure', @@ -40,32 +49,94 @@ const variableTypes: readonly VariableType[] = [ 'TreeTable', ] as const; -describe('getPanelConnectionTreeItem', () => { - const getConsoleTypes: IDhcService['getConsoleTypes'] = vi - .fn() - .mockResolvedValue(new Set(['python'])); +describe('getConnectionServerTreeItem', () => { + it('should return a labeled server tree item', () => { + const server: ServerState = { + type: 'DHE', + url: new URL('https://my-dhe-server:8123'), + label: 'My DHE Server', + isConnected: true, + isRunning: true, + connectionCount: 2, + }; + expect(getConnectionServerTreeItem(server)).toMatchSnapshot(); + }); + + it('should fall back to the url host when there is no label', () => { + const server: ServerState = { + type: 'DHE', + url: new URL('https://my-dhe-server:8123'), + isConnected: true, + isRunning: true, + connectionCount: 2, + }; + + expect(getConnectionServerTreeItem(server)).toMatchSnapshot(); + }); +}); + +describe('getWorkerNodeLabel', () => { + it.each([ + [ + 'clips the trailing id of a Code Studio worker', + 'Code Studio - Web - l9hnYDTiEosKmJwe4Fma5', + 'Code Studio - Web - l9hnYD', + ], + [ + 'clips the trailing id of a VS Code worker', + 'IC - VS Code - KM7lskRLXCESeQpTBPSTB', + 'IC - VS Code - KM7lsk', + ], + ['leaves a name with no id segment alone', 'IC - VS Code', 'IC - VS Code'], + [ + 'leaves a short trailing segment alone', + 'Code Studio - Web - abc', + 'Code Studio - Web - abc', + ], + [ + 'leaves a trailing segment containing whitespace alone', + 'A - B - two words', + 'A - B - two words', + ], + ['leaves an unstructured name alone', 'my worker', 'my worker'], + ])('%s', (_label, input, expected) => { + expect(getWorkerNodeLabel(input)).toBe(expected); + }); +}); + +describe('getPanelVariableLeaves', () => { const serverUrl = new URL('http://localhost:10000'); - it.each(matrix(boolValues, boolValues))( - 'should return panel connection tree item: isConnected:%s, isInitialized:%s', - async (isConnected, isInitialized) => { - const connection = { - isConnected, - isInitialized, - serverUrl, - getConsoleTypes, - } as IDhcService; + const makePanelService = (variables: unknown[]): IPanelService => + ({ getVariables: () => variables }) as unknown as IPanelService; - vi.mocked(isInstanceOf).mockReturnValue(true); + it('drops variables that cannot open as a panel', () => { + const panelService = makePanelService([ + { id: 'v1', title: 't1', name: 't1', type: 'Table' }, + { id: 'v2', title: 'acl', name: 'acl', type: 'AclService' }, + ]); - const actual = await getPanelConnectionTreeItem(connection, async () => { - const [consoleType] = await getConsoleTypes(); - return isInitialized ? consoleType : undefined; - }); - expect(actual).toMatchSnapshot(); - } - ); + expect( + getPanelVariableLeaves(panelService, serverUrl).map( + ([, variable]) => variable.title + ) + ).toEqual(['t1']); + }); + + it('sorts the leaves by title and pairs each with the worker url', () => { + const panelService = makePanelService([ + { id: 'v1', title: 'c', name: 'c', type: 'Table' }, + { id: 'v2', title: 'a', name: 'a', type: 'Figure' }, + { id: 'v3', title: 'b', name: 'b', type: 'Table' }, + ]); + + expect(getPanelVariableLeaves(panelService, serverUrl)).toEqual([ + [serverUrl, { id: 'v2', title: 'a', name: 'a', type: 'Figure' }], + [serverUrl, { id: 'v3', title: 'b', name: 'b', type: 'Table' }], + [serverUrl, { id: 'v1', title: 'c', name: 'c', type: 'Table' }], + ]); + }); }); describe('getPanelVariableTreeItem', () => { @@ -79,18 +150,56 @@ describe('getPanelVariableTreeItem', () => { type, } as VariableDefintion; - const actual = getPanelVariableTreeItem([url, variable]); + const actual = getPanelVariableTreeItem([url, variable], true); expect(actual).toMatchSnapshot(); } ); + + it('offers the delete action when the variable can be deleted', () => { + const variable = { + title: 'some title', + name: 'some_name', + type: 'Table', + } as VariableDefintion; + + expect(getPanelVariableTreeItem([url, variable], true).contextValue).toBe( + 'canDeleteDeephavenVariable' + ); + }); + + it('offers no delete action for a browse-only (PQ) object', () => { + const variable = { + title: 'some title', + name: 'some_name', + type: 'Table', + } as VariableDefintion; + + expect( + getPanelVariableTreeItem([url, variable], false).contextValue + ).toBeUndefined(); + }); + + it('offers no delete action for a protected variable name', () => { + const [protectedName] = DH_PROTECTED_VARIABLE_NAMES; + const variable = { + title: 'some title', + name: protectedName, + type: 'Table', + } as VariableDefintion; + + expect( + getPanelVariableTreeItem([url, variable], true).contextValue + ).toBeUndefined(); + }); }); describe('getServerContextValue', () => { - it.each(matrix(boolValues, boolValues, boolValues))( - 'should return contextValue based on server state: isConnected=%s, isManaged=%s, isRunning=%s', - (isConnected, isManaged, isRunning) => { + it.each(matrix(boolValues, boolValues, boolValues, boolValues))( + 'should return contextValue based on server state: isConnected=%s, isConnecting=%s, isManaged=%s, isRunning=%s', + (isConnected, isConnecting, isManaged, isRunning) => { const actual = getServerContextValue({ isConnected, + isConnecting, isDHE: false, isManaged, isRunning, @@ -137,10 +246,15 @@ describe('getServerGroupTreeItem', () => { }); describe('getServerIconID', () => { - it.each(matrix(boolValues, boolValues, boolValues))( - 'should return icon id based on server state: isConnected=%s, isManaged=%s, isRunning=%s', - (isConnected, isManaged, isRunning) => { - const actual = getServerIconID({ isConnected, isManaged, isRunning }); + it.each(matrix(boolValues, boolValues, boolValues, boolValues))( + 'should return icon id based on server state: isConnected=%s, isConnecting=%s, isManaged=%s, isRunning=%s', + (isConnected, isConnecting, isManaged, isRunning) => { + const actual = getServerIconID({ + isConnected, + isConnecting, + isManaged, + isRunning, + }); expect(actual).toMatchSnapshot(); } ); @@ -157,19 +271,22 @@ describe('getServerTreeItem', () => { connectionCount: 0, }; - it.each(matrix(typeValues, boolValues, boolValues, boolValues))( - 'should return server tree item: type=%s, isConnected=%s, isManaged=%s, isRunning=%s', - (type, isConnected, isManaged, isRunning) => { - const actual = getServerTreeItem({ - ...dhcServerState, - ...(isManaged - ? { isManaged: true, psk: 'mock.psk' as Psk } - : { isManaged: false }), - type, - connectionCount: isConnected ? 1 : 0, - isConnected, - isRunning, - }); + it.each(matrix(typeValues, boolValues, boolValues, boolValues, boolValues))( + 'should return server tree item: type=%s, isConnected=%s, isConnecting=%s, isManaged=%s, isRunning=%s', + (type, isConnected, isConnecting, isManaged, isRunning) => { + const actual = getServerTreeItem( + { + ...dhcServerState, + ...(isManaged + ? { isManaged: true, psk: 'mock.psk' as Psk } + : { isManaged: false }), + type, + connectionCount: isConnected ? 1 : 0, + isConnected, + isRunning, + }, + isConnecting + ); expect(actual).toMatchSnapshot(); } @@ -206,3 +323,360 @@ describe('groupServers', () => { expect(actual).toMatchSnapshot(); }); }); + +describe('getPersistentQueryIconId', () => { + it('returns the filled circle for a Running PQ', () => { + expect(getPersistentQueryIconId('Running')).toBe('circle-large-filled'); + }); + + it.each([ + ['Stopped'], + ['Failed'], + ['Error'], + ['Disconnected'], + ['Completed'], + ])('returns the stop sign for a stopped status: %s', status => { + expect(getPersistentQueryIconId(status)).toBe('circle-slash'); + }); + + it('returns the spinner for Stopping — it is still winding down', () => { + expect(getPersistentQueryIconId('Stopping')).toBe('sync~spin'); + }); + + it.each([[null], [undefined], ['']])( + 'returns the open circle for an unset status (not the same as stopped): %s', + status => { + expect(getPersistentQueryIconId(status as string | null)).toBe( + 'circle-large-outline' + ); + } + ); + + it.each([['Initializing'], ['Connecting'], ['Queued']])( + 'returns the spinner for a status between running and stopped: %s', + status => { + expect(getPersistentQueryIconId(status)).toBe('sync~spin'); + } + ); +}); + +describe('getPersistentQueryStatus', () => { + const makeQueryInfo = ( + overrides: Record + ): Parameters[0] => + overrides as unknown as Parameters[0]; + + it('returns the designated worker status', () => { + expect( + getPersistentQueryStatus( + makeQueryInfo({ designated: { status: 'Running' } }) + ) + ).toBe('Running'); + }); + + it('returns undefined when there is no designated worker', () => { + expect(getPersistentQueryStatus(makeQueryInfo({}))).toBeUndefined(); + }); +}); + +describe('isPersistentQueryNode', () => { + it('is true for a node carrying queryInfo', () => { + const node = { + dheServerUrl: new URL('https://dhe.example.com/'), + queryInfo: { name: 'PQ' }, + } as unknown as Parameters[0]; + expect(isPersistentQueryNode(node)).toBe(true); + }); + + it('is false for a server state node', () => { + const server = { url: new URL('https://dhe.example.com/') } as ServerState; + expect(isPersistentQueryNode(server)).toBe(false); + }); +}); + +describe('getPersistentQueryTreeItem', () => { + /** The designated-worker endpoints a browsable PQ must have. */ + const browsableUrls = { + jsApiUrl: 'https://dhe.example.com/worker/1/jsapi/dh-core.js', + ideUrl: 'https://dhe.example.com/worker/1/ide', + }; + + function makeNode( + designated: unknown, + scriptLanguage = 'Python' + ): Parameters[0] { + return { + dheServerUrl: new URL('https://dhe.example.com/'), + queryInfo: { + serial: 'serial-1', + name: 'My PQ', + owner: 'alice', + designated, + scriptLanguage, + }, + } as unknown as Parameters[0]; + } + + it('renders name + owner + context value', () => { + const item = getPersistentQueryTreeItem( + makeNode({ + ...browsableUrls, + status: 'Running', + objects: [{ title: 't1', type: 'Table' }], + }) + ); + expect(item.label).toBe('My PQ'); + expect(item.description).toBe('alice'); + expect(item.contextValue).toBe('isPersistentQuery'); + }); + + it('identifies a query by serial, not by its (possibly duplicated) name', () => { + const item = getPersistentQueryTreeItem( + makeNode({ status: 'Running', objects: [] }) + ); + expect(item.id).toBe('pq:https://dhe.example.com/:serial-1'); + }); + + it('renders the status circle for a running PQ', () => { + const item = getPersistentQueryTreeItem( + makeNode({ status: 'Running', objects: [] }) + ); + expect((item.iconPath as vscode.ThemeIcon).id).toBe('circle-large-filled'); + }); + + it('renders the stop sign for a stopped PQ', () => { + const item = getPersistentQueryTreeItem( + makeNode({ status: 'Stopped', objects: [] }) + ); + expect((item.iconPath as vscode.ThemeIcon).id).toBe('circle-slash'); + }); + + it('renders the open circle for a PQ with no status', () => { + const item = getPersistentQueryTreeItem(makeNode(undefined)); + expect((item.iconPath as vscode.ThemeIcon).id).toBe('circle-large-outline'); + }); + + it('renders the spinner for a transitional PQ', () => { + const item = getPersistentQueryTreeItem( + makeNode({ status: 'Initializing', objects: [] }) + ); + expect((item.iconPath as vscode.ThemeIcon).id).toBe('sync~spin'); + }); + + it('is collapsible when the PQ exposes browsable objects (Collapsed = 1)', () => { + const item = getPersistentQueryTreeItem( + makeNode({ + ...browsableUrls, + status: 'Running', + objects: [ + { title: 't1', type: 'Table' }, + { title: 'f1', type: 'Figure' }, + ], + }) + ); + expect(item.collapsibleState).toBe(1); + // Object + table counts surfaced in the tooltip (no expansion needed). + expect(item.tooltip).toBe('My PQ (Running) — 2 objects (1 table)'); + }); + + it.each([ + ['no ideUrl (e.g. a RevertHelper query)', { ideUrl: null }], + ['an empty ideUrl', { ideUrl: '' }], + ['no jsApiUrl', { jsApiUrl: null }], + ])( + 'is non-expandable when the objects cannot be opened: %s', + (_label, overrides) => { + const item = getPersistentQueryTreeItem( + makeNode({ + ...browsableUrls, + ...overrides, + status: 'Running', + objects: [{ title: 't1', type: 'Table' }], + }) + ); + expect(item.collapsibleState).toBe(0); + expect(item.tooltip).toBe( + 'My PQ (Running) — 1 object (1 table) (worker not browsable)' + ); + } + ); + + it('ignores object entries with an empty title or type', () => { + const item = getPersistentQueryTreeItem( + makeNode({ + ...browsableUrls, + status: 'Running', + objects: [ + { title: '', type: 'Table' }, + { title: 't1', type: '' }, + ], + }) + ); + expect(item.collapsibleState).toBe(0); + expect(item.tooltip).toBe('My PQ (Running) — no objects'); + }); + + it('ignores objects whose type is not an openable panel', () => { + const item = getPersistentQueryTreeItem( + makeNode({ + ...browsableUrls, + status: 'Running', + objects: [ + { title: 'd1', type: 'deephaven.ui.Dashboard' }, + { title: 'm1', type: 'TableMap' }, + { title: 'tm1', type: 'Treemap' }, + { title: 'w1', type: 'OtherWidget' }, + { title: 'acl', type: 'AclService' }, + ], + }) + ); + expect(item.collapsibleState).toBe(0); + expect(item.tooltip).toBe('My PQ (Running) — no objects'); + }); + + it('counts deephaven.ui panels as openable objects', () => { + const item = getPersistentQueryTreeItem( + makeNode({ + ...browsableUrls, + status: 'Running', + objects: [{ title: 'ui1', type: 'deephaven.ui.Element' }], + }) + ); + expect(item.collapsibleState).toBe(1); + expect(item.tooltip).toBe('My PQ (Running) — 1 object'); + }); + + it('is non-expandable when the PQ exposes no objects (None = 0)', () => { + const item = getPersistentQueryTreeItem( + makeNode({ status: 'Running', objects: [] }) + ); + expect(item.collapsibleState).toBe(0); + expect(item.tooltip).toBe('My PQ (Running) — no objects'); + }); + + it('treats a PQ with no designated worker as non-expandable', () => { + const item = getPersistentQueryTreeItem(makeNode(undefined)); + expect(item.collapsibleState).toBe(0); + expect(item.tooltip).toBe('My PQ — no objects'); + }); +}); + +describe('canBrowsePersistentQueryObjects', () => { + const makeQueryInfo = ( + designated: unknown + ): Parameters[0] => + ({ designated }) as unknown as Parameters< + typeof canBrowsePersistentQueryObjects + >[0]; + + it('is true when the designated worker has both endpoints', () => { + expect( + canBrowsePersistentQueryObjects( + makeQueryInfo({ + jsApiUrl: 'https://w/jsapi/dh-core.js', + ideUrl: 'https://w/ide', + }) + ) + ).toBe(true); + }); + + it.each([ + [undefined], + [{ jsApiUrl: 'https://w/jsapi/dh-core.js', ideUrl: null }], + [{ jsApiUrl: 'https://w/jsapi/dh-core.js', ideUrl: '' }], + [{ jsApiUrl: null, ideUrl: 'https://w/ide' }], + [{ jsApiUrl: '', ideUrl: 'https://w/ide' }], + ])('is false without both endpoints: %s', designated => { + expect(canBrowsePersistentQueryObjects(makeQueryInfo(designated))).toBe( + false + ); + }); +}); + +describe('persistentQueryHasTables', () => { + const makeQueryInfo = ( + objects: unknown + ): Parameters[0] => + ({ designated: { objects } }) as unknown as Parameters< + typeof persistentQueryHasTables + >[0]; + + it('returns true when a table-typed object is present', () => { + expect( + persistentQueryHasTables( + makeQueryInfo([ + { title: 'f1', type: 'Figure' }, + { title: 't1', type: 'TreeTable' }, + ]) + ) + ).toBe(true); + }); + + it('returns false when only non-table objects are present', () => { + expect( + persistentQueryHasTables(makeQueryInfo([{ title: 'f1', type: 'Figure' }])) + ).toBe(false); + }); + + it('returns false when there are no objects', () => { + expect(persistentQueryHasTables(makeQueryInfo([]))).toBe(false); + }); +}); + +describe('getPersistentQueryServerTreeItem', () => { + it('renders an expanded server grouping node', () => { + const server = { + type: 'DHE', + url: new URL('https://dhe.example.com/'), + label: 'DHE', + } as ServerState; + const item = getPersistentQueryServerTreeItem(server); + expect(item.label).toBe('DHE'); + expect(item.contextValue).toBe('isPersistentQueryServer'); + // Server icon, matching the Workers / Panels tree server nodes. + expect((item.iconPath as vscode.ThemeIcon).id).toBe('vm-connect'); + // Expanded = 2 in the vscode mock enum. + expect(item.collapsibleState).toBe(2); + }); +}); + +describe('getPersistentQueryObjectLeaves', () => { + const workerUrl = new URL('https://dhe.example.com/worker/1/'); + + it('maps designated.objects to [url, variable] leaves', () => { + const queryInfo = { + designated: { + objects: [ + { title: 't', name: 't', type: 'Table', id: 'v1' }, + { title: 'f', name: 'f', type: 'Figure', id: 'v2' }, + ], + }, + } as unknown as Parameters[1]; + + const leaves = getPersistentQueryObjectLeaves(workerUrl, queryInfo); + expect(leaves).toHaveLength(2); + expect(leaves.map(([, v]) => v.title)).toEqual(['t', 'f']); + leaves.forEach(([url]) => expect(url).toBe(workerUrl)); + }); + + it('returns an empty array when there are no objects', () => { + const queryInfo = { + designated: { objects: [] }, + } as unknown as Parameters[1]; + expect(getPersistentQueryObjectLeaves(workerUrl, queryInfo)).toEqual([]); + }); + + it('filters out untitled / untyped objects defensively', () => { + const queryInfo = { + designated: { + objects: [ + { title: 't', name: 't', type: 'Table', id: 'v1' }, + { title: null, name: 'x', type: 'Table', id: 'v2' }, + { title: 'y', name: 'y', type: null, id: 'v3' }, + ], + }, + } as unknown as Parameters[1]; + const leaves = getPersistentQueryObjectLeaves(workerUrl, queryInfo); + expect(leaves).toHaveLength(1); + }); +}); diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index 686ed10c9..73615fa9b 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -1,20 +1,32 @@ import * as vscode from 'vscode'; +import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; import type { ConnectionState, ConsoleType, + IPanelService, + IServerManager, NonEmptyArray, + PersistentQueryHiddenNode, + PersistentQueryNode, + PersistentQueryTreeNode, ServerGroupState, ServerState, VariableDefintion, VariableType, } from '../types'; import { + CONNECTION_TREE_ITEM_CONTEXT, DH_PROTECTED_VARIABLE_NAMES, + FILTER_PERSISTENT_QUERIES_CMD, ICON_ID, + isStoppedQueryStatus, OPEN_VARIABLE_PANELS_CMD, + PERSISTENT_QUERY_TREE_ITEM_CONTEXT, SERVER_TREE_ITEM_CONTEXT, type ServerTreeItemContextValue, } from '../common'; +import { formatCount, sortByStringProp } from './dataUtils'; +import { isOpenablePanelVariable } from './panelUtils'; /** * Get a tree item vscode.ThemeIcon for a variable type. @@ -49,60 +61,90 @@ export function getVariableIconPath( } /** - * Get `TreeItem` for a panel connection. - * @param connection Connection state - * @param getConsoleType Function to get the console type for the connection. + * Get the icon id for a console type / language, used for worker (connection / + * persistent query) tree nodes. Falls back to the generic worker icon when the + * console type is unknown (e.g. a plain DHC connection or one whose console type + * has not resolved yet) — never the server icon, so a worker node is always + * distinguishable from its parent server. + * @param consoleType Console type (language) of the connection, if known. + * @returns Icon id from `ICON_ID`. */ -export async function getPanelConnectionTreeItem( - connection: ConnectionState, - getConsoleType: ( - connection: ConnectionState - ) => Promise, - serverLabel?: string -): Promise { - const descriptionTokens: string[] = []; - - const consoleType = await getConsoleType(connection); - - if (consoleType) { - descriptionTokens.push(consoleType); +export function getConsoleTypeIconId( + consoleType: ConsoleType | undefined +): string { + switch (consoleType) { + case 'python': + return ICON_ID.python; + case 'groovy': + return ICON_ID.groovy; + default: + return ICON_ID.worker; } +} + +/** + * Separator DHE worker names are built from (e.g. `Code Studio - Web - `, + * `IC - VS Code - `). + */ +const WORKER_LABEL_SEPARATOR = ' - '; + +/** Number of characters of a worker name's trailing id segment to keep. */ +const WORKER_LABEL_ID_LENGTH = 6; + +/** + * Shorten a worker name for display in a tree node. DHE worker names end in a + * generated id (`Code Studio - Web - l9hnYDTiEosKmJwe4Fma5`) that is long enough + * to push the meaningful part of the name out of the sidebar, so the trailing + * segment is clipped to its first few characters. Callers pair this with the + * untruncated name as the node tooltip. + * + * Only a name with at least three ` - ` segments is touched, and only when its + * last segment looks like an id (no whitespace) and is long enough to be worth + * clipping — a name like `IC - VS Code` (no id) is left alone. + * @param label The full worker name. + * @returns The name to show on the node. + */ +export function getWorkerNodeLabel(label: string): string { + const segments = label.split(WORKER_LABEL_SEPARATOR); - if (connection.tagId) { - descriptionTokens.push(connection.tagId); + if (segments.length < 3) { + return label; } - const label = serverLabel ?? connection.serverUrl.host; - const description = - descriptionTokens.length === 0 ? undefined : descriptionTokens.join(' - '); + const id = segments[segments.length - 1]; - return { - label, - description, - collapsibleState: vscode.TreeItemCollapsibleState.Expanded, - iconPath: new vscode.ThemeIcon( - connection.isConnected ? ICON_ID.connected : ICON_ID.connecting - ), - }; + if (/\s/.test(id) || id.length <= WORKER_LABEL_ID_LENGTH) { + return label; + } + + return [...segments.slice(0, -1), id.slice(0, WORKER_LABEL_ID_LENGTH)].join( + WORKER_LABEL_SEPARATOR + ); } /** * Get `TreeItem` for a panel variable. - * @param variable + * @param variable The worker URL + variable to render. + * @param canDelete Whether the node offers the delete (trash) action. Only + * variables living in a console session can be deleted — a persistent query's + * exported objects are browse-only, so the action must not be offered for them + * (`ExtensionController.onDeleteVariable` would ignore it anyway, leaving a + * button that does nothing). */ -export function getPanelVariableTreeItem([url, variable]: [ - URL, - VariableDefintion, -]): vscode.TreeItem { +export function getPanelVariableTreeItem( + [url, variable]: [URL, VariableDefintion], + canDelete: boolean +): vscode.TreeItem { const iconPath = getVariableIconPath(variable.type); const variablesToOpen: NonEmptyArray = [variable]; return { label: variable.title, iconPath, - contextValue: DH_PROTECTED_VARIABLE_NAMES.has(variable.name) - ? undefined - : 'canDeleteDeephavenVariable', + contextValue: + canDelete && !DH_PROTECTED_VARIABLE_NAMES.has(variable.name) + ? 'canDeleteDeephavenVariable' + : undefined, command: { title: 'Open Panel', command: OPEN_VARIABLE_PANELS_CMD, @@ -111,24 +153,363 @@ export function getPanelVariableTreeItem([url, variable]: [ }; } +/** + * The openable panel variables of a worker, as the `[URL, VariableDefintion]` + * leaves both worker-hosting trees render. Variables that cannot open as a + * panel are dropped rather than rendering a node that clicks onto nothing, and + * the rest are alphabetized by title. + * @param panelService Panel service holding the worker's variables. + * @param serverUrl The worker URL whose variables to list. + */ +export function getPanelVariableLeaves( + panelService: IPanelService, + serverUrl: URL +): [URL, VariableDefintion][] { + return [...panelService.getVariables(serverUrl)] + .filter(isOpenablePanelVariable) + .sort(sortByStringProp('title')) + .map(variable => [serverUrl, variable]); +} + +/** + * Get the status icon for a persistent query, using the same circle vocabulary + * as the Servers tree: + * - `Running` → filled circle. + * - stopped (`Stopped`, `Failed`, …) → stop sign. `Stopping` is deliberately not + * one of these: it is still winding down, so it gets the spinner alongside the + * other statuses in motion (see `isStoppedQueryStatus`). + * - unset → open circle. An unknown status is NOT the same as a stopped one: a + * PQ can be listed with no status yet (no `designated` block), and claiming it + * stopped would be wrong. A spinner would be equally wrong — nothing is known + * to be in progress. + * - anything else (`Initializing`, `Connecting`, …) → spinner. + * @param status The PQ status (`designated.status`, falling back to `status`). + */ +export function getPersistentQueryIconId( + status: string | null | undefined +): string { + if (status === 'Running') { + return ICON_ID.serverConnected; + } + + if (status == null || status === '') { + return ICON_ID.serverRunning; + } + + if (isStoppedQueryStatus(status)) { + return ICON_ID.serverStopped; + } + + return ICON_ID.connecting; +} + +/** + * Get the the status of a persistent query. + * @param queryInfo The PQ whose status to read. + */ +export function getPersistentQueryStatus( + queryInfo: QueryInfo +): string | null | undefined { + return queryInfo.designated?.status; +} + +/** Variable types that render as tables (mirrors the table icon set). */ +const TABLE_VARIABLE_TYPES: ReadonlySet = new Set([ + 'Table', + 'TableMap', + 'TreeTable', + 'HierarchicalTable', + 'PartitionedTable', +]); + +/** + * The exported objects of a PQ, read straight from `designated.objects` — no + * worker connection or node expansion required — filtered to the named + typed + * entries that render as object leaves. This is the same set + * {@link getPersistentQueryObjectLeaves} produces, so a caller can cheaply tell + * whether a PQ has any (or any table-typed) objects before expanding it. + * @param queryInfo The PQ whose exported objects to read. + */ +export function getPersistentQueryObjects( + queryInfo: QueryInfo +): VariableDefintion[] { + const objects = queryInfo.designated?.objects ?? []; + // Only objects that can actually open as a panel: counting an unopenable one + // would put an expander on a PQ whose children open onto nothing. + return objects.filter((obj): obj is VariableDefintion => + isOpenablePanelVariable(obj) + ); +} + +/** + * Whether a PQ's exported objects can actually be opened. The tree opens them + * through a browse connection derived from the designated worker + * (`getWorkerInfoFromQueryInfo`), which requires both `jsApiUrl` and `ideUrl`. + * Helper / system queries (e.g. `RevertHelper`) can be `Running` and report + * objects while having no IDE endpoint (`designated.ideUrl` is nullable), so + * without this check they render an expander that can never produce children. + * @param queryInfo The PQ to check. + */ +export function canBrowsePersistentQueryObjects(queryInfo: QueryInfo): boolean { + const { designated } = queryInfo; + + return ( + designated != null && + designated.jsApiUrl != null && + designated.jsApiUrl !== '' && + designated.ideUrl != null && + designated.ideUrl !== '' + ); +} + +/** + * Whether a PQ exposes at least one table-typed object, determined from + * `designated.objects` without connecting to or expanding the PQ. + * @param queryInfo The PQ to check. + */ +export function persistentQueryHasTables(queryInfo: QueryInfo): boolean { + return getPersistentQueryObjects(queryInfo).some(obj => + TABLE_VARIABLE_TYPES.has(obj.type) + ); +} + +/** + * Get `TreeItem` for a persistent-query node. The node carries the PQ name plus + * its {@link getPersistentQueryIconId} status circle, and is collapsible only when + * the PQ exposes objects *and* those objects can be opened + * ({@link canBrowsePersistentQueryObjects}) — otherwise it renders + * non-expandable so the tree never opens onto an empty list (both are known + * cheaply from `designated`, no connection required). + * @param node The persistent-query node. + */ +export function getPersistentQueryTreeItem( + node: PersistentQueryNode +): vscode.TreeItem { + const { queryInfo } = node; + const status = getPersistentQueryStatus(queryInfo); + + const objects = getPersistentQueryObjects(queryInfo); + const tableCount = objects.filter(obj => + TABLE_VARIABLE_TYPES.has(obj.type) + ).length; + const canBrowse = canBrowsePersistentQueryObjects(queryInfo); + + const plural = (n: number, noun: string): string => + `${n} ${noun}${n === 1 ? '' : 's'}`; + const countSuffix = + objects.length === 0 + ? ' — no objects' + : ` — ${plural(objects.length, 'object')}${tableCount > 0 ? ` (${plural(tableCount, 'table')})` : ''}${canBrowse ? '' : ' (worker not browsable)'}`; + + return { + // Serial, not name: VS Code falls back to generating an id from the label + // when none is given, and two queries on a server can share a name. The + // colliding ids that produces confuse selection and the tree's find widget. + id: `pq:${node.dheServerUrl.href}:${queryInfo.serial}`, + label: queryInfo.name, + description: queryInfo.owner ?? undefined, + tooltip: `${queryInfo.name}${status == null ? '' : ` (${status})`}${countSuffix}`, + iconPath: new vscode.ThemeIcon(getPersistentQueryIconId(status)), + collapsibleState: + objects.length > 0 && canBrowse + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None, + contextValue: PERSISTENT_QUERY_TREE_ITEM_CONTEXT.isPersistentQuery, + }; +} + +/** + * Get `TreeItem` for the trailing "N hidden" node under a filtered server. The + * node exists so a filter is never a silent omission: it states the count and + * clicking it reopens the picker that produced it. + * @param node The hidden-count node. + */ +export function getPersistentQueryHiddenTreeItem( + node: PersistentQueryHiddenNode +): vscode.TreeItem { + const { hiddenCount } = node; + + return { + // Fixed id: the label carries a count that changes on every table tick, and + // a label-generated id would make this a different node each time. + id: `pq:${node.dheServerUrl.href}:more`, + label: `More (${formatCount(hiddenCount)})`, + tooltip: `${formatCount(hiddenCount)} ${ + hiddenCount === 1 ? 'query is' : 'queries are' + } hidden by the status filter. Click to change it.`, + iconPath: new vscode.ThemeIcon(ICON_ID.hidden), + collapsibleState: vscode.TreeItemCollapsibleState.None, + contextValue: PERSISTENT_QUERY_TREE_ITEM_CONTEXT.isPersistentQueryHidden, + command: { + title: 'Filter Persistent Queries', + command: FILTER_PERSISTENT_QUERIES_CMD, + }, + }; +} + +/** + * Type guard for the trailing hidden-count node. It carries a `hiddenCount`, + * which nothing else in the Persistent Queries tree does. + * @param node The node to check. + */ +export function isPersistentQueryHiddenNode( + node: PersistentQueryTreeNode +): node is PersistentQueryHiddenNode { + return (node as PersistentQueryHiddenNode).hiddenCount != null; +} + +/** + * Get `TreeItem` for a DHE server node in the Persistent Queries tree. This is a + * grouping container whose children are the server's (non-InteractiveConsole) + * persistent queries. + * @param server DHE server state. + */ +export function getPersistentQueryServerTreeItem( + server: ServerState +): vscode.TreeItem { + return { + id: `pq:${server.url.href}`, + label: getConnectionServerLabel(server), + iconPath: new vscode.ThemeIcon(ICON_ID.connected), + collapsibleState: vscode.TreeItemCollapsibleState.Expanded, + contextValue: PERSISTENT_QUERY_TREE_ITEM_CONTEXT.isPersistentQueryServer, + }; +} + +/** + * Type guard distinguishing a persistent-query node from the server or + * connection nodes it shares a tree with. A `PersistentQueryNode` carries a + * `queryInfo`. + * @param node The node to check. + */ +export function isPersistentQueryNode( + node: + | ServerState + | ConnectionState + | PersistentQueryHiddenNode + | PersistentQueryNode +): node is PersistentQueryNode { + return (node as PersistentQueryNode).queryInfo != null; +} + +/** + * Map a `QueryInfo`'s exported objects (`designated.objects`) to + * `VariableDefintion` leaves paired with the given worker URL — the same + * `[URL, VariableDefintion]` shape the Panels tree uses for object leaves. + * Filters out unnamed / untyped entries defensively. + * @param workerUrl The worker URL objects are hosted on (for the open command). + * @param queryInfo The running PQ whose objects to enumerate. + */ +export function getPersistentQueryObjectLeaves( + workerUrl: URL, + queryInfo: QueryInfo +): [URL, VariableDefintion][] { + return getPersistentQueryObjects(queryInfo).map(obj => [workerUrl, obj]); +} + +/** + * Type guard for a (DHE) server node within the connection / panel tree root. + * `ServerState` carries `url`; `ConnectionState` carries `serverUrl`. + * @param node A server or connection root node. + */ +export function isServerStateNode( + node: ServerState | ConnectionState +): node is ServerState { + return 'url' in node; +} + +/** + * Get the label shown for a server node in the connection / panel tree views. + * @param server Server state. + */ +export function getConnectionServerLabel(server: ServerState): string { + return server.label ?? server.url.host; +} + +/** + * Compute the root nodes for the connection / panel tree views. Every + * connection is grouped under its parent server node (DHC and DHE alike), so a + * community server with a single worker has the same hierarchy shape as an + * enterprise server with many. Roots are sorted by their displayed label. + * + * In addition to every server that has connections, any DHE server with a live + * client is included so its node (and its "+" create-worker action) stays + * reachable even with zero workers. + * @param serverManager Server manager. + * @returns The server root nodes (servers with connections, plus connected DHE + * servers). + */ +export function getConnectionTreeRootNodes( + serverManager: IServerManager +): ServerState[] { + const servers = new Map(); + + for (const connection of serverManager.getConnections()) { + const server = serverManager.getServerForConnection(connection); + if (server != null) { + servers.set(server.url.toString(), server); + } + } + + // DHE servers with a live client appear even with zero workers, so the server + // node (and its "+" create-worker action) stays reachable after a cancelled + // worker creation or after the last worker is detached. + for (const server of serverManager.getServers({ type: 'DHE' })) { + if (server.isConnected) { + servers.set(server.url.toString(), server); + } + } + + return [...servers.values()].sort((a, b) => + getConnectionServerLabel(a).localeCompare(getConnectionServerLabel(b)) + ); +} + +/** + * Get `TreeItem` for a DHE server node in the connection / panel tree views. + * This is a grouping container whose children are the server's worker + * connections. + * @param server DHE server state + */ +export function getConnectionServerTreeItem( + server: ServerState +): vscode.TreeItem { + return { + label: getConnectionServerLabel(server), + iconPath: new vscode.ThemeIcon(ICON_ID.connected), + collapsibleState: vscode.TreeItemCollapsibleState.Expanded, + contextValue: + server.type === 'DHE' + ? CONNECTION_TREE_ITEM_CONTEXT.isDHEServerConnectionParent + : undefined, + }; +} + /** * Get `contextValue` for server tree items. * @param isConnected Whether the server is connected + * @param isConnecting Whether a client connection is currently being established * @param isDHE Whether the server is a DHE server * @param isManaged Whether the server is managed * @param isRunning Whether the server is running */ export function getServerContextValue({ isConnected, + isConnecting, isDHE, isManaged, isRunning, }: { isConnected: boolean; + isConnecting: boolean; isDHE: boolean; isManaged: boolean; isRunning: boolean; }): ServerTreeItemContextValue { + if (isConnecting) { + return SERVER_TREE_ITEM_CONTEXT.isServerConnecting; + } + if (isManaged) { return isConnected ? SERVER_TREE_ITEM_CONTEXT.isManagedServerConnected @@ -217,19 +598,26 @@ export function getServerGroupTreeItem( /** * Get icon id for a server in the UI. e.g. for tree nodes. * @param isConnected Whether the server is connected + * @param isConnecting Whether a client connection is currently being established * @param isManaged Whether the server is managed * @param isRunning Whether the server is running * @returns Icon id for server tree item */ export function getServerIconID({ isConnected, + isConnecting, isManaged, isRunning, }: { isConnected: boolean; + isConnecting: boolean; isManaged: boolean; isRunning: boolean; }): string { + if (isConnecting) { + return ICON_ID.connecting; + } + return isRunning ? isConnected ? ICON_ID.serverConnected @@ -246,9 +634,13 @@ export function getServerIconID({ * number of connected workers in the case of DHE) * @param isManaged Whether the server is managed * @param isRunning Whether the server is running + * @param isConnecting Whether a client connection is currently being established * @returns Tree item representing the server */ -export function getServerTreeItem(server: ServerState): vscode.TreeItem { +export function getServerTreeItem( + server: ServerState, + isConnecting: boolean +): vscode.TreeItem { const { connectionCount, isConnected, @@ -259,6 +651,7 @@ export function getServerTreeItem(server: ServerState): vscode.TreeItem { const contextValue = getServerContextValue({ isConnected, + isConnecting, isDHE: type === 'DHE', isManaged, isRunning, @@ -266,24 +659,25 @@ export function getServerTreeItem(server: ServerState): vscode.TreeItem { const description = getServerDescription(connectionCount, isManaged); - const urlStr = server.url.toString(); - const canConnect = contextValue === SERVER_TREE_ITEM_CONTEXT.isManagedServerDisconnected || contextValue === SERVER_TREE_ITEM_CONTEXT.isServerRunningDisconnected || contextValue === SERVER_TREE_ITEM_CONTEXT.isDHEServerRunningConnected || contextValue === SERVER_TREE_ITEM_CONTEXT.isDHEServerRunningDisconnected; - const url = new URL(urlStr); - const label = server.label ?? url.host; + const label = getConnectionServerLabel(server); return { label, description, - tooltip: canConnect ? `Click to connect to ${label}` : label, + tooltip: isConnecting + ? `Connecting to ${label}…` + : canConnect + ? `Click to connect to ${label}` + : label, contextValue, iconPath: new vscode.ThemeIcon( - getServerIconID({ isConnected, isManaged, isRunning }) + getServerIconID({ isConnected, isConnecting, isManaged, isRunning }) ), command: canConnect ? { diff --git a/src/util/uiUtils.spec.ts b/src/util/uiUtils.spec.ts index c78b4fb54..f5e406419 100644 --- a/src/util/uiUtils.spec.ts +++ b/src/util/uiUtils.spec.ts @@ -9,11 +9,20 @@ import { createConnectionQuickPickOptions, createSeparatorPickItem, promptForCredentials, + promptForQueryStatusFilter, + setViewIsFiltered, } from './uiUtils'; +import { + DEFAULT_HIDDEN_QUERY_STATUSES, + STOPPED_QUERY_STATUSES, + UNSET_QUERY_STATUS, + type ViewID, +} from '../common'; import type { ConnectionState, CoreConnectionConfig, IDhcService, + IServerManager, ServerState, } from '../types'; @@ -55,7 +64,7 @@ describe('createConnectionQuickPickOptions', () => { ['Active A', serverUrlA], ])( 'should return quick pick options: editorActiveConnectionUrl=%s', - (_label, editorActiveConnectionUrl) => { + async (_label, editorActiveConnectionUrl) => { const serversWithoutConnections: ServerState[] = [ { type: 'DHC', @@ -73,27 +82,59 @@ describe('createConnectionQuickPickOptions', () => { }, ]; const connections: ConnectionState[] = [ - { serverUrl: serverUrlA, isConnected: true }, - { serverUrl: serverUrlC, isConnected: true }, + { + label: 'ServerA Connection', + serverUrl: serverUrlA, + isConnected: true, + }, + { + label: 'ServerC Connection', + serverUrl: serverUrlC, + isConnected: true, + }, ]; - const actual = createConnectionQuickPickOptions( + const serverManager = { + getServerForConnection: vi.fn( + (connection: ConnectionState): ServerState => ({ + type: 'DHC', + url: connection.serverUrl, + isConnected: true, + isRunning: true, + connectionCount: 1, + }) + ), + getWorkerInfo: vi.fn(async () => undefined), + } as unknown as IServerManager; + + const actual = await createConnectionQuickPickOptions( serversWithoutConnections, connections, 'python', + serverManager, editorActiveConnectionUrl ); expect(actual).toMatchSnapshot(); } ); - it('should throw if no servers or connections', () => { + it('should throw if no servers or connections', async () => { const servers: ServerState[] = []; const connections: IDhcService[] = []; - expect(() => - createConnectionQuickPickOptions(servers, connections, 'python') - ).toThrowError('No available servers to connect to.'); + const serverManager = { + getServerForConnection: vi.fn(), + getWorkerInfo: vi.fn(async () => undefined), + } as unknown as IServerManager; + + await expect( + createConnectionQuickPickOptions( + servers, + connections, + 'python', + serverManager + ) + ).rejects.toThrowError('No available servers to connect to.'); }); }); @@ -240,3 +281,228 @@ describe('promptForCredentials', () => { } ); }); + +describe('setViewIsFiltered', () => { + it.each([[true], [false]])( + 'should set the `${viewId}.isFiltered` context key: %s', + isFiltered => { + const viewId = 'mock.viewId' as ViewID; + + setViewIsFiltered(viewId, isFiltered); + + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + 'setContext', + 'mock.viewId.isFiltered', + isFiltered + ); + } + ); +}); + +describe('promptForQueryStatusFilter', () => { + /** Grab the items the picker was shown, ignoring the options argument. */ + function shownItems(): { + label: string; + description?: string; + picked?: boolean; + kind?: vscode.QuickPickItemKind; + }[] { + return vi.mocked(vscode.window.showQuickPick).mock.calls[0][0] as never; + } + + /** The checkbox rows only, with the group separators dropped. */ + function shownStatusRows(): ReturnType { + return shownItems().filter( + item => item.kind !== vscode.QuickPickItemKind.Separator + ); + } + + it('splits the rows into a Running and a Stopped section', async () => { + vi.mocked(vscode.window.showQuickPick).mockResolvedValue( + undefined as never + ); + + await promptForQueryStatusFilter(new Map(), new Set()); + + // `Stopping` sits with the live statuses (it is still winding down), and + // the unset row leads the stopped ones (a query reporting no status has + // stopped without saying so). + expect( + shownItems().map(item => + item.kind === vscode.QuickPickItemKind.Separator + ? `-- ${item.label} --` + : item.label + ) + ).toEqual([ + '-- Running --', + 'Running', + 'Uninitialized', + 'Connecting', + 'Authenticating', + 'Acquiring Worker', + 'Finding Dispatcher', + 'Initializing', + 'Executing', + 'Stopping', + '-- Stopped --', + '(no status)', + 'Stopped', + 'Failed', + 'Error', + 'Disconnected', + 'Completed', + ]); + }); + + it('checks exactly the Running section under the default filter', async () => { + vi.mocked(vscode.window.showQuickPick).mockResolvedValue( + undefined as never + ); + + await promptForQueryStatusFilter( + new Map(), + new Set(DEFAULT_HIDDEN_QUERY_STATUSES) + ); + + const rows = shownStatusRows(); + // The Stopped section is the tail of the list, one row per stopped status. + const stoppedIndex = rows.length - STOPPED_QUERY_STATUSES.length; + + expect(rows.slice(0, stoppedIndex).every(row => row.picked)).toBe(true); + expect(rows.slice(stoppedIndex).some(row => row.picked)).toBe(false); + }); + + it('is a multi-select picker (canPickMany, not canSelectMany)', async () => { + vi.mocked(vscode.window.showQuickPick).mockResolvedValue( + undefined as never + ); + + await promptForQueryStatusFilter(new Map(), new Set()); + + expect( + vi.mocked(vscode.window.showQuickPick).mock.calls[0][1] + ).toMatchObject({ + canPickMany: true, + ignoreFocusOut: true, + placeHolder: 'Select the query statuses to show', + }); + }); + + it('shows counts (0 when absent) and checks the statuses that are not hidden', async () => { + vi.mocked(vscode.window.showQuickPick).mockResolvedValue( + undefined as never + ); + + await promptForQueryStatusFilter( + new Map([ + ['Running', 13], + [UNSET_QUERY_STATUS, 2], + ]), + new Set(['Stopped', UNSET_QUERY_STATUS]) + ); + + const byLabel = new Map(shownItems().map(item => [item.label, item])); + + expect(byLabel.get('Running')).toMatchObject({ + description: '13', + picked: true, + }); + expect(byLabel.get('(no status)')).toMatchObject({ + description: '2', + picked: false, + }); + expect(byLabel.get('Stopped')).toMatchObject({ + description: '0', + picked: false, + }); + expect(byLabel.get('Connecting')).toMatchObject({ + description: '0', + picked: true, + }); + }); + + it('formats large counts with thousands separators', async () => { + vi.mocked(vscode.window.showQuickPick).mockResolvedValue( + undefined as never + ); + + await promptForQueryStatusFilter( + new Map([ + ['Running', 20007], + [UNSET_QUERY_STATUS, 20001], + ]), + new Set() + ); + + const byLabel = new Map(shownItems().map(item => [item.label, item])); + + expect(byLabel.get('Running')?.description).toBe('20,007'); + expect(byLabel.get('(no status)')?.description).toBe('20,001'); + }); + + it('puts unrecognized statuses at the end of the Running section, alphabetized', async () => { + vi.mocked(vscode.window.showQuickPick).mockResolvedValue( + undefined as never + ); + + await promptForQueryStatusFilter( + new Map([ + ['Zombie', 1], + ['Hibernating', 3], + ]), + new Set(DEFAULT_HIDDEN_QUERY_STATUSES) + ); + + const items = shownItems(); + // Note both the separator and a status row are labelled 'Stopped'; match on + // the kind so this finds the section header. + const stoppedSeparator = items.findIndex( + item => + item.kind === vscode.QuickPickItemKind.Separator && + item.label === 'Stopped' + ); + + // Immediately before the Stopped separator, i.e. last in the Running list. + expect( + items + .slice(stoppedSeparator - 2, stoppedSeparator) + .map(item => item.label) + ).toEqual(['Hibernating', 'Zombie']); + // An unknown status is not known to have stopped, so it stays visible. + const byLabel = new Map(shownItems().map(item => [item.label, item])); + expect(byLabel.get('Zombie')?.picked).toBe(true); + }); + + it('returns the statuses that were NOT picked (the hidden set)', async () => { + vi.mocked(vscode.window.showQuickPick).mockImplementation((async ( + items: { status: string }[] + ) => items.filter(item => item.status === 'Running')) as never); + + const hidden = await promptForQueryStatusFilter(new Map(), new Set()); + + expect(hidden).toBeDefined(); + expect(hidden?.has('Running')).toBe(false); + expect(hidden?.has('Stopped')).toBe(true); + expect(hidden?.has(UNSET_QUERY_STATUS)).toBe(true); + }); + + it('returns an empty hidden set when everything is picked', async () => { + vi.mocked(vscode.window.showQuickPick).mockImplementation( + (async (items: unknown[]) => items) as never + ); + + const hidden = await promptForQueryStatusFilter(new Map(), new Set()); + + expect(hidden?.size).toBe(0); + }); + + it('returns undefined when the picker is dismissed, so the caller leaves the filter alone', async () => { + vi.mocked(vscode.window.showQuickPick).mockResolvedValue( + undefined as never + ); + + expect( + await promptForQueryStatusFilter(new Map(), new Set(['Stopped'])) + ).toBeUndefined(); + }); +}); diff --git a/src/util/uiUtils.ts b/src/util/uiUtils.ts index 53ceaa19f..480d44fae 100644 --- a/src/util/uiUtils.ts +++ b/src/util/uiUtils.ts @@ -7,11 +7,14 @@ import type { PasswordCredentials, Username, } from '@deephaven-enterprise/auth-nodejs'; +import { QueryStatus } from '@deephaven-enterprise/query-utils'; import { SELECT_CONNECTION_COMMAND, STATUS_BAR_CONNECTING_TEXT, STATUS_BAR_DISCONNECTED_TEXT, ICON_ID, + STOPPED_QUERY_STATUSES, + UNSET_QUERY_STATUS, type ViewID, } from '../common'; import { assertDefined, type BaseThemeKey } from '../shared'; @@ -19,6 +22,7 @@ import type { ConnectionType, ConsoleType, ConnectionPickItem, + IServerManager, ServerState, SeparatorPickItem, ConnectionPickOption, @@ -31,7 +35,12 @@ import type { LoginPromptCredentials, MultiAuthConfig, } from '../types'; -import { getFilePathDateToken, sortByStringProp } from './dataUtils'; +import { + formatCount, + getFilePathDateToken, + sortByStringProp, +} from './dataUtils'; +import { getConsoleTypeIconId } from './treeViewUtils'; import { Logger } from './Logger'; const logger = new Logger('uiUtils'); @@ -54,45 +63,63 @@ export interface WorkspaceFolderConfig { /** * Create options for a connection quick pick. + * + * Active-connection items mirror the WORKERS-tree worker node: a leading + * language icon plus the worker name, with the server host:port shown in the + * description. * @param servers The available servers * @param connections The available connections * @param editorLanguageId The language id of the editor + * @param serverManager The server manager used to resolve each connection's + * parent server and worker info. * @param editorActiveConnectionUrl The active connection url of the editor * @returns */ -export function createConnectionQuickPickOptions< +export async function createConnectionQuickPickOptions< TConnection extends ConnectionState, >( servers: ServerState[], connections: TConnection[], editorLanguageId: string, + serverManager: IServerManager, editorActiveConnectionUrl?: URL | null -): ConnectionPickOption[] { +): Promise[]> { const serverOptions: ConnectionPickItem<'server', ServerState>[] = servers.map(data => ({ type: 'server', - label: data.url.toString(), - description: data.label ?? (data.isManaged ? 'pip' : undefined), + label: data.label ?? data.url.toString(), + description: data.isManaged ? 'pip' : undefined, iconPath: new vscode.ThemeIcon(ICON_ID.server), data, })); - const connectionOptions: ConnectionPickItem<'connection', TConnection>[] = []; - - for (const dhService of connections) { - const isActiveConnection = - editorActiveConnectionUrl?.toString() === dhService.serverUrl.toString(); - - connectionOptions.push({ - type: 'connection', - label: dhService.serverUrl.toString(), - iconPath: new vscode.ThemeIcon(ICON_ID.connected), - description: isActiveConnection - ? `${editorLanguageId} (current)` - : editorLanguageId, - data: dhService, - }); - } + const connectionOptions: ConnectionPickItem<'connection', TConnection>[] = + await Promise.all( + connections.map(async dhService => { + const isActiveConnection = + editorActiveConnectionUrl?.toString() === + dhService.serverUrl.toString(); + + const parentServer = serverManager.getServerForConnection(dhService); + assertDefined(parentServer, 'parentServer'); + + const descriptionTokens = [parentServer.label ?? parentServer.url.host]; + + if (isActiveConnection) { + descriptionTokens.push('(current)'); + } + + return { + type: 'connection' as const, + label: dhService.label, + iconPath: new vscode.ThemeIcon( + getConsoleTypeIconId(editorLanguageId as ConsoleType) + ), + description: descriptionTokens.join(' '), + data: dhService, + }; + }) + ); if (serverOptions.length === 0 && connectionOptions.length === 0) { throw new Error('No available servers to connect to.'); @@ -626,6 +653,120 @@ export async function saveRequirementsTxt( vscode.window.showTextDocument(uri); } +/** + * The picker's "Running" section: every status a user watching the view might + * still care about — `Running` itself, the transitional statuses in + * `QueryStatus` declaration order, and `Stopping`, which is winding down but has + * not finished. The complement is {@link STOPPED_QUERY_STATUSES}. + * + * Listed explicitly (rather than derived by subtraction) so the row order is + * stable regardless of what `QueryStatus` gains later; anything new simply falls + * in with the unrecognized rows at the end of this section. + */ +const LIVE_QUERY_STATUSES: readonly string[] = [ + QueryStatus.running, + QueryStatus.uninitialized, + QueryStatus.connecting, + QueryStatus.authenticating, + QueryStatus.acquiringWorker, + QueryStatus.findingDispatcher, + QueryStatus.initializing, + QueryStatus.executing, + QueryStatus.stopping, +]; + +/** A row in the {@link promptForQueryStatusFilter} picker. */ +interface QueryStatusPickItem extends vscode.QuickPickItem { + readonly status: string; +} + +/** + * Prompt for the persistent-query statuses to show, as a multi-select checkbox + * list with one row per status and its current count. + * + * The rows sit in two separator-headed sections, splitting queries that have + * finished moving from ones still worth watching: + * - **Running** — `Running`, the transitional statuses, and `Stopping` (winding + * down, but not gone), followed by any status observed in the data that this + * extension doesn't recognize. An unknown status is not known to have stopped, + * so it belongs with the live ones and stays visible by default. + * - **Stopped** — {@link STOPPED_QUERY_STATUSES}, including the unset row, since + * a query that reports no status has stopped without saying so. + * + * The Stopped section is exactly what the filter hides on first run. + * + * Separators are visual only: VS Code ignores every property but `label` on + * them and they cannot be picked, so there is no way to toggle a whole section + * in one click. + * + * Returns the new set of statuses to HIDE — the storage the filter actually + * uses — or `undefined` if the picker was dismissed. A dismissal must be + * treated as "no change" by the caller; an empty set means "hide nothing". + * @param statusCounts How many queries currently carry each status (unset + * bucketed under `''`). + * @param hiddenStatuses The statuses currently hidden, used to seed the + * checkboxes. + */ +export async function promptForQueryStatusFilter( + statusCounts: ReadonlyMap, + hiddenStatuses: ReadonlySet +): Promise | undefined> { + const known = [...LIVE_QUERY_STATUSES, ...STOPPED_QUERY_STATUSES]; + const knownSet = new Set(known); + + // Any status the server reported that isn't in the known vocabulary still + // gets a row so it can be hidden / shown like the rest. It goes in the + // Running section: nothing says it has stopped. + const unrecognized = [...statusCounts.keys()] + .filter(status => !knownSet.has(status)) + .sort((a, b) => a.localeCompare(b)); + + const statuses = [...known, ...unrecognized]; + + const toItem = (status: string): QueryStatusPickItem => ({ + status, + // `getDisplayString` returns 'None' for the unset status, which reads like + // a status literally named "None" — spell it out instead. + label: + status === UNSET_QUERY_STATUS + ? '(no status)' + : QueryStatus.getDisplayString(status), + description: formatCount(statusCounts.get(status) ?? 0), + picked: !hiddenStatuses.has(status), + }); + + const items: (SeparatorPickItem | QueryStatusPickItem)[] = [ + createSeparatorPickItem('Running'), + ...[...LIVE_QUERY_STATUSES, ...unrecognized].map(toItem), + createSeparatorPickItem('Stopped'), + ...STOPPED_QUERY_STATUSES.map(toItem), + ]; + + // `canPickMany` (a `QuickPickOptions` field) is what makes this a checkbox + // list. `canSelectMany` is the `QuickPick` *class* property and would silently + // give a single-select picker here. + const picked = await vscode.window.showQuickPick(items, { + canPickMany: true, + ignoreFocusOut: true, + title: 'Filter Persistent Queries', + placeHolder: 'Select the query statuses to show', + }); + + if (picked == null) { + return undefined; + } + + // Separators are never returned by the picker, but the item union includes + // them — narrow before reading `status`. + const visible = new Set( + picked + .filter((item): item is QueryStatusPickItem => 'status' in item) + .map(item => item.status) + ); + + return new Set(statuses.filter(status => !visible.has(status))); +} + /** * Set the `isVisible` state of a given view id. Uses extension context * `${viewId}.isVisible` to store the state. This can then be used in package.json @@ -640,3 +781,39 @@ export function setViewIsVisible(viewId: ViewID, isVisible: boolean): void { isVisible ); } + +/** + * Set the `isFiltered` state of a given view id. Uses extension context + * `${viewId}.isFiltered` to store the state. This lets package.json swap between + * two view-title actions bound to the same handler, since VS Code takes an + * action's icon from its command (hollow funnel vs. filled funnel). + * @param viewId The view ID to set the filtered state for. + * @param isFiltered Whether a filter is currently hiding anything. + */ +/** + * Set the visibility of a filter section for a given view id, as + * `${viewId}.${section}Visible`, so package.json `when` clauses can pick which + * of a checked / unchecked menu row pair to show. + * @param viewId The view the section belongs to. + * @param section The section name (used verbatim, lower-cased, in the key). + * @param isVisible Whether the section's items are currently listed. + */ +export function setViewSectionIsVisible( + viewId: ViewID, + section: string, + isVisible: boolean +): void { + vscode.commands.executeCommand( + 'setContext', + `${viewId}.${section.toLowerCase()}Visible`, + isVisible + ); +} + +export function setViewIsFiltered(viewId: ViewID, isFiltered: boolean): void { + vscode.commands.executeCommand( + 'setContext', + `${viewId}.isFiltered`, + isFiltered + ); +}