Skip to content
| Marketplace
Sign in
Visual Studio Code>Linters>FRX — Flutter Redux ScaffolderNew to Visual Studio Code? Get it now.
FRX — Flutter Redux Scaffolder

FRX — Flutter Redux Scaffolder

Andrii Ivanov

|
1 install
| (0) | Free
Scaffold AsyncRedux substates and auto_route pages via the frx CLI, with a build_runner watch toggle.
Installation
Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter.
Copied to clipboard
More Info

FRX — VSCode extension

An editor wrapper around the frx scaffolding CLI:

  • Every capability by name in the Command Palette — type FRX: and the whole inventory is there, searchable and bindable to a key.
  • A status-bar frx item (monorepo only) that opens an action overlay: the same inventory, grouped, with the live build_runner watch state its icon also shows. One click, everything in one place.
  • An FRX tree view in the Explorer: the app's substates and routes, read live from one frx graph --json. Click a leaf to open its source; inline actions add an action to a substate or remove a substate/page.
  • frx doctor findings in the Problems panel — wiring drift, stale codegen and misplaced declarations show up as squiggles, refreshed after every scaffold/removal.
  • Right-click a Dart symbol → FRX: Rename… renames that artifact, resolving what is under the cursor rather than asking which one you meant.

The CLI does the real work (file generation + AST wiring). This extension adds the affordances a terminal can't: it resolves the CLI even when VSCode has no shell PATH, prompts before overwriting, opens the new file, and runs (or, with watch on, skips) build_runner.


Install the frx CLI

The extension is a front end; the binary is what edits your code. One line, no Dart SDK needed — it downloads the release for your platform, checks it against the release's checksums.txt, and puts it in ~/.frx/bin (%LOCALAPPDATA%\frx\bin on Windows):

# macOS · Linux
curl -fsSL https://raw.githubusercontent.com/pro100andrey/flutter_redux_templates/main/tools/scripts/install.sh | sh
# Windows
irm https://raw.githubusercontent.com/pro100andrey/flutter_redux_templates/main/tools/scripts/install.ps1 | iex

The PATH edit is for your terminal, not for this extension. The extension looks for the file in both install directories regardless of what PATH says — which is the point, because a Dock- or Start-menu-launched editor never sees your shell's PATH (see How it runs the CLI).

Other ways in: download an archive from Releases, dart install . in tools/ from a checkout, or point frx.path at a binary you put wherever you like. And with none of them, the extension still works from a clone by running the CLI from source — correct, but seconds per call instead of milliseconds.

Without a project the extension stays out of the way: everything is gated on frx.isMonorepo, so in an unrelated repository there is no tree, no status bar item and no commands. frx create <name> writes a project that satisfies it.


Features

Add Substate

  • FRX: Add Substate… — Command Palette or the action overlay.
  • Pick a kind — a quick pick chooses the substate flavour:
    • value — a single nullable value field + SetValueAction.
    • search — a query string + IList<int> view + SetQueryAction.
    • table — a byId IMap table + view + Add… / Retrieve… actions.
  • AST wiring — composed into AppState (import + factory field + initial() entry) by the CLI.

Add Page

  • FRX: Add Page… — Command Palette or the action overlay.
  • Pick access — Protected (default) or Public (reachable while logged out → the route is added to the auth guard's _authArea, via --public).
  • AST wiring — generates the page (ui) + @RoutePage() connector (app), inserts the connector import and an AutoRoute(...) entry into AppRouter.

FRX tree view

The Explorer gains an FRX view (monorepo only) with two groups:

  • Substates — every field composed into AppState (name + type), each expanding into what belongs to it: its actions and its selectors. Click any row to open its source. Inline on a substate: Add action… (pre-fills it) and Remove.
  • Routes — every route registered in AppRouter, with its path and what makes it special. Click → opens the page connector. Inline: Remove.
FRX
├─ Substates
│  ├─ logIn                   LogInState
│  │  ├─ ⚡ LogInWithEmailAction   async · WaitingAction · throws
│  │  ├─ ⚡ SetEmailAction
│  │  └─ ƒ  isWaiting
│  ├─ session                 SessionState
│  │  ├─ ⚡ SetTokenAction         nothing dispatches
│  │  └─ ƒ  token                  nothing reads it
│  └─ wait                    Wait
└─ Routes
   ├─ SplashRoute             /splash · initial
   ├─ LogInRoute              /login · public
   └─ HomeRoute               /home

An action row carries how it runs — async, its async_redux mixins, and whether it can throw a UserException. A selector sheds its Select… prefix, because the row above already says it. Clicking a selector lands on the getter, not at the top of selectors.dart — every selector in the app shares that one file, so the file alone answers "which file" and not "which one".

nothing dispatches and nothing reads it mark an action frx found no dispatcher for and a selector it found no reader for. Both are warning icons rather than Problems entries on purpose: the caller may be the code you are about to write, and in a template a selector can be API offered to whoever builds on it — so doctor stays quiet, and an ambient tree shows it without claiming a defect. A selector read only by another selector nothing reads is marked too (read only by selectors nothing reads); see the CLI's notes for how that is worked out and where it deliberately guesses "used".

A substate only offers an expand arrow when something is under it; async_redux's own wait field owns nothing of ours and stays a leaf.

The view reads frx graph --json — one read where two list-* calls used to go, carrying the same rows plus what each substate owns and the facts a flat list drops. It refreshes after every add/remove, on external edits to the redux / navigation / connectors sources (file-watcher, debounced), or via its title-bar ↻; the graph is cached per refresh, so expanding rows costs nothing.

Picking an existing substate / page

When a command needs an existing substate (Add action / field / selector) or artifact (Rename / Remove) and you didn't launch it from the tree or a lens (which pre-fill the target), FRX shows a type-to-filter quick pick of what actually exists, read live from frx list-substates / list-routes. Start typing to filter; it falls back to a free-text box if the list can't be read.

Rename / Remove take either kind, so their picker is grouped:

── Substates ──
  logIn            LogInState
  session          SessionState
── Pages ──
  LogIn            /login
  Splash           /splash

Picking from a group also tells FRX which kind you meant, so it passes --kind and never has to re-ask when a substate and a page share a name.

Remove

FRX: Remove… (overlay, palette, or a tree item) is the inverse of the scaffolders: it previews the removal plan — files to delete, wiring to undo (AppState field, selectors, AutoRoute entry, auth-area membership) — in the same plan document rename uses, and only applies it (CLI remove --apply) after you press ✓ Apply on that tab. Ambiguous names (both a substate and a page exist) prompt for which one. build_runner then finishes the job, honouring the watch state and the frx.runBuildRunner setting.

Rename (F2)

Press F2 on a symbol that belongs to a substate or page — its state class (LogInState), Select… selector, route (HomeRoute), connector (LogInPageConnector), or the bare field/folder name — and FRX renames the whole artifact: moves the files, rewrites the classes, and updates every wiring reference (AppState, selectors, AutoRoute, imports) via frx rename. Any other symbol falls through to the Dart extension's normal rename.

Which symbol maps to which artifact — and the canonical name handed to frx rename — is resolved by the CLI (frx which), so the conventions aren't re-encoded in the editor. FRX: Rename… (overlay, palette, tree item, or editor context menu) does the same thing with prompts, and is the reliable path if F2 is claimed by another extension. Turn F2 off with frx.editorRename.

Invoked with no artifact named — from the palette or the editor context menu — it tries the cursor first, through the same resolver, and only shows the artifact picker when the symbol is not one of ours (or no Dart editor is open).

Nothing is renamed sight-unseen. Every path — F2 included — first runs the rename as a preview (frx writes nothing without --apply), and the plan opens as a markdown document in the built-in preview, beside your code:

# Rename "theme" → "appTheme"

**2 files moved · 1 file deleted · 8 files edited**

| | file |
| --- | --- |
| move | `business/lib/redux/theme/models/theme_state.dart` → `…/app_theme_state.dart` |
| delete | `business/lib/redux/theme/models/theme_state.freezed.dart` |
| edit | `business/lib/redux/app_state.dart` |

Every path is a monospace cell you can click into the file it names (a move links its source — its destination does not exist yet), and each edit brings its unified diff along in a fenced diff block. Remove shows the same document.

The document answers itself. Its own tab carries the two buttons — ✓ Apply and ✕ Discard — so nothing is ever in front of the plan while you decide:

┌ frx-plan-7.md ───────────────────────── ✓ Apply   ✕ Discard ┐
│ # Rename "theme" → "appTheme"                               │
│ **2 files moved · 1 file deleted · 8 files edited**          │

That replaces a modal, and the modal was not a cosmetic problem. VSCode's modals are application-modal: it blocked the whole workbench, so the document it pointed at ("the plan is open beside this dialog") could not be scrolled or clicked until you had already answered. You saw what a rename would do only after agreeing to it — the one thing a preview exists to prevent.

A preview can carry buttons; just not in its body. Its tab's toolbar is where frx.routes and frx.flow had been living all along, and it is where ✓/✕ live now, gated on a context key raised exactly while the plan's own tab is active.

Three things end the wait and nothing else does: ✓, ✕, and closing the tab (a No). Moving to another tab deliberately does not — following a path out of the table is the reading the plan is for. While an answer is outstanding a status-bar chip says so, and clicking it shows the plan rather than applying it: there is no route to applying that does not go past the plan.

The extension ships no renderer for this. The document is drawn by the built-in markdown preview, the same platform renderer the Flow view uses. It is opened through vscode.openWith rather than markdown.showPreview, and that is load-bearing: the preview-as-custom-editor gives the tab a uri, where markdown.showPreview gives a webview tab carrying only a viewType. Only the first can say which document a closed tab held — which is what makes "closing the tab is a No" exact, and what stops the buttons appearing on an unrelated markdown preview.

Every plan gets its own document — hence the number in the tab name, and hence a tab and its file that are both retired the moment you answer. Plans used to share one path, and that was wrong twice over: VSCode caches a text model by URI and keeps it alive past the editor that used it, so writing a new plan to a familiar path and reopening it rendered the previous one (ask for a removal right after a rename, and the rename's plan is what appeared); and with one path, a plan replacing another could not close "the plan tab" without closing the tab its replacement had just opened. A fresh path has neither problem.

And the document is built from the CLI's machine plan (rename --json), not by re-parsing the human report: rendering a table needs the plan as data.

Discarding leaves nothing written; closing the tab forgets it.

What ran is checked against what you read. Because the answer can now wait as long as you like, the tree can move in between — and --apply recomputes from disk rather than replaying the preview, so it always derives correct edits for the tree as it stands, but not necessarily the ones you were shown. The apply runs with --json too and the two changesets are compared; when they differ, the notification says so (the tree changed since the plan: 9 files edited, where the plan showed 8 files edited). Checked rather than prevented: the CLI's pre-flight and atomic rollback already cover the cases that are actually dangerous, and what was left — that what ran might not be what you read — is said out loud instead of smoothed over.

The native refactor-preview panel was considered and set aside, and the reason is worth recording: it would render paths as a real file tree with inline diffs, but its per-change checkboxes are only honest if the editor applies the edits — and application belongs to the CLI, together with formatting, the derived-docs refresh and codegen. Splitting that to get a prettier preview is a larger decision than a presentation change can make.

Add action

FRX: Add action… (palette, overlay, the tree's inline ⚡, or the state-file lens) scaffolds a ReduxAction into a substate. Four prompts: the name, the substate (skipped when the tree or the lens already named it), the body shape —

  • sync — AppState? reduce()
  • async — Future<AppState?> reduce() async
  • waiting — extends Action with WaitingAction

— and then a multi-select of async_redux behaviour mixins, read live from frx list-mixins --json. Press Enter with nothing ticked to skip them.

Conflicts resolve as you pick. async_redux makes some pairs a compile error (they collide on a private member), so a combination the CLI would refuse must not survive the picker: ticking debounce unticks retry, and the placeholder says which went and why. It is judged over the whole selection, newest first — a rule phrased over what changed misses a conflicting pair that arrives in one event, since both are then new.

The rows themselves never change. Removing the conflicting ones was the obvious design and it does not work — assigning items clears the selection and reports that a tick later, so the pick that triggered the rebuild is wiped a moment after you make it. Unticking touches only the selection, and it reads better anyway: you see what happened instead of watching a row disappear.

The exclusion is the CLI's, not the editor's: conflictsWith arrives with the implications already folded in, so noDialog excludes abortWhenNoInternet through the checkInternet it implies. The editor does set membership and nothing else — it used to carry its own list of mixins, and that copy had drifted to eight of the ten.

The CLI still resolves the rest: it pulls in a mixin's dependencies (noDialog implies checkInternet, shown in the row) and emits the tuning overrides worth surfacing — a debounce or throttle arrives with its duration already in the class.

Add tabs

FRX: Add tabs… scaffolds an AutoTabsScaffold shell with its tab pages: enter the flow's name, then the tab names as a comma-separated list (at least two — one tab is not a tab flow). Each name is validated the same way an artifact name is.

The CLI writes a page + connector per tab plus the shell connector, and wires one nested AutoRoute(page: <Shell>.page, children: [...]) into AppRouter. The first tab page opens afterwards, since that is the one you flesh out first.

Add field

FRX: Add field… (overlay, palette, or the state-file lens) grows an existing substate's @freezed state: enter name:type (e.g. email:String?, count:int, tags:IList<String>), a @Default(…) for a non-nullable type, and optionally a Set<Field>Action setter. The field is spliced into the factory via AST and freezed regenerates (honouring the watch / frx.runBuildRunner).

The right-click gesture

FRX has no folder entries. It had three — Add Substate… on a redux/ folder, Add Page… on pages//connectors/, and a "New here…" menu on any folder — and they are gone. The clicked folder was only ever used to compute the repo root, and the first workspace folder yields the same answer, so in a single-root workspace (which is how this monorepo is opened) those entries carried no information at all. The only case where the click decided anything was a multi-root workspace holding two frx monorepos, and that is not worth two menu entries and two visibility conditions.

Two things went with them: the manifest no longer names the redux, pages or connectors folders in a visibility condition, and the extension no longer keeps its own copy of the non-substate folder list — that copy existed only because "New here" had a clicked folder and no resolved workspace, so it could not consult the CLI live.

What right-click does carry is the Dart editor entry. On a symbol in a .dart file, FRX: Rename… resolves the artifact under the cursor through the same identifier resolver F2 uses, and renames it without asking which one. On a symbol frx does not own it falls back to the artifact picker: it cannot chain to the Dart extension's rename — a menu item is a command, not a link in a chain — and hiding it conditionally would need a CLI call on every cursor movement, since visibility conditions are evaluated synchronously.

Widget: archetype, then folder

FRX: Add Widget… asks three things: the name, the archetype, and the folder.

The archetype (view, field, choice, action, container) decides what the widget takes in and which primitive it wraps — so it is asked before the folder, whose suggestion depends on it.

The folder picker is a live QuickPick over frx list-widget-dirs --json: the folders already in use, the archetype's usual home first and labelled as such. Type a name that matches none of them and it appears as its own row — cards ⊕ new folder. --dir is deliberately open, and showQuickPick can only ever return one of its items, which is why this one is driven directly.

The list comes from the CLI rather than from a directory scan here, so the shell completion and this picker cannot disagree about what exists.

Add selector

FRX: Add selector… (overlay or palette) adds a computed getter to a substate's Select<Pascal> block in the selectors facade — enter the getter name and return type; the body defaults to reading the state field of the same name (customize with --expr on the CLI). No codegen — selectors are hand-written.

Wire navigation

FRX: Wire navigation… (overlay or palette) makes one page push another: pick the source and destination from the registered routes, pick which GoAction (push / replace / navigate), and frx writes the _Vm field, the dispatch that fills it, the argument handed to the page, and the page's own parameter — five edits across two packages, four of which leave code that does not compile if you stop halfway.

Both sides are picked rather than typed because add-nav refuses an unregistered destination: auto_route generates no route class to push. It is idempotent — wiring the same hop twice reports that the callback is already there and stops.

Flow — a page's use cases as a sequence diagram

FRX: Flow… (overlay, palette, or the Flow lens on a page connector) renders what actually happens when the user interacts with a page: every view-model callback, what it dispatches and how (dispatchSync vs an awaited dispatchAndWait, drawn with activation bars), each action's mixins and the copyWith field it writes, a UserException it can throw, guarded steps as alt blocks, and where the flow navigates.

The diagram comes from frx flow <page>, which reads it out of the source AST — so it always describes the code as written rather than a stale drawing.

Rendering is VSCode's own. Since 1.121 the built-in mermaid-markdown-features draws mermaid in the markdown preview, so the view writes a markdown document and opens it there rather than shipping its own renderer. The extension vendors nothing, and pan/zoom, copy source and open in editor come from the platform. The two view-switching buttons — Navigation map and Flow… — sit in the preview's own toolbar (when: activeWebviewPanelId == 'markdown.preview'), and the same commands stay in the palette, the FRX overlay, the tree's title bar, and the Flow lens above a page connector.

This is why engines.vscode is ^1.121.0 — the release that merged Matt Bierner's Markdown Preview Mermaid Support into VSCode as a built-in. On an older VSCode the preview would show the diagram as a plain code block.

Navigation map — the whole app in one graph

FRX: Navigation map (overlay, palette, or the tree's title bar) shares that panel and zooms out: every route the router registers — nested tab children included — and every hop between them, from frx flow --routes.

Hops are collected from the GoAction dispatches in each connector and in the reducers those connectors reach, so a navigation buried in an action shows up (marked ⚡) — that's the edge a hand-drawn map always misses. A solid arrow is a push, thick replaces the stack, dashed is a pop drawn back to the screen that pushed it. The stadium node is initial: true, the subgraph is the guard's _authArea, and a dashed border means nothing pushes that screen — you arrive by path, deep link or a guard redirect.

Run frx flow --md once to export all of it to docs/flows/; from then on frx doctor (and so this extension's Problems panel) fails when the docs fall behind the code, with a regenerate docs/flows quick-fix on the finding. Those exported files are ordinary markdown, so previewing them renders the same diagrams — and the links between them work.

Map — the structural picture

FRX: Map (overlay, palette, or the tree's title bar) opens a webview whose purpose is orientation: arriving in unfamiliar code and seeing how the app is put together. It is drawn from one frx graph --json read.

flowchart LR
    subgraph screens["Screens &amp; actors"]
        direction TB
        persistor["AppPersistor<br>persistor"]
        pLogIn["logIn<br>/login"]
        pHome["home<br>/home"]
    end

    subgraph state["State"]
        direction TB
        sLogIn["logIn<br>LogInState<br>▸ 3 actions · 3 selectors"]
        sSession["session<br>SessionState<br>▸ 1 action · 2 selectors"]
    end

    persistor ---|"restores · reads"| sLogIn
    pLogIn ---|"dispatches · reads"| sLogIn
    pLogIn ---|"reads"| sSession
    pLogIn ---|"navigates"| pHome

The shape, not the rendering: the webview draws its own SVG — this diagram stands in for it here, and the properties below are what that renderer adds. The panel also carries what the graph could not resolve:

⚠ 1 unresolved edge(s)
  dispatch-target  SomeFactory()
      dispatched, but no imported `*_action.dart` declares it

Two rows are joined by one line, however many relations run between them. A page that both dispatches into a substate and reads it is two relations with the same two endpoints; drawn separately they lie exactly on top of each other — indistinguishable anywhere, and doubling every crossing they take part in. Direction folds in too: the picture draws no arrowheads, so two pages that navigate to each other are one stroke. Hovering the line names every relation it carries, and the ones running the other way are marked.

Two relations never leave a row from the same point. Each is given a slot along the row's edge, ordered by the row it reaches so a row's fan does not cross itself, and relations across the middle are drawn as curves rather than chords — two that leave a few pixels apart and land far apart then stay apart the whole way instead of converging into one stroke at each end.

A relation inside a column runs in the margin, not across the picture. A page navigating to another page, or a substate read by another substate, joins two rows on the same side. Drawn straight it would leave one row's right edge and enter its neighbour's left edge, looping across the whole canvas and crossing everything between; instead it arcs out into that column's own margin and back, bulging wider the further it reaches — up to the width of the margin, past which the arcs share it. Those relations also get a say in the order, so a page that only navigates sits beside the page it navigates to instead of sinking to the bottom.

The rows are ordered by their edges, not by name. The number of crossings in a two-column drawing is decided entirely by the order of the two columns, so the columns are arranged to reduce it — each row placed near the mean position of the rows it connects to, swept back and forth until it settles, from a number of starting orders. Alphabetical order has nothing to do with the edges: on this repository it left 44 crossings where 2 was available, over the same nineteen rows and twenty lines. The arrangement is a function of the graph alone, so re-opening the picture or refreshing it after an unrelated edit does not rearrange it. A row nothing connects to has no place to be near, so it sinks to the bottom of its column rather than splitting the connected ones apart.

Legibility comes from a skeleton, not from filtering. Substates and pages are always visible and form the shape of "how it is built"; actions and selectors collapse into counts on their owner and expand on demand. The count of substates and pages grows slowly as an app grows while the count of actions and selectors grows fast, and that asymmetry is what keeps the view readable at ten times this template's size — where an overview matters most and drawing everything degenerates into a hairball. Four dispatches from one page into one substate are one line.

Hovering a row dims everything it is not attached to. The cheapest large win in legibility, and the one that changes nothing about what the picture contains: the crossings that remain stop mattering when a reader can isolate one row's relations instead of following a line through the ones that cross it. Attached means directly — the rows this one relates to and the wires between them; the transitive reach is what frx graph --focus X -d inbound is for.

Every node opens its source — a substate its state file, a page its connector, a selector the exact getter (every selector in the app shares one file, so the file alone answers "which file" and not "which one"). ↻ Refresh re-reads.

Unresolved edges are shown. A diagram reads as exhaustive, so it owes the reader a statement of where its own edges are incomplete — the argument the CLI already makes for naming unresolved edges, applying harder to a picture than to a list.

The rule: which surface does a feature belong to?

The tree is an actionable inventory of what exists. The picture is the relationships between what exists.

Apply it to a proposed feature by asking which of the two it is about. Hygiene marks — nothing dispatches, nothing reads it — describe an absence of relationships, so they belong to the tree and are deliberately absent here. Unresolved edges describe the picture's own edges, so they are here.

Each surface keeps what the other cannot have. A tree cannot state "these three pages dispatch the same action" without repeating the row and destroying the fact. The picture cannot have inline actions on its items, or ambient presence in the sidebar.

Code lenses

On the monorepo's conventional files (always on — see Settings):

  • redux/<sub>/models/<sub>_state.dart — ⚡ Add action… and ➕ Add field… above the state class, pre-filled with that substate.
  • app/lib/connectors/<x>_page_connector.dart — Open page jump (shown only when the counterpart file exists) and Flow, which opens that page's sequence diagram.
  • ui/lib/pages/<x>_page.dart — Open connector jump (same condition).

Lenses are derived from paths and a class-name regex — no CLI call, so they render instantly.

Doctor → Problems panel

frx doctor findings are mirrored into the Problems panel (via doctor --json): errors and warnings appear as squiggles on the file they anchor to, refreshed on activation, after every scaffold / removal / build_runner run, and on external edits to the wiring sources (same file-watcher as the tree). A second status-bar chip next to the watch toggle shows the ambient health — $(pass) doctor when clean, ⚠ N / red ✗ N otherwise — and clicking it opens the Problems panel. The overlay's Doctor entry still streams the full report to the FRX output channel.

Auto-fixable findings (a missing generated part, an orphan substate) get a quick-fix lightbulb — "generate missing code" / "remove orphan substate" — that runs frx doctor --fix and re-audits. Report-only findings (a route without a connector, say) carry no fix.

One fixable finding has no lightbulb, and cannot. An empty artifact folder is fixable but anchors on no file — the Problems panel squiggles files, not directories — so the chip counts it and Problems cannot show it. It is named in the overlay's Doctor report (the FRX output channel) and cleared by Doctor — fix. The asymmetry is the finding's, not the panel's.

One inventory, two renderings

Every capability has a command identity, so it is findable by name in the Command Palette, bindable to a key, and invocable from a task. The action overlay carries the same inventory, grouped under the CLI's own families (Create & wire, Edit existing, Inspect, Workflow) with each row's one-line description — and the live watch state, which is the one thing a searchable palette cannot show.

That is a rule, not an arrangement: a capability appears in both indexes, so a new one needs no placement decision. It is worth stating because the previous arrangement rotted quietly in both directions — three commands had no palette entry at all (which in this editor means visible in every workspace, not hidden), the action scaffolder was explicitly hidden, eight capabilities had no command identity whatsoever, and the report-only audit was reachable only from the overlay while the destructive Doctor — fix was the searchable one.

Every command is gated on frx.isMonorepo. There are no submenus: the seven single-file scaffolders that used to hide behind a New… row (widget, connector, model, enum, service, Retrofit client, theme extension) each have their own entry now. Declaring them made existing capability discoverable; it added none — the surface count rose, the concept count did not.

One stated exception, for a stated reason. The rule's subject is capabilities of the tooling — things that change the code or reveal something about it — and a control that acts on the tooling's own UI is not one. So the tree's Refresh stays on the tree's title bar and out of both indexes, the way a scrollbar would; and FRX: Actions…, which is the overlay, is in the palette but not a row inside itself. Both are pinned by extension_contract_test.dart, which fails if any other declared command is missing from either index.

A command id is checked in both directions. The contract test and validate-manifest.ts between them ask: is every declared command registered, surfaced in the palette and in the overlay, and named only by menus that exist — and is every id the source invokes declared. That last one was missing, and its absence cost a renamed command: a code lens is reached by neither the manifest nor the registration, so it kept firing an id that was gone, and only a click found out.

Action overlay & watch toggle

In our monorepo the status bar shows an frx item. Its icon reflects the dart run build_runner watch --workspace state; clicking it — or pressing Ctrl+Alt+F — opens the action overlay:

Three states, and which one you are in is enabled (persisted) × running (a live child process) rather than one flag:

stateDiagram-v2
    direction LR
    [*] --> off : workspaceState OFF
    [*] --> running : workspaceState ON — resume() on activation

    off : off (default)
    off : $(debug-start) frx  ·  overlay $(circle-large-outline) → start
    running : running
    running : $(sync~spin) frx watch  ·  overlay $(check) → stop
    stopped : enabled but stopped
    stopped : $(warning) frx watch, amber chip  ·  overlay $(warning)

    off --> running : toggle — persist ON, spawn
    running --> off : toggle — persist OFF, SIGTERM
    running --> stopped : build_runner exits on its own
    stopped --> running : toggle — restart
    stopped --> running : next reload — resume()

enabled but stopped is the state nothing asks for: the toggle never lands there, only a watch dying under it does — which is why it gets an amber chip instead of the plain icon, and why one click out of it restarts rather than switching off. The toggle branches on the process, not on the persisted flag — those two agree everywhere except here, and branching on the flag made that click persist OFF, so the click the crash notification, the chip and the overlay row all call "restart" needed a second one behind it.

  • Persisted per-workspace — a watch left ON auto-resumes on the next VSCode reload (stored in workspaceState, not a shared setting).
  • Skips the build_runner prompt — while the watch is running, Add Substate / Add Page don't offer to run build_runner; the watch regenerates on save.
  • Watch logs stream to the FRX watch output channel (FRX: Show watch output, also in the overlay once the watch has run).
  • Builder failures land in the Problems panel — the watch output is parsed (E <builder> on <file>: blocks with their package: locations) and each failure becomes a squiggle at the exact line/column; a following clean build cycle clears them, and so does stopping the watch.

Shared

  • Name in any casing — myProfile, my_profile, MyProfile all work (matches the CLI's Casing.parse, which also rejects illegal characters).
  • Overwrite guard — a modal prompt instead of a silent clobber (re-runs with --force only on confirmation).
  • No plan before creating. Destructive operations always show one, creative ones never do — see Risk grading.
  • Opens the new file so you can start editing immediately. Always: if you scaffolded it, you are about to edit it.
  • build_runner integration — generate the freezed part / auto_route class right after (frx.runBuildRunner), unless the watch is already running.
  • Output channel — CLI output streams to the FRX channel; it pops open automatically on failure.

How it runs the CLI

The extension never assumes frx is on PATH, and — this is the part that used to be wrong — it never asks the operating system to find it either. It resolves the invocation in order:

flowchart TD
    need(["a command needs frx"]) --> setting{"frx.path set?"}
    setting -->|yes| run["spawn it by absolute path"]
    setting -->|no| look["look for an frx <i>file</i>:<br>every PATH directory, then<br>dart install's bin dir, then ~/.frx/bin"]
    look --> found{"a file found?"}
    found -->|yes| ver{"frx --version answers?"}
    ver -->|yes| run
    ver -->|"no — some other tool named frx"| fb["dart run …/tools/bin/frx.dart<br>zero-install fallback, ~6.7 s per call<br>said out loud once in the FRX channel"]
    found -->|no| fb
  1. frx.path setting — an explicit path to the executable, if you set one.

  2. An installed binary, found as a file: each directory on PATH first, so a deliberately arranged PATH still decides which binary wins, then the two directories frx installs into —

    Linux macOS Windows
    dart install ~/.local/state/Dart/install/bin ~/Library/Application Support/Dart/install/bin %LOCALAPPDATA%\Dart\install\bin
    install.sh / install.ps1 ~/.frx/bin ~/.frx/bin %LOCALAPPDATA%\frx\bin

    It is then run by absolute path, and confirmed with frx --version (which also rejects an unrelated tool that happens to be named frx).

    dart install's directory is searched first. The two collide only on a machine that has both, and there the dart install binary was built from a checkout — by somebody working on frx itself, whose next question is whether the change they just made behaves. A downloaded release answering that question is the one wrong answer with no visible symptom.

  3. dart run …/tools/bin/frx.dart — zero-install fallback. The extension finds the monorepo's tools/ package (from the workspace or from its own location) and runs the CLI from source. Works straight from a fresh clone, provided the Dart SDK is on PATH and tools/ has had dart pub get.

Why step 2 searches rather than spawns. A GUI-launched editor does not have your shell's PATH: on Linux the desktop session's environment comes from systemd, which never reads a shell rc file, and a Dock-launched VSCode on macOS has the same hole — so the directory dart install told you to add is often simply absent. Spawning the bare name frx and letting the child inherit that PATH made "is the CLI found" depend on whether the editor happened to be started from a terminal. Looking for the file, and spawning its absolute path, does not.

Because of the fallback, installing the CLI is optional — but it is not free. dart run re-compiles the CLI, analyzer and all, on every invocation, and the extension invokes frx on every file event, tree expansion and doctor pass:

--version graph --json doctor --json
installed binary 6 ms 22 ms 17 ms
dart run 6.7 s 6.7 s 6.7 s

So landing on the fallback is said out loud, once, in the FRX output channel. The channel also names the binary that was chosen — the first line of every run tells you which of the three you are on:

$ /home/you/.local/state/Dart/install/bin/frx graph --json --root /repo   (cwd: /repo)

Either install puts you on the fast path, and the extension finds both with no PATH edit:

curl -fsSL https://raw.githubusercontent.com/pro100andrey/flutter_redux_templates/main/tools/scripts/install.sh | sh
# …or, from a checkout:
cd tools && dart install .

Project detection

Everything is gated on real project markers — nothing is guessed:

Layer Mechanism Purpose
Editor entry when: frx.isMonorepo && editorLangId == dart FRX: Rename… on the context menu of a Dart file only
Status bar / watch / tree / Problems pubspec.yaml with workspace: in a workspace folder the frx toggle, FRX tree view, and doctor diagnostics appear only in the monorepo (frx.isMonorepo)
Authoritative check frx AppStateSource.locate() / RoutesSource.locate() walks up to app_state.dart / app_router.dart — the single sources of truth

No condition names a folder any more — see The right-click gesture for why the entries that did are gone.

Settings

Setting Default Description
frx.path "" Explicit path to the frx executable. Empty → auto-resolve (see above).
frx.runBuildRunner ask always | ask | never — generate code after scaffolding. Ignored while the watch toggle is running.
frx.editorRename true Let F2 on a substate/page symbol rename the whole artifact via frx rename. Off → always use the Dart rename.

Three, and each describes a real difference. frx.path is the only way to intervene when CLI resolution guesses wrong. frx.runBuildRunner has three values and its wrong value costs time on every generation. frx.editorRename is the only one whose two branches are caused from outside this repository — F2 may be claimed by another extension on one machine and not another.

Three more were removed, because they described forks nobody takes. Their behaviour is frozen at the value people already used:

Removed Now
frx.openAfterCreate The created file always opens. If you scaffolded it, you are about to edit it.
frx.confirmPlan No plan is shown before creating — see Risk grading.
frx.codeLens Lenses always render.

The lens setting is the one that took a real capability with it, and that is worth naming rather than glossing: the editor's own global lens setting already hides every provider's lenses, so ours added only the granularity of killing frx's while keeping Dart's. It is dropped because nobody asked for it.

Risk grading

Destructive operations always show a plan. Creative ones never do.

Creation is the most frequent operation, and a step added there is paid every time — while the thing a plan guards against is undone by one frx remove. Rename and removal continue to show theirs unconditionally, as they always have: they move and delete files across packages, and that is not undone by one command.

The watch on/off choice is not a setting either — it lives in per-workspace state and is driven by the status-bar toggle.


Develop

TypeScript, compiled to CommonJS in out/ — VSCode loads out/extension.js.

npm install
npm run compile     # or: npm run watch
npm test            # compiles first, then node --test over out/test
npm run typecheck   # tsc --noEmit, the CI gate

Open tools/vscode/ in VSCode and press F5 (run npm run watch alongside so edits recompile). A second "Extension Development Host" window launches with the extension loaded; open the monorepo there, right-click a symbol in a .dart file, and check the frx item in the status bar.

tsconfig.json runs under full strict. One setting is load-bearing and worth knowing before you touch it: the tests swap in a mutable vscode stub through the module loader and reach into it by identity, which survives TypeScript's __importStar wrapper only because that wrapper installs live getters. For the same reason a test that replaces an export (frx.run = …) must import the module with import x = require('…') — a plain import * as would hand it a wrapper, and the stub would never reach the module under test.

extension.ts is pure wiring (activate + command registration); everything else lives under src/, split into layers:

tools/vscode/
├── extension.ts            # activation + wiring: services + command registration
├── src/
│   ├── app.ts              # the `App` context every command handler receives
│   ├── frx.ts              # resolve & run the CLI (Invocation/RunResult), output channel
│   ├── queries.ts          # typed --json reads (graph/list-*/doctor/which/flow) + plan parse
│   ├── paths.ts            # workspace-root / package-root finding
│   ├── discover.ts         # finding the installed CLI (pure: PATH + dart install)
│   ├── layout.ts           # ordering the picture's columns to reduce crossings (pure)
│   ├── naming.ts           # casing (camel / pascal / strip*) — pure
│   ├── config.ts           # typed accessors for the frx.* settings
│   ├── ui.ts               # prompts, confirms, pickSubstate/pickArtifact, fail
│   ├── diagnostics.ts      # publishByFile (findings → DiagnosticCollection)
│   ├── scaffold.ts         # the create-and-wire engine (runScaffold + build_runner)
│   ├── doctor.ts           # doctor audit service (Problems + status chip) + run/fix
│   ├── watch.ts            # build_runner watch process + status-bar toggle
│   ├── buildlog.ts         # pure parser: watch output → Problems findings
│   ├── tree.ts             # FRX tree view (FrxTreeItem)  codelens.ts  # code lenses
│   ├── cursor.ts           # what artifact the cursor is on (F2 + the editor entry)
│   ├── rename_provider.ts  # F2 → frx rename        code_actions.ts # doctor quick-fixes
│   ├── map.ts              # FRX Map: the graph folded into a structural picture
│   ├── flow_view.ts        # FRX Flow: diagram → markdown → the built-in preview
│   ├── plan_view.ts        # a rename/removal plan → markdown → the same preview
│   └── commands/           # command handlers on the shared `app` context
│       ├── create.ts       # substate / page / action / field / selector / tabs
│       ├── artifact.ts     # rename / remove
│       └── menu.ts         # the FRX action overlay
├── test/                   # node --test suite (see below)
├── out/                    # compiled output — what the VSIX ships (gitignored)
├── validate-manifest.ts    # CI gate: declared ↔ registered ↔ invoked ↔ menus
├── tsconfig.json
├── package.json
└── README.md

Tests — npm test compiles, then runs a node --test suite over out/test (a small vscode stub in test/helpers.ts lets the modules load under plain node). It covers the pure logic (naming, the queries --json/plan parsing, the buildlog parser, discover's CLI resolution, layout's column ordering), the renderers that turn a CLI read into a document or a picture (plan_view, flow_view, map), the watch toggle's state machine (through a spawn seam, so no build_runner runs), the tree and code-lens item building, cursor's symbol→artifact mapping, the doctor quick-fixes, the overlay's rows, the pickSubstate/pickArtifact/--dir pickers, and a load-smoke over every module (catches a broken require path or circular-load break). npm run validate runs the manifest gate; the palette ↔ overlay contract is pinned on the CLI side by tools/test/extension_contract_test.dart.

Build & install a VSIX

Packaging uses @vscode/vsce (listed as a devDependency).

cd tools/vscode

# Option A — with npx, no install:
npx @vscode/vsce package                 # → frx-<version>.vsix

# Option B — via the package scripts:
npm install                              # pulls in @vscode/vsce
npm run package                          # → frx-<version>.vsix

# Install the built VSIX into VSCode:
npm run install:vsix                     # reads the version out of package.json

You can also install a .vsix from the UI: Extensions view → … menu → Install from VSIX…. From the monorepo, make ext PROFILE=<name> does compile → package → install into a named VSCode profile in one step — which matters, because a VSIX installed into the Default profile is invisible while you work in another one.

Releasing

One tag ships the CLI and the extension together, because the extension reads the CLI's contract out of generated constants and a version pair that can drift will:

# bump all three, in one commit:
#   tools/pubspec.yaml            version:
#   tools/lib/src/version.dart    frxVersion
#   tools/vscode/package.json     version
git tag v0.3.0 && git push origin v0.3.0

.github/workflows/release.yml refuses the tag unless those three already agree with it, then compiles frx for macOS (arm64, x64), Linux (x64, arm64) and Windows (x64), attaches the archives and the VSIX to a GitHub release with a checksums.txt, and publishes the extension to the Visual Studio Marketplace (VSCE_PAT) and Open VSX (OVSX_PAT) — the latter being where Cursor, Windsurf and VSCodium install from. Both publishes take the release's own VSIX rather than re-packaging, so what the marketplaces serve is byte-identical to the attached asset. A missing token skips its marketplace with a warning instead of failing the release; a tag with a -suffix ships as a prerelease and publishes to neither.

  • Contact us
  • Jobs
  • Privacy
  • Manage cookies
  • Terms of use
  • Trademarks
© 2026 Microsoft