From d2e9b3b569802ac1993bc5bd605f2812e657dfe5 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 27 Jul 2026 13:05:02 +0200 Subject: [PATCH] fix: retain completed results during live editing --- CHANGELOG.md | 10 + README.md | 7 +- SOURCE.md | 10 +- docs/EXTRACTION_MODEL.md | 2 +- docs/FLAVOUR_SUPPORT.md | 2 +- docs/MINIMIZATION.md | 2 +- docs/PORTAL_REQUIREMENTS.md | 4 +- docs/REFERENCE_IMPLEMENTATIONS.md | 4 +- docs/RELEASE.md | 9 +- package-lock.json | 4 +- package.json | 2 +- public/toolbox-app.json | 2 +- scripts/package-release.mjs | 2 +- src/components/ExplanationTree.tsx | 16 +- .../ReplacementPanel.completeness.test.tsx | 18 + src/components/ReplacementPanel.tsx | 54 ++- src/components/Workbench.test.tsx | 105 +++++- src/components/Workbench.tsx | 346 ++++++++++++++---- src/styles.css | 18 + src/toolbox/manifest.source.json | 2 +- src/version.ts | 2 +- tests/browser/workbench.spec.ts | 72 +++- 22 files changed, 576 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fefb158..2bbe912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +## 0.4.1 — 2026-07-27 + +- Keep the last completed syntax and execution frame visible while edited + inputs are parsed and executed, then replace it atomically with the newest + revision instead of rendering transient empty panels. +- Bind every retained result to its exact flavour, options, pattern, flags, + subject, replacement, mode and syntax snapshots. Mark it as stale, disable + execution until current syntax is ready, and suppress stale editor ranges + and cross-panel selection. + ## 0.4.0 — 2026-07-27 - Expand the workbench from four to twelve selectable execution profiles: diff --git a/README.md b/README.md index acb2b36..b7e20cd 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ Patterns, replacement templates, test text and project files stay in the browser. There is no account, backend, upload, remote execution, telemetry, runtime CDN, remote font, or cloud-save service. -## Current release — 0.4.0 +## Current release — 0.4.1 -The v0.4.0 release contains twelve selectable, executable profiles: +The v0.4.1 release contains twelve selectable, executable profiles: - **JavaScript (ECMAScript)** — the current browser's native `RegExp`, with ECMAScript 2025 syntax supplied by regexpp 4.12.2; @@ -99,6 +99,9 @@ relabeled Scala engine. - Parsing and execution run in separate workers. - Actual execution can be terminated by killing its worker. - The worker is recreated after timeout, crash or cancellation. +- Live editing retains the last completed, input-bound syntax and result frame + until the newest revision finishes. Stale frames are labelled and cannot + enable Run, decorate current editors, or drive current-input selections. - ECMAScript capture ranges use the engine's `d` indices result; every other profile returns its native range records through a bounded fixed bridge. - Named, numbered, optional, empty and repeated-final captures are represented. diff --git a/SOURCE.md b/SOURCE.md index 0899ba4..7d3dece 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -1,10 +1,10 @@ # Source identity - Project: Regex Tools -- Release version: `0.4.0` -- Release tag: `v0.4.0` +- Release version: `0.4.1` +- Release tag: `v0.4.1` - Repository: -- Previous release tag: `v0.3.0` +- Previous release tag: `v0.4.0` - Licence: GPL-3.0-or-later - Copyright: © 2026 Albrecht Degering @@ -82,7 +82,7 @@ metadata, closed file sets, checksums and exact licence/notice material are installed under `public/engines/` and included in the application source and build. -The v0.4.0 release includes native browser ECMAScript, standalone PCRE2 10.47, +The v0.4.1 release includes native browser ECMAScript, standalone PCRE2 10.47, PHP 8.5.8 `preg` with PCRE2 10.44, legacy Perl 5.28.1 via WebPerl 0.09-beta, CPython 3.14.2 `re`, CRuby 4.0.0 `Regexp`, TeaVM 0.15.0 `java.util.regex`, Emscripten 6.0.4 libc++ `std::wregex`, Go 1.26.5 `regexp`, Rust `regex` 1.13.1, @@ -91,4 +91,4 @@ TeaVM-backed Scala/JVM compatibility profile. Earlier releases remain available under their historical tags and artifact coordinates. A future public artifact must bump package, manifest, tag and -source identity together; it must not reuse `v0.4.0`. +source identity together; it must not reuse `v0.4.1`. diff --git a/docs/EXTRACTION_MODEL.md b/docs/EXTRACTION_MODEL.md index 55406c0..fbe6e9a 100644 --- a/docs/EXTRACTION_MODEL.md +++ b/docs/EXTRACTION_MODEL.md @@ -18,7 +18,7 @@ budget. Consumers must inspect the preview status rather than treating a preview as complete. In particular, list export is disabled if its template would consume an incomplete value. -Repeated captures expose only the final retained value in all twelve v0.4.0 +Repeated captures expose only the final retained value in all twelve v0.4.1 profiles. This includes .NET even though its native API can retain capture history: the current adapter deliberately normalizes only the final record and reports `captureHistory: false`. The UI labels the limitation and does not diff --git a/docs/FLAVOUR_SUPPORT.md b/docs/FLAVOUR_SUPPORT.md index feeea00..f482af9 100644 --- a/docs/FLAVOUR_SUPPORT.md +++ b/docs/FLAVOUR_SUPPORT.md @@ -1,6 +1,6 @@ # Flavour support -Version 0.4.0 exposes twelve executable profiles. “Partial lexical provider” +Version 0.4.1 exposes twelve executable profiles. “Partial lexical provider” means that Regex Tools recognizes common structure and replacement tokens for explanation only; the named runtime compiler and replacement operation remain authoritative. diff --git a/docs/MINIMIZATION.md b/docs/MINIMIZATION.md index 8f19c4e..313d335 100644 --- a/docs/MINIMIZATION.md +++ b/docs/MINIMIZATION.md @@ -11,7 +11,7 @@ selected real ECMAScript or PCRE2 worker. It can preserve: authoritative, untruncated output. PHP, Perl, Python, Ruby, Java, C++, Go, Rust, .NET and Scala compatibility are -not supported minimization targets in v0.4.0. The workspace disables reduction +not supported minimization targets in v0.4.1. The workspace disables reduction for those active profiles; it does not substitute ECMAScript, PCRE2 or a lexical provider as an execution oracle. diff --git a/docs/PORTAL_REQUIREMENTS.md b/docs/PORTAL_REQUIREMENTS.md index 90ceb0f..070e747 100644 --- a/docs/PORTAL_REQUIREMENTS.md +++ b/docs/PORTAL_REQUIREMENTS.md @@ -38,9 +38,9 @@ should test both a direct root mount and a nested mount such as `/apps/regex/`, including cold loading of all twelve profiles. The Portal must consume the immutable release ZIP, verify its SHA-256, verify -manifest ID `de.add-ideas.regex-tools` and version `0.4.0`, and mount it at a +manifest ID `de.add-ideas.regex-tools` and version `0.4.1`, and mount it at a relative target such as `apps/regex/`. -The package, manifest, tag, artifact URL and checksum must all use the v0.4.0 +The package, manifest, tag, artifact URL and checksum must all use the v0.4.1 identity. Historical artifact coordinates remain immutable and must not be overwritten or reused. diff --git a/docs/REFERENCE_IMPLEMENTATIONS.md b/docs/REFERENCE_IMPLEMENTATIONS.md index 4b2d726..0dffa40 100644 --- a/docs/REFERENCE_IMPLEMENTATIONS.md +++ b/docs/REFERENCE_IMPLEMENTATIONS.md @@ -4,7 +4,7 @@ Inspection date: 2026-07-27. | Reference | Exact revision/version | Licence | Use and copying status | | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -| Regex Tools | Release identity `v0.4.0` | GPL-3.0-or-later | Application source written for this project. | +| Regex Tools | Release identity `v0.4.1` | GPL-3.0-or-later | Application source written for this project. | | Toolbox SDK | `53c40a61ba1581246f65773fcbb1c1cfd31ac98e` / 0.2.2 | Apache-2.0 | Contract, shell and release conventions; package APIs used, no source adapted. | | Toolbox Portal | `9347554324299c8ec9dfaec2c9730907736f62c3` / aggregate v0.8.0 / package 0.2.7 | AGPL-3.0-only | Assembly/lock conventions inspected; no source copied into the app. | | regexpp | 4.12.2; pinned npm integrity in `package-lock.json` | MIT | ECMAScript 2025 syntax provider; normalized in the syntax worker. | @@ -30,6 +30,6 @@ Inspection date: 2026-07-27. | RegexLib | Website inspected | Redistribution licence not established | No fixture copied. | | RGXP.RU | Website inspected | Redistribution licence not established | No fixture copied. | -All v0.1.0 through v0.4.0 conformance cases are project-authored. See +All v0.1.0 through v0.4.1 conformance cases are project-authored. See `tests/fixtures/README.md`. Runtime pack metadata and adjacent licence/notice files are authoritative for the exact binary distribution. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 90f2990..34816e1 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -18,8 +18,8 @@ on publication failure, replaces an existing exact version only with `--force`, and produces: ```text -release/regex-tools-0.4.0.zip -release/regex-tools-0.4.0.zip.sha256 +release/regex-tools-0.4.1.zip +release/regex-tools-0.4.1.zip.sha256 ``` The release scan checks ASCII/UTF-8, UTF-16LE and UTF-16BE content in text, @@ -43,6 +43,5 @@ anchored base-runtime legal files. The C++ gate requires its exact traced component/source manifest and six independently pinned legal files. The release packager names every one of those files explicitly. -The existing v0.1.0, v0.2.0 and v0.3.0 ZIPs and matching sidecars are -historical immutable artifacts. Creating v0.4.0 must not replace, rename or -remove them. +The existing v0.1.0 through v0.4.0 ZIPs and matching sidecars are historical +immutable artifacts. Creating v0.4.1 must not replace, rename or remove them. diff --git a/package-lock.json b/package-lock.json index 5eb7922..f595226 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "regex-tools", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "regex-tools", - "version": "0.4.0", + "version": "0.4.1", "license": "GPL-3.0-or-later", "dependencies": { "@add-ideas/toolbox-contract": "0.2.2", diff --git a/package.json b/package.json index c938fe5..b4c270e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "regex-tools", - "version": "0.4.0", + "version": "0.4.1", "description": "Develop, explain, test and apply regular expressions locally in the browser.", "license": "GPL-3.0-or-later", "author": "Albrecht Degering", diff --git a/public/toolbox-app.json b/public/toolbox-app.json index ca8642e..774feef 100644 --- a/public/toolbox-app.json +++ b/public/toolbox-app.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "id": "de.add-ideas.regex-tools", "name": "Regex Tools", - "version": "0.4.0", + "version": "0.4.1", "description": "Develop, explain, test and apply regular expressions locally in the browser.", "entry": "./", "icon": "./favicon.svg", diff --git a/scripts/package-release.mjs b/scripts/package-release.mjs index 085d1d6..65f5b01 100644 --- a/scripts/package-release.mjs +++ b/scripts/package-release.mjs @@ -19,7 +19,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const zipEpoch = new Date(1980, 0, 1, 0, 0, 0); const gplVersion3TextSha256 = "fb981668c18a279e285fc4d83fba1e836cc84dd4daa73c9697d3cfd2d8aca6e0"; -const expectedApplicationVersion = "0.4.0"; +const expectedApplicationVersion = "0.4.1"; const requiredRootFiles = [ "LICENSE", "README.md", diff --git a/src/components/ExplanationTree.tsx b/src/components/ExplanationTree.tsx index 8220ba3..03f5b8c 100644 --- a/src/components/ExplanationTree.tsx +++ b/src/components/ExplanationTree.tsx @@ -8,6 +8,7 @@ export interface ExplanationTreeProps { readonly root?: NormalizedRegexNode; readonly selectedId?: string; readonly flags?: readonly string[]; + readonly stale?: boolean; readonly onSelect: (node: NormalizedRegexNode) => void; } @@ -165,6 +166,7 @@ export function ExplanationTree({ root, selectedId, flags = [], + stale = false, onSelect, }: ExplanationTreeProps) { const totalNodes = useMemo(() => (root ? countNodes(root) : 0), [root]); @@ -191,8 +193,18 @@ export function ExplanationTree({

Pattern structure

Explanation tree

- - Deterministic · syntax provider + + {stale + ? "Stale snapshot · checking current pattern" + : "Deterministic · syntax provider"} {totalNodes > renderedNodes ? ( diff --git a/src/components/ReplacementPanel.completeness.test.tsx b/src/components/ReplacementPanel.completeness.test.tsx index 6e030aa..e8ab3b0 100644 --- a/src/components/ReplacementPanel.completeness.test.tsx +++ b/src/components/ReplacementPanel.completeness.test.tsx @@ -74,6 +74,24 @@ describe("ReplacementPanel completeness", () => { }, ); + it("labels retained tokens with the flavour that parsed the snapshot", () => { + render( + undefined} + onSelectToken={() => undefined} + onSelectContribution={() => undefined} + />, + ); + + expect(screen.getByText("Python re template syntax")).toBeInTheDocument(); + expect( + screen.getByText("Java Pattern derived token explanation"), + ).toBeInTheDocument(); + }); + it("labels a match-limited replacement as partial", () => { renderResult({ execution: { ...execution, truncated: true }, diff --git a/src/components/ReplacementPanel.tsx b/src/components/ReplacementPanel.tsx index 244576e..b6d7646 100644 --- a/src/components/ReplacementPanel.tsx +++ b/src/components/ReplacementPanel.tsx @@ -71,7 +71,11 @@ export function ReplacementPanel({ replacement, onReplacementChange, syntax, + presentedSyntax, + syntaxStale = false, + presentedFlavour, result, + resultFlavour, preview, selectedToken, selectedContributionId, @@ -83,7 +87,11 @@ export function ReplacementPanel({ readonly replacement: string; readonly onReplacementChange: (value: string) => void; readonly syntax?: ReplacementSyntaxResult; + readonly presentedSyntax?: ReplacementSyntaxResult; + readonly syntaxStale?: boolean; + readonly presentedFlavour?: RegexFlavourId; readonly result?: RegexReplacementResult; + readonly resultFlavour?: RegexFlavourId; readonly preview?: ReplacementPreview; readonly selectedToken?: ReplacementToken; readonly selectedContributionId?: string; @@ -96,8 +104,16 @@ export function ReplacementPanel({ const activeFlavour = flavour ?? result?.execution.engine.flavour ?? "ecmascript"; const engineLabel = TEMPLATE_FLAVOUR_LABELS[activeFlavour]; - const retainedTokens = syntax?.tokens ?? []; - const editorTokens = retainedTokens.slice( + const presentedEngineLabel = + TEMPLATE_FLAVOUR_LABELS[presentedFlavour ?? activeFlavour]; + const resultEngineLabel = + TEMPLATE_FLAVOUR_LABELS[ + resultFlavour ?? result?.execution.engine.flavour ?? activeFlavour + ]; + const retainedSyntax = presentedSyntax ?? syntax; + const retainedTokens = retainedSyntax?.tokens ?? []; + const currentTokens = syntax?.tokens ?? []; + const editorTokens = currentTokens.slice( 0, TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks, ); @@ -129,7 +145,7 @@ export function ReplacementPanel({ maximumLengthUtf16={ DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16 } - selectedRange={selectedToken?.range} + selectedRange={syntaxStale ? undefined : selectedToken?.range} marks={[ ...editorTokens.map((token) => ({ id: token.id, @@ -137,7 +153,7 @@ export function ReplacementPanel({ className: `cm-replacement-token cm-replacement-${token.kind}`, label: token.explanation, })), - ...(selectedToken + ...(!syntaxStale && selectedToken ? [ { id: "selected-replacement-token", @@ -163,7 +179,7 @@ export function ReplacementPanel({ unit limit.

) : null} - {retainedTokens.length > + {currentTokens.length > TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks ? (

Editor decorations limited. Showing the first{" "} {TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks.toLocaleString()}{" "} - of {retainedTokens.length.toLocaleString()} complete replacement + of {currentTokens.length.toLocaleString()} complete replacement tokens.

) : null} @@ -182,9 +198,24 @@ export function ReplacementPanel({ >
-

Derived token explanation

+

+ {presentedEngineLabel} derived token explanation +

Replacement tokens

+ + {syntaxStale + ? "Stale snapshot · checking current template" + : "Current template"} +
{renderedTokens.length > 0 ? ( <> @@ -222,12 +253,12 @@ export function ReplacementPanel({ ) : (

- {syntax?.accepted === false + {retainedSyntax?.accepted === false ? "Token parsing is unavailable until the template is within the input limit." : "Enter a replacement template."}

)} - {syntax?.diagnosticsTruncated ? ( + {retainedSyntax?.diagnosticsTruncated ? (

@@ -252,7 +284,7 @@ export function ReplacementPanel({

- Native matches · bounded {engineLabel} substitution + Native matches · bounded {resultEngineLabel} substitution

Replacement output

diff --git a/src/components/Workbench.test.tsx b/src/components/Workbench.test.tsx index 5800648..13010df 100644 --- a/src/components/Workbench.test.tsx +++ b/src/components/Workbench.test.tsx @@ -37,6 +37,7 @@ const syntaxHarness = vi.hoisted(() => ({ const engineHarness = vi.hoisted(() => ({ requests: [] as RegexExecutionRequest[], + emitWholeFirstCharacterMatch: false, })); vi.mock("../editors/CodeEditor", () => ({ @@ -45,18 +46,35 @@ vi.mock("../editors/CodeEditor", () => ({ onChange, label, readOnly, + marks, + selectedRange, + onSelectionChange, }: { readonly value: string; readonly onChange?: (value: string) => void; readonly label: string; readonly readOnly?: boolean; + readonly marks?: readonly unknown[]; + readonly selectedRange?: { readonly startUtf16: number }; + readonly onSelectionChange?: (range: { + readonly startUtf16: number; + readonly endUtf16: number; + }) => void; }) { return (