Skip to content

Unity Roslyn REPL v0.8.0

Choose a tag to compare

@djdcks12 djdcks12 released this 19 May 00:14
· 15 commits to main since this release

[0.8.0] - 2026-05-19

Added (Toolbar _ carry-over badge)

  • New toolbar pill _ : Player (GameObject) surfaces the current _ carry-over target so users can see at a glance whether the engine has a live value bound to _ (the static the wrapper exposes inside snippets and Watch expressions). The badge appears whenever ReplEngine.LastResult is non-null and hides when it's null — Run that returns a non-null value, Object Browser inspect, and explicit ReplEngine.SetLastResult all light it up; an explicit Clear or Reset Project Data extinguishes it. Closes #59.
  • Clicking the badge re-inspects the live _ value: clears Output and renders the same SimpleObjectSerializer.ToTree shape Browse-inspect emits, so the user can fold the tree open and read the object's fields without typing return _; themselves. The summary chip reads Inspect _ while in this mode so it's distinguishable from a Run or Browse landing. The badge's hover tooltip prints a concrete usage example (return _; / _.someField) so a newer user who's never seen the wrapper static doesn't have to guess what "available" means.
  • A small ✕ sits inside the same pill and drops the carry-over via ReplEngine.ResetLastResult(). The clear routes through the engine (not directly at the badge) so every subscriber — this UI, future panels, the engine's own follow-up paths — sees the transition through one event.
  • Object Browser inspect now emits an inline **→ available as \_` in Code and Watch. Try: return ;** info line right above the rendered tree. Without it users had to notice the toolbar badge separately to realise their next snippet / watch could reach the value through `; the inline mention plus a concrete invocation removes both the "available?" and the "available where?" ambiguity from the same glance as the inspection itself.

Changed (ReplEngine carry-over plumbing)

  • ReplEngine.LastResult mutations now route through a single private AssignLastResult chokepoint (SetLastResult, ResetLastResult, and the in-Execute success path all funnel through it). The new public LastResultChanged event fires from there with a force flag so explicit assignment (Set / successful Execute) notifies subscribers even when the new reference equals the current one — same-reference reassigns happen naturally when a snippet mutates and returns the same object (_.name = "Hero"; return _;) and the badge would otherwise freeze on the prior name. Reset keeps the ReferenceEquals short-circuit so a no-op ResetLastResult() on an already-null engine doesn't churn the UI.
  • Subscribers are invoked through GetInvocationList() with a per-handler try / catch. A misbehaving UI surface that throws from LastResultChanged (a torn-down panel, a stale VisualElement) can no longer propagate up through Execute's outer catch and convert a successful snippet into a ReplResult.RuntimeError. The carry-over field still updates and remaining subscribers still fire; the failure surfaces as a single Debug.LogWarning so the diagnostic isn't silent.

Added (Object Browser row context menu)

  • Object Browser rows now expose an explicit right-click menu — Inspect, Set as _, Patch Method…, Copy Type Name, Copy Inspect Snippet — so the panel reads as a set of action entry points instead of a list whose only contract is "double-click does something depending on mode." Closes #60.

  • Inspect mirrors the Output-mode double-click flow: clears Output, renders SimpleObjectSerializer.ToTree, binds _ to the value, refreshes Watch. Patch Method… mirrors the Patches-mode double-click flow (method picker for the row's runtime type, falls back to DeclaredType when the live value is null, e.g. singleton accessors). Both routes share the same helpers double-click drives, so the menu can't drift away from the existing behaviour.

  • Set as _ binds the value without rewriting Output — same ReplEngine.SetLastResult(...) the inspect path uses, just without the tree render. The toolbar _ badge picks the new value up through LastResultChanged, and the action also fires _watch.Refresh() at the same commit point Inspect uses so a Watch panel holding _.foo expressions immediately re-evaluates against the new target instead of showing stale data from the previous _. The pane summary chip reads Bound \_`rather thanBrowsed`.

  • Copy Type Name renders the runtime type through CSharpTypeName.Render so the clipboard receives valid C# source (Outer.Inner, Dictionary<int, string>) rather than the reflection form (Outer+Inner, generic backticks, assembly-qualified arguments). Falls back to the display TypeName when the type isn't expressible as source (open generic parameters); paste-and-Run scenarios get something useful when the type permits and never get silently-broken syntax. Copy Inspect Snippet writes a small C# template into the clipboard, shaped by the row's category:

    • MonoBehaviour → resolves by InstanceID so the snippet returns the exact row the user right-clicked. Live rows emit return UnityEditor.EditorUtility.InstanceIDToObject({id}) as T;; rows with no live value (surfaced through DeclaredType alone) fall back to FindFirstObjectByType<T>(). The previous first-match form picked whichever component the scene-wide query found first, missed inactive GameObjects entirely, and never reached prefab-asset components — InstanceID covers all three. The ID is Editor-session-scoped (a domain reload / Play Mode transition rotates them); right-click again for a fresh snippet after either event.
    • ScriptableObject → resolves the specific asset by path, not by type-wide search. Main-asset rows get LoadAssetAtPath<T>("Assets/.../Foo.asset"); sub-asset rows get LoadAllAssetsAtPath + .OfType<T>().FirstOrDefault(a => a.name == "...") so they don't collide with the path's main asset. The previous FindAssets("t:T").FirstOrDefault() shape would land on whichever asset came first in the project — wrong row whenever multiple SO instances of the same type existed. In-memory ScriptableObjects (created via CreateInstance, no asset path) fall back to the type-wide template with a comment flagging that the template may not pick the same row.
    • Singleton → comment-only template with a TODO: the locator doesn't carry the accessor name (.Instance vs .I vs a property is project-specific) so we don't guess.

    Both copy actions echo a one-line confirmation into Output so the click doesn't feel like a no-op.

  • Menu entries are gated by liveness / renderability so the surface never promises an action it can't deliver. Inspect / Set as _ go Disabled when the row's value resolved to null or a destroyed UnityEngine.Object. Patch Method stays enabled when the row has a DeclaredType even if the live value is gone (matching the singleton fallback). Copy Inspect Snippet goes Disabled when the row's type can't be rendered as C# source (open generic / generic parameter / no type at all) — same CSharpTypeName.IsRenderable check the snippet builder uses, so a successful click always produces source the user can paste and Run.

  • Double-click behaviour is unchanged. ObjectBrowserView exposes a parallel OnRowAction event in addition to the legacy OnInstanceChosen; host wires both, and the action handlers all reuse the same RenderBrowserInspect / OpenBrowserPatchMethod helpers the double-click path calls. A pooled ContextualMenuManipulator attached once in MakeRow walks up from the click target's userData to find the bound InstanceEntry, so the manipulator allocates the action lambdas at creation rather than per scroll-bind.

Added (Output tree node context menu)

  • Output tree rows now expose a right-click menu — Add Watch, Copy Path, Copy Value, Inspect This, Set as _ — so the rendered tree reads as a probe surface instead of a static dump. Right-click any field, list element, or dictionary entry and the menu acts on that exact node. Closes #61.
  • Add Watch pipes the node's expression path into WatchStore.Add (which dedupes + fires Changed, picked up by the Watch panel automatically). Copy Path writes the same expression to the system clipboard so users can paste it into the Code editor, a Watch row, or external notes. Copy Value copies the row's 1-line Preview text (works even on truncation / error placeholders so it's never a dead-end). Inspect This clears Output and renders a fresh SimpleObjectSerializer.ToTree against the node's value — same shape Browse-inspect and the _ badge re-inspect emit, including the → available as \` in Code and Watch. Try: return ;hint and thecarry-over rebind. **Set as** is Inspect-without-the-tree: rebinds to the node's value and refreshes Watch at the same commit point so.foo` rows track the new target, leaving the existing Output intact.
  • Menu entries are gated so the surface never promises an action it can't deliver. Add Watch / Copy Path require a safe expression path and a fresh root anchor — path-based actions disable themselves and pick up a " (stale — \` rebound)"label suffix wheneverReplEngine.LastResultno longer points at the tree's root, because the same.foo.barpath would otherwise silently resolve against a different object after the user reboundvia a later **Set as** / Object Browser action / toolbar ✕. Two un-stick paths: **Set as ** on the (result) row re-roots the *existing* tree (the engine's matchesRootValueagain, so the menu drops the stale labels and Add Watch / Copy Path light back up), or **Inspect This** on any inner row rebuilds a fresh tree rooted at that node. **Set as** on an inner row deliberately *doesn't* clear the stale state — it rebinds but leaves the previous tree visible with its oldRootValue, so those rows stay stale-disabled; the user wanting paths anchored on the new builds a new tree via Inspect This. **Inspect This** / **Set as** stay enabled across rebinds because they use the captured node.Valuedirectly. They're also gated on liveness (disabled for null entries and destroyedUnityEngine.Objectrows); **Copy Value** stays enabled whereverPreview` is populated.
  • New ReplValueNode.Value, ReplValueNode.ExpressionPath, and ReplValueNode.RootValue fields carry the per-node payload from SimpleObjectSerializer.ToTree to the menu. RootValue is stamped on every node in the tree (including placeholder / error nodes) so the stale-check has a stable identity reference even on rows that don't have their own usable Value. ToTree gained an optional rootPath parameter (defaults to "_", matching the carry-over the UI flows always rebind on the same beat); WatchEvaluator.BuildTree overrides it with (<userExpression>) so a Watch sub-tree's paths grow from the row the user actually typed rather than a stale _. Field / property paths accumulate as parent.name only when the name is a path-safe C# identifier under the same grammar Watch's fallback parser reads (letter / underscore start, letter / digit / underscore continue); compiler-generated fields on anonymous, state-machine, and display types (<A>i__Field, <>1__state, etc.) are reflection-visible because IncludeNonPublic defaults to true, but their names start with < so they get a null child path — the row still renders so the user can read the value, Add Watch / Copy Path just disable. Enumerable indices accumulate as parent[idx]. Dictionary keys are narrowed to exactly what the Watch fallback parser (WatchEvaluator.TryReadIndex) can re-parse — non-negative int digits and string keys with no control chars, embedded ", or \. Bool / char / single-value enum / negative-int / long / unsigned / float / decimal / escaped string keys all reject (ExpressionPath = null); the normal compile path supports more, but the encoder only emits forms both code paths can resolve so Add Watch never lights up on a row that would silently drift the moment Watch falls back.
  • A pooled ContextualMenuManipulator attached once per cell in MakeColumn.makeCell reads the bound ReplValueNode off userData (set in bindCell on every recycle) and walks up from the click target's evt.target to find it. Same allocation-light shape the Object Browser menu uses; right-clicking any of the three columns (Name / Type / Value) produces the same menu against the same row, so the user doesn't have to remember which cell is the "row handle."

Added (Per-row enable / disable for Watch)

  • Each Watch row now leads with a small checkbox so the user can silence individual rows without deleting them. The header gains ✓ All / ✗ All bulk buttons for the obvious "turn everything off during this Run" / "turn everything back on" affordances. Closes #62.
  • Disabled rows stay visible in the panel but render muted (italic, 50% opacity across the expression / value / type cells and any "Resolved from" line) with a fixed (disabled) preview so the row's existence — and its persisted expression — is still obvious at a glance. The expand chevron disables on those rows since there's no tree to walk until the next refresh re-evaluates the expression.
  • WatchEvaluator.RefreshAll walks WatchStore.LoadEntries() instead of Load(), emitting a WatchResult { Disabled = true, Preview = "(disabled)" } placeholder for off rows. No call into EvaluateOne for those, so the rebind cost of a heavy expression goes to zero the moment the user toggles it off — including any side effects the fallback path's Resources.FindObjectsOfTypeAll sweep would normally pay for. The previous-preview snapshot map still tracks disabled expressions so re-enabling a row doesn't fire a false "changed" highlight against a stale value.
  • WatchStore on-disk shape promoted from envelope v1 (List<string>) to v2 (List<WatchEntry> — { expression, enabled }). The reader peeks at the version field first and chooses an envelope class accordingly; v1 files load through a string-to-entry migrator (enabled = true for everything) and the next Persist rewrites the file in v2 shape, so the migration is one-time per project with no user action. A Reset Project Data still clears the file outright.
  • New public surface: WatchStore.LoadEntries() (full entry list, including disabled), WatchStore.SetEnabled(expression, bool) (single-row toggle, no-op when the flag is already at the target value), WatchStore.SetAllEnabled(bool) (bulk; only persists when at least one row actually changes so a repeat click against an already-uniform state doesn't churn the file). Legacy WatchStore.Load() is retained but narrowed to "enabled expressions only" — the evaluator now goes through LoadEntries() directly so it can render the off rows, and external callers that just want the active expression list (none ship today) see the same List<string> they always have.
  • WatchResult.Disabled is the panel-side flag the toggle reads; binding the row's Toggle.value to !r.Disabled and routing RegisterValueChangedCallback straight into WatchStore.SetEnabled (which itself fires Changed) means the user click → store mutation → panel rebuild loop closes without an extra Refresh from the view.
  • ResetProjectData counts watches through WatchStore.LoadEntries().Count rather than the now-active-only Load().Count. A project with everything muted would otherwise see Reset's stale-file detector misread watches.json (which is fine, just full of enabled:false rows) as unreadable, and the post-clear summary would report Cleared 0 items while three persisted watch expressions actually got wiped. Snippet / history / usings counts are unaffected — only the watch store grew an "active subset" projection in this version.