Skip to content

Runlet 0.3.0

Choose a tag to compare

@filipac filipac released this 03 Oct 14:38
· 900 commits to main since this release
88a8293

Runlet 0.3.0 adds SQL tabs: run SQL through your application's own database connection, with no credentials. Website: https://filipac.github.io/runlet/

What's new

  • SQL tabs. File ▸ New SQL Tab, or open a .sql file. A statement runs through the connection your application already uses: Laravel's DB::connection(), Symfony's Doctrine, WordPress's $wpdb, or your project driver's sqlConnection(). Results show as a sortable, filterable table with CSV export. Runlet never asks for or stores database credentials.
    • Completion. Keywords and functions always. With the schema, it also completes table and column names (o. lists the columns of orders o). The schema is read when you load it, or along with your first run, and only names and types.
    • Run All Statements (⌥⇧⌘R). Runs a whole script in order and stops at the first error, in one transaction by default, so a failure rolls everything back.
    • SQL snippets. Personal snippets, and .sql files in .runlet/snippets/ that you can share with your team.
    • Production targets ask before every SQL run. Run All lists each statement with a warning when it can change data.
  • App Info. Click the framework chip to see where a snippet will run: php artisan about-style details for Laravel, Symfony, and WordPress, PHP's settings, and your driver's own sections. Secrets are hidden.
  • Parameterised snippets. @input int $orderId "Order ID" in a snippet's docblock asks for the value when it opens.
  • Tests in the Commands pane. Run the project's tests (all of them, one file, or a filter) in a terminal tab, with php artisan test, Pest, or PHPUnit. Not available on production targets.
  • Production guard. When the application says it runs in production but its target isn't marked, Runlet offers Mark as Production. History remembers how each run's target was marked.
  • Output pane settings (off by default): keep the pane hidden until a tab runs, and let Escape hide it. Docker profiles have Browse… for the container's directories.
  • Fixes: the editor no longer opens scrolled sideways; red error lines and bracket highlights no longer stick; line numbers line up on blank lines; loaded code uses the editor font. Laravel completion handles natively typed relations and casts().

Install

Download Runlet-0.3.0.dmg (drag Runlet to Applications) or Runlet-0.3.0.zip. Requires macOS 26 or later, on Apple silicon or Intel.

Runlet is an early release and is not notarized yet (ad-hoc signed), so macOS blocks the first launch. Open Runlet once, then go to System Settings ▸ Privacy & Security and click Open Anyway next to the message about Runlet. Or remove the download quarantine in Terminal:

/usr/bin/xattr -dr com.apple.quarantine /Applications/Runlet.app

Updating from 0.2.x: replace the app in Applications. Your snippets, history, sessions, and targets stay where they are and load unchanged.

Checksums (SHA-256)

6eeb57c1d74827b411f3b6ab681323ad7861ffdc9404c65a33c682dd378baf7f  Runlet-0.3.0.dmg
1dc64b76a506570913197d9e5e3fa25ba822373e94e35de1e509d1fedf087d31  Runlet-0.3.0.zip

Notes

  • Early preview. Package tests passed in each pull request. For this build, the packaged self-test passed (5 of 5 checks: resources, the runlet command, sandbox install, a sandbox run on host PHP, and PHPantom completion). The UI test suite was not re-run. SQL tabs were tested against SQLite; MySQL, MariaDB, PostgreSQL, and SQL Server weren't tested against live servers.
  • Free and open source under the MIT License.
Full changelog for 0.3.0

2026-10-03 — SQL tabs: completion (#128)

  • SQL tabs complete as you type (two letters, or . after a table or alias) and on Show
    Completions: keywords, phrases (ORDER BY, LEFT JOIN, IS NOT NULL, …), and common functions
    (COUNT() with the caret inside) always, with no connection; keywords follow the case you type.
  • With the connection's schema: tables after FROM, JOIN, UPDATE, INTO, and TABLE; the
    columns of the statement's tables first, with their table and type; alias. and table. list
    that table's columns, schema. a PostgreSQL schema's tables. Names that need quotes are
    inserted quoted (backticks on MySQL). Nothing is offered inside strings, comments, or quoted
    names.
  • The SQL bar's schema menu ("3 tables", "No schema") has Load Schema, Reload Schema, and Forget
    Schema (also Load SQL Schema in the palette). Load Schema reads the schema in a fresh runner,
    apart from the tab's output, and production targets confirm it every time. The first successful
    Run or Run All on a connection also reads it, after the statements, except on production.
  • Only table and column names and types are read: information_schema on MySQL, MariaDB,
    PostgreSQL, and SQL Server, sqlite_master on SQLite; a callable connection tries each in turn.
    Drivers can return the schema themselves with the new optional sqlSchema(?string $connection).
    At most 2,000 tables and 50,000 columns; kept in memory per target and connection until Forget
    Schema, a target edit, or quitting, never saved. A schema that can't be read never fails a run.
  • Runner: SqlTab::schema(), a schema flag on run() and runAll(), and an sqlSchema event.
    Debug builds add the sql-schema:load|forget|state step.

2026-10-03 — SQL tabs: Run All Statements (#129)

  • Run ▸ Run All Statements (⌥⇧⌘R), the SQL bar's Run All, or the palette runs every
    statement of the selection (or the tab) in order, on one connection, in one PHP process. Each
    statement gets its own result card ("Statement 2 of 5", its line and text). Runlet stops at the
    first error and says which statement failed, what was rolled back, and what didn't run.
  • In a Transaction (the SQL bar's checkbox, on by default, saved with the tab): committed after
    the last statement, rolled back when one fails. PDO connections use PDO's transactions;
    callables get BEGIN, COMMIT, and ROLLBACK. MySQL and MariaDB commit DDL and some other
    statements at once: the output says so before running, and Runlet opens a new transaction after
    each, so a failure rolls back only what came after. Scripts with their own BEGIN, COMMIT,
    ROLLBACK, or SAVEPOINT are refused while it is on.
  • On production, Run All asks once and lists every statement with its line and its own write
    warning, and whether it runs in a transaction. Run (⌘R) still runs one statement and refuses
    several. History keeps the script as one SQL entry.
  • Runner: SqlTab::runAll(), SqlStatementFailed, and statement on sql events. Debug builds
    add the sql-run-all and sql-transaction:on|off steps.

2026-10-03 — SQL snippets (#130)

  • Save as Snippet from an SQL tab saves an SQL snippet ("Save SQL Snippet"); saving to the project
    writes .runlet/snippets/<slug>.sql with -- @label and -- @description lines. SQL
    snippets show an SQL badge and open as SQL tabs (or switch the current tab); opening never runs
    them, and they have no @inputs.
  • Project snippets can be .sql files, listed with the PHP ones; metadata is the first run of
    -- comment lines with @label or @description (or a /** */ docblock).
  • Personal snippets store "language": "sql"; PHP snippets don't write the key, so existing
    libraries load unchanged. Duplicate, Copy to Personal, and History's Save as Snippet keep the
    language.
  • MCP: list_snippets and get_snippet return language; add_snippet takes an optional
    language (php or sql). run_php still never runs SQL.

2026-10-03 — Production guard: app environment and history (#12)

  • Runs report the environment the application says it is in, in the bootstrapped event:
    app()->environment() for Laravel, Lumen, and Laravel Zero, the kernel's environment for
    Symfony, and wp_get_environment_type() for WordPress. Plain PHP and Composer projects
    report none. Only the name is read, and the Run Log's "Booted …" line shows it.
  • Project drivers can report it with a new optional environment() method (no native return
    type, so drivers that already had a method of that name keep loading; a throwing
    environment() is a Run Log line and the run continues). See docs/drivers.md.
  • When the application says production, prod, prd, or live and the target isn't marked
    production, the tab shows a notice: the run that revealed it didn't ask first, Mark as
    Production
    marks the target as its settings would (the badge and confirmations apply from
    the next run; nothing runs), and Dismiss hides the notice for that target, also after a
    restart. A target marked production whose application says local, development, or dev
    gets an informational note with Dismiss only. Runlet never changes a marking by itself.
  • History keeps, for each run, how its target was marked (environment and colour) and the
    environment the application reported. Rows show a PROD (or STAGING) badge and the colour from
    that snapshot, so editing the target later doesn't relabel earlier runs; the status line and
    tooltip show the reported environment, and searching History for production finds them.
    History saved by earlier versions loads unchanged, without badges.
  • Opening, importing, or restoring code still never runs it, and production confirmations are
    unchanged.

2026-10-03 — App Info panels (#19)

  • Click the framework chip, in the status bar or on a vertical tab card, to open App Info
    for the tab's target: key/value sections that show where a snippet will run. Library ▸
    Show App Info and the command palette open it too. The status bar's chip reads "App Info"
    until the framework is known.
  • Laravel shows what php artisan about shows (environment, debug mode, URL, maintenance
    mode, caches, drivers, storage links, and sections packages add), read in the booted
    application instead of running Artisan; Runlet doesn't run composer --version, so the
    Composer version is left out. Lumen and Laravel before 9.21 get the same rows from the
    configuration. Symfony shows its version and support dates, environment, debug, charset,
    kernel, and folders; WordPress its version, environment type, URLs, theme, multisite,
    plugins, debug constants, and database. Every target gets a PHP section (version, memory
    limit, OPcache, Xdebug, time zone, php.ini, PDO drivers, extensions).
  • Drivers add their own sections with panels(): array ('Title' => ['Label' => value]),
    shown after Runlet's. A failing panels() keeps the built-in sections and shows the error.
    See drivers.md; Tests/Fixtures/custom-driver/ has an example.
  • Values that look like secrets are never shown or copied (•••••• with a lock): by label
    (password, secret, token, key, salt, credential, …) and by value (credentials in URLs and
    DSNs, password= pairs, Laravel base64: keys, JWTs, private keys, known API token
    formats). The rule runs in the runner and again in the app. App Info is bounded (20
    sections, 100 rows each, 2,000 bytes a value, 256 KB in all), and paths are shown relative to
    the project or with ~.
  • App Info boots the application, so it loads only on that click (or Refresh), never on open,
    import, or restore. The result is kept per target, with its age, until Refresh or an edit of
    the target. Production targets confirm every load (⌘↩), outside the snippet-run grace; SSH
    hosts are reached only then, under the usual connection rules. Boot errors and timeouts show
    in the popover with Try Again.
  • Runner: mode: "panels" and two panels events (built-in, then the driver's); new
    Resources/Runner/src/Panels.php. Debug builds add the app-info[:card|off] and
    app-info-state steps.

2026-10-03 — SQL tabs (#35)

  • SQL tabs. File ▸ New SQL Tab, Switch Tab Language (PHP/SQL) in the Window menu, the palette, and a tab's context menu, or open a .sql file (File ▸ Open…, Finder, runlet query.sql). The language is kept in sessions, workspaces, duplicates, Reopen Closed Tab, and run history; files from before load as PHP. SQL tabs have an SQL highlighter and -- comments, and no PHPantom (no PHP diagnostics or completion on SQL).
  • One statement per run, through the application's own connection. Run sends the selected statement, or the statement at the caret. A selection with several statements is refused before anything runs, and the runner prepares natively, so the database rejects a second statement too. Rows show as a sortable, filterable table (Copy/Export CSV, row copy) with the row count and time; other statements show the rows they affected. At most 1,000 rows, 200 columns, 8 KiB per cell, and 8 MiB per result; a cut result says so.
  • Connections without credentials. The bar above the editor picks the default connection or a named one (the names the driver reported after a run, or any name). Runlet asks the project's driver first, then uses Laravel's DB::connection(), Symfony's Doctrine registry, WordPress's $wpdb, or an Eloquent connection the application set up. Projects with none (plain PHP, Composer, Symfony without Doctrine) get a clear "No SQL connection" message. Runlet never asks for or stores database credentials.
  • Driver API. sqlConnection(?string $connection) returns a PDO, a callable that returns rows or an affected-row count, or null; sqlConnections() lists names for the picker. The built-in Laravel, Symfony, and WordPress drivers implement both, and Runlet\SqlConnections has helpers for Eloquent, Doctrine DBAL, and $wpdb. See drivers.md.
  • Safety. Opening, importing, or restoring an SQL tab never runs it; SQL tabs never auto-run, can't be profiled, and are never run by AI clients' run_php. On production every SQL run asks (the 10-minute grace doesn't apply), showing the statement, the connection, and a warning when it can write (UPDATE, DDL, SELECT … INTO, FOR UPDATE, EXPLAIN ANALYZE …). Write detection is best-effort; development and staging targets don't ask.
  • Guide: sql-tabs.md. Follow-ups: SQL completion (#128), multi-statement scripts (#129), snippets with a language (#130).

2026-10-03 — Parameterised snippets (#14)

  • Snippet docblocks can declare inputs: @input <type> $<name> ["Label"] [= default] [{choice, …}]
    with the types int, float, string, and bool, for example
    @input int $orderId "Order ID" or @input string $reason = "duplicate" {duplicate, fraudulent}.
    Project snippets read them from the metadata docblock, personal snippets from a docblock at the
    start of their code. Lines Runlet can't read are listed with the reason (an orange badge in the
    Snippets panel, a notice in the form) and left out; nothing fails silently.
  • Opening such a snippet (Snippets panel, ⇧↩ Insert, Open Anything) first shows a form with one
    field per input: a text field, a menu for choices, or a checkbox, starting at the default and
    validated as you type (an int must be a whole number in PHP's 64-bit range; a float refuses
    INF, NAN, and hex). Cancel opens nothing; snippets without inputs open as before.
  • Open puts each value on its own line, $orderId = 1042;, after the opening tag, docblocks, and
    use imports. An assignment to the input in the snippet's opening lines is a placeholder and
    gets the value in place instead of a second assignment. The tab never runs on its own; Run
    and production confirmations work as before.
  • The literals are generated in Swift with var_export semantics: ints and floats exactly as PHP
    writes them (-9223372036854775807-1, 1.0E+25, -0.0), strings single-quoted with \ and
    \' escaped, and strings with control characters double-quoted on one line ("a\nb", "\x00",
    "\u{202E}"). A test evaluates about 9,000 literals with a local PHP and compares the values
    and the var_export text.
  • Copy to Personal Snippets keeps a project snippet's @input lines. MCP get_snippet returns a
    parameterised snippet's inputs (and input_problems). See
    docs/snippet-inputs.md.

2026-10-03 — Gutter line numbers line up on blank lines (#124)

  • The line number of a blank line sat lower than the others (3 points at the default 13-point font and 1.15 line height, 4 at 17 points and 1.5), and so did the empty last line's after a blank line; an empty editor's only number sat about a point high. A blank line's only glyph is its newline, which TextKit places at the bottom of the line, and the gutter put the number on that glyph. Every number now sits on its line's text baseline, at every font, size, and line height, with soft wrap on or off; the execution-error dot and the magic-comment bars move with it.
  • All numbers also sit up to half a point higher than before: exactly on the text's baseline rather than just below it.
  • Debug builds: editor-check also compares each line number with its line's text baseline (for a blank line, where typed text would sit): blank lines and the empty last line at two font sizes and line heights, an empty editor, soft wrap, and the Monaco and Menlo fonts with an emoji line.

2026-10-03 — Tests group in the Commands pane (#40)

  • The Commands pane has a Tests group under Open REPL: Run All, File… (one test
    file), and Filter… (the tests matching --filter), each in a terminal tab on the tab's
    target, in the project's folder, with the target's PHP. The tab stays open after the tests
    finish. Like Open REPL it works without listing commands first, and nothing runs until you
    click: opening, importing, or restoring code never runs tests.
  • Runlet picks the runner from the project's files: php artisan test when artisan,
    Laravel's Collision (vendor/nunomaduro/collision), Pest or PHPUnit, and phpunit.xml or
    phpunit.xml.dist exist (it starts Pest when installed, else PHPUnit); else Pest
    (php vendor/bin/pest); else PHPUnit (php vendor/bin/phpunit), each with phpunit.xml,
    phpunit.dist.xml, or phpunit.xml.dist. The pane names it, e.g. "php artisan test ·
    PHPUnit".
  • Local projects and the sandbox are checked on your Mac, including the configuration's
    <testsuite> folders: a project without tests shows no Tests group (the bundled sandbox
    ships without tests/). File… opens a file picker limited to the project's folder,
    starting in its first test folder. Docker profiles, SSH hosts, and SSH container steps
    choose in the container or on the server, say so in the pane, take File… as a path, and
    explain in the tab when the project has no runner (e.g. a deploy without dev dependencies).
  • Filter… and the remote File… are inline prompts with a preview of the command. A file or
    filter reaches the runner as one argument (--filter=<text>, quoted; ./ before a path
    that starts with -); spaces, quotes, $, and backticks pass through unchanged.
  • Disabled on production targets, with the reason in the pane: "Tests can reset the
    database; they're disabled on production targets." Test suites often reset or migrate the
    database (RefreshDatabase, migrate:fresh). The Artisan test command and a
    composer test script stay in the command list and ask first, like every command there.
  • SSH connects only on the click, under the same rules as Open REPL (a password or two-factor
    host must be connected first).
  • Tests: ProjectTestsTests covers detection for each layout, the selection script under sh
    and dash, quoting through sh, dash, bash, zsh, and the SSH wrapping, the exact
    request per target kind, the production rule, and picked files outside the project. Live
    runs: php artisan test --filter in the Laravel fixture and in the fixture Docker
    container, and a stand-in PHPUnit on the SSH fixture (the filter arrives as one argument,
    in the profile's directory).

2026-10-03 — Code loaded into an empty tab uses the editor font (#114)

  • Code loaded into an empty editor (a new tab, then Open, a History or Snippets entry, or a file reload) was drawn in the system's proportional font (Helvetica), with tab stops and line heights that did not match the line numbers, until the editor settings were applied again. Text put into the editor now always gets the editor's font, line height, tab stops, and color, whatever the editor held before.
  • Undo no longer brings text back in the font, size, or line height it had before a settings change.
  • Debug builds: editor-check also checks loading, inserting, and reloading into an empty editor, loading after a settings change, inserting at the end, and undo and redo, comparing each character's attributes and the line heights with a newly opened editor's.

2026-10-03 — Workflow: in progress label on issues (#125)

  • AGENTS.md: opening a draft pull request adds the in progress label to the issues it will close; the label comes off as soon as no agent works on the issue (when the pull request is ready for review, or when the work stops).

2026-10-03 — Laravel completion: native-typed relations and casts() without a trailing comma (#55)

  • Relations declared with only a native return type (public function posts(): HasMany, as
    make:model and the Laravel docs write them) complete the related model:
    $user->posts->first()-> offers Post's attributes and relations, and posts() is
    HasMany<Post>. PHPantom 0.10.0 found the related model only for relations with a generic
    @return or with no return type.
  • The last entry of a casts() array without a trailing comma is read, so its attribute
    completes with the cast type (source: cast). PHPantom 0.10.0 dropped it.
  • How: when the language server starts, Runlet reads the project's model sources and opens
    adjusted copies in memory under the files' own paths (the return type blanked to spaces, the
    missing comma added), only for files with one of these two shapes. Nothing is written to the
    project. A model edited on disk is seen after Restart Language Server, as before.
  • Macros registered in a service provider's boot() were already offered when the provider is
    registered (bootstrap/providers.php, as in every Laravel 11+ app). The earlier
    "unsupported" result came from a test project without that file; the tests now cover it.
  • keyBy() and groupBy() on Eloquent collections still lose the model type. It is a PHPantom
    bug with no workaround in Runlet; the report draft is in docs/compatibility.md, and the
    follow-up is #117.

2026-10-03 — Bracket-match highlight no longer sticks after typing (#113)

  • Typing after an opening bracket, or deleting one, could leave the other bracket of the pair highlighted until that character was deleted. The editor now removes the previous pair's highlight wherever the edit moved it, so only the pair at the caret is highlighted.
  • The bracket match and the failed line's red (#87) are each found through a marker attribute of their own, looked up only where every edit has moved them, not across the whole document.
  • Debug builds: editor-check also checks typing after a bracket, moving the caret, deleting a bracket, undo and redo, and edits elsewhere in the text.

2026-10-03 — A failed line's red background no longer sticks (#87)

  • When a run failed, the line's red background could stay after you edited above the line, typed inside it, deleted it, or used undo and redo, even after later successful runs, sometimes on part of a line that never failed. The editor now clears it wherever the edits moved it. Bracket matches and syntax colors stay as they were.
  • Debug builds: the editor-check step (RUNLET_DEBUG_STEPS) checks these cases on an off-screen editor and prints RUNLET_DEBUG_EDITOR_CHECK lines.

2026-10-03 — Output: hide until a run, Escape hides (#60)

  • Two switches in Settings ▸ General ▸ Output, both off by default, so nothing changes unless you
    turn them on.
  • Hide the output pane until a run: a tab that hasn't run shows the editor alone. Starting a
    run (Run, Run Selection, Profile Run, an approved AI client run, or sandbox auto-run) shows the
    pane right of or below the editor, where it was and at its saved size. Clear Output on a tab
    that isn't running hides it again, and switching tabs shows or hides it with the tab. Show/Hide
    Output Pane (⌃⌘O) shows or hides it for the current tab. This is per tab and never saved:
    opened and restored tabs start hidden, and nothing runs when code is opened or restored.
  • Escape hides the output pane: Escape in the editor hides the tab's output pane until its
    next run or Show/Hide Output Pane, without changing the saved Show/Hide setting. Completions,
    hover and signature popups, and inline-value panels still close first, a visible find bar or
    text being composed keeps Escape, and ⌘. still stops a run. The palette, sheets, and the
    terminal have their own focus, so Escape there is unchanged.
  • Neither switch moves or resizes the pane: the saved layout and divider position are used as
    they are. Show/Hide Output Pane and Move Output Right/Below work as before.
  • Debug builds have an editor-key:<key> step that hands a key press to the current tab's editor
    while Runlet stays in the background, for screenshots and checks.

2026-10-03 — Browse directories in Docker profiles (#62)

  • Docker profiles have Browse… next to the working directory, as SSH profiles do. It opens
    the folder picker on the container selected in the list: path field, breadcrumb, up and home,
    Laravel/Symfony/WordPress/Composer/.runlet badges, and hidden folders on request.
  • It lists folder names only, with a read-only php -r run by docker exec as the profile's
    execution user with its PHP, so a folder that lists is one runs can open. It runs only when you
    click Browse… or open a folder. Opening or editing a profile still lists containers without
    exec'ing into any of them, and saving runs no snippet code.
  • It lists only in the selected container. That container is checked again before every
    listing, and the profile's identity rules apply: a recreated Compose service is followed with a
    notice, as runs follow it. Several replicas, a recreated container known only by its name, or
    a stopped or removed container are errors that send you back to the container list.
  • Errors are explained: a folder the user can't open (permission denied), a missing folder, a
    file instead of a folder, a missing PHP or execution user, and a container that isn't running.
    A subfolder the user can't open is marked with a lock and can't be chosen.
  • Paths are kept exactly as chosen: absolute, with . and .. resolved by name and symlinks
    (such as Debian's /bin → usr/bin or a Forge-style current) kept, not resolved.
  • The SSH profile's container step uses the same container listing, so its errors now name the
    container and the user ("doesn't exist in …").
  • DEBUG steps browse:<path> and browse:select:<folder> drive the open folder picker for
    screenshots.

2026-10-03 — Docker tests touch only runlet-fixtures containers (#80)

  • A plain swift test no longer lists, inspects, or execs into the developer's own
    containers. TestSupport.docker, the Docker CLI of every package test that runs Docker,
    now runs the real CLI only through Tests/Fixtures/docker/fixtures-only-docker, which lets
    through only the runlet-fixtures and runlet-fixtures-recreate Compose projects and
    Runlet's own sandbox containers. It no longer takes whatever docker is first on PATH.
    The tests find the real CLI as the app does (PATH, then the usual install folders);
    RUNLET_REAL_DOCKER picks another. There is no opt-out, since no test needs other
    containers.
  • TestSupport hands the wrapper to Docker through a small generated docker launcher. Tests
    that run the CLI's executable themselves (in a terminal, a Process, or a wrapping script)
    go through the wrapper as well.
  • The wrapper is stricter. A container argument must be a full ID, a name, or an ID prefix of
    one of those containers, and the wrapper passes Docker the full ID. Docker therefore can't
    resolve a short ID to another container that has that name. The wrapper no longer asks
    Docker whether some other container exists. To inspect, another container doesn't exist
    ("No such object", as for one that is gone), and exec, cp, pause, unpause, kill,
    and rm refuse it. Each call lists the allowed containers once, with label-filtered
    docker ps only. cp treats absolute and ./ paths as local, as Docker does. The wrapper
    refuses to wrap itself, for example through an old docker symlink to it on PATH.
  • FixturesOnlyDockerTests proves it with a recording stand-in for Docker
    (Tests/Fixtures/docker/recording-docker) placed behind the wrapper the same way. The
    stand-in has a fixture, a sandbox container, a container of another Compose project, and
    one named like the fixture's short ID. Discovery, profile resolution, inspect, exec
    (probes, PHP version, runs), cp, pause, kill, and rm never hand Docker another
    container. Every ps it gets is label-filtered, and other commands are refused. The tests
    also check that TestSupport.docker is the wrapper.

2026-10-03 — Editor no longer opens scrolled sideways (#78)

  • A tab now opens with column 1 just right of the line-number gutter. Before, with soft
    wrap off and a line wider than the editor, a restored tab (and any tab opened with its
    code, such as Explain, history, or snippet tabs) started scrolled right by the gutter's
    width, so the start of every line was hidden until you scrolled back or moved the caret.
  • Cause: the editor's scroll view extends the clip view under the gutter and sets the clip
    view's left inset to the gutter's width while laying out, but AppKit doesn't move the
    scroll position with it. A new editor's first layout left the position at the old edge.
    The same thing hid about a column when the gutter widened past 99 lines. clipsToBounds
    (#64) wasn't involved: the bug shows without it too.
  • The editor's scroll view (EditorScrollView) now keeps the distance from the text's
    leading and top edges when those insets change. A position you scrolled to yourself is
    kept. The workaround that scrolled generated Explain tabs to their start (#4) is gone.
  • Regression check: scripts/check-editor-scroll.sh <Debug Runlet.app> opens long files in
    a fresh launch, then restores them, switches tabs, and changes the tab layout and window
    size, hidden and with scratch data. After each change, the new DEBUG step editor-scroll
    prints every loaded editor's horizontal offset, and all of them must be 0.

2026-10-03 — Install steps: call /usr/bin/xattr by its full path (#105)

  • The first-launch command is now /usr/bin/xattr -dr com.apple.quarantine /Applications/Runlet.app in the README and on the website. A Python xattr from pip, pyenv, or Homebrew earlier on PATH doesn't support -r.