From e1e822ce086e929a64c060be7642e1e99c4766ea Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:59:22 -0400 Subject: [PATCH 1/2] Add a pause/play toggle for automatic rendering in the repl The toolbar gets a pause button. While paused (`?autorender=off`), edits do not compile, and a Render button compiles the current text on demand. The paused state lives in the URL, so shared links and embeds keep it. The output input comes from reactiveweb's keepLatest, which holds the last rendered file while paused. reactiveweb 1.9.4 fixes keepLatest dropping its initial value, which a page that loads paused needs. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/repl/app/components/compiler.gts | 4 +- apps/repl/app/controllers/application.ts | 4 + apps/repl/app/services/editor.ts | 61 +++++++++ .../edit/layout/controls/auto-render.gts | 46 +++++++ .../templates/edit/layout/controls/index.gts | 2 + apps/repl/package.json | 2 +- .../tests/application/auto-render-test.gts | 129 ++++++++++++++++++ apps/tutorial/package.json | 2 +- packages/app-support/shared/package.json | 2 +- packages/limber-ui/package.json | 2 +- pnpm-lock.yaml | 29 ++-- pnpm-workspace.yaml | 2 +- 12 files changed, 263 insertions(+), 22 deletions(-) create mode 100644 apps/repl/app/templates/edit/layout/controls/auto-render.gts create mode 100644 apps/repl/tests/application/auto-render-test.gts diff --git a/apps/repl/app/components/compiler.gts b/apps/repl/app/components/compiler.gts index 21db71e4c..e9d3616b6 100644 --- a/apps/repl/app/components/compiler.gts +++ b/apps/repl/app/components/compiler.gts @@ -14,7 +14,7 @@ interface Signature { export default class Compiler extends Component { @@ -22,7 +22,7 @@ export default class Compiler extends Component { @service declare editor: EditorService; get formatQP() { - return this.editor.format; + return this.editor.renderInput?.format ?? this.editor.format; } get formatQPParts() { diff --git a/apps/repl/app/controllers/application.ts b/apps/repl/app/controllers/application.ts index 49334bc1b..4030bec3e 100644 --- a/apps/repl/app/controllers/application.ts +++ b/apps/repl/app/controllers/application.ts @@ -72,6 +72,10 @@ export default class ApplicationController extends Controller { // or force it to not be rendered in to a shadow-dom if falsey value is passed 'shadowdom', + // When falsey ("off"), the output only compiles when the "Render" button is clicked, + // instead of on every edit. + 'autorender', + // Disable shiki highlighting on page load // this is primarily an optimization for tests 'nohighlight', diff --git a/apps/repl/app/services/editor.ts b/apps/repl/app/services/editor.ts index 26299ef7e..6f9998d50 100644 --- a/apps/repl/app/services/editor.ts +++ b/apps/repl/app/services/editor.ts @@ -1,11 +1,16 @@ import { tracked } from '@glimmer/tracking'; +import { registerDestructor } from '@ember/destroyable'; import Service, { service } from '@ember/service'; +import { castToBoolean } from 'ember-primitives/qp'; +import { use } from 'ember-resources'; +import { keepLatest } from 'reactiveweb/keep-latest'; import { link } from 'reactiveweb/link'; import { FileURIComponent } from 'limber/utils/editor-text'; import type { DemoEntry } from '../snippets'; +import type Owner from '@ember/owner'; import type RouterService from '@ember/routing/router-service'; import type { FormatQP } from '#app/languages.gts'; @@ -38,6 +43,62 @@ export default class EditorService extends Service { return this.fileURIComponent.format; } + /** + * `?autorender=off` pauses compiling as the user types. + * + * This is a field instead of a getter on the router, because every edit writes the URL. + * A getter would invalidate renderInput on each keystroke and compile the same text again. + */ + @tracked autoRender = true; + + constructor(owner: Owner) { + super(owner); + + this.router.on('routeDidChange', this.#syncAutoRender); + registerDestructor(this, () => this.router.off('routeDidChange', this.#syncAutoRender)); + } + + #syncAutoRender = () => { + const value = this.router.currentRoute?.queryParams?.['autorender']; + const next = typeof value === 'string' ? castToBoolean(value) : true; + + // Setting a tracked field dirties it even when the value is the same. + if (next !== this.autoRender) this.autoRender = next; + }; + + @tracked renderRequests = 0; + #seenRenderRequests = -1; + + render = () => { + this.renderRequests++; + }; + + /** + * The text and format for the output pane. + * + * While paused, the value function returns undefined without a read of `text`, + * so keepLatest keeps the last rendered file and edits do not compile. + * + * Exception: a render reads `text` and `format`, which come from the URL, + * so the first edit after it compiles the same file once more. + */ + @use renderInput = keepLatest({ + when: () => !this.autoRender, + value: () => { + if (!this.autoRender && this.renderRequests === this.#seenRenderRequests) { + return undefined; + } + + const { text, format } = this; + + // Before the route loads a document there is no text, + // and that must not count as the render for a paused first load. + if (text) this.#seenRenderRequests = this.renderRequests; + + return { text, format }; + }, + }); + get nohighlight() { return (this.router.currentRoute?.queryParams ?? {}).nohighlight; } diff --git a/apps/repl/app/templates/edit/layout/controls/auto-render.gts b/apps/repl/app/templates/edit/layout/controls/auto-render.gts new file mode 100644 index 000000000..2319f1da3 --- /dev/null +++ b/apps/repl/app/templates/edit/layout/controls/auto-render.gts @@ -0,0 +1,46 @@ +import Component from '@glimmer/component'; +import { service } from '@ember/service'; + +import FaIcon from '@fortawesome/ember-fontawesome/components/fa-icon'; +import { faForwardStep, faPause, faPlay } from '@fortawesome/free-solid-svg-icons'; + +import { Button } from './button.gts'; + +import type RouterService from '@ember/routing/router-service'; +import type EditorService from 'limber/services/editor'; + +export class AutoRender extends Component { + @service declare editor: EditorService; + @service declare router: RouterService; + + toggle = () => { + const url = new URL(this.router.currentURL ?? '/edit', location.origin); + + if (this.editor.autoRender) { + url.searchParams.set('autorender', 'off'); + } else { + url.searchParams.delete('autorender'); + } + + this.router.replaceWith(url.pathname + url.search); + }; + + +} diff --git a/apps/repl/app/templates/edit/layout/controls/index.gts b/apps/repl/app/templates/edit/layout/controls/index.gts index 031103c26..d4c5e45f1 100644 --- a/apps/repl/app/templates/edit/layout/controls/index.gts +++ b/apps/repl/app/templates/edit/layout/controls/index.gts @@ -17,6 +17,7 @@ import { castToBoolean, qp, withQP } from 'ember-primitives/qp'; import currentURL from 'limber/helpers/current-url'; +import { AutoRender } from './auto-render.gts'; import { Button } from './button.gts'; import { FormatDocument } from './format-document.gts'; @@ -70,6 +71,7 @@ export const Controls: TOC<{ {{/if}} +