A VS Code extension that gives the Bruno monorepo a dev panel: run any root script, rebuild a shared package, or run the test for whatever file you're looking at.
For contributors to Bruno itself. The panel appears only in a folder that contains
packages/bruno-appβ it is tooling for working on Bruno, not a plugin for the Bruno API client. In any other project it stays out of the way entirely.
It lives outside the Bruno repos on purpose β nothing to commit, and it works against any Bruno checkout you open.
Several ways in, all showing the same page:
| Surface | How |
|---|---|
| Activity bar | Click the logo in the left rail β opens in the sidebar |
| Explorer | Bruno Dev accordion at the top of the file tree |
| Source Control | Bruno Dev accordion in the SCM sidebar |
| Status bar | Click π Bruno at the bottom left β opens as a tab |
| Editor title bar | Click the logo at the top right of the editor toolbar |
| Command palette | Cmd+Shift+P β Bruno: Open Bruno Dev |
Cmd+Shift+T (Ctrl+Shift+T on Windows/Linux) runs the test for the active file from anywhere β
no need to open the panel. It runs the same sticky target the panel shows, so the keyboard and the
button never disagree.
That chord is VS Code's built-in Reopen Closed Editor, so the binding is gated on
when: brunoDev.inBrunoRepo β it only overrides inside a window that has a Bruno checkout
open, and Reopen Closed Editor keeps working everywhere else. To keep both, rebind ours in
Keyboard Shortcuts (search brunoDev.runTestForActiveFile).
Every surface shares one repo selection and one theme setting, so changing it anywhere keeps the
rest in sync. The Explorer accordion starts collapsed so it doesn't crowd the file tree, and it β
along with the status bar item and the title bar button β only appears when the window actually has
a Bruno checkout open (gated on the brunoDev.inBrunoRepo context key).
Being the only entry in the manifest's explorer array gives the accordion sort order 0, which
places it above the built-in Folders view (order 1) and Outline (order 117). There's no order
property to set directly β VS Code derives it from array position. Note that VS Code persists
per-profile view order once you've dragged anything in the Explorer, and a persisted order wins over
the contributed one, so on a well-used profile it may land at the bottom until you drag it up once.
The header is one row: the path field first with its pencil inset at the right, then Run test beside it. The button reads as the verb applied to the file next to it rather than to a caption above it, and the short label leaves the field the width it needs.
The path is its own <button> inside the field rather than the field being the button β a button
nested in a button is invalid markup and swallows the inner click, which would break the pencil.
Clicking the path still opens the file. The pencil is a 22px inset control, deliberately smaller
than the 28px row-level controls, since it sits inside a field rather than in a row. It renders only
when a file is selected, so every reference to it in the client script is guarded.
Run is one row of icon tiles rather than four labelled sections: the two scripts, then changed
unit/E2E, then branch unit/E2E. Those four sections cost ~200px of a sidebar to hold six one-click
actions, so the label moves to the tooltip and the count becomes a badge on the tile β the count
is the part you actually read. A tile with nothing to run shows a dim 0 badge and disables.
The three groups β scripts, changed, branch β are separated by hairline dividers, with no scope
text on screen. Each tile carries an aria-label; with no visible label that's the only thing naming
it, and the only thing telling the changed beaker from the branch beaker, so every accessible name in
the bar is asserted unique.
The script tiles are labelled β Run dev, Run setup β while the four test tiles stay square.
The asymmetry is deliberate: a test tile's badge already tells you what it will run, whereas a bare
glyph says nothing about which script it starts. They still take different glyphs as well, or they
would be the same tile twice: SCRIPT_ICONS maps a script name to its icon (setup takes the
tray-and-arrow that reads as "install") and anything unmapped falls back to the terminal.
Each group is a .bar-group that wraps as a unit, so a divider never ends up stranded from the
pair it separates when the row breaks. Measured intact from 280px to 760px.
Branch tests answers a different question with the same two buttons: everything this branch
changed against the default branch, committed included. Switch to a branch where you already
pushed five files and the counts appear β git diff HEAD, which drives the Changed section, sees
nothing there because the working tree is clean.
Scoped to commits you authored (--author=<user.email>), not to everything in base..HEAD. A
branch routinely carries other people's commits β you merged main in, or pulled upstream while
checked out here β and if the local main ref is behind, those commits aren't reachable from it, so
a plain range diff counts their files as your branch work. On a real branch here that was 51 files
and 8 specs instead of 3. Empty user.email falls back to every commit on the branch.
The range starts at the merge base (origin/main, falling back to the local branch), and merges are
excluded β a merge commit's diff is the whole of main. --diff-filter=d is applied per commit, so a
spec you added and later deleted still appears; anything no longer on disk is dropped rather than
handed to jest as a missing path. On the default branch there is no branch work to scope to, so both
buttons read (0) and disable.
Working-tree and branch runs use separate terminal slots (unit/e2e vs unit-branch/
e2e-branch): they answer different questions, and starting one shouldn't kill the other mid-run.
CLI runs this checkout's bru run against a collection folder anywhere on disk, so the CLI you
are editing is the one exercised.
One row at rest β βΆ Run CLI Β· bruno-tests/collection plus a gear β so the section keeps the same
rhythm as every other one (53px) instead of standing three form rows tall (122px). The gear reveals
the collection picker, the arguments and the run target. The copy button rides the resting row with
the gear and carries the resolved command on hover β copying is a section-level action like configuring,
and putting it beside a field crowded whichever field it sat next to.
With no folder chosen yet the panel starts open: the gear would otherwise be the only way in and
nothing on screen would say so. Enter in the arguments field runs, so the keyboard path never needs
the mouse. Expanding doesn't move the caret β three controls appear, and focusing the arguments
field presumes you came for that one while hijacking the keyboard from the other two.
Three details that came out of drawing the alternatives side by side (cli-approaches.html):
- The collection row is a field plus a separate
Choosebutton, the same rhythm as the arguments row and its copy button below it. The field takes input chrome and is inert β the verb lives on the button. It carries no chevron: that promises a list that drops down, and this opens a file dialog. Chevrons stay on the three real dropdowns. Note the field can't be a<button>wrappingChoose; a button inside a button is invalid markup and swallows the inner click. - The path shows its last two segments, the same rule the target filename at the top uses; the full path is on hover.
The folder, arguments and run target are per project. They live in workspaceState keyed by repo
root, not globalState: globalState is shared by every window and every project on the machine, so
one setting there would mean every checkout pointing at the same collection. The repo-root suffix
covers the other direction β one window holding two checkouts keeps them apart too. The theme
deliberately stays in globalState, since that's a personal preference rather than project state.
The bin is <repo>/packages/bruno-cli/bin/bru.js β derived from whichever checkout is open, so
the same panel drives the OSS CLI in one window and the enterprise CLI in another. It runs as
node "<bin>" run <args> rather than executing the file directly, since the shebang only works where
the executable bit survived. The collection folder becomes the working directory rather than an
argument: bru run resolves the collection from the cwd, and that's also what makes relative
arguments like --csv-file-path test.csv resolve the way they do in your own shell.
Runs reuse their terminal. Clicking Run again sends the command to the same terminal instead of
replacing it, so you keep the previous runs' output to compare against. That's the opposite of the
script buttons, and deliberately: re-running dev has to kill the old server first, while a CLI run
is short-lived and has nothing to kill. On the Terminal.app side, do script with no target always
opens another window, so the command goes in front window β the bare form is only used when
Terminal has no window to reuse.
Terminal is the alternative to the VS Code terminal, via osascript β¦ do script against
Terminal.app. The whole
command is nested inside an AppleScript string literal, so quotes and backslashes are escaped β the
double quotes around the bin path would otherwise close the literal early and run a truncated
command. It's macOS-only; elsewhere it says so and falls back to the VS Code terminal. Unlike the
other sections, CLI is not width-gated β it's a reason to open the panel, not a setting.
Git has three actions in two rows: the switch spans the first row on its own, with Pull and Push
sharing the row below. Switching is the precondition for the other two, so it reads top-down and gets
the emphasis of a full-width row. Each is labelled with the resolved remote and branch (e.g.
Pull upstream/main) so the panel states what it will do in whatever clone it's running in.
Pull and push are only enabled on the default branch. Off it, pull would merge upstream into your
feature branch and push would publish the wrong ref, so both grey out and the tooltip says to switch
first. confirmRemoteAction enforces the same rule, so a programmatic call refuses too.
Both dialogs share one shape β title <Verb> <remote>/<branch>?, detail <command> then the
<url>. They previously differed (pull showed the command, push showed only the URL), which made the
pair read as two unrelated features.
Remotes are resolved by name, never by URL. Every fork has a different URL, but origin and
upstream are local labels each developer sets once β so the same extension works in anyone's clone
with no configuration. Three settings exist as an escape hatch for clones that name things
differently: brunoDev.upstreamRemote, .originRemote, .mainBranch (all empty = auto-detect).
The branch comes from refs/remotes/<remote>/HEAD, which is a local read β no network call β falling
back to main then master. A remote that doesn't exist disables the button and the warning names
the setting to fix it.
Pull is a plain git pull <upstream> <main>, confirmed first. Dismissing runs nothing.
One caveat this design accepts: git pull honours each machine's pull.rebase config, so on a clone
with pull.rebase=true the same button rebases instead of merging. Add --no-rebase to the command in
pullUpstream if you want that pinned.
Switch to main checks out the default branch, and is disabled (reading On main) when you're
already there. git checkout with a dirty tree does not fail β it carries your uncommitted edits
across, which is how work ends up sitting on main. So a dirty tree asks first and offers Stash &
switch (git stash push -u, recoverable with git stash pop) alongside Switch anyway. A clean
tree switches with no prompt. If no local branch exists yet, it creates one tracking the remote
(git checkout -b main --track origin/main).
Push asks first β it publishes to a fork. Both actions run in a terminal (not a captured subprocess) so credential prompts and
push output are visible, and they share a git terminal slot separate from dev/build/test.
The branch-dependent paths are covered by a test that builds a throwaway repo (bare remote + clone, origin + upstream, a feature branch, a dirty tree) rather than asserting against whatever branch the real clone is on β those assertions broke the moment the branch changed.
Build shared packages is a dropdown plus a Build button rather than one button per package.
All chains everything with && so a failure stops the run instead of scrolling past. Every build
shares one terminal slot, so Build always restarts β including when you switch packages mid-build.
The choice is remembered in globalState.
The list is the dependency graph, not a hardcoded set. BUILD_SEEDS in repo.js names the four
packages worth a click day to day (common, converters, filestore, requests); everything they depend
on is pulled in from the real package.json files and the list is topologically sorted. That is why
bruno-schema-types appears without being a seed: converters and filestore list it as a
devDependency.
Building one package builds its dependencies first. Picking bruno-filestore runs
npm run build:bruno-common && npm run build:schema-types && npm run build:bruno-filestore
This is not caution for its own sake. bruno-filestore typechecks against
@usebruno/schema-types' dist, not its source, so building it alone against a stale dist fails
with two dozen Property 'beforeCallStart' does not exist on type 'Script'-style errors that read
like source bugs. The Build button's tooltip always shows the exact chain for the current selection.
Script names are discovered, not derived β they don't follow one rule (build:bruno-common but
build:schema-types), so repo.js reads the root build:* scripts and maps each to its workspace
via --workspace=packages/<dir>. Dependencies with no root build script (bruno-lang, bruno-schema)
drop out on their own: there is nothing to run for them.
Not listed: bruno-query, bruno-graphql-docs and bruno-sqlite. They build, but nothing in the
list depends on them β they feed bruno-js and bruno-app. Add them to BUILD_SEEDS if you want them.
Clicking a script always restarts it. Each task gets its own terminal, keyed by repo + script
name; if one is already running, its terminal is disposed β killing the shell and its children β
before a fresh one starts. So clicking npm run dev while dev is up restarts dev, but it won't
touch a build running alongside it, or the same script in your other checkout. Re-running a test
replaces the previous test run.
Scripts reach the panel by allowlist β GROUPS in repo.js decides, and anything no group claims
stays off. That keeps installers, watchers, test runners and lint tasks out of the way; widen a
matcher or add a group to surface more.
Colors come from VS Code's own theme via the --vscode-* CSS variables, so the panel follows
whatever theme you've selected β including live switches β with no setting of its own. The stylesheet
aliases them to semantic names (--fg, --surface, --accent, β¦) in one :root block.
Every --vscode-* lookup must carry a fallback. A theme that doesn't define a colour makes the
whole declaration invalid at computed-value time, which silently drops a background to transparent β
that showed up as a black "Run test for active file" button. Colours may appear literally only as
the last link of a var() chain.
The Git and Theme sections appear on every surface but only past 600px wide β at a default-width sidebar it stays hidden, and dragging the sidebar wider reveals it. It's a 220px control plus a 12-row menu, which is more than a narrow sidebar should spend on a setting you touch once.
The gate is CSS (#appearance { display: none } + one min-width query), not a server-side flag:
the section is always in the HTML, so widening reveals it instantly, with no repaint and no
resize handler. This is the only media query in the page β padding and layout stay identical at
every width, because stepping those is what made the panel jump while dragging the edge.
It picks between Default (VS Code) and twelve bundled palettes:
five light (Bruno Orange, Paper, Slate, Cobalt, Sand) and seven dark (Midnight, Nord, Graphite,
Solarized Dark, Dracula, RosΓ©, Forest). The choice persists in globalState and applies to every surface at
once (tab, activity bar, Explorer, Source Control).
This costs almost nothing structurally because the stylesheet reads only semantic tokens
(--fg, --surface, --accent, --glyph-hover, β¦) and the base :root maps each one onto a
--vscode-* variable. A theme is therefore just an override block for those same tokens
(themes.js), and default needs no block at all β the base mapping is the VS Code theme.
Nothing outside :root may read a --vscode-* variable directly, or a custom theme would silently
leak the editor's colours; theme.js in the test suite enforces this by rendering every palette on
a host stripped of all --vscode-* variables and failing if any token still resolves to one.
Palettes are written as a small designer-level spec (bg, elevated, chip, chipHover, accent,
β¦) rather than one entry per token, so the relationships stay visible. Contrast is measured, not
eyeballed: the suite computes WCAG ratios for body text, chip text, headings and the primary button
against their own backgrounds and fails below threshold. That caught white-on-#f97316 at 2.8:1 β
fixed by keeping Bruno's exact brand orange and darkening the label instead of the brand colour.
One typeface throughout. The panel used the editor's monospace family for the target path, the
dropdown trigger and options, and the args input, and the UI font everywhere else β two families
across four sizes read as two designs stacked. Everything now inherits --vscode-font-family, and
the --mono token is gone. Note <input> does not inherit font-family by default, so #extra-args
needs an explicit inherit. A side benefit: the proportional font is narrower, so filenames that
used to ellipsize now fit.
Disabled buttons are muted by colour, not opacity. opacity: 0.5 fades the chip's background
and border along with its label, so a disabled button dissolved into the page β measured at
2.42:1 in the light theme. Those labels carry real information (the count, the branch name), so
they use color: var(--muted) at full opacity instead: 3.61:1 light, 4.80:1 dark, and 3.9β5.3:1
across the eleven palettes. .run-glyph inherits its button's colour, so the icon mutes with it and
needs no rule of its own.
Two tokens hold the metrics: --control-h: 28px and --gap: 6px. Every interactive control gets
min-height: var(--control-h) and every row gap: var(--gap). Before that, each control set its own
padding and let the content decide the rest, so heights had drifted to four different values
(26px on the target row and pencil, 27px on the primary and dropdowns, 28px on the icon tiles, 29px on
the git buttons and CLI rows) and row gaps to three (4, 6, 8px). Padding is now 4px 10px for text
buttons, 4px 8px for field-like rows, and zero on icon-only squares, which are --control-h both
ways. Corner radius is 4px throughout.
Type sizes are em, never rem. rem resolves against the 16px document root, so changing
VS Code's UI font moved the inherited body text and left every heading and button sitting still. In
em the whole panel scales with --vscode-font-size: title 1.05em, buttons and dropdowns 1em,
target path and args input 0.92em. That ordering is deliberate β the section title used to be the
smallest text on the page (12.48px against 13px body), which read as a caption rather than a
heading once the uppercase went.
Section headings carry a trailing hairline rule instead of boxing each group. They render in
sentence case at normal weight β no text-transform, no letter-spacing, no bold β so separation
comes from the hairline and the space above rather than from typographic emphasis. h2 is bold by
browser default, so font-weight: 400 is explicit; the size sits at 0.78rem rather than 0.72rem
because lowercase reads smaller than caps at the same size. Action buttons take a
leading icon. Script buttons use a terminal glyph (>_) rather than a play triangle β that's literally
what clicking does, and it keeps βΆ meaning "run the test". The glyph sits at low opacity until hover,
so a column of buttons stays calm. Every interactive state β hover background, the glyph tint, the
active depress β is guarded with :not(:disabled), so a disabled button doesn't light up under the
cursor and read as clickable. The target file row is a bordered strip with a file icon, and its folder segment
is dimmed the same way.
The script row is flex, not grid, so the two buttons share the width and stay on one line at every
size. The build row is nowrap for the same reason: the dropdown and Build never stack.
The package dropdown holds a fixed 220px with min-width: 160px / max-width: 260px, rather than
a percentage of the row β so it looks the same in a tab and in the sidebar. It may shrink toward the
minimum when the row is tight (197px at 300px sidebar width) but never past it; 160px is what the
longest label, All (4 packages), needs before it would ellipsize.
There are no media queries. Padding used to step between a sidebar and a full tab
(16px 18px 32px β 12px 12px 24px), which made the layout jump while dragging the panel edge. The
tighter sidebar values are now the only values, at every width. "Run test for active file" is always
auto width β it hugs its label rather than stretching, at every size.
Verified at 300px and 760px in both light and dark that nothing scrolls horizontally.
The build picker is a custom dropdown (.dropdown + initDropdown) rather than a native <select>,
which can't be themed to match the editor and renders with OS chrome. It's reusable: mark a root with
data-dropdown, and it emits a bubbling dropdown:change event with the new value while keeping the
value on data-value. Supports click, arrow keys, Home/End, Enter, Escape and click-outside.
Only spec files are eligible. *.spec.* / *.test.* under tests/, playwright/ or
packages/<pkg>/ can become the target; a .yml fixture or a source file is rejected. Without that
check they produced a plausible command β npm test --workspace=packages/bruno-electron -- src/ipc/β¦js
β that matches no tests and exits as a pass.
The selection is sticky. Opening a non-spec file leaves the previous spec in place, so the target doesn't vanish the moment you look at the code under test. Opening a different spec replaces it. The button and the palette command both run the sticky target rather than re-resolving, so what runs always matches the filename displayed above it.
Open a test file and it appears just above the button as folder/filename β the containing folder is
enough to tell same-named specs apart, while the full repo-relative path would be far too long for a
sidebar. Hover it for the full path and the command it will run. The row is
rendered only for a testable file, so it can't linger with a stale name while you click through
non-test files; with nothing testable open, it simply isn't there.
Source Control counts too. Clicking a file there opens a read-only diff whose sides carry the
git: scheme (the "(Index)" tab with a lock icon), not file:. activeFileUri prefers the
working-tree side of a diff tab and maps git: URIs back to disk via the path the git extension
encodes in the URI query, so selecting a spec in the SCM view resolves the same as opening it.
The pencil at the right of the toolbar reveals an input for extra arguments β --headed,
--workers=1, -g "some name". They're appended verbatim, so quoting is yours to get right.
Enter runs it, and the value is saved in globalState so it survives repaints and reloads. Since
the input is collapsed by default, the pencil is tinted with the theme's link colour whenever args
are in effect. Clear it to go back to the bare command.
Run test for active file picks the right runner from the file's path:
| File location | Command |
|---|---|
tests/, playwright/ |
npx playwright test <file> --project=default |
packages/<pkg>/ |
npm test --workspace=packages/<pkg> -- <file> |
A folder counts as a Bruno repo if it contains packages/bruno-app/ β structural, not name-based,
so the OSS repo and the golden edition both match. Open both in one window (or a multi-root
workspace) and Bruno: Open Bruno Dev asks which one you mean; each gets its own terminal.
Scripts are read fresh from whichever repo is selected, so the two are free to diverge β the page
re-reads them every time it comes back into view.
media/logo.svg is the single source of artwork β used by the activity bar, the editor title
button, and the panel tab. icon.png is the Extensions-list logo, rasterized from it at 128Γ128;
regenerate it with any SVGβPNG renderer whenever the SVG changes.
The logo is a full-color badge rather than a monochrome glyph, so VS Code renders it as-is instead of tinting it to the current theme's foreground. That's intentional, but it does mean the activity bar shows a colored tile where other extensions show a themed outline.
The status bar shows the built-in $(tools) codicon rather than the logo: that slot only renders
text and VS Code's own icon font, and a custom glyph there would mean packaging a WOFF icon font.
npx --yes @vscode/vsce package --allow-missing-repository --skip-license
code --install-extension bruno-dev-1.43.0.vsix --forceThen reload VS Code. To iterate on the code instead, press F5 from this folder to launch an
Extension Development Host.