diff --git a/apps/electron/package.json b/apps/electron/package.json index cf3edec..e96e2ac 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,6 +1,6 @@ { "name": "@gts-viewer/electron", - "version": "0.3.0", + "version": "0.3.1", "description": "GTS Viewer Electron Application", "main": "dist/main/main.js", "scripts": { diff --git a/apps/server/package.json b/apps/server/package.json index 844398f..40d7f05 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@gts/server", - "version": "0.3.0", + "version": "0.3.1", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/apps/vscode-extension/CHANGELOG.md b/apps/vscode-extension/CHANGELOG.md index cbe09f9..426a965 100644 --- a/apps/vscode-extension/CHANGELOG.md +++ b/apps/vscode-extension/CHANGELOG.md @@ -2,7 +2,26 @@ All notable changes to the GTS Viewer extension will be documented in this file. -## [Unreleased] +## [0.3.1] - 2026-10-26 + +### Added +- Explorer actions and context menus in the GTS file tree and viewer tree +- Per-folder Rescan button in the GTS file tree (progress shown on the folder row), plus scan and validation progress at the top of the tree +- Transitive `any-valid` validation: an instance referencing an invalid entity is reported as invalid + +### Changed +- Validation rebased on `gts-ts` v0.11.0 (GTS spec 0.17): `x-gts-ref` is replaced by `x-gts-type-ref`, `x-gts-instance-ref` and `x-gts-pattern-ref`; schema regexes must follow the GTS safe regex profile; `re2.wasm` is no longer shipped +- Much faster, non-blocking scans and validation of large workspaces (100K+ files) +- One problem severity and standard problem decorations for all GTS problems + +### Fixed +- Diagnostics anchored to the offending node instead of `$id`; fewer false positives (`gts://` in URL fields, GTS-id-shaped scalar values, `x-gts-pattern-ref` operands and `x-gts-traits` values) +- Instances of invalid types, duplicate and malformed GTS ids are reported everywhere +- Rescan starts immediately and fully rebuilds the GTS store; files open from the tree without delay during scans +- Deleted files are dropped from discovery; not-yet-validated files show "validation pending" instead of green +- Stack overflow on workspaces with 100K+ files +- Workspace validation stalling on large workspaces when other extensions react to diagnostics changes + ## [0.3.0] - 2026-09-18 diff --git a/apps/vscode-extension/README.md b/apps/vscode-extension/README.md index 7aa5c69..26179b8 100644 --- a/apps/vscode-extension/README.md +++ b/apps/vscode-extension/README.md @@ -18,8 +18,7 @@ GTS supports **type derivation** through chaining: a derived type extends a base **1. Base type** -- defines the common fields every user has: -```jsonc -// file: gts.x.core.idp.user.v1~.schema.json +```json { "$id": "gts://gts.x.core.idp.user.v1~", "$schema": "http://json-schema.org/draft-07/schema#", @@ -29,7 +28,7 @@ GTS supports **type derivation** through chaining: a derived type extends a base "required": ["id", "type", "email", "display_name", "status"], "properties": { "id": { "type": "string", "format": "uuid" }, - "type": { "type": "string", "x-gts-ref": "/$id" }, + "type": { "type": "string", "x-gts-type-ref": "/$id" }, "email": { "type": "string", "format": "email" }, "display_name": { "type": "string" }, "properties": { "type": "object" } @@ -39,8 +38,7 @@ GTS supports **type derivation** through chaining: a derived type extends a base **2. Derived type** -- extends the base with admin-specific fields. The chained `$id` shows that `platform_admin` inherits from `user`: -```jsonc -// file: gts.x.core.idp.user.v1~x.core.idp.platform_admin.v1~.schema.json +```json { "$id": "gts://gts.x.core.idp.user.v1~x.core.idp.platform_admin.v1~", "$schema": "http://json-schema.org/draft-07/schema#", @@ -67,8 +65,7 @@ GTS supports **type derivation** through chaining: a derived type extends a base **3. Instance** -- a concrete platform administrator that conforms to the derived type: -```jsonc -// file: gts.x.core.idp.user.v1~x.core.idp.platform_admin.v1~.examples.jsonc +```json { "id": "f0e1d2c3-b4a5-6789-0123-456789abcdef", "type": "gts.x.core.idp.user.v1~x.core.idp.platform_admin.v1~", diff --git a/apps/vscode-extension/build.mjs b/apps/vscode-extension/build.mjs index 56475d5..c7b4ae5 100644 --- a/apps/vscode-extension/build.mjs +++ b/apps/vscode-extension/build.mjs @@ -1,29 +1,5 @@ import * as esbuild from 'esbuild' -import { promises as fs } from 'fs' -import path from 'path' -import { createRequire } from 'module' -const require = createRequire(import.meta.url) - -/** - * gts-ts compiles schema patterns with re2-wasm, which loads its `re2.wasm` - * binary from the directory of the running script. Bundled, that is `dist/`, - * so the binary must sit next to `dist/extension.js` or the extension throws - * on load. Resolved through gts-ts so it's the exact copy gts-ts depends on. - * - * @type {esbuild.Plugin} - */ -const copyRe2WasmPlugin = { - name: 'copy-re2-wasm', - setup(build) { - build.onEnd(async result => { - if (result.errors.length > 0) return - const gtsTsDir = path.dirname(require.resolve('@globaltypesystem/gts-ts/package.json', { paths: ['../../packages/shared'] })) - const re2Dir = path.dirname(require.resolve('re2-wasm/package.json', { paths: [gtsTsDir] })) - await fs.copyFile(path.join(re2Dir, 'build', 'wasm', 're2.wasm'), path.join('dist', 're2.wasm')) - }) - }, -} const production = process.argv.includes('--production') const watch = process.argv.includes('--watch') @@ -64,7 +40,6 @@ async function main() { mainFields: ['module', 'main'], plugins: [ esbuildProblemMatcherPlugin, - copyRe2WasmPlugin, ], }) diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index aa193d4..53ba889 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -2,7 +2,7 @@ "name": "gts-kit", "displayName": "Global Type System (GTS) Kit", "description": "Global Type System (GTS) support for VS Code — browse, validate,visualize, and manage GTS schemas and instances", - "version": "0.3.0", + "version": "0.3.1", "private": true, "publisher": "GlobalTypeSystem", "license": "Apache-2.0", @@ -59,6 +59,12 @@ "category": "GTS", "icon": "$(refresh)" }, + { + "command": "gts-kit.rescanFolder", + "title": "Rescan Folder", + "category": "GTS", + "icon": "$(refresh)" + }, { "command": "gts-kit.openToSide", "title": "Open to the Side" @@ -137,7 +143,23 @@ "when": "resourceExtname =~ /\\.(json|jsonc|gts)$/" } ], + "commandPalette": [ + { + "command": "gts-kit.rescanFolder", + "when": "false" + } + ], "view/item/context": [ + { + "command": "gts-kit.rescanFolder", + "group": "inline", + "when": "view == gts-kit.fileExplorer && viewItem == gtsFolder" + }, + { + "command": "gts-kit.rescanFolder", + "group": "navigation@10", + "when": "view == gts-kit.fileExplorer && viewItem == gtsFolder" + }, { "command": "gts-kit.openToSide", "group": "navigation@10", diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index 9f193f4..063fbab 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -4,13 +4,14 @@ import * as fs from 'fs' import { parseGtsFileContent, DEFAULT_GTS_CONFIG } from '@gts/shared' import type { EntityValidationDto, ObjValidationDto, InvalidFileValidationDto, ValidationRelayPayload } from '@gts/shared' import { setLastScanFiles } from './scanStore' -import { rebuildRegistry, indexFile as indexFileInRegistry, removeFile as removeFileFromRegistry, getRegistry } from './registryStore' +import { rebuildRegistry, indexFile as indexFileInRegistry, indexFiles, removeFile as removeFileFromRegistry, getRegistry, getPathsWithMalformedIds, type RegistryFileInput } from './registryStore' import { getWorkspaceIgnore, resetWorkspaceIgnore, isGitIgnored, type FolderIgnore } from './gitignore' import { WorkspaceLayoutStorage } from './storage' -import { initValidation, resetValidationDiagnostics, validateOpenDocument, validateWorkspaceInBackground, revalidateDependents, onValidationCompleted } from './validation' +import { initValidation, resetValidationDiagnostics, validateOpenDocument, validateWorkspaceInBackground, revalidateDependents, revalidateFiles, onValidationCompleted, forgetValidationState, whenWorkspaceValidationIdle } from './validation' import { isGtsCandidateFile, isIndexableGtsDocument } from './helpers' import { GtsLinkProvider } from './linkProvider' -import { registerGtsExplorer, type GtsExplorer } from './gtsExplorer' +import { registerGtsExplorer, type GtsExplorer, type GtsTreeElement, type FolderProgress } from './gtsExplorer' +import { TimeSlicer, CancelledError, filterSliced, yieldToEventLoop } from './timeSlice' import type { LayoutSaveRequest, LayoutTarget, LayoutSnapshot } from '@gts/layout-storage' // Glob used for all GTS workspace scans and the on-disk file watcher. @@ -97,7 +98,10 @@ async function findGtsFiles(ignores: Map, baseExclude: str if (capPerFolder !== undefined && found.length >= capPerFolder) { console.warn(`[GTS] Phase 1 hit its ${capPerFolder}-file cap in "${folder.name}"; the rest is indexed by phase 2`) } - uris.push(...found) + // Not `uris.push(...found)`: spreading passes every element as a call + // argument, which overflows the stack ("Maximum call stack size exceeded") + // once a folder holds more than ~120K files. + for (const uri of found) uris.push(uri) } return uris } @@ -110,33 +114,38 @@ async function findGtsFiles(ignores: Map, baseExclude: str // once and let the most-recently-scanned/edited path win (so an open file, which // is scanned first, stays canonical and gets its in-editor diagnostics). const realPathIndex = new Map() +// Reverse of realPathIndex (indexed path -> real path), so forgetting a path is +// a lookup instead of a scan of every indexed file. +const realPathOfIndexedPath = new Map() + +function setCanonicalPath(real: string, fsPath: string): void { + const previous = realPathIndex.get(real) + if (previous !== undefined) realPathOfIndexedPath.delete(previous) + realPathIndex.set(real, fsPath) + realPathOfIndexedPath.set(fsPath, real) +} + +function clearCanonicalPaths(): void { + realPathIndex.clear() + realPathOfIndexedPath.clear() +} /** Resolve a path to its canonical real path; fall back to the input on error. */ function resolveRealPath(fsPath: string): string { try { return fs.realpathSync.native(fsPath) } catch { return fsPath } } -/** Drop any canonical-path entries that point at `fsPath` (on delete/rename). */ -function forgetIndexedPath(fsPath: string): void { - for (const [real, p] of realPathIndex) { - if (p === fsPath) realPathIndex.delete(real) - } +/** Async resolveRealPath, for bulk scans (the sync one blocked the extension host for seconds). */ +async function resolveRealPathAsync(fsPath: string): Promise { + try { return await fs.promises.realpath(fsPath) } catch { return fsPath } } -/** - * Keep only one URI per physical file, recording the canonical path chosen. - * First-seen wins, so callers should pass higher-priority paths (open files) - * first. Duplicates reached through other symlinks are dropped. - */ -function dedupeUrisByRealPath(uris: vscode.Uri[]): vscode.Uri[] { - const out: vscode.Uri[] = [] - for (const uri of uris) { - const real = resolveRealPath(uri.fsPath) - if (realPathIndex.has(real)) continue - realPathIndex.set(real, uri.fsPath) - out.push(uri) - } - return out +/** Drop the canonical-path entry that points at `fsPath` (on delete/rename). */ +function forgetIndexedPath(fsPath: string): void { + const real = realPathOfIndexedPath.get(fsPath) + if (real === undefined) return + realPathOfIndexedPath.delete(fsPath) + if (realPathIndex.get(real) === fsPath) realPathIndex.delete(real) } /** @@ -152,7 +161,7 @@ function claimCanonicalPath(fsPath: string): void { removeFileFromRegistry(existing) forgetIndexedPath(existing) } - realPathIndex.set(real, fsPath) + setCanonicalPath(real, fsPath) } let viewerPanel: vscode.WebviewPanel | null = null @@ -172,6 +181,57 @@ function enqueueScan(operation: () => Promise): Promise { return run } +// Bumped by every full scan request. Queued and running scans (full or folder) +// check it at each time slice and stop once a newer full scan was requested, +// so a Rescan click takes over right away instead of waiting behind them. +let scanGeneration = 0 + +/** + * Run a scan step in the scan queue. With a `status`, the GTS files view + * shows its progress bar and the status from the moment this is called, even + * while the step still waits in the queue (folder rescans report progress on + * their own row instead). The step is abandoned (at its next time slice) when + * a newer full scan supersedes `generation`. + */ +async function runScan(status: string | undefined, generation: number, step: (slicer: TimeSlicer) => Promise): Promise { + const isCurrent = () => generation === scanGeneration + const run = () => enqueueScan(async () => { + if (!isCurrent()) return + if (status) gtsExplorer?.setScanStatus(status) + // Let the progress bar and status reach the UI before the heavy lifting. + await yieldToEventLoop() + pathsTouchedDuringScan = new Set() + try { + await step(new TimeSlicer(() => !isCurrent())) + } catch (error) { + if (!(error instanceof CancelledError)) throw error + console.log('[GTS] Scan superseded by a newer one') + } finally { + pathsTouchedDuringScan = null + if (status && isCurrent()) gtsExplorer?.setScanStatus(undefined) + } + }) + if (!status) return run() + gtsExplorer?.setScanStatus(status) + await vscode.window.withProgress({ location: { viewId: 'gts-kit.fileExplorer' } }, run) +} + +/** Calls `report(done)` at most every 200ms (and always for the last file). */ +function throttledProgress(total: number, report: (done: number) => void): (done: number) => void { + let last = 0 + return done => { + const now = Date.now() + if (done < total && now - last < 200) return + last = now + report(done) + } +} + +/** Reports "