Architecture
Runlet is a native macOS app on top of a Swift package, plus a PHP program, the runner, that it streams into whichever PHP runs your code. This page starts with an overview: the modules, how a run works, the targets, where data lives, and how Runlet is built and updated. The reference below it has the detail, file by file and subsystem by subsystem.
Modules
| Module | What it holds |
|---|---|
Runlet (Runlet/) | The app: SwiftUI windows, an AppKit code editor, and AppModel, which owns the tabs, targets, snippets, history, and settings. Its code runs on the main actor by default. |
runlet (RunletCLI/) | The command-line tool and runlet mcp, shipped inside the app in Contents/Helpers. It talks to the running app. |
| RunletCore | Models, the run protocol's types, persistence, and the rules the app applies: production confirmations, tab pinning, SQL parsing, MCP approvals, and more. |
| RunletExecution | ExecutionEngine and its adapters for local PHP, Docker, and SSH; PHP discovery; the Laravel sandbox; the Docker and SSH clients. |
| RunletLanguage | The PHPantom language server client, document mapping, navigation, and the Format Code driver. |
The runner (Resources/Runner) | The PHP side of every run: boots the project, compiles and evaluates the snippet, and reports back. Bundled into one file with a scoped copy of nikic/php-parser. |
| Bundled tools | The Laravel sandbox template, PHPantom (language intelligence), and Mago (Format Code). |
The three libraries live in Packages/RunletKit, import only Foundation and Darwin, and have no third-party dependencies. RunletExecution and RunletLanguage both depend on RunletCore, never on each other, so running PHP and language intelligence stay independent. The app adds SwiftTerm for its terminal panel and Sparkle for updates. The full graph is in Module Boundaries.
How a Run Works
Snapshot. Pressing Run captures the code (only the selection, for Run Selection) and resolves the tab's target into a
TargetSnapshot: the working directory, the PHP executable, the container, the user. A production target asks first. Edits made after this point never change the run.Admission.
ExecutionEngineruns one run per tab, up to four tabs at once; further runs wait in a queue.Launch. The target's adapter builds a process spec, an executable plus arguments with no shell, such as
php,docker exec -i …, orssh … /bin/sh -c '…'. The runner, with the request appended as base64 JSON, is streamed to PHP on standard input. Nothing is written into the project, the container, or the server (except, on SSH hosts with Keep the runner and compiled PHP on the server, the runner and PHP's opcode files in~/.cache/runlet, so a run sends only its request: #48).In PHP. The runner boots the project through its framework driver, compiles the snippet (the last expression becomes the result, and magic comments become probes), and evaluates it in a function scope of its own.
Events. The runner writes framed events to standard output, each marked with a random per-run nonce; every other byte is the program's own output:
text0x1E "RL1:" <nonce> ":" <decimal byte length> ":" <json> "\n"RunSessiondecodes them into numberedRunEvents and guarantees exactly onefinishedevent, whatever happens to the process.Output. The app applies the events to the tab in paced batches, so a run that prints thousands of lines leaves the window responsive.
Stop ends a local run's whole process group. In a container, it signals the runner's PID, after checking the process carries the run's RUNLET_RUN_ID; on an SSH host, every process with that id; the Docker sandbox's own container is killed. An SQL statement is first cancelled on the database server, from a second runner.
The details: Runner and Transport, Run Lifecycle, and Cancellation.
Targets
| Target | How a run reaches PHP |
|---|---|
| Laravel sandbox | A writable copy of the bundled template in the data folder, run with local PHP 8.3 or later, or in a php:8.4-cli container that mounts it. |
| Local project | The PHP you choose, in the project's folder. |
| Docker container | docker exec -i into a running container, found again by its Compose labels when it's recreated. Runlet never picks a different container on its own. |
| SSH host | The system's /usr/bin/ssh with your ~/.ssh/config, over one shared OpenSSH connection per host. Optionally, docker exec into a container on that host. |
Every target has an environment (development, staging, or production). A production target asks before a run (confirming can skip the question for snippet runs for 10 minutes), and every time before an SQL statement, a project command, a shell, a REPL, or App Info. The details: Docker Targets, SSH Targets, Laravel Sandbox, and Production Guard.
Data and Settings
- The data folder is
~/Library/Application Support/Runlet, orRunlet Devfor Debug builds.RUNLET_DATA_DIRreplaces it, for tests and screenshots. - State files (
State/settings.json,targets.json,snippets.json,history.json,session.json,onboarding.json, andcommand-usage.json) go throughJSONDocumentStore: a versioned envelope, atomic writes, a last-good copy, and unreadable files kept aside instead of overwritten. - Secrets stay out of files: saved database passwords are in the login Keychain and reach the runner only on standard input; AI providers' API keys (#368) are in the Keychain too, under a service of their own, and go only into a request header. SSH keys and passwords stay with OpenSSH.
- Restoring never runs code. Opening a session, a history entry, a snippet, or a file only loads it.
The folder layout: Persistence.
Language Intelligence
PHPantom runs as one process per workspace (a project, or a basic workspace without local source), with a minimal environment and an app-owned configuration. Each tab is an in-memory document under the project's root, so completion sees the project without a file ever being written. Format Code runs the bundled Mago on the tab's text through standard input. Details: Language Service.
Building and Updates
- The project is generated from
project.ymlwith XcodeGen. The app targets macOS 15 and later, in Swift 6, with the hardened runtime and without the App Sandbox, so it can run your PHP and Docker. - A build phase embeds the runner, the sandbox, PHPantom, and Mago into the bundle. Builds are signed ad-hoc.
scripts/package.shbuilds the universal app, verifies it, runs its headless self-test, and makes the zip and DMG.- Updates use Sparkle 2: an EdDSA-signed feed on the repository's
appcastbranch, signed archives, and a watchdog that puts the old version back when a new one doesn't start.
How to build: Building Runlet. How to release: Releasing. The detail: Build and Distribution and In-App Updates.
Reference
The rest of this page describes the code subsystem by subsystem, with the files, types, and decisions behind each one. Changes update it in the same pull request.
Platform and Toolchain
| Item | Value | Where it is set |
|---|---|---|
| Deployment target | macOS 15.0 (#248) | project.yml (deploymentTarget, MACOSX_DEPLOYMENT_TARGET, LSMinimumSystemVersion); Package.swift .macOS(.v15). APIs newer than 15 need an #available check with a fallback (ProcessSupervisor uses posix_spawn_file_actions_addchdir on 26 and …_np before); the bundled binaries need macOS 11 (PHPantom, Mago) or 12 (Sparkle, Runlet's PHP). |
| Swift language mode | Swift 6 | project.yml SWIFT_VERSION: 6.0; Package.swift swift-tools-version: 6.2 |
| Toolchain used | Xcode 27.0, Swift 6.4, on macOS 27.0 arm64 | compatibility.md |
| Project generation | XcodeGen: project.yml generates Runlet.xcodeproj. Edit project.yml, not the generated project. | project.yml |
| Swift package | Packages/RunletKit with library products RunletCore, RunletExecution, and RunletLanguage. It has no third-party Swift dependencies. | Packages/RunletKit/Package.swift |
| Third-party Swift packages (app target only) | SwiftTerm 1.11.2 (MIT), the terminal panel's emulator; Sparkle 2.10.0 (MIT), in-app updates (#233) | project.yml packages.SwiftTerm and packages.Sparkle (exactVersion) |
| Architectures | ARCHS_STANDARD. Debug builds only the active architecture. Release builds arm64 and x86_64. | project.yml |
| App target concurrency | SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, SWIFT_APPROACHABLE_CONCURRENCY = YES (app target only) | project.yml |
| Tests | Swift Testing in the three package test targets (458 tests; ShellIntegrationLiveTests starts real zsh, bash, and fish under script(1) with temporary HOME and ZDOTDIR, skipping shells that are not installed; SSHRunTests runs against a disposable OpenSSH fixture container, including a password login driven through script(1)). XCTest UI tests in the RunletUITests target: RunletUITests, ScenarioUITests, and the opt-in VisualTourUITests. The scheme turns off automatic screenshots and screen recordings. | Packages/RunletKit/Tests, RunletUITests/, project.yml |
Repository Layout
project.yml XcodeGen spec: Runlet app, RunletUITests, Runlet scheme
Runlet/ macOS app target (SwiftUI + AppKit)
App/ RunletMain (+ --self-test), AppModel, TabModel, SelfTest, AppResources
Editor/ AppKit code editor, PHPantom binding, SwiftUI wrapper
Features/ SwiftUI views: window, tabs, targets, output, sheets, settings, library
AppIcon.icon/ app icon for Icon Composer (Liquid Glass), from scripts/app-icon/
Assets.xcassets/ classic AppIcon.appiconset (fallback), from scripts/app-icon/
RunletCLI/ the `runlet` command-line tool and `runlet mcp` (copied into Contents/Helpers)
RunletUITests/ XCUITests driving the built app
Packages/RunletKit/ Swift package: RunletCore, RunletExecution, RunletLanguage + tests
Resources/Runner/ PHP runner: src/ (Runner.php, Drivers.php, Inspector.php, Benchmark.php, Profiler.php), build deps (composer.json, build/vendor),
generated bundle dist/runlet-runner.php
Resources/Sandbox/laravel/ pinned Laravel sandbox template and runlet-sandbox.json
Resources/LSP/ PHPantom universal binary (fetched) and its license
Resources/Licenses/ license notices of bundled third-party code (SwiftTerm, Sparkle)
Tests/Fixtures/ disposable plain, Composer, Laravel, LSP, and Docker fixtures,
plus fake-docker/ (a fake Docker CLI for screenshot tours)
scripts/ build, fetch, embed, fixture, packaging, and app icon scripts
docs/ architecture, compatibility, validation
build/ generated: DerivedData, UI test logs and result bundles
dist/ generated by scripts/package.sh: Runlet.app, .zip, .dmg, self-test.jsonThe plan's suggested layout used three separate packages. This repository uses one package, Packages/RunletKit, with three library targets instead.
Module Boundaries
Runlet (app) ──► RunletCore, RunletExecution, RunletLanguage, SwiftTerm, Sparkle, RunletCLI (embedded tool)
RunletCLI (runlet) ──► RunletCore
RunletExecution ──► RunletCore
RunletLanguage ──► RunletCoreRunletExecution and RunletLanguage do not depend on each other, so PHP execution and language intelligence stay independent. Package targets import only Foundation and Darwin. AppKit, SwiftUI, and Observation are used only in the app target.
RunletCore
| File | Contents |
|---|---|
ActiveConnections.swift | The Connection Manager's model (#180; see connections.md). ActiveConnection (one row: a stable id such as ssh:<profile>, tunnel:<connection>, run:<run id>, work:<id>, mcp:<client>; its ActiveConnectionKind of ssh, tunnel, database, phpRun, aiClient; title, destination, owner and owning tab, start, environment, details, via (the rows it runs over: an SSH master, a tunnel), needsLoginToReconnect, inUseBy (a tunnel's leases), isClosing). ActiveConnectionList redacts, de-duplicates, and sorts the rows (by kind, then oldest first), groups and counts them (summary, the status bar's tooltip), says what uses a row (users(of:), usage(of:)), and decides Close's question (closeConfirmation(for:): an SSH master that carries work or needs a password or 2FA login again, a tunnel in use; never for runs, statements, or AI clients, and never the production question). ConnectionRows builds the rows of SSH masters, tunnels, and AI clients from what those subsystems report. ConnectionText removes passwords and tokens from row text (redacted), picks a code's first line that isn't a comment, and formats elapsed time. |
RunProtocol.swift | runProtocolVersion = 1. RunRequest holds runId, tabId, documentVersion, a TargetSnapshot, code, an optional SourceSelection, and strictTypes; editorLine(forSnippetLine:) and editorColumn(forSnippetLine:column:) map runner positions to editor positions. TargetSnapshot has kind (sandboxLocal, sandboxDocker, local, docker, ssh), a label, the target ID, the profile revision, the working directory, the PHP executable, Docker fields, and for SSH targets an SSHEndpoint (ssh). RunRequest.inspector (RunInspectorOptions: enabled, interceptMail, previews) configures the run inspector; RunRequest.profile (RunProfileOptions, Profile Run) asks for Excimer sampling. RunRequest.sqlConnection (#138) carries an SQL tab's saved DatabaseConnection (a definition without a password). StartedInfo.profilers reports the run's profiler extensions. RunEvent has runId, sequence, and kind: started, bootstrapped, stdout, stderr, dump, result, error, notice, snippetMessage (a SnippetMessage, #196), inspector (an InspectorEvent), log (a RunLogEntry), finished. DumpInfo and ResultInfo carry an optional HTMLPreview. The file also holds the payload types, RunErrorStage (launch, bootstrap, parse, execute, transport), and FinishedInfo (status of completed/failed/cancelled, reason, exitCode, elapsedMs, peakMemory, truncation, and optional startedAt, bootstrapMs, executeMs). |
MailInterception.swift | The mail chip's model (#193). TargetLibrary.mailInterception(for:global:runInspector:) returns a MailInterception: whether runs intercept mail, its Source (target option or settings), the target's own option (interceptMailOverride(for:)), whether it has one (hasMailOption(_:); the sandbox doesn't), and the chip's State (intercepting, sending, inspectorOff). asksFirst(choosing:global:production:) is the production rule: only a choice that makes an intercepting production target send asks. InspectorInfo.interceptionWarning is the run's warning when no driver confirmed interception, which the output and the chip share. |
RunInspection.swift | The run inspector's types: RunInspectorOptions; InspectorEvent (ready(InspectorInfo) with the driver's sections and whether mail is intercepted, record(InspectorRecord), limit(RecordLimitInfo)); InspectorRecord (index, section, title, snippet line or file and line, and content: query(QueryRecord), mail(MailRecord), log(LogRecord), html(HTMLRecord), value(ValueNode), benchmark(BenchmarkRecord), profile(ProfileRecord), http(HTTPRecord), job(JobRecord), event(EventRecord), or unknown(kind:)); HTMLPreview; and RunInspection, which folds one run's events into sections (Queries, Mail, Log, HTTP, Jobs, Events first, then driver sections in order of appearance), per-section records, merged limits, query time, and the intercepted-mail count. |
RunRecorder.swift | The HTTP, Jobs, and Events sections' records (#5): HTTPRecord (method, redacted URL and headers, status and its Outcome, duration, FAKED, error, bodies with their format, size, and omitted bytes), JobRecord (status, class, the wrapped class, connection, queue, delay, IDs, attempts, duration, exception), EventRecord (name and payload summary), and RunInspection's httpRequests, jobs, failure counts, and filters. |
Benchmarks.swift | #41: BenchmarkRecord (a benchmark record: source runlet or laravel, per-callable Results with min/mean/median/p95/p99/max, std dev, ops/s, the cold first call, iterations and whether the time budget stopped them, Memory, a bounded Histogram with normalized heights, and a run-order series; fastest, relativeToFastest, plainSummary) and BenchmarkFormat (durations, throughput, bytes, and "2.40× slower", three significant digits). |
Profiling.swift | #41: PHPProfilers (Excimer and SPX versions; the probe's PHP code and parse), ProfileRunAvailability (ready, unchecked, or unavailable with the reason Profile Run is disabled), RunProfileOptions, ProfileRecord (collapsed stacks, frame locations, truncation), and FlameGraph: collapsed stacks folded into a bounded tree (maxNodes 50,000, maxDepth 256; malformed lines and cut samples counted), children by samples, placements(focus:minimumWidth:) for zoomed drawing, matches/matchedSamples for search, and hottestFunctions. |
InlineValues.swift | Magic comments (#10): InlineProbe, InlineRejection (with a short label for the inline text), InlineProbesInfo (the probes event), InlineHit (the inline event), and InlineEvent (RunEvent.Kind.inline). InlineValues folds them per editor line (probes in source order, hit count, the latest hit, up to 100 hits plus the latest samples for the hover list, final counts, rejections) and renders the drawn InlineSummary (×N, values via ValueNode.compactSummary, which previews small arrays, Eloquent models' attributes, and collections' items; times; ✓ for reached lines; short reasons for refused comments). Whether they appear as the code runs or when it ends is the Output setting's (RunEventGate in OutputDelivery.swift). InlineLineTracker follows lines with values through edits by their character ranges: lines above or below an edit move with their text, an edited line is dropped; the work per edit is proportional to the tracked lines. |
OutputDelivery.swift | Output realtime or at once (#82). OutputDelivery (realtime, atOnce; AppSettings.outputDelivery). RunEventGate holds a run's output events (stdout, stderr, dump, result, error, notice, inspector, inline) in At once mode and releases them, in arrival order, with finished; status events (started, bootstrapped, log, remember) always pass. RunEventFeed reads a run's events off the stream on its own task and hands them out in batches no earlier than a deadline the consumer picks (finished and the end of the stream never wait). OutputPacer picks that deadline: 100 ms after the last batch was applied, or three times as long as the main thread then stayed busy (up to 1 s), measured from how late the next batch reached it. |
OutputPaneVisibility.swift | Output pane auto-hide and Escape (#60). OutputPaneVisibility decides whether a tab shows the output pane from Show/Hide Output Pane (AppSettings.outputVisible), Hide the output pane until a run (AppSettings.hideOutputUntilRun), and the tab's own unsaved state (revealed by a run, dismissed by Escape), and applies runStarted, outputCleared(running:), toggle, and show; escape(hidesPane:) hides a shown pane until the tab's next run when AppSettings.escapeHidesOutput is on. Nothing in it moves or resizes the pane. |
ChunkedText.swift | ChunkedText: printed output kept in pieces that end at a line break once they hold 64 lines or 8 KiB (a longer line is split between Unicode scalars at 32 KiB), with line counts per piece and tail(lines:). Appending costs as much as the new text. |
QueryAnalysis.swift | SQLText (display-only binding interpolation that skips quoted strings, identifiers, and comments, keeps ?? and ::, and handles named parameters; statement fingerprints; isSelect) and QueryAnalysis (count, total and slowest time, groups of similar statements, and duplicate/nPlusOne hints: the same statement and bindings run more than once, or a similar SELECT run nPlusOneThreshold (3) or more times with different bindings). |
ValueNode.swift | A bounded value tree. Types are null, bool, int, float, string, array, object, enum, closure, resource, and unknown. Entries are ordered and carry keyType (int, string, property, or field for a driver caster's fields), visibility, and the declaring class. An object a driver's caster showed has cast (the driver, the declared type, the raw object, or the caster's error; #6). Reference handling uses referenceId, repeated, and recursion. In a value's Values tree (#307), an Eloquent model has model and attribute and relation entries (hidden, dirty, original), and a collection or paginator of models has collection; ModelValues.swift has their titles and each surface's choice of tree. truncation gives a reason (depth, children, length, budget, or rows with its limit). Strings that are not valid UTF-8 are base64 encoded. The file also renders values as plain text for copying. |
Models.swift | TargetRef, LocalProject, ContainerIdentity, DockerProfile (with validate()), AppSettings (decoding tolerates missing keys; appearance, font size, tab width, spaces, output layout, default PHP and target, Run-prefers-selection, history limit, language service on/off, sandbox runtime preference, output display mode, value expansion, strict types, the default for Eloquent models' Values or Object display (modelDisplay, #307), editor font, line height, ligatures, soft wrap, external editor with its custom command, tab layout, vertical tab width, shortcut overrides, and the terminal panel's terminalVisible, terminalHeight, and terminalOptionAsMeta), TabState (with pinned, #279, and modelDisplay, #307), SessionState, HistoryEntry, Snippet, TargetLibrary (with strictTypes(for:global:): a project's or profile's override, else the global setting), and matchesSearch. |
SSHProfile.swift | TargetEnvironment (development, staging, production; unknown values read as development), TargetColor, SSHAuthentication (automatic: agent or keys in BatchMode; interactive: password or 2FA through Connect…), SSHProfile (host alias, user/port/jump overrides, remote directory, PHP, authentication, keep-alive minutes or until Disconnect, compression, local folder, language PHP version, strict types, environment, colour, drift check; tolerant decoding and validate() for every value that reaches ssh arguments), SSHEndpoint (what a run snapshot needs to reach the host, including the control socket path), and SSHControlPaths (short <8 hex>.sock names in a 0700 folder, with a per-user temporary fallback when the path would pass macOS's 104-byte socket limit). TargetRef.ssh(UUID) and TargetLibrary.sshProfiles (with sshProfile(_:), environment(for:), color(for:), isProduction(_:), and localFolder(for:)) are in Models.swift. |
ProductionGuard.swift | N14: GuardedAction (run, listCommands, command, shell, repl, appInfo) and ProductionGrace, which decides when a production target asks (always, except snippet runs inside a 10-minute grace granted per target; revoke on edits; in memory only) and builds the confirmation's 12-line preview. LocalProject and DockerProfile have optional environment and color; SSHProfile has both too. |
QuickRun.swift | The Quick Run panel's rules (#25, guide): QuickRunDraft (code and target, saved as SessionState.quickRun), QuickRun.offeredTargets (the sandbox first; never a production target, nor one whose application reported production), refusal(for:in:reportedEnvironment:) (checked at ⌘R and again before the launch), restored, and handoff (Open in Tab: the code and target move, the panel keeps the target). The global shortcut: GlobalHotKey (virtual key code, KeyCombo, Carbon modifiers), GlobalHotKeyStatus, the GlobalHotKeyRegistering protocol, and GlobalHotKeyController, which registers, re-registers, and unregisters as Settings say, and refuses a combo without ⌘/⌃/⌥, one of Runlet's commands, or one macOS uses. |
ProjectSnippets.swift | ProjectSnippet and ProjectSnippets: loads <project>/.runlet/snippets/*.php (metadata docblock with @label, @description, and @input; sorted by label), writes snippet files (fileContents, fileName(forLabel:), save, which refuses to overwrite unless asked). ProjectSnippet.inputs and personalCode (the code with the metadata's @input lines, for Copy to Personal). See project-snippets.md. |
SnippetInputs.swift | Parameterised snippets (#14): SnippetInput (int, float, string, bool; label, default, choices), SnippetInputValue, SnippetInputSet (inputs and SnippetInputProblems), SnippetInputs (@input lines of the leading docblocks, parseDeclaration, PHP literals with var_export semantics in phpInt/phpFloat/phpString, and code(_:inputs:values:), which fills placeholder assignments in place and puts the other values after the opening tag, docblocks, declare, namespace, and use lines), SnippetInputForm (the form's fields, validation, and preview), Snippet.inputs, and MCPCatalog.snippetInputs. See snippet-inputs.md. |
SnippetPromotion.swift | Promote a snippet (#39, guide): SnippetPromotion.artisanCommand and test (Pest or PHPUnit) turn a snippet into reviewable PHP. They lift use imports (and a selection's tab imports), declare(strict_types), and top-level declarations out of the code, drop namespace, remove magic comments, map @inputs to command arguments and options or to test values (through SnippetInputs.code(_:inputs:expressions:)), dump() the result in a command or assign it to $result in a test, and re-indent the code. ProjectLayout reads composer.json (PSR-4 namespaces for a folder, Pest) and tests/TestCase.php; naming helpers make class, command, and test method names. Nothing runs. |
PHPCodeScan.swift | A small PHP lexer for SnippetPromotion (#39): tokens outside comments and literals, comments, and literal ranges (strings through {$…} interpolation, heredocs and nowdocs with PHP 7.3's flexible closing marker, inline HTML), so a line starting inside a value is never re-indented; top-level statements (blocks ended by } with else/catch/finally, do … while, ?> as a terminator, a missing final ;), expression statements, declarations, and assignments. |
TabPins.swift | Pinned tabs (#279, tabs.md): TabPinOrder keeps a window's tab ids with the pinned ones first and applies the rules (pin to the end of the pinned tabs, unpin to the start of the others, new tabs after the pinned ones, moves and reopened tabs kept in their group, what Close Other Tabs and Close Tabs to the Right close, the tab ⌘1…⌘9 select); TabPinning.paletteMatches (Open Anything lists Pin Tab for "pin" and "unpin"); TabPinning.asksBeforeClosing (only Close Tab, ⌘W, on a pinned tab asks first: AppModel.closeTabForCommandW shows the sheet). The app applies it with WindowModel.pinOrder and apply(_:). |
TabStripDrag.swift | Dragging a tab along the horizontal tab bar (#322, tabs.md): TabStripDrag decides where the dragged tab lands (its leading edge past a neighbour's middle), how far the tabs it passes slide aside, and keeps it inside its pinned or unpinned group; TabStripEdgeScroll is how fast the bar scrolls near its ends; TabPinOrder.neighbourIndex(of:by:) is Move Tab Left and Right |
JSONStore.swift | JSONDocumentStore<T> (versioned envelope with atomic writes and recovery) and AppPaths |
AppUpdates.swift | In-app updates (#233), without Sparkle: RunletVersion (semantic version plus build; a pre-release sorts before its release, then build numbers), UpdateChannel (Stable, Beta; the default follows the running build), UpdateSelection.best (the newest allowed candidate newer than the running version, without the skipped one), UpdateCheckPolicy (when automatic checks may run, and that offers wait for runs), UpdateInstallLocation (translocated, read-only volume, needs an administrator, ready), UpdateFiles, UpdateRecord, UpdateResult, UpdateWatchdog (the shell script and its arguments), DetachedProcess (posix_spawn in a new session), and UpdateBackup (clonefile, else a copy). See In-app updates. |
DatabaseConnection.swift | Saved database connections (#138): DatabaseDriverKind (mysql, pgsql, sqlite, and, #140, sqlsrv and custom; default ports), DatabaseConnection (id, name, scope: TargetRef, driver, host, port, database or SQLite path, user, connect timeout, and, #139, readOnly, environment, color; #140, socket, charset, tls, initStatements, options, dsn; all left out of the JSON at their defaults, and an unreadable tls leaves the connection out; revision; no password field; normalized, validate(others:) that keeps DSN parts from adding options, summary, duplicated), EnvironmentMarking (#139: the stricter of a target's and a connection's environment, fromConnection, and the colour; TargetLibrary.marking(for:connection:)), SQLConnectionRef (app:<name> / saved:<uuid>, the schema caches' key), and TargetLibrary's connection helpers (databaseConnections(for:), lookup by id then name, save, remove, and the cascade for a removed target). |
DatabaseConnectionOptions.swift | Connection options (#140): DatabaseTLSMode (libpq's five sslmodes), DatabaseTLS (mode and CA, certificate, key paths), DatabaseOption (an extra DSN keyword), what each DatabaseDriverKind supports (tlsModes, supportsTLSFiles, supportsSocket, supportsCharset, supportsOptions, supportsReadOnly, usesCredentials, the keys its fields manage, the editor's TLS note), and SQLScript.initStatementRefusal(of:driver:readOnly:) (no transaction control anywhere; on read-only connections the read-only rules, except session SETs that keep the session read-only and change nothing server-wide). DatabaseConnection.validate checks them; the runner's SqlReadOnly::initRefusal holds the same rules. |
SQLReadOnly.swift | Read-only saved connections (#139): SQLScript.readOnlyRefusal(of:driver:) (session changes, then transaction control allowed, then SQLScript.effect's writes and unknowns; checked as each database reads the text: tokenize(_:backslashEscapes:hashComments:) and MySQL's executable comments opened) and SQLReadOnlyRefusal with its messages. The runner's SqlReadOnly (in SqlConnect.php) holds the same rules; SQLReadOnlyConnectionTests checks they agree. |
SQLRelations.swift, SQLRelationsLayout.swift, SQLRelationsSVG.swift | The relations diagram (#153): SQLRelations (a schema's Relations from Table.foreignKeys, else from columns' references; a focus table's Graph one or two hops out; joinedTable and join(_:joining:driver:) for Copy Join), SQLRelationsLayout (the deterministic layered layout in points: Box, Group, Edge, the 50-table collapse and wrapped columns), and SQLRelationsSVG (the SVG export from the layout). |
SQLPaging.swift | Load Next (#146): SQLPaging.plan(for:driver:) (which statements page, Refusal, Mode.append/.skip, Dialect), Plan.page(offset:size:) and Page, the row/cell/byte limits (nextPageSize), pageSizes for Settings, and SQLTabRun.pageCode. |
SQLTableBrowse.swift, SQLTableEdits.swift | Browse Table (#151): SQLTableBrowse.Dialect (from the schema's PDO driver, $wpdb as MySQL, including WordPress's fallback labels, isWordPressWpdb, #208; quote, which always quotes and doubles the quote, table for schema.table), ColumnKind (a column's type family from its type name: how values are typed, value(_:), which filters it takes, whether it comparesExactly), Request/Query/query(_:) (a page's SELECT: the schema's columns, filters as conditions with positional SQLBindings, ORDER BY the sort then the primary key, LIMIT size+1 OFFSET or OFFSET … FETCH), Refusal, and SQLTabRun.browseCode; SQLTableEdits.refusal (who may edit), Changes (pending cells, deletions, new rows), display and Marks (the page with its changes for the grid), statements (DELETE, UPDATE with the optimistic check and MySQL's verifySQL, INSERT), historyCode, and SQLTabRun.applyCode. |
SQLPlan.swift | Explain Statement (#147): SQLExplain (the generated SqlTab::explain() code with #145's bindings, refusals of statements that are already an EXPLAIN, Explain Analyze's write check and production warning), SQLPlan (a plan's steps kept flat in tree order with depth, parent, and descendant count, so views collapse a subtree by skipping rows; full scans; summary and copy text), SQLPlanInfo (the sqlPlan event; the plan is parsed once in init(from:), off the main thread), and SQLPlanParser (MySQL/MariaDB JSON v1 and v2, MySQL's EXPLAIN ANALYZE tree, PostgreSQL JSON, SQLite EXPLAIN QUERY PLAN rows). |
CredentialStore.swift | SensitiveString (prints, interpolates, dumps, and mirrors as •••; not Codable), the CredentialStore protocol (set, read, delete, exists, and label, which reads an item's label without its secret), InMemoryCredentialStore (tests, Debug runs), and KeychainCredentialStore: generic passwords in the login keychain, service dev.runlet.Runlet.database (plus a hash of a scratch RUNLET_DATA_DIR), account = connection id, not synchronizable. Only the app uses a store. |
AIProvider.swift, AIRequest.swift, AIStream.swift, AIError.swift, AIPrivacy.swift, AIClient.swift | AI providers (#368, guide): Anthropic's Messages API and OpenAI's Responses API over plain HTTPS. AIProvider (defaults, keys page, fixed Keychain account), AIModelTraits (which ids take effort or Anthropic's fallbacks: "default"), AIKeyMask and AIKeyLabel (the masked key, kept in the item's label); AIEndpoint builds the requests; SSEDecoder, AIStreamDecoder, and AIResponseParser read streams, whole answers, and model lists; AIError and AIErrorMapping (statuses, stream errors, retry-after, AIRedaction); AIDisclosure, AIDisclosurePart, and AIConsent, the privacy rules every feature shares; AIClient sends only a disclosure its consent covers, through an AITransport (URLSessionAITransport, ephemeral). The app's AppModel+AI.swift holds the keys' store (Keychain service dev.runlet.Runlet.ai) and streamAI, the one place features send. |
ExplainError.swift | Explain or Fix This Error (#44, guide): ExplainErrorPrompt (the instructions, the disclosure's three parts, the error as text with project-relative paths), FrameSourceExcerpt, AIAnswer (text and code blocks, the runlet-fix block as the fix, open blocks while streaming), and LineDiff (changes and rows with context). |
CommandLineTool.swift | The runlet tool's contract: OpenRequest (folders, files, workspaces as absolute paths; --target; --new-window; the recipient process) and OpenReply, both sent as JSON; CommandLineTool.parse (arguments to help, version, mcp, or a request; refuses / and the home folder as projects; --target paths made absolute) and its usage text; the notification names and launch argument; TargetLibrary.target(matching:), which finds a target by name (exact, then unique prefix; local:/docker:/ssh: prefixes, <kind>:<id>) or project folder; and selector(for:), a name that resolves back to exactly one target. See cli.md. |
MCPServer.swift, MCPTools.swift, MCPJSON.swift | The MCP server of runlet mcp (#43): JSONLineFramer (newline-delimited messages with a size limit), MCPServer (JSON-RPC 2.0; dual-era: per-request _meta under 2026-07-28 with server/discover, resultType, and -32022, or an initialized legacy revision; tools only; progress heartbeats and cancellation), MCPTools (the six tools' schemas, annotations, and argument checks into MCPToolCall), MCPClientInfo (self-reported, cleaned for display), MCPToolResult, and MCPJSON (integers, floats, and booleans kept apart). See mcp.md. |
MCPBridge.swift, MCPAppClient.swift | The socket between runlet mcp and the app: MCPBridge messages, MCPSocketPaths (<data>/MCP/runlet.sock, or a per-user temporary folder for long paths; 0700 folder), MCPSocketListener (the app's end: 0600 socket, getpeereid check, bounded messages, refuses foreign files and live sockets), MCPSocketClient, and MCPAppClient (the tool's backend: connects, optionally starts Runlet, multiplexes calls, forwards cancellation and status). |
MCPApproval.swift, MCPRunReport.swift | MCPApprovalPolicy (when an AI client's run asks: always, except on a target the user allowed for that connection, which can be any target but production (#326); production always asks and ignores the grace; SSH never connects silently, an allowed host skips the sheet only while connected, and hosts that need a login are refused), MCPSessionAllowance (one connection's allowed targets, each with its TargetLibrary.settings(of:) as the sheet showed them, so an edit, removal, or production ends it; prune(_:)), MCPCatalog (list_targets JSON, project snippet ids), and MCPRunReport (a run's events as text and structured content for the client, capped). |
CommandLineInstall.swift | Installs the tool as one symbolic link in a chosen folder: status (not installed, installed, a link to another Runlet, or something else in the way), install (never replaces a non-link; another Runlet's link only with replacing), uninstall (links only), needsAdministrator, the AppleScript used with administrator privileges (every path quoted; removes only a link), and PATH checks. |
FolderWatcher.swift | FolderWatcher (#51) watches a folder's contents: one FSEvents stream with file-level events and WatchRoot while the folder exists, a kqueue source on its nearest existing ancestor while it doesn't (FSEvents doesn't reliably report a path that was missing when its stream started), moving down as folders appear and checking again after arming so mkdir -p isn't missed; calls back once per burst, latency after the last event. ProjectSnippetCache.swift: project snippets per root (@MainActor @Observable), a FolderWatcher per cached root's .runlet/snippets, generation bumped only when a re-read changes the snippets, retain(roots:) keeps only the open tabs' roots (AppModel.syncProjectSnippetWatchers, on every session save). |
LogParsing.swift, LogBuffer.swift, LogFiles.swift | The log viewer's model (#20; see logs.md). LogLevel (Monolog's numbers and the usual names), LogEntry (header, continuation lines, message/context/extra, time with or without a zone, byte offset, arrival time), LogLineParser.classify (Monolog line headers, JSON lines, PHP's error log, continuation lines, plain lines), LogMonologParts (the message/context/extra split over the whole entry, tolerating Laravel's multi-line JSON strings), LogFrames (#n path(line), at path:line, on line n, eval()'d code(n)), and LogTimestamps. LogBuffer turns byte chunks into entries (partial lines wait; a new source closes the open entry) within bounds (5,000 entries, 2,000 lines and 256 KB per entry, 32 KB per line, 48 MB in all) and counts what it drops; LogFilter and LogRunWindow (byte range, arrival time, or log time). LogTail reads a file's last bytes or a byte range; LogFileFollower follows a file with kqueue sources on the file and its folder plus a slow poll, throttled rather than debounced, and reports appends, truncation, rotation (the old descriptor drained first), and removal; LogDiscovery finds storage/logs/**, var/log/**, wp-content/debug.log, and a driver's paths in a folder. |
FileWatcher.swift | FileWatcher watches one file and its folder with DispatchSource file-system sources, follows atomic saves (a new inode at the path is re-watched), deletion, and re-creation, ignores other files in the folder, and calls back once per burst of events. DiskSync.evaluate(disk:baseline:editor:) decides by content what an external change means for a tab: unchanged, reload (no unsaved edits), in sync, conflict, or missing; saveNeedsConfirmation tells ⌘S when the file changed since the tab last loaded or saved it. |
HistoryLog.swift | One history entry per code and target (recording, collapsingDuplicates), ordered(_:preferring:) for Open Anything's ! scope (the current target's runs first), and insertable(_:), which drops a leading <?php tag before ⇧↩ inserts library code at the cursor. |
ProcessSupervisor.swift | ProcessSpec, SupervisedProcess (uses posix_spawn), and runCommand |
ValueTable.swift | ValueTable: a table built from a bounded ValueNode when the value is a non-empty list of arrays or objects, a Laravel collection, or a list of Eloquent models (their attributes; in a Values tree, their attributes and loaded relations, #307). Up to 40 columns; numeric cells keep a number for sorting; each row also keeps its own typed fields (rowFields) for copying; rows the runner omitted are counted. CSV for the whole table or one row. |
OutputExport.swift | ValueExport (a value or a table row as JSON, keys in order, lists as arrays; or as PHP source with keys kept, objects as /* Class */ [...]), MarkdownText (fences longer than any backtick run inside, tables with escaped pipes, values as a table when tabular, inline escaping), and OutputLinks (web links written with their scheme, http, https, mailto, in text up to 512 K UTF-16 units). Built from bounded values only; nothing runs. |
EditorLinks.swift | ExternalEditor (none, PhpStorm, VS Code, Cursor, Zed, Sublime Text, TextMate, custom) with known bundle IDs, URL schemes, and bundled CLI paths per variant; EditorLinks builds "open file at line" URLs and CLI arguments, and splits a custom command template into an argument array (quotes only, no shell; {file} and {line} substituted after splitting); EditorPathMapping maps a run's paths to host paths (host for local and local-sandbox runs; container working directory → local source for Docker, /sandbox → install directory for the Docker sandbox) or explains why it can't. |
SourceExcerpt.swift, FrameSource.swift | Source excerpts in error cards (#8): SourceExcerptReader reads the lines around one line (2 before and after by default) from a file, streamed in 64 KB chunks and stopped after the last line needed, never past 8 MB, each line cut to 300 characters (UTF-8 kept whole, Latin-1 fallback, CRLF trimmed; folders, FIFOs, binary files, missing files, and lines past the end are SourceExcerptFailures, never errors), or from the code a run sent (snippet(_:snippetLine:), numbered like the editor through the selection; the runner never moves lines). FrameSourceResolver finds the file on this Mac behind a path from a run through EditorPathMapping: none for PHP's pseudo-files, unavailable(path:reason:), or a FrameSourceFile with its origin (project, vendor/, outside the project; project folders behind symlinks match through realpath) and isLocalCopy for Docker and SSH profiles' local folders (not the Docker sandbox, which mounts the sandbox itself). Reusable by anything that lists frames (the log viewer, #20). SourceExcerptStore (an actor, shared) reads off the main thread and keeps each run, file, and line once (400 entries). |
TerminalRequest.swift, TerminalLaunch.swift | TerminalRequest (title, working directory, optional commandLine typed into the user's shell, optional executable argv run directly, and isCommand: the tab exists to run a command, so it stays open after the process exits; defaults to commandLine != nil) is the contract for opening terminal tabs. TerminalLaunch.make turns it into an exec spec: without executable it starts the account's login shell (getpwuid(getuid())->pw_shell, then $SHELL, then /bin/zsh) with argv[0] = -zsh/-bash/…, so the user's own profile and rc files run unchanged; with executable it resolves argv[0] and runs it without a shell. The environment is the app's, minus variables describing another terminal (TERM_SESSION_ID, ITERM_*, TMUX, SHLVL, PWD, …) or Xcode debugging (DYLD_*, __XPC_DYLD_*), plus TERM=xterm-256color, COLORTERM=truecolor, TERM_PROGRAM=Runlet, TERM_PROGRAM_VERSION, SHELL (login shells), and LANG only when missing (the user's <lang>_<REGION>.UTF-8 if installed, else en_US.UTF-8). A missing directory falls back to home. When a commandLine is typed into zsh, bash, or fish and the caller passes the installed ShellIntegration directory, the launch also carries the integration (see Terminal) and a readyToken; plain shells never do. |
LineMove.swift | The text transform behind Move Line Up/Down and Duplicate Line Up/Down (#234), in UTF-16 units: the lines a selection touches (spans such as folded blocks kept whole), \r\n/\n/\r line endings and a last line without one, the edits (only the lines that make room are replaced), the selection after, and where the displaced lines went. |
ShellIntegration.swift | Shell-readiness reporting for command tabs: the zsh startup files (.zshenv, .zprofile, .zshrc, .zlogin) and the bash rc file, written by install(in:) (unchanged files are left alone), the fish --init-command, the marker ESC ] 6973 ; RunletReady ; <token> BEL, and ReadyScanner, which finds the marker in output delivered in arbitrary chunks. |
ExecutableLocator.swift | Resolves executables without relying on the terminal PATH. It searches the process PATH, then Herd, herd-lite, Homebrew, /usr/local/bin, ~/.docker/bin, OrbStack, Rancher Desktop, Docker.app, /usr/bin, and /bin. |
AppInfo.swift | App Info (#19): AppInfoValue (text, integer, number, flag, list, none; displayText, copyText), AppInfoRow (with redacted), AppInfoSection (origin builtin/driver, source, omittedRows), and AppInfoReport, which decodes panels events (add(panelsFrame:)), applies AppInfoLimits (20 sections, 100 rows, 50 list items, title/key/value lengths) and AppInfoRedaction again, and keeps notes, the driver's panels() error, errors, and loadedAt. AppInfoRedaction is the secret rule shared with Panels.php (secret words in keys; credentials and tokens in values). AppInfoPolicy.onOpen decides what opening App Info does (show the cache, load, or confirm on production, then load) and age(of:) words its age. |
ProjectCommand.swift | ProjectCommand (name, description, command line, group, origin driver/composer, source driver) and ProjectCommandCatalog (commands plus the driver, framework, PHP version, errors, and notices of the listing run). groups(matching:) filters by whitespace-separated tokens and groups for display: the driver's top-level commands, its namespaces alphabetically, then Composer scripts. |
ProjectCommandList.swift | ProjectCommandList: what the Commands pane's list shows, as values (sections from groups(matching:) with whether each is expanded, and rows with whether their command is launching), so the pane redraws the list only when it changes (#320). |
RunletExecution
| File | Contents |
|---|---|
RunnerScript.swift | RunLimits (output and value limits, the run inspector's maxQueries, maxRecords, maxRecordBytes, and maxBodyBytes, and the magic comments' maxInlineHits (100 hits per probe with values) and maxInlineBytes (16 MiB of values per run)) and RunnerBundle. RunnerBundle assembles the per-run script (Mode.run, Mode.commands, or Mode.panels, with the inspector options for runs), generates the nonce, and holds the fixed php arguments. |
FrameDecoder.swift | Splits runner stdout into raw output and nonce-framed events |
RunSession.swift | Internal RunSession and RunControl. Turns one process into sequenced RunEvents and guarantees a single finished event. Host wall-clock start time and monotonic total duration accompany the runner-reported bootstrap and execution phases on every terminal path; absent phases stay nil. Frame types without a RunEvent case (such as commands) go to an optional handler. Inspector records past maxQueries + maxRecords or about maxRecordBytes + maxBodyBytes are dropped (a backstop for drivers that bypass Runlet\Inspector) and reported as a limit with reason app before finished. Magic comments' probes and inline frames become .inline events; hits past about maxInlineBytes plus 4 MiB are dropped (final counts are always kept). |
ExecutionEngine.swift | The ExecutionEngine actor (with a DockerCLI and an SSHClient). Also holds the internal adapters LocalAdapter, DockerExecAdapter, and DockerSandboxAdapter (docker run --rm --init), plus CancelOutcome, ExecutionError, and PreparedLaunch (spec, stop, and an optional explainFailure that turns a transport's own failure output into plain language). |
SSH.swift | RemoteShell (POSIX quoting, /bin/sh -c wrapping, base64-inlined php -r code, the run script), SSHConnectionStatus and SSHControlSocket (status from the control socket on this Mac, without starting ssh), SSHClient (the system /usr/bin/ssh: batch, control, and Connect… argument sets; run, connectCommand, disconnect, and effectiveConfiguration from ssh -G), SSHFailure (plain explanations of exit 255, the missing-directory marker, and exit 127), the internal SSHExecAdapter, and RemoteSignal (the runner-only Docker helper and the whole-run SSH helper). |
DockerCLI.swift (SSH transport) | DockerCLI.Transport is .local or .ssh(client, endpoint, command:): spec then wraps <docker command> <args> in ssh … /bin/sh -c '…', so runningContainers, inspect, DockerProfileResolver, probe, detectFacts, phpVersion, DockerExecAdapter, and its Stop work on an SSH host unchanged. phpCode(_:) base64-wraps php -r code over SSH; explainFailure turns ssh exit 255, a missing Docker, a denied Docker socket, or a sudo prompt into plain language. ContainerInfo.containerPath(forHostPath:) maps a server path into a container through its bind mounts. |
SSHProbe.swift | SSHProbe and SSHClient.probe: Test Connection's read-only php -r (PHP, user, OS, directory and its real path, framework, tokenizer, Stop capability and /proc, profiler extensions, PHP binaries, application folders, composer.json name, git branch/commit/remote from .git files, composer.lock CRC), and CheckoutState. |
LocalCheckout.swift | LocalCheckout (branch, commit, and origin from .git files, composer.lock CRC-32; no git binary), CheckoutDrift (commits first, else composer.lock), CRC32 (matches PHP's crc32b), and LocalFolderSuggestions (local checkouts that match the server's git remote, composer.json name, or folder name, from known folders and a shallow scan of common project roots). |
SSHConfigHosts.swift | Host aliases from ~/.ssh/config (patterns skipped, Include followed) for the profile form and the import sheet, and SSHHostImport (environment guess from words such as prod or staging, new aliases, the imported profile). |
RemoteDirectories.swift | Detect and the directory browser: RemoteDirectories (two read-only php -r programs: the home folder plus folders that look like PHP applications, and one folder's subfolders with symlinks kept and application markers), RemoteDirectoryListing, RemoteDirectoryEntry, RemoteDirectoryDetection, and SSHClient.detectDirectories / listDirectory (BatchMode through the shared connection; a missing PHP still yields the home folder from the shell). DockerCLI.listDirectory runs the same list program with docker exec [--user …] <container> <php> -r … -- <path> <limit> (no -i, -t, or --workdir; the path is one argument) for a container, locally or on an SSH host; its errors name the container and the execution user (RemoteDirectories.Place). |
DockerDirectories.swift | Browse… for a local Docker profile (#62). DockerCLI.listProfileDirectory inspects the container selected in the profile form before every listing and lists only in it; when it is gone or stopped, DockerDirectoryBrowsing.target resolves the profile's identity like a run (DockerProfileResolver): a recreated Compose service is followed with a notice, while several replicas, a recreated name-only container, or nothing running is an error. DockerExecFailure.explain turns Docker's messages (container not running, paused, or removed; PHP or the user missing; the daemon or its socket) into plain language. |
AppInfoLoader.swift | ExecutionEngine.loadAppInfo(target:timeout:) runs the runner in panels mode through the same adapters as a run and collects its panels events (AppInfoFrameCollector) with started, bootstrapped, errors, and notices into an AppInfoReport. Bootstrap failures, exit(), and the 120 s timeout are errors in the report; cancelling stops the runner. |
ProjectCommands.swift | ExecutionEngine.listCommands(target:timeout:) runs the runner in commands mode through the same adapters as a run (local PHP, docker exec with the profile's user, working directory, and TMPDIR, or the Docker sandbox) and collects its commands events into a ProjectCommandCatalog. Cancelling the caller or exceeding the timeout (120 s) stops the runner. ProjectCommandLauncher builds the TerminalRequest that runs a command for a resolved TargetSnapshot (always isCommand, including the docker exec/docker run argv forms); for SSH targets an ssh -t argv (SSHClient.terminalCommand: BatchMode, strict host keys, the shared connection) running /bin/sh -lc 'cd <dir> … ; <command>' with a leading php replaced by the profile's PHP, or a login shell with the command typed when it needs input; sshShellRequest builds Shell on Host. |
ProjectREPL.swift | Open REPL (N19, #32). ProjectREPL.kind picks the REPL from the project's files, with no PHP run: Tinker (php artisan tinker) when artisan and vendor/laravel/tinker/ exist, else PsySH (php vendor/bin/psysh), else PHP's interactive shell (php -a). selectionScript(php:place:) makes the same choice on the target with [ -f ]/[ -d ] in POSIX sh, sets the tab title with OSC 2 ("Tinker · <place>"), and execs the REPL. terminalRequest(target:kind:place:dockerExecutable:ssh:) follows ProjectCommandLauncher: local projects and the sandbox type '<php>' artisan tinker (checked on this Mac) into the user's shell in the project directory; Docker profiles run docker exec -it [--user] [--env TMPDIR] -w <dir> <container> sh -lc <script>; the Docker sandbox a labelled docker run --rm -it; SSH hosts ssh -t with `/bin/sh -lc 'cd <dir> |
ProjectTests.swift | The Commands pane's Tests group (N37, #40). ProjectTests.detect picks the runner from the project's files, with no PHP run: php artisan test when artisan, vendor/nunomaduro/collision/, vendor/bin/pest or vendor/bin/phpunit, and phpunit.xml or phpunit.xml.dist exist (the two files Collision passes on), else php vendor/bin/pest, else php vendor/bin/phpunit, each with phpunit.xml, phpunit.dist.xml, or phpunit.xml.dist. detect(projectDirectory:) also reads the configuration's <testsuite> <directory>/<file> entries (testLocations(configuration:)) and needs one of them to exist (tests when none are listed; globs are assumed to match; an unreadable configuration is left to the runner). Action is .all, .file(path) (./ added before a leading -), or .filter(text) (--filter=<text>), each one shell word. selectionScript(php:action:place:) makes the same choice in POSIX sh, sets the tab title with OSC 2, and execs the runner, or explains on stderr and exits 1. terminalRequest(target:action:runner:place:dockerExecutable:ssh:) follows ProjectREPL: local projects and the sandbox type '<php>' artisan test … into the user's shell (throws when there is no runner); Docker profiles docker exec -it … sh -lc <script>; the Docker sandbox a labelled docker run --rm -it; SSH hosts ssh -t with `/bin/sh -lc 'cd <dir> |
DockerCLI.swift | DockerCLI (uses the selected Docker context), ContainerInfo, and discovery through docker ps and docker inspect. inspect tolerates containers that vanished between ps and inspect. |
DockerProfiles.swift | DockerProfileResolver, ProfileResolution, ContainerProbe with DockerCLI.probe (including the container PHP's profiler extensions), and workingDirectorySuggestions |
SandboxManager.swift | SandboxManifest, SandboxRuntime, and SandboxManager (install, reset, runtime choice) |
Executables.swift | PHPInstallation and PHPDiscovery. Discovery searches PATH, the well-known directories, Herd phpXY shims, and Homebrew opt/php*. It validates each binary with php -n -r, records the version and tokenizer availability, and treats PHP 7.4 or newer as supported by the runner. PHPDiscovery.profilers(executable:) then checks Excimer and SPX with the PHP's own php.ini (extensions load there) and auto_prepend_file/auto_append_file turned off (PHPInstallation.profilers), and, at the same time, drivers(executable:) reads its PDO drivers and whether it loads phpredis and ext-mongodb (PHPInstallation.drivers, PHPDrivers in RunletCore; #184). PHPDriverCache.swift: PHPDriverCache keeps those per path, and probes a path discovery doesn't list (a default PHP typed in Settings) once, shared by concurrent callers, until update(installations:) (each discovery). |
LogFollow.swift | Remote log follows (#20). LogFollowCommand builds docker logs --follow --tail 500, docker exec -i … /bin/sh -c '<tail script>', and the SSH remote commands (a host's file, a file in a container on the host, a container's output there), each wrapped so it ends when its standard input ends (stopsWithInput: the command in the background, a watcher reading a copy of fd 0 that kills it), plus Find Logs' findScript. LogProcessFollower runs one with its input kept open, passes whole lines on (LineAssembler), reports the command's own messages as notices and a failed exit with an explanation, and stop() closes the input, then ends the local client's process group. |
SSHTunnel.swift | SQL tunnels (#143). SSHForwardSpec (-L 127.0.0.1:<port>:<host>:<port>, IPv6 bracketed), SSHForwardPorts (pickFree(): bind 127.0.0.1:0, read the port, close; isListening for tests), SSHTunnelError, the SSHForwarding protocol, and SSHClient's side of it: forwardArguments(for:spec:cancel:) (`-F /dev/null -o BatchMode=yes -o LogLevel=ERROR -S <ctl> -O forward |
LocalConnectionLaunch.swift | Saved connections opened from this Mac (#142). candidates(runlet:defaultPath:installations:) orders the PHPs (Runlet's PHP, the default PHP, PHPDiscovery.preferred, then every other discovered PHP, each path once) with labels that never show a path ("Runlet's PHP 8.5.8", "Herd PHP 8.4.25"), and choosePHP(runlet:defaultPath:installations:) is the first of them; #184: choosePHP(for:candidates:drivers:) returns a Choice, the first candidate whose PHPDrivers meet the connection's PHPDriverRequirement (a PHP whose drivers aren't known is tried only when no known one has the driver), with label ("Herd PHP 8.4.25, the first PHP here with pdo_sqlsrv or pdo_dblib") and reason for the PHPs passed over, and noPHPMessage(_:checked:) names the driver and the PHPs checked; directory(in:) creates the empty <data>/LocalConnections folder (0700); snapshot(connection:php:directory:) makes the local TargetSnapshot (targetId this-mac, label "<name> · this Mac (<PHP>)"); check(_:target:), called by ExecutionEngine.start, loadSQLSchema, and testSQLConnection before anything launches, refuses a connection that opens from this Mac on any other snapshot, and (#143) a tunnelled connection on a snapshot without sqlTunnel, so its password never goes to a host this Mac resolves; snapshot(connection:php:directory:tunnel:) adds the route and " through <profile>" to the label. |
MongoLaunch.swift | The PHP that opens MongoDB connections from this Mac (#191). candidates(runlet:defaultPath:installations:) orders Runlet's PHP first (build r3 and later have ext-mongodb, #212), then the default PHP, PHPDiscovery.preferred, and every other discovered PHP, each path once, with LocalConnectionLaunch's labels; choosePHP(candidates:) returns the first whose PHP loads mongodb (hasMongoDB, 3 s at most, no ini prepend), for tests and checks: since #184 the app picks with LocalConnectionLaunch.choosePHP(for:candidates:drivers:) and the drivers discovery read, so a run never probes; candidates is LocalConnectionLaunch.candidates. |
RunletPHPStore.swift | Runlet's own PHP (#2). RunletPHPRelease.current (RunletCore) pins a static PHP build per CPU type: URL, SHA-256, and size of a .tar.gz published by .github/workflows/php-runtime.yml. install(progress:) downloads it only on request, checks the checksum, unpacks php-<version>-<build>/ with tar, requires bin/php to run and report the expected version, and only then moves it to <data>/PHP/<version>-<build>/ (older releases are removed). merged(discovered:runlet:) appends it after every discovered installation, so automatic choices use it only when no installed PHP fits. |
RunletLanguage
| File | Contents |
|---|---|
LSPTypes.swift | JSONValue, LSPPosition, LSPRange, LSPTextEdit, LSPDiagnostic, CompletionItem, HoverInfo, and SignatureHelpInfo |
LSPConnection.swift | JSON-RPC 2.0 with Content-Length framing over the child's stdin/stdout. stderr is kept only as a bounded log. Requests have per-request timeouts, and cancelling the calling task sends $/cancelRequest. Server requests get a generic answer (null), then go to onRequest. |
LanguageServer.swift | LanguageWorkspace, LanguageServerState, and LanguageServerSession (one PHPantom process). The session handles the server's messages in order: diagnostics, $/progress, and file-watcher registrations, which start and stop its WorkspaceFileWatcher (#336). activityUpdates() streams LanguageServerActivity; reindex() is Reindex Project. Also the internal PHPantomConfig and ExecutableLocator.minimalEnvironment(). |
EloquentOverlay.swift | Adjusted in-memory copies of Eloquent models (#55); see compatibility.md. OverlayTracker keeps a session's copies up to date with the files on disk (#340). |
WatchedFiles.swift | File watching's pure parts (#336): LSPGlob (LSP glob patterns, plain and relative), WatchedFileRegistry (registered watchers and kinds), FileChangeBatcher (debounced batches: 300 ms after the last event, at most 2 s after the first, 2,000 changes per notification; changes decided by what's on disk), and WatchedPaths (FSEvents' real paths mapped back to the workspace root; .git skipped) |
WorkspaceFileWatcher.swift | An FSEvents stream with file-level events on the workspace root, on its own queue, feeding FileChangeBatcher and sending workspace/didChangeWatchedFiles |
WorkDoneProgress.swift | WorkDoneProgressTracker ($/progress begin, report, and end by token), LanguageServerProgress, LanguageServerActivity, and LanguageStatusSummary (the status bar item's text) |
LanguageService.swift | The LanguageService actor: one session per workspace, shared by tabs. Also builds scratch URIs. |
DocumentMapping.swift | ScratchDocumentMapping (synthetic <?php\n line), TextLineIndex (converts between UTF-16 offsets and LSP positions), and SnippetText (turns LSP snippet syntax into plain text) |
CompletionInsertion.swift | CompletionInsertion: the text and caret the editor inserts for an accepted completion item (calls become name()) |
SnippetFormatter.swift | Format Code (#36): SnippetFormatter runs the bundled Mago on a tab's text through stdin, with an app-written config in a private temporary directory; handles a missing <?php and final semicolon, and refuses output that loses a comment or changes what a magic comment shows. FormattingEdit is the editor's one replacement and caret. See format-code.md. |
PHPScanner.swift | A small PHP lexer (code, comments, strings, heredocs, inline HTML) for the formatter's checks |
LanguageNavigation.swift | Navigation requests (#22): LanguageFeature and LanguageNavigation.supports (a provider is used only when initialize advertises it), LSPLocation (Location, Location[], LocationLink[]), InlayHint, LSPWorkspaceEdit (changes and documentChanges, with file operations recorded), LSPCodeAction (bare commands dropped; quick fixes first), LSPFoldingRange, and the session's definition, references, inlayHints, codeActions, resolve(_:), and foldingRanges |
NavigationResolver.swift | Where a location goes: the tab (through the hidden lines), a hidden @var line (named), a project file (external editor), a peek (vendor/, outside the project, in-memory documents), or nowhere (PHPantom's phpantom-stub:// built-ins); NavigationFile.runtimePath through EditorPathMapping.runtimePath(forHostPath:) for Docker and SSH. ReferenceList orders and trims references with their lines. See navigation.md. |
NavigationFile+FrameSource.swift | NavigationFile(frameSource:line:): a stack frame's line as a read-only peek (#8), with the container or server path for a local copy |
ScratchEdits.swift | ScratchEditPlanner: a code action's WorkspaceEdit as editor replacements for the tab only (other files, file operations, hidden text, and overlaps refused; insertions in hidden text go to the snippet's start or end). InlayHintPlacement (kinds 1 and 2, editor offsets, the visible lines' request range) and FoldingPlacement (editor regions, the hidden characters of a fold, folds after an edit) |
App Target (Runlet/)
The app target depends on all three package libraries. It uses SwiftUI for windows and AppKit for the editor, and runs with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor.
App/
| File | Contents |
|---|---|
RunletApp.swift | RunletMain is the @main entry point. With --self-test it runs SelfTest headless and exits with its status; otherwise it starts RunletApp. RunletApp has a WindowGroup of main windows (WindowRoot → MainWindow), a single-instance Window scene docker-profiles (the Profiles window, ProfileManager, opened through AppModel.openSingleWindowAction, never restored or opened at launch), and a Settings scene. AppDelegate opens .php files passed on the command line or from Finder in tabs (never runs them), presents the main window on document launches, and on quit calls AppModel.shutdown(). Before the first window opens it applies the Appearance setting to NSApp.appearance (AppModel.applyAppearance(), which runs again whenever the setting changes; nil for System), so AppKit panels and popups follow the app's choice as the SwiftUI windows' preferredColorScheme does (#135). RunletCommands defines the menus and shortcuts: New/Duplicate/Close Tab, Open PHP File, Open Project, Save/Save As, Toggle Line Comment (⌘/), Complete (⌥Esc), Run (⌘R), Run Selection (⇧⌘R), Stop (⌘.), Copy Output (⌥⌘C), Clear Output (⌘K), Switch Target (⌘P), History (⌘Y), Snippets (⇧⌘L), Save as Snippet (⌥⌘S), New Docker Profile (⇧⌘N), Manage Profiles… (no default shortcut), New SSH Profile…, Import SSH Hosts from ~/.ssh/config…, Connect/Disconnect, Open Shell on SSH Host, Restart Language Server, Reset Sandbox, Next/Previous Tab, and View ▸ Appearance (Auto (System), Light, Dark; no default shortcuts). FilePanels wraps the open and save panels. With RUNLET_SNAPSHOT_DIR set, a Debug menu renders Runlet's own windows to PNG (WindowSnapshots). |
AppModel.swift | AppModel (@Observable) composes AppPaths, the five JSONDocumentStores, ExecutionEngine, SandboxManager, DockerCLI, and LanguageService. It owns tabs, the target library, snippets, history, and settings. On launch it restores tabs (code and targets only; nothing runs) and shows recovery notes, then discovers PHP, checks Docker, installs the sandbox, and binds each tab to PHPantom. snapshot(for:) turns a tab's target into a TargetSnapshot: the sandbox uses local PHP or Docker according to SandboxManager.chooseRuntime; a Docker profile is resolved with DockerProfileResolver, and an ambiguous or name-only match raises a ContainerChoice instead of running. run snapshots code, selection, target, and the effective strict-types value (strictTypes(for:)) before launching, so later edits cannot redirect a run; stop cancels through the engine and shows unconfirmed outcomes. It also records history (trimmed to historyLimit), manages snippets, opens and saves files (saving never runs code), resets the sandbox, pulls the sandbox image, and chooses each tab's language workspace (sandbox install, project root, a Docker profile's mapped source, or the basic workspace). projectRoot(for:) (a local project's path or a Docker profile's local source) and projectSnippets(for:) back project snippets, cached per root in ProjectSnippetCache (RunletCore, which watches each root's folder, #51; syncProjectSnippetWatchers keeps the open tabs' roots on every session save); saveProjectSnippet writes one. |
WindowModel.swift, WindowRoot.swift | WindowModel: one window's tabs, selection, workspace file, terminal panel, and isFloating (Window ▸ Float on Top, for this launch only). WindowRoot hosts a window by id; WindowAccessor sets the title's proxy icon, the edited dot, the close-button guard, and the window level (.floating while it floats; child panels such as the palette follow it). |
TabModel.swift | TabModel: title, target, file URL, run state (idle, preparing, running, stopping, finished), ordered OutputItems (header, text, dump, result, error, notice, warning, snippetMessage, mail, finished), the run's RunInspection and the output pane's selected section (outputSection), the last run's PHP and framework versions, the language state and notes. Inspector events fold into inspection; each recorded mail also becomes a mail item in the output, and interception that no driver supports becomes a warning. The run header says "mail intercepted" when the run asked for it. outputMarkdown renders the output as Markdown (cards as headings, values in fences or tables, then the inspector's queries and mail). apply(_:) takes a batch of events, ignores events from any other run, logs each to the Run Log as it arrives, and passes the rest through the run's RunEventGate (Output: realtime or at once); it maps dump and error lines back to editor lines, and magic-comment events go to the editor's InlineValueOverlay with the same mapping. endOfEvents() releases anything still held if a stream ever ended without finished. Printed output is a ChunkedText. outputGeneration changes when the output is replaced (a new run, Clear Output, which also drops held events) and outputRevision once per batch that changed it. beginRun(code:selection:magicComments:delivery:) tells the editor which lines may show values (and drops the previous run's); Clear Output clears them too. The tab's EditorController is created on first use and kept for the tab's lifetime. outputPaneRevealed and outputPaneDismissed are the tab's unsaved part of OutputPaneVisibility (#60), and onEditorEscape receives the editor's unused Escape. outputText(for:) renders Plain or Raw text for Copy Output. |
ExternalEditor.swift | InstalledEditor (detects installed editor apps through Launch Services, checks the URL scheme in the app's Info.plist), ExternalEditorLauncher (opens a host file at a line through the URL scheme, else the bundled CLI, else the app; runs a custom command with SupervisedProcess, never a shell), and AppModel helpers: openInExternalEditor(path:line:), editorLink(forRuntimePath:in:), projectFolder(for:), openProjectInEditor(for:), and toggleSoftWrap(). |
AppModel+Profiling.swift | Profile Run (#41): profilerFacts(for:) (the sandbox's or local project's PHP from discovery; containers, servers, and the Docker sandbox from their last run or probe, kept in TargetFacts.profilers), profileRunAvailability(for:), profileRun(_:) (run(_:profile: true): the same production guard and target resolution as Run), noteProfilers, and ProfilerText for probe rows. |
AppModel+Inspector.swift | interceptMail(for:) (a project's or Docker profile's override, else the global setting), inspectorOptions(for:) (the RunInspectorOptions a run sends; interception keeps the inspector on), toggleMailInterception(), the mail chip's mailInterception(for:), mailInterceptionReport(for:) (the last run on the target that asked for interception, kept until Runlet quits), setInterceptMail(_:for:) (writes the target's Mail option through saveProject/saveDockerProfile/saveSSHProfile, as the editors do), and showGeneralSettings() (#193), and saveOutput(of:) (Run ▸ Save Output As…: Markdown, plain text, or raw output, chosen in the save panel; the default follows the display mode). |
AppModel+OutputPane.swift | Applies OutputPaneVisibility to the saved settings and the tab (#60): isOutputPaneShown(for:) (read by TabContent), updateOutputPane(_:for:) (Show/Hide Output Pane, Move Output Right/Below, and startRun, which reveals the pane), clearOutput(_:) (⌘K and the pane's Clear), and editorEscapePressed(in:) (the editor's unused Escape). The layout and split fractions are left alone, so the pane reappears where it was. |
AppModel+Commands.swift | Project commands per target (ProjectCommandsStore, keyed by TargetRef.stableKey): loadCommands(for:) resolves the tab's target with snapshot(for:) and calls ExecutionEngine.listCommands; commandsState(for:)/commands(for:) read the cache; runProjectCommand(_:in:) resolves the target again and passes a TerminalRequest to openTerminal (an ssh -t command tab for SSH hosts), or copies the command when no terminal is available. Nothing loads unless the user opens the Commands panel or presses Refresh. openREPL(for:in:) (Open REPL: the Commands panel, Library ▸ Open REPL, the palette) asks first on production (GuardedAction.repl, every time), then resolves the target with snapshot(for:) and opens ProjectREPL.terminalRequest; replKind(for:) is known for local projects and the sandbox, nil for Docker and SSH targets (chosen there). It is never called by opening, importing, or restoring code. |
AppModel+Tests.swift | The Tests group (#40). testDetection(for:) checks local projects and the sandbox on this Mac (ProjectTests.detect(projectDirectory:), nil for Docker and SSH targets); offersTests(for:) is true for those with a runner and tests, and always for Docker and SSH targets (they choose there). runTests(_:in:window:) refuses production targets (a notice with ProjectTests.productionReason), resolves the target with snapshot(for:), checks the environment again, and opens ProjectTests.terminalRequest like Open REPL (copied when no terminal is available). chooseTestFile(in:window:) is File… for local projects and the sandbox: an NSOpenPanel sheet starting in the first test folder, whose delegate dims and refuses anything outside the project's folder; the file runs as its path relative to the project. |
AppModel+SSH.swift | SSHConnectionStore (per-profile status, Connect… requests, Test Connection results, drift warnings) and the SSH API: sshEndpoint(for:), profile save/remove/touch, refreshSSHStatus (local socket check), connectSSH/disconnectSSH, sshSnapshot(for:profileId:), testSSHConnection, local-folder suggestions (lookUpFolderSuggestions, useSuggestedFolder), and the drift check (updateDrift, checkDriftOnServer, after Connect…, Test Connection, and the first run of a session when the profile enables it). In Debug builds RUNLET_SSH_CONFIG replaces ~/.ssh/config and RUNLET_SSH_EXECUTABLE replaces /usr/bin/ssh (the screenshot tour uses Tests/Fixtures/fake-ssh/ssh). |
AppModel+AppInfo.swift | App Info per target (AppInfoStore, keyed by TargetRef.stableKey, states idle/loading/loaded/failed): openAppInfo(for:refresh:present:) follows AppInfoPolicy (shows the cache, or asks on production with GuardedAction.appInfo, then resolves the target with snapshot(for:) and calls ExecutionEngine.loadAppInfo); cancelAppInfo(for:); forgetAppInfo(for:) (called by targetEdited); frameworkChipText(for:) (the last run's framework, else the target's facts). Only the framework chip, Show App Info, and Refresh call it. |
AppModel+Production.swift | ProductionConfirmation and the ProductionGuard store (pending confirmation per window, ProductionGrace), guardProduction(_:target:text:…) (runs the action now or asks), confirmProduction/cancelProduction, and targetEdited (revokes the grace; called by every target save). run asks before startRun; loadCommands, runProjectCommand, openSSHShell, openREPL, and App Info loads ask every time; targetEdited also drops the target's App Info. |
AppModel+QuickRun.swift | The Quick Run panel (#25): QuickRunModel (the draft, the panel's TabModel in no window, whether it's open, the hotkey's status), showQuickRun/toggleQuickRun/closeQuickRun, setQuickRunTarget (offered targets only), runQuickRun (⌘R: refuses a production target, then startRun), openQuickRunInTab (moves the tab into a window with adopt(_:into:)), and applyQuickRunHotKey. |
GlobalHotKeys.swift | CarbonHotKeyRegistrar: Quick Run's global shortcut through RegisterEventHotKey (exclusive; no Accessibility permission) and CopySymbolicHotKeys (macOS's own shortcuts). DebugHotKeyRegistrar stands in for it in Debug runs with RUNLET_DEBUG_* variables, which never register one. |
AppModel+Terminal.swift | Terminal API: installTerminalSupport() sets AppModel.openTerminal (open a TerminalRequest as a tab in the active window and show the panel) and hangs up every shell on willTerminate; toggleTerminal() and newTerminal() act on the active window; openTerminal installs the ShellIntegration scripts (in AppPaths.shellIntegration, rewritten when outdated) for requests that type a command; a shell that exits with 0 closes its tab, but command tabs (isCommand) and failures stay open; runTerminalAgain replaces a finished command tab with a fresh session for the same request; closeTerminal asks only when a foreground program other than the session's own process is running; closeFocusedTerminal() lets Close Tab (⌘W, from the command registry, so a remapped shortcut works too) close the terminal tab whose view has keyboard focus instead of the editor tab; closing the last terminal tab hides the panel and gives the keyboard back to the editor if the terminal had it; openContainerShell resolves the Docker profile with snapshot(for:) (an ambiguous or recreated container opens the choice sheet; nothing is substituted) and runs docker exec -it [--user U] -w <dir> <id> sh -c 'command -v bash >/dev/null && exec bash || exec sh'. New shells start in the selected tab's target directory: the project path, the sandbox install, or a Docker profile's local source (else home). windowDidClose ends the window's sessions. |
AppModel+SnippetInputs.swift | SnippetInputRequest (one pending input form, for the window that opened the snippet) and askForInputs(of:…), which every snippet open and ⇧↩ insert goes through: without @input lines it opens at once, otherwise confirmSnippetInputs hands the code with the values to the open action and cancelSnippetInputs opens nothing. Nothing runs (#14). SnippetInputDebugSteps.swift adds DEBUG steps that drive the form. |
AppModel+Promotion.swift | File ▸ Save as Artisan Command… and Save as Test… and the Snippets panel's same actions (#39): promotionUnavailableReason (SQL, no local project folder, not a Laravel-family project for commands), promote(…) (a save panel in app/Console/Commands or tests/Feature with the name filled in; PromotionNameValidator refuses names that can't hold the class), and PromotedFile for the sheet afterwards. Writes only the chosen file; runs nothing. |
AppModel+MCP.swift | The MCP server in the app (#43): MCPStore (listener, connections, the approval queue, the last MCP run), MCPConnection (client name, its MCPSessionAllowance, its tab), and MCPApprovalRequest (with the target's settings as the sheet showed them). Handles tool calls on the main actor; run_php resolves the target, asks MCPApprovalPolicy, queues a sheet on a window without another sheet (brought forward; a short pause between sheets and a check that re-shows one SwiftUI didn't attach), re-checks the target on Run, records the session allowance when it was ticked, and runs through startRun with a RunObserver feeding MCPRunReport. Withdraws requests on cancel, disconnect, expiry (5 minutes), or when the server is turned off. pruneMCPAllowances(), called from saveLibrary(), drops allowances of targets that were edited, removed, or marked production. |
AppModel+TableBrowser.swift | Browse Table (#151): TableBrowser (@Observable: the table, dialect, driver, target, connection, the weak tab it was opened from, editRefusal, page size, sort, filter rules, the page, pending SQLTableEdits.Changes with the grid's display and marks, the cell editor, Review Changes, phase and report), browseSchemaTable (opens the window through ResultWindows.openBrowser and reads the first page), loadTablePage (guardProduction(.sql, …, sqlTable: .browse), then SQLTabRun.browseCode), applyTableChanges (the statements as SQLStatementChecks with sqlTransaction: true, sqlTable: .apply, then SQLTabRun.applyCode, one HistoryEntry, and the page read again off production), and startTableRun (like Load Next: sqlSnapshot, a pseudo tab id, the saved connection on the request, Stop through engine.cancel(runId:), trackDatabaseWork with .browse/.applyEdits). |
AppModel+Connections.swift | The Connection Manager (#180): ConnectionProviders that read existing state and close through each subsystem's own path: SSHConnectionProvider (profiles whose control socket is up; since = the socket's birth time; Close = disconnectSSH(_:confirmed:)), SSHTunnelConnectionProvider (SQLTunnelStore.active; Close = closeSQLTunnel(_:force: true)), TabRunConnectionProvider (tabs' runs: PHP runs, and SQL statements, Run All, and Explain as database sessions with the sqlSession id; Close = stop(_:)), DatabaseWorkConnectionProvider (DatabaseWork registered with trackDatabaseWork(_:until:) by Load Next, Load Schema, Show Definition, and the Server section's reads and actions; Close = their own Stop or Done), and MCPClientConnectionProvider (Close = disconnectMCPClient). activeConnections aggregates them (reading it in a view observes them all); requestCloseConnection asks per the list's rule (ConnectionManagerStore.pendingClose), and closing marks rows whose Close is under way. Nothing polls the network; the window re-checks the SSH control sockets on this Mac every 3 s while it is open. |
AppModel+Logs.swift | The Logs window's state (#20): LogViewerStore (target, filters, files found per target, Find Logs results, Other Path… entries, run marks), LogSession (one source: its LogBuffer, state, notices, Pause, the file follower or the remote LogProcessFollower), LogSource (a file on this Mac, in the container, on the server, or the container's output). logSources(for:) lists a target's sources; files on this Mac are tail-read off the main thread and followed at once; followLog starts a remote follow after the production question and the SSH connect question (ensureLogConnection, as tunnels ask), resolving the container like runs; logRunStarted/logRunEnded (called from startRun) note the target's log sizes for Last Run; logFrameDestination finds frames with #8's FrameSourceResolver over logPathMapping(for:) (project files to the external editor, others to the read-only peek next to the entry). LogFollowConnectionProvider lists remote follows in the Connection Manager (log:<session>). |
CommandLineRequests.swift | Receives runlet requests: the distributed notification dev.runlet.Runlet.cli.open (only those addressed to this process) or the --runlet-open-request launch argument. Each request ID is handled once, after the first window is up, and answered with an OpenReply notification. AppModel.open(_: OpenRequest) resolves --target, opens folders (openFolder: openProject(at:) plus openTargetInTab), files (openFile, then the target), and workspaces, in a new window for -n, and never runs code. AppModel.open(_: URL) also sends folders (Finder, the Dock, open -a) to openFolder. |
FileSync.swift | File-backed tabs follow their files. FileSyncStore (per AppModel, keyed by tab id) holds a FileWatcher per tab, the contents each tab last loaded, saved, or accepted (its baseline), and open DiskIssues. syncFileWatchers() runs when the session is saved, after a file is opened or saved, and once the first window is up, so exactly the open file tabs are watched; returning to Runlet re-checks them all. checkDisk reloads a tab without unsaved edits (keeping the caret and scroll position, undoable), shows DiskIssueBanner with Reload / Keep Mine when the tab has edits (or, restored from the session, differs), and Save / Dismiss when the file is gone. confirmSaveOverDiskChanges makes ⌘S ask before replacing a file another app changed. Opened and saved files are noted as recent documents. Nothing here writes a file on its own or runs code. |
DockMenu.swift | applicationDockMenu: the Dock icon's menu lists up to eight recently used local projects and Docker profiles (by lastOpenedAt); choosing one opens it with AppModel.openTargetInTab, which reuses a blank current tab or adds a tab, and never runs code. macOS lists recent documents (PHP files and workspaces) on its own. |
AppUpdater.swift | In-app updates (#233): AppUpdater (@Observable, on AppModel.updater) is Sparkle's SPUUpdaterDelegate and SPUUserDriver. See In-app updates. UpdateDebugSteps.swift has its update… debug steps. |
SelfTest.swift | Runlet --self-test [--docker] checks a packaged build without the UI and prints a JSON report: bundled resources, the bundled runlet tool (--version), sandbox installation, a sandbox run with local PHP (skipped without compatible PHP), optionally a Docker sandbox run, and PHPantom startup plus completion from Contents/Helpers. It uses RUNLET_DATA_DIR or a temporary directory, never the user's data. |
AppModel.swift (AppResources) | Bundle locations: Contents/Resources/Runner/runlet-runner.php, Contents/Resources/Sandbox/laravel, Contents/Helpers/phpantom_lsp, and Contents/Helpers/mago (Format Code). |
Editor/ (AppKit)
| File | Contents |
|---|---|
CodeTextView.swift | NSTextView subclass for code: rich text, smart quotes/dashes, text replacement, spelling, link detection, inline predictions, and Writing Tools are off; the find bar and incremental search are on; undo is on. It adds auto-indentation, bracket and quote pairing, soft tabs, indent/outdent of selected lines, // comment toggling, Ctrl-Space or ⌥Esc for completion, and mouse-rest hover. Escape alone that neither the controller (completions, hover, signature and inline-value panels) nor the text system (a visible find bar, marked text) needed goes to onUnhandledEscape (#60: Escape hides the output pane); ⌘., which AppKit also sends as cancelOperation:, never does. Accessibility identifier code-editor. |
EditorController.swift | Owns one tab's scroll view, text view, and ruler for the tab's lifetime, so undo, selection, scroll position, and input-method state survive SwiftUI updates and tab switches. It debounces highlighting (30 ms), highlights matching brackets, marks the execution-error line, and draws diagnostics as dotted underlines. applySettings(_:) applies EditorPreferences (font family with a system-monospaced fallback, size, line height, ligatures, soft wrap, indentation, appearance); soft wrap tracks the visible width (clip view minus its content insets) and, as layout-manager delegate, never breaks a line inside an operator. As text-storage delegate it gives every edited range the editor's font, paragraph style, and color, so code loaded into an empty editor or put back by undo looks like the rest (#114). Completion is requested after ->, ::, $, \, or two identifier characters, and explicitly; signature help after ( and ,; hover on mouse rest. |
EditorController.swift (LanguageBinding) | Connects a document to a PHPantom session through the scratch URI and the synthetic-tag mapping. It sends the full text with a new version on every change, maps diagnostics back to editor ranges, and drops diagnostics tagged with an older document version. |
PHPHighlighter.swift | A single-pass UTF-16 PHP scanner (heredoc and interpolation are approximated; magic comments are their own token kind) and EditorTheme for light and dark appearance |
InlineValueOverlay.swift | Magic comments' values in one editor: InlineValueOverlay draws each visible line's InlineSummary after its text (over CodeTextView.draw, on a soft pill, redrawn at most about 20 times a second while hits stream), the magic comments' highlight behind the text (drawBackground), and the gutter markers; without soft wrap it asks for a text view wide enough for the drawn values. It follows edits through the text storage delegate (InlineLineTracker). InlineValuePanel is the hover panel (a non-activating PopupPanel child window hosting InlineValueDetailsView: per magic comment, the selected hit's ValueTreeView, its error or time, and the list of hits). |
EditorScrollView.swift | The editor's NSScrollView. The clip view extends under the line-number ruler, with a left inset of the ruler's thickness, and AppKit sets that inset while tiling without moving the clip view. tile() keeps the scroll offset from the text's leading and top edges across inset changes, so a new or restored editor starts with column 1 just right of the gutter, and widening the gutter past 99 lines hides nothing (#78). |
LineNumberRulerView.swift | Line numbers for logical lines (drawn on a wrapped line's first row, aligned to the text baseline), static-diagnostic markers by severity, the execution-error marker, and a bar between the number and the code on lines whose magic comments ran (warning-colored when one shows nothing). For PHP tabs (#22): fold controls in a column of their own, no numbers on folded lines, and a light bulb (a quick fix on the caret's line) in place of its marker |
EditorFonts.swift | EditorPreferences (what CodeEditorView re-applies) and EditorFonts: font resolution (ligatures off also disables calt, which programming fonts use for ligatures) and the installed fixed-pitch families (monospace trait or equal advances) |
EditorPopups.swift | PopupPanel (non-activating child panel), CompletionPopup (table completion-list; keyboard focus stays in the editor), and InfoPopup for hover, diagnostics, and signature help with light Markdown rendering |
EditorNavigation.swift | Go to Definition (F12, ⌘-click), Find References (⇧F12), and code actions (⌥↩, the gutter's light bulb) for one editor (#22), with context-menu items. Destinations come from NavigationResolver; the app's EditorNavigationHost (TabNavigationHost in AppModel+Navigation.swift) opens project files in the external editor. applyCodeAction applies ScratchEditPlanner's edits as one undo step. |
NavigationPopovers.swift | CodePeekController (a read-only, highlighted peek scrolled to the definition, with Open in editor and Reveal in Finder; accessibility identifier code-peek) and NavigationListController (references, several definitions, or code actions; references / code-actions) |
EditorInlayHints.swift | Inlay hints: a marked .kern on the character before each hint makes room, and the label is drawn there over the text; the characters and undo never change. Requests cover the visible lines, debounced; edits drop the hints they touch and move the rest. |
EditorFolding.swift | Folding: folded characters stay in the text storage and are left out of layout through the layout manager's delegate (null glyphs, newlines with no advance; the first one is the ⋯ placeholder). The ruler hides folded lines' numbers and draws the fold controls. The caret or an edit inside a fold unfolds it. |
EditorLineMoves.swift | Move Line Up/Down and Duplicate Line Up/Down (#234): LineCommand (ids, titles, default shortcuts), EditorLineCommands (acts on the key window's editable editor; otherwise hands the key back to the focused view), and EditorController.perform(_:), which applies LineMove as one undo step that also restores the selection, and folds again the blocks moved over. |
CodeEditorView.swift | The SwiftUI wrapper. It installs the selected tab's existing editor; SwiftUI updates never recreate editors. |
Features/ (SwiftUI)
| File | Contents |
|---|---|
MainWindow.swift | MainWindow (toolbar with target menu, Run, Run Selection, Stop, Save Snippet, terminal toggle, inspector toggle; sheets and alerts; the window's TerminalPanel below the selected tab's content), TabStrip (new tab; the shared context menu, TabContextMenu; compact pinned tabs first, #279, see tabs.md), TabContent (editor and output in a PaneSplit, side by side or stacked per the output-layout setting, with the divider position remembered per layout; the output pane shows when AppModel.isOutputPaneShown(for:) says so, #60), TargetMenu, Banner, and StatusBar (Ready, Preparing, Running or Stopping with elapsed seconds, Completed/Failed/Stopped with milliseconds, the PHP version of the last run, the framework chip (AppInfoButton: the last run's or the target's framework, else "App Info"; opens App Info), and PHPantom state: ready, limited, indexing, restarting, failed, or no completion) |
OutputPane.swift | Output with three display modes: Structured (cards for stdout/stderr, dumps with line links, results, errors with stage, line/column navigation, a source excerpt, and traces (StackTraceView, #8); file paths outside the snippet are FileLocationLinks that open in the external editor; captured mail as MailOutputRows; inspector warnings), Plain (CLI-style transcript), and Raw (exactly the bytes PHP wrote to stdout and stderr). Structured is StructuredOutputList: a lazy stack of rows, one per card and one per piece of printed output (OutputRow), showing the last 5,000 lines of a printed output and the last 1,000 cards (left out 250 at a time, with Show All), and following new output while scrolled to the bottom. Plain and Raw are TranscriptView, a native text view that appends new output (selection and Find work on all of it). In At once mode a running tab shows HoldingOutputRow. With inspector sections, OutputSectionBar switches the pane to InspectorSectionView. Results and dumps with an HTML preview show it first (ValueContentView: Preview, Tree, Table). The header of a PHP tab always shows MailInterceptionChip, the target's mail mode (Intercepting Mail, Sending Mail, or Mail: inspector off), whose MailInterceptionPopover switches the target's Mail option (#193). Copy Output, an export menu (Copy Output as Markdown, Save Output As…), and Clear (which also clears the inspector). Result and dump cards copy their value as text, JSON, PHP, or Markdown; table rows copy as JSON, a PHP array, or CSV from their context menu. Web links in stdout/stderr cards and in Plain and Raw mode are clickable. The finished row shows total/peak memory/query time plus available bootstrap/execute timings and local start time. The row and completed status share a detailed tooltip (TabModel.timingDetails); completion query metrics survive Clear Output until the next run. ValueTreeView expands values lazily (collapsed, first level, or all). ValueContentView offers JSON, searchable text, bounded PNG/JPEG/SVG, and restricted HTML views for strings (#7, guide), a Table view for tabular values, and ValueTableView (ValueTableGrid.swift) is a filter field, Open in Window, and CSV copy and export above ValueTableGrid, a native NSTableView that makes views only for the visible rows, built from ValueTable in RunletCore (#162). |
SnippetInputSheet.swift | SnippetInputSheet (a parameterised snippet's form: a row per input with a text field, menu, or checkbox, validation messages, unreadable declarations, a preview of the PHP lines, Cancel and Open; shown by MainWindow for its window) and SnippetInputsBadge (the Snippets panel's inputs and input-problem badges). |
PromotedFileSheet.swift | After Save as Artisan Command… or Save as Test… (#39): the saved file, how to run it once reviewed, its TODOs, and Open in <editor>, Reveal in Finder, and Done. |
Sheets.swift | ContainerChoiceSheet (explicit container choice after recreation or ambiguity; never runs code), SaveSnippetSheet (personal, or .runlet/snippets in the target's project; asks before replacing a file), ProjectSettingsSheet (per-project PHP executable, language PHP version, strict-types and mail-interception overrides), StrictTypesPicker (Default / On / Off), MailInterceptionPicker (Default / Intercept / Send), and PHPPicker |
InspectorViews.swift | The run inspector in the output pane. OutputSectionBar (Output, then the run's sections with counts; an orange mark on Queries with duplicate/N+1 hints and on Mail with intercepted messages). QueriesSectionView: summary (count, total time, repeated statements), hint chips that filter to one group, Group Similar, a filter, and QueryRowViews (index, time with the slowest in orange, connection and driver, hints, snippet-line or file link, the statement with bindings inlined, expandable to the SQL with placeholders and the typed bindings; Copy SQL, Copy SQL with Bindings, Copy Bindings as JSON, Go to Line). MailSectionView and MailCard (status: sent, intercepted, or queued; headers, mailable, mailer, attachments, and one HTML preview at a time), with a banner when mail was intercepted or queued. RecordsSectionView for Log (level, message, context) and driver sections (values like dumps, HTML with a preview on request). Sections that reached a limit say how many records were left out. |
RunRecorderViews.swift | The HTTP, Jobs, and Events sections (#5): HTTPSectionView and HTTPRowView (method and status badges, URL, FAKED, time, expandable redacted headers and bodies, a filter), JobsSectionView and JobRowView (status, title and wrapper, connection and queue, delay, time, exception, details), and EventsSectionView and EventRowView (name, payload tree, a filter). Expanded rows live in TabModel.expandedRecords. |
BenchmarkViews.swift | BenchmarkCard (in the output where bench() ran and in the Benchmarks section): the stats grid, BenchmarkHistogram with median/p95 markers, BenchmarkSparkline in run order, a comparison of several callables (relative bars and a table), and notes (early stops, caps, Laravel's averages, the timer floor); BenchmarksSectionView. |
FlameGraphView.swift | The Profile section: ProfileView (samples, engine, truncation notes, search, Reset Zoom, copy as collapsed stacks, hottest functions), FlameGraphCanvas (a SwiftUI Canvas, root on top, 18 pt rows, colors by snippet, project code, vendor/, and Runlet/folded frames; hover tooltip with function, file:line, samples and %, click to zoom, context menu with Go to Line or Open in the external editor), FrameKind, and ProfileOutputRow (the output's line pointing at the flame graph). DEBUG steps `flame:hover |
HTMLPreview.swift | HTMLPreviewView (HTML, Text, and Source; Load Remote Images per preview, off by default; Copy HTML/Text; Open in Window) and LockedWebView: a WKWebView with JavaScript off, a non-persistent data store, a content rule list that blocks everything but about: and data: (remote images only when allowed), a Content Security Policy injected into the document, and a navigation delegate that cancels every navigation (clicked links open in the default browser). The HTML loads only after the rule list is installed; if it can't be compiled, the preview stays off. ReadOnlyTextView shows large text bodies; PreviewWindows opens a preview in its own window. |
SourceExcerptViews.swift | Source excerpts in error cards (#8): ExcerptSource (the run's code, a FrameSourceFile, or unavailable), AppModel.frameSourceResolver(for:) (the tab's editorPathMapping and the last run's snapshot), SourceExcerptView (loads through SourceExcerptStore, dedents, highlights with PHPHighlighter and the editor theme, marks the line, labels a local copy, says "Source not available here" with the path; a click opens the tab's line, a project file in the external editor, or vendor code, files outside the project, and any file without an editor in CodePeekView, #22's CodePeekController in a popover), and StackTraceView (the trace for ErrorCard and SnippetMessageCard; frames with source open to their excerpt, the first project frame open at first). Debug step excerpt-state (SourceExcerptDebugSteps.swift) prints what each card shows; scripts/source-excerpt-screenshots.py takes the screenshots |
DockerProfileEditor.swift | DockerProfileForm creates and edits one Docker profile in place (it never saves): searchable running-container list, Compose identity, name, working directory with suggestions, PHP executable, execution user, temporary directory, optional local source and language PHP version, auto-resolve, and a probe (Test) that runs only php -r in the container. Browse… next to the working directory opens RemoteDirectoryBrowser over the selected running container (DockerCLI.listProfileDirectory, with the profile's PHP and user, fixed when clicked); opening or editing a profile lists containers but never execs into one. DockerProfileEditor is the single-profile sheet around it (target menu, ⇧⌘N, Settings ▸ Targets ▸ Edit…) with Delete/Cancel/Save. DockerProfile.newDraft() and normalizedForSaving are shared by both. |
SSHProfileEditor.swift | SSHProfileEditor (sheet) and SSHProfileForm: host with ~/.ssh/config aliases and the ssh -G summary, overrides, directory with Detect (a popover of the home folder and application folders) and Browse…, PHP with Test Connection suggestions, authentication and keep-alive, compression, local folder, and SSHProbeResults. Validation runs on the saved form of every value (SSHProfile.normalized), and Connect… from the sheet steps aside and reopens it after the login (SSHConnectionStore.draftsAwaitingLogin), even for a profile that can't be saved yet. |
SSHConfigImport.swift | SSHConfigImportSheet: Import SSH Hosts from ~/.ssh/config (aliases from SSHConfigHosts, each with its ssh -G summary, an optional directory, and an environment preselected by SSHHostImport.likelyEnvironment; aliases already used by a profile are shown but not importable). Reads the config only; nothing connects. |
SSHContainerStepViews.swift | SSHContainerStepSection ("Docker on This Host" in the SSH form: the container step's fields, List Containers…, and Browse… inside the container), RemoteContainerPicker (the server's running containers grouped by Compose project; lists only when opened), and RemoteContainerCheckResults (Test Connection's container results). |
RemoteDirectoryBrowser.swift | RemoteDirectoryBrowser (Browse… for an SSH profile's directory, its container step, and a local Docker profile's working directory: a folder picker with a path field, breadcrumb, up and home, application badges, symlinks kept as chosen, hidden folders on request, inline errors, and a notice when the listing followed a recreated container; a subfolder the listing user can't open can't be chosen) and DetectedDirectoriesView (Detect's popover). |
AppInfoViews.swift | App Info (#19): AppInfoButton wraps the status bar's framework chip and the vertical tab card's (Chip.opensAppInfo) and owns the popover; it answers Show App Info (Notification.Name.appInfoRequested). AppInfoPopover: the driver and target, the age, Refresh or Stop, the sections as label/value grids with a copy button per value (redacted values show a lock and can't be copied), notes, Copy All, and errors with Try Again. ChipButtonStyle tints chips on hover. |
ProductionViews.swift | EnvironmentBadge (PRODUCTION / STAGING), TargetEnvironmentFields (environment and colour pickers for project options and both profile forms), and ProductionConfirmationSheet (target, destination, first 12 lines; ⌘↩ confirms, ↩ and Esc cancel; the 10-minute checkbox for snippet runs). |
SSHConnectionViews.swift | SSHLocalFolderBanner (a suggested local folder, applied only with a click), SSHDriftBanner, SSHConnectionBanner (above an SSH tab: not connected, login ended, login in progress, or a run that failed for a reason Connect… fixes, with a Connect… button) and SSHConnectionControls (status with Connect… or Disconnect, for the profile form). |
ProfileManager.swift | Library ▸ Manage Profiles… (the Profiles window, SSH-7): the editor (DockerProfileForm or SSHProfileForm, edited as a ProfileManager.Draft) on the left and every saved profile on the right in two sections, Docker and SSH Hosts (search, Docker status dot from the last container listing or SSH status from the control socket, identity or user@host, local folder, environment, tabs using it; + menu for a new Docker or SSH draft and Import SSH Hosts, − delete through confirmDeleteTarget, Duplicate, Use in Current Tab, Connect…/Disconnect). ProfileManagerRequests lets Settings, menus, and the palette open it on a profile or with the import sheet. Connect… from its SSH form logs in with the draft's values without closing anything. Edits stay in a draft until Save (↩/⌘S); Revert restores the saved values; switching, creating, duplicating, or closing with unsaved changes asks Save / Don't Save / Cancel. Changes saved elsewhere are followed unless the draft has edits. A window accessor makes the close button and ⌘W ask first and ⌘S save while the window is key. |
SettingsView.swift | General (appearance, output-pane layout, Run prefers selection, strict types, Output (Realtime or At once, hide the output pane until a run, Escape hides the output pane, show Eloquent models as Values or Object), magic comments on/off, run inspector: record queries/mail/logs, intercept mail, previews; default target, history limit, clear history), Editor (font family, size, line height with preview, ligatures, soft wrap, tab width, spaces, external editor with custom command and Test, language service on/off), PHP (default PHP, discovered installations, rescan), Docker (status, CLI path), and Sandbox (Laravel version, runtime preference Automatic/Local PHP/Docker, runtime status, image download, reveal, reset) |
LibraryInspector.swift | Inspector with searchable history and snippets. Restoring history or opening a snippet only loads code. Keyboard-first: ⌘Y and ⇧⌘L focus the pane's search (LibrarySearchFocus); while typing, the best match is selected, ↑/↓ move the selection, ↩ opens it where Settings says (AppModel.openLibraryCode, like double-click), ⌘↩ opens it in a new tab, ⇧↩ inserts it at the cursor without its <?php tag (HistoryLog.insertable), and esc clears the search or returns to the editor. With the list focused, ↩ is the primary action, ⌘↩/⇧↩ work the same, ⌫ deletes (history at once, personal snippets after confirming) and selects the next row, and typing continues in the search field. Snippets show their target association (TargetBadge) and can be edited in a sheet whose text view disables substitutions. When the active tab's target has a project root, a read-only "Project snippets" section (from AppModel.projectSnippets(for:), kept up to date by ProjectSnippetCache's folder watchers, #51, and on its reload button) offers Open in Current/New Tab, Copy Code, Copy to Personal Snippets, and Reveal in Finder. |
MCPViews.swift | MCPApprovalSheet (client, target with its badge, where it runs, all of the code, production and SSH-connect warnings, "Allow runs on <target> from <client> for this session" on every target but production, expiry countdown; ⌘↩ runs, ↩ and Esc cancel), MCPSetup (the tool's path, the Claude Code command, and the mcpServers JSON), and AIClientsSettingsTab (Settings ▸ AI Clients: the switch, status, connected clients with their allowed targets and Revoke, setup, and the approval rules). |
TableBrowserView.swift | Browse Table's window content (#151): the header (badges, rows per page, Reload), server filter rules and Apply Filters, the edit bar (Add Row, Delete Row, pending summary, Discard, Review Changes), ValueTableGrid with marks, double-click, and edit menu items (.id per page, so columns fit each page), the footer (rows, status, Stop, Previous/Next), TableCellEditor (Edit Value with NULL and Default), and TableReviewSheet (each statement, its values, notes, Copy SQL, Apply). ResultWindow.swift's ResultDocument.browser switches a result window to it. |
ConnectionManager.swift | Window ▸ Connections (⇧⌘C, #180): ConnectionManagerView (one Window scene, connections; sections per kind with notes, rows with destination, owner, since, usage, details, production badge, Close, and a context menu with Reveal Tab; the empty state; Close's question as an alert) and ConnectionsStatusItem, the status bar's count (dimmed at zero; the tooltip has the counts per kind). ConnectionDebugSteps.swift adds the connections, connection-close, connection-confirm, connections-state, and connections-wait debug steps (scripts/connection-manager-screenshots.py). |
LogViewer.swift | View ▸ Logs (⌘L, #20): LogViewerView (one Window scene logs; closing it stops every follow): the target menu, Reload, Follow/Stop, Pause, Clear, a source list with Find Logs and Other Path…, the level picker, search, and Last Run, the entries (LogEntryRow: time, LevelBadge, channel, message with the context dimmed; opened, the message, context, and extra with FrameLinkedText links), and a status footer with counts and what was dropped. |
UpdateWindow.swift | The Software Update window (#233, UpdateWindow, an NSWindow with SwiftUI content): checking, up to date, the offer (version, size, ReleaseNotesView for the item's Markdown notes, Install and Relaunch, Later, Skip This Version), download progress with Cancel, unpacking, and problems (not set up, move to Applications, couldn't check, not verified, rolled back). |
CommandLineToolView.swift | Runlet ▸ Install Command-Line Tool… (CommandLineToolWindow): examples, a choice of /usr/local/bin, ~/.local/bin, or another folder with password and PATH notes (from HostShellEnvironment), the exact link it will create, Install/Replace and Remove Link (through CommandLineInstall, with administrator privileges via NSAppleScript only when the folder isn't writable), and a refusal while the app runs translocated. CommandLineToolSettingsSection shows where it is installed in Settings ▸ General. |
TerminalPanel.swift | TerminalPanelModel (per window, owned by WindowModel.terminals: sessions, selection, visibility, focus requests), TerminalPanel (resize handle, height 90 pt up to the window height minus ~250 pt, remembered in terminalHeight), TerminalTabStrip (tabs titled by the program's OSC title or the request title; × closes; "+" opens a shell, its menu adds "Shell in <profile> Container" for Docker targets, "Shell on <host>" for SSH targets, and the Option-as-Meta toggle; tabs running ssh show a server icon; chevron hides; a finished command tab shows a green check or an orange mark and offers Run Again in its context menu), TerminalNoticeBar (a slim bar above the terminal: "runs when the shell shows its prompt" with Run Now / Don't Run once a command has waited 15 s, or Run Again / Close for a finished command tab), and TerminalHostView, which re-parents the session's long-lived view and starts the process once it has a size. A window that opens with the panel shown starts one shell; closing the last tab hides the panel. |
TerminalSession.swift | TerminalSession: one SwiftTerm LocalProcessTerminalView subclass (10,000 lines of scrollback) and its process. A commandLine is typed (followed by Return) only once the shell is ready (see Terminal). Exits are detected from SwiftTerm and from a waitpid(WNOHANG) poll, because SwiftTerm 1.11 can miss an exit when it reads end-of-file first; both end in processEnded, which prints — Process exited with code N — (dim, red when non-zero) and calls onExit. Return in a finished command tab asks to close it (onCloseRequest). terminate() sends SIGHUP to the foreground process group and the shell, closes the pty, and reaps the child (SIGKILL after ~3 s). TerminalTheme holds the light and dark colors and 16-color ANSI palettes; the font is the editor's monospaced system font at the editor font size. |
LibraryKeyboard.swift | LibrarySearchField (an AppKit-backed field that hands ↑/↓, ↩, ⌘↩, ⇧↩, and esc to its pane), LibrarySearchFocus (focus requests from ⌘Y/⇧⌘L, answered by the frontmost window's field, also when it appears only after the request), AppModel.insertLibraryCode, and AppModel.focusSelectedEditor. |
InspectorList.swift | Keeping an inspector pane's list still (#320): SwiftUI's List estimates rows it hasn't drawn at about 28 points, so a resize or re-measure moves its scroller and rows. The rules for panes (a header and footer that keep their height, rows that take values instead of closures or settings reads, stable ids), StableInspectorList (evaluates a list only when its value changes), and InspectorActions (what rows do, in a box they compare by identity). InspectorScrollDebugSteps.swift adds the inspector-scroll, inspector-scroll-watch, inspector-scroll-state, inspector-scroll-sweep, inspector-click, and inspector-wait debug steps, which measure the visible pane's list (offset, document height, frame, AppKit updates, and which pane and row views were evaluated again); scripts/inspector-scroll-check.py runs them for every pane. |
Palette.swift | Open Anything (⌘P) and the Command Palette (⇧⌘P) in a child panel over the window. Open Anything searches targets, snippets, and recent files, plus the three Appearance commands when every word of the search names one of AppearancePreference.searchWords (PaletteQuery.names: "dark", "theme", "mode"; #135); a checked command shows a checkmark and its checkedLabel ("On", or "Current" for the Appearance choice); / scopes to local projects, @ to Docker profiles, # to snippets, and ! to history (runs on the current tab's target first, HistoryLog.ordered; matched on the code too; ↩ opens where Settings says, ⌘↩ in a new tab). Choosing anything never runs code. |
QuickRunPanel.swift | Window ▸ Quick Run (#25): QuickRunPanel (a borderless, non-activating, floating NSPanel that never becomes main; it takes ⌘R, ⌘↩, ⌘., ⌘W, and the editing keys itself, and is never key in scripted Debug runs), QuickRunPanelController (placement near the top of the pointer's screen, growing downwards), QuickRunView (target menu, the tab's editor without its gutter, the result, a compact error row, the status, Open in Tab), and QuickRunEditorStyle. |
ProjectCommandsView.swift | The Commands panel for the active tab's target: searchable, collapsible groups (top-level, namespaces, Composer scripts), a Run button per command, context menu (Run in Terminal, Copy Command, Copy Name), double-click to run, Refresh and Cancel, and banners for bootstrap errors and launch problems. Loads only when it appears for a target that was never listed (never for SSH or production targets). SSH targets get "List Commands on <host>" (or Connect… first for a password host that isn't logged in), a server icon on commands that run on the host, and a Shell on Host button. An Open REPL row under the header works in every state (it doesn't need the list) and names the REPL: "Tinker · php artisan tinker" when known, or "chosen on the server/in the container"; it is disabled while a password host isn't logged in. Under it, the Tests group (#40): the runner ("php artisan test · PHPUnit", or "chosen on the server/in the container"), Run All, File… (an open panel for local projects and the sandbox, an inline path prompt for Docker and SSH targets), and Filter… (an inline prompt with the command preview; Return runs, Escape cancels, and it closes when the tab's target changes). It is hidden for a local project or the sandbox without a runner and tests, disabled while a password host isn't logged in, and disabled on production targets with "Tests can reset the database; they're disabled on production targets." |
UI tests and screenshots use these hooks: RUNLET_DATA_DIR isolates app data, and UI tests seed State/*.json envelopes instead of driving file pickers. Docker scenarios run only when the runner gets TEST_RUNNER_RUNLET_DOCKER_FIXTURES=1, because the sandboxed test runner cannot call Docker itself. VisualTourUITests needs TEST_RUNNER_RUNLET_SNAPSHOT_DIR and points Runlet at Tests/Fixtures/fake-docker/docker, so screenshots never show the developer's real containers.
Runner and Transport
Build. The runner source is Resources/Runner/src/, written with PHP 7.4-compatible syntax: Inspector.php (the run inspector and its database hooks), Drivers.php (the driver API and built-in drivers), Recorders.php (the HTTP, Jobs, and Events sections' redaction and listeners, #5), Benchmark.php (Runlet\bench() and Runlet\Benchmark), Profiler.php (Profile Run's Excimer sampling), ModelValues.php (Values for Eloquent models, #307), Runner.php, SqlTab.php (SQL tabs' statement runner, #35), SqlConnect.php (saved database connections, #138), WordPressDatabase.php (WordPress's own PDO connection from wp-config.php, else $wpdb with the reason, #208), SqlSchema.php (their catalog reader for completion, the schema explorer, and the relations diagram's foreign key constraints, #128, #21, #153), SqlExplain.php (Explain Statement's dialects and Explain Analyze's guards, #147; Runlet\explainPlan(), which the inspector's Explain tab calls, #170), SqlDefinition.php (Show Definition's catalog reads and PostgreSQL reconstruction, #148), SqlCancel.php (Stop's server cancel, #144), SqlServerInfo.php (the Database pane's Server section: server details, sizes, sessions, Cancel Query and Kill Session, #150), SqlTable.php (Browse Table's checks and Apply's transaction with the exactly-one-row check, #151), SqlCsv.php (Export Query to CSV's streamed frames and Import CSV's transactional, bound inserts, #152), MagicComments.php (the magic comments' probe planner and the Probe runtime), and Panels.php (App Info's built-in sections, bounds, and redaction, #19). scripts/build-runner.php writes a single file, Resources/Runner/dist/runlet-runner.php, that contains:
- The runner, in that order.
- nikic/php-parser 5.9.0, with its namespace rewritten from
PhpParser\toRunletVendor\PhpParser\. Builders, pretty printers, and other unused parts are left out.
The scoped parser never collides with a project's own php-parser. Edit src/, never dist/.
Per-run script. RunnerBundle.script appends namespace { \RunletRunner\Runner::main('<base64 JSON>'); } to the bundle. The request carries:
protocolVersionandrunIdnoncemode:"run"(default) runscode;"commands"boots the project the same way and lists its commands instead (see Project commands);"panels"boots it and reports its App Info sections (see App Info)codebootstrap("auto")strictTypes: true, only when the run declares strict typesmagicComments: false, only when magic comments are turned off in Settings: the runner then leaves them alone (no probes, noprobesevent)inspector(runs only):enabled,interceptMail,previews. Without it the runner records nothing (except benchmarks and profiles, which the run asked for).profile(Profile Run only):engine(excimer),periodMs(1),eventType(wall, orcpuon Linux and BSD)- limits:
maxDepth,maxChildren,maxStringBytes,maxNodes,maxValueBytes, for magic commentsmaxInlineHits(100) andmaxInlineBytes(16 MiB), and for the inspectormaxQueries(2,000),maxRecords(2,000 other records),maxRecordBytes(8 MiB for all records), andmaxBodyBytes(2 MiB per HTML or text body)
Transport. The whole program is streamed to php on stdin, with the arguments -d display_errors=stderr -d html_errors=0 -d log_errors=0. Nothing is written into the project or the container, so read-only filesystems and non-root users work. The request at the end of the program (base64 JSON) is the only place a saved connection's password travels (sqlConnection, #138): never in arguments, environment variables, files, or the Run Log, which logs the script's size. The plan's first candidate was a per-run event file. It was not used, but the event contract is unchanged.
Event framing. Events are framed records on stdout:
0x1E "RL1:" <nonce> ":" <decimal byte length> ":" <json> "\n"- The nonce is 16 random bytes in hex, generated per run with
SecRandomCopyBytes. - All other stdout and stderr bytes are the application's raw output.
FrameDecoderhandles frames and markers split at any byte. It holds back a possible partial marker at the end of a chunk.- Frames with another nonce stay raw output.
- A truncated or malformed frame becomes a
transporterror.
Runner frame types.
started: pid, PHP version and binary, SAPI, working directory, framework, euid, andprofilers({excimer?, spx?}versions of the loaded profiler extensions)bootstrapped: framework, framework version,bootstrapMs, and the application'senvironmentwhen the driver reports onedump, with an optionalpreview(below)result:hasValue,value, and an optionalpreview:{kind, title, subject?, html, text?, htmlOmittedBytes?, error?}, the driver'spreview()of a returned or dumped object (mailables, mail notifications, views,Htmlable/Renderable, HTML Symfony responses), only wheninspector.previewsis on. A failing render sendserrorinstead ofhtml.error. Anexit()during bootstrap gets a hint: the driver'sbootstrapExitHint()(WordPress: the redirect it tried, its status, and the file and line that sent it, with advice forinstall.phpand HTTPS redirects), then the project file loaded last.notice:{message}, Runlet's own notice. Withlevel(notice,warning,error) anduser: trueit is a card from\Runlet\notice(),warning(), orerror()(#196), with the caller'sinSnippet/snippetLineorfile/line, and optionalcontext(a value node),omittedBytes, andexception(className, where it was thrown,trace,previous).SnippetMessage.noticeEvent(payload:)decodes a leveled one asRunEvent.Kind.snippetMessage, which never sets the run's error state.SnippetMessages.phpbounds them (16 KB per message, 200 cards and 8 MB per run, then one plain notice with the count).log:{source, message, detail?}, a Run Log line (Run ▸ Show Run Log): the driver chosen and why, the boot time and variables, a WordPress request's host and redirects,exit()during bootstrap with the last files loaded, and driver lines fromDriver::log(). The app adds its own lines:launch(the executable and arguments as one shell line, the working directory, and the runner script's size on stdin; never environment values),runner(started: PHP, binary, pid, uid),stderr,error, andexit(status, reason, exit code, elapsed).TabModel.runLogkeeps up to 500 lines per run.remember:{key, value}, a value the app keeps for this target until it quits and sends back as the request'shints(driver=builtin:<key>@<.runlet stamp>;wordpress.siteUrl=<wp-config.php mtime:size> <url>). The runner checks each hint is still valid before using it: a built-in driver must still match file-based detection, as well as the.runletstamp, so memory only skips project-driver discovery (#377). The WordPress URL keeps its ownwp-config.phpmodification-time and size check. The app drops all of a target's hints when its profile is saved or a run fails while booting. The hints remain a flat string map.inspector(runs with the inspector on, afterbootstrapped):sectionsthe driver shows even when empty,interceptMail(asked for),interceptingMail(a driver confirmed it),driverName,interceptMailReason?(why the driver can't intercept, #192)record:{index, section, kind, title?, inSnippet?, snippetLine?, file?, line?, data}.kindisquery(sql,bindingsas{type, value?, name?, size?, omittedBytes?},timeMs?,connection?,driver?,rawSql?,omittedBindings?,omittedBytes?),mail(subject,from/to/cc/bcc/replyToas{address, name?},html?,text?,attachments,mailer?,mailable?,caller?,intercepted,error?,queued?,queueConnection?),log(level,message,channel?,context?value),html(html),value(value),benchmark(source,method,resultsof{label, iterations, requestedIterations, warmup, stoppedBy, firstNs, totalNs, minNs, maxNs, meanNs, medianNs, p95Ns, p99Ns, stddevNs, opsPerSec, memory{peakBytes, perCallBytes, firstCallBytes}, histogram{lowNs, highNs, binNs, counts, above}, series, averageOnly?},budgetMs,overheadNs,notes), orprofile(engine,version,eventType,periodMs,samples,durationMs,outsideSamples,maxStacks,maxDepth,collapsed,framesof name →{line, file?, inSnippet?},truncated{stacks?, foldedSamples?, deepSamples?}). Unknown kinds decode asunknownso newer runners stay readable.recordLimit:{section, omitted, reason}(countorbytes), sent when the run finishes, for records the limits left outrollback(dry runs,request.rollback, #13):begun(the connections in transactions, before the snippet),warning(an implicit commit, a commit in the code, a change on a connection outside the dry run, a transaction that couldn't begin, orrefused: a statement Runlet stopped before it ran), andfinished(each connection's status and counts) fromResources/Runner/src/Rollback.php;RunEvent.Kind.rollback(RollbackReport)(RunletCore/RollbackMode.swift).Rollback::begin()runs afterinspect()(which a dry run calls even with the inspector off) and counts statements throughInspector::observeStatements(). Before a statement runs,Rollback::guard()refuses a MySQL/MariaDB implicit commit, and anything on a connection whose transaction couldn't begin, by throwingRunlet\DryRunRefused(its file and line are the snippet's;Runner::userTrace()skips the runner's leading frames): it is called from Laravel'sbeforeExecuting()and WordPress'squeryfilter (installed byRollback), and from Doctrine's SQL logger and DBAL 4 middleware throughInspector::guardStatements()/beforeStatement(). A transaction that can't begin inbegin()throwsDryRunRefusedtoRunner::main()("Rollback mode: nothing ran."); one that joins later throws out of theConnectionEstablishedlistener oradopt().Runner::finish()callsRollback::finish()after the inspector's finishers. See dry-run.md and drivers.md.commands(commands mode only):origin(composerordriver),source,commandsof{name, command, description, group}; the driver event also hasframeworkand, for project drivers,driverFilepanels(panels mode only):origin(builtin, thendriver),source,sectionsof{title, rows: [{key, value, redacted?}], omittedRows?}(values are strings, numbers, booleans, null, or lists of strings, already bounded and redacted),redacted, and optionalomittedSections,notes,error(a failing built-in orpanels()), anddriverFileprobes(runs with magic comments, before the snippet runs):probesof{id, line, kind, comment}(kindisvalue,time, orreached) andrejectedof{line, comment, reason, label}inline:{probe, line, kind, hit, t, value?, ms?, error?, sampled?, final?, omitted?}, one per hit while the snippet runs (see Magic comments)runnerFinished: reason,elapsedMs,peakMemory,executeMs
RunSession turns runnerFinished into the backend's finished event.
Run inspector. Runs create one Runlet\Inspector before bootstrap (so WordPressDriver can turn on SAVEQUERIES), then call the driver's inspect() after bootstrapped and before the snippet compiles; a throwing hook becomes a notice. Records carry the snippet line from the backtrace: frames of eval()'d code count as the snippet only when evaluated on Runner::evaluate's line, so other evaluated code (the DBAL 4 middleware) never claims a snippet line. Callbacks registered with atFinish() (the Eloquent query-log fallback) run in finish(), before recordLimit and runnerFinished. The drivers' hooks and the API are documented in drivers.md.
Bootstrap. Framework detection in auto mode:
laravelifartisanandbootstrap/app.phpexist.composerifcomposer.jsonorvendor/autoload.phpexists.plainotherwise.
Composer and Laravel projects require vendor/autoload.php. If it is missing, the runner reports a bootstrap error that tells the user to run composer install. For Laravel, the runner requires bootstrap/app.php, resolves the console kernel, calls bootstrap(), and reports $app->version(). Plain directories run without an autoloader, and relative includes resolve against the working directory.
Snippet compilation (SnippetCompiler):
- If the code does not start with
<?phpor<?=(after whitespace), the runner prepends<?phpon the same line, so line numbers do not change. Column numbers for parse errors on line 1 are corrected for the prefix length. WithstrictTypes, and unless the code has its owndeclare(strict_types=…)(found with the tokenizer),declare(strict_types=1);is inserted right after the opening tag on the same line, after the other steps, so lines and parse-error columns are unchanged. Whitespace before a snippet's own<?phpmoves after the declaration; a leading<?=gets<?php declare(strict_types=1); ?>in front. - The code is parsed with
ParserFactory::createForHostVersion(). If parsing fails, the runner retries with"\n;"appended, which accepts an omitted final semicolon. If that also fails, it reports aparseerror with the snippet line and column. - The runner finds the last top-level statement, looking inside a trailing namespace and skipping trailing comments (php-parser's
Nopstatements, so1 + 1; // notestill has a result). A final expression statement (other thanexit) becomesreturn <expr>;(returnis inserted before it and the rest of the statement becomes;). A finalreturnis kept. - A sentinel
return \RunletRunner\NoResult::instance();is appended. It goes inside a trailing braced namespace if there is one, and PHP mode is reopened if the source ends in inline HTML. In the result,NoResultmeans "no implicit result", which is distinct fromnull. - Magic comments become probe calls inserted at byte offsets (see Magic comments). All edits are insertions or replacements applied back to front; edits at one offset are joined in a fixed order (closing parts, replacements, inserted statements,
return, opening calls from the outermost in). - Without the tokenizer extension, no implicit result is captured and the runner emits a
notice.
Evaluation. eval() runs inside the private static method Runner::evaluate, so snippet variables live in their own function scope and never touch runner state. Every run is a new PHP process, so no state carries over between runs.
Dumps (installDumpHandler):
VarDumper::setHandleris installed onSymfony\Component\VarDumper\VarDumperand on any VarDumper class referenced by the files that define the activedump()/dd().- The search follows php-scoper-style aliases up to depth 3, which covers tools loaded with
auto_prepend_filesuch as global Ray. - If no VarDumper and no
dump/ddfunctions exist, the runner defines its owndump()anddd(). - A
ddcall is detected from the backtrace. It is markedorigin: "dd", and the run finishes with reasondd.
Values (ValueNormalizer):
- No getters,
__toString,__debugInfo,__get, or JSON serialization are invoked. A project driver's casters are the explicit exception:Casters.phpruns them for the classes they name, within these limits (Project Drivers ▸ Casters). - Limits: depth 8, 200 children, 64 KiB per string, 20,000 nodes, and 2 MiB per value.
- Objects get reference IDs, and an object seen again is marked
repeated. Array references are markedrecursion. - A result, dump, or magic-comment value that holds Eloquent models is walked twice, with the whole budget each time (#307):
valueis the full dump, andmodelValuesthe Values tree (ModelValues.php,normalizeViews()), where a model is its key, attributes, and loaded relations, a collection or paginator its items, and a list of models gets up tomaxRows(1,000) whole rows. Casters run once per object for both walks. See Eloquent Models: Values or Object. var_dumpandprint_rstay plain text on stdout.
Errors. Throwables become error events with:
- stage, class, and message (eval paths are rewritten to "snippet")
snippetLineor file and line- a trace of up to 40 frames, with runner frames removed
- the previous exception
A shutdown function reports fatal errors as FatalError with fatal: true and reason fatal. exit and die finish with reason exit.
Run Lifecycle (ExecutionEngine)
Snapshot. A
RunRequestcaptures:- the tab ID and document version
- the code, which is only the selection for Run Selection, with the selection's start position
- a
TargetSnapshotwith the resolved working directory, PHP executable, container ID, user, and profile revision
Later edits or target changes cannot redirect an active run.
Admission. Each tab can have one active run. A second
startfor the same tab throwsExecutionError.tabBusy. Docker targets require a located Docker CLI.Bounded concurrency. Runs in different tabs execute concurrently, up to
maxConcurrentRuns(default 4). Further runs wait for a free slot, in the order they were accepted (#183):ExecutionEngine.slotsandslotChanges(aRunSlotsafter every change) say which runs hold a slot since when and which wait, so the Connection Manager lists waiting runs as queued without polling. Stop on a waiting run takes it out of the queue before anything launches.Launch. The adapter builds a
ProcessSpec: an executable path plus an argument array, with no shell.SupervisedProcessthen:- starts the child with
posix_spawnin a new process group, with default signal dispositions - writes stdin on a background thread, with
SIGPIPEsuppressed - reads stdout and stderr on dedicated threads
- reaps the child on its own thread
RUNLET_RUN_IDis set in the runner's environment for every adapter.- starts the child with
Event pump.
RunSessiondecodes frames and numbers events 1…n. Byte order is preserved within each raw stream, but interleaving between stdout and stderr is not guaranteed.Output limit. Up to
RunLimits.maxRawOutputBytes(8 MiB) of raw stdout plus stderr is kept per run. Further output is still read and discarded, so a full pipe cannot deadlock the process, andfinished.truncationreports the discarded byte count. How the app applies the events is in Applying events.Exactly one
finished. It is always the last event:
| Situation | status | reason |
|---|---|---|
| Snippet completed | completed | completed |
dd() | completed | dd |
exit/die | completed if exit code 0, else failed | exit |
| Parse, bootstrap, or execute error | failed | error |
| PHP fatal error | failed | fatal |
| Stop after launch | cancelled | cancelled, or cancelled: <note> when the stop could not be confirmed |
| PHP or directory missing, container gone or not running, spawn failure, or process exited before the runner started | failed | launch-failed (with a launch error) |
Process ended without runnerFinished | failed | transport-closed (with a transport error) |
| Stop before the process launched | cancelled | cancelled (cancel reports "Stopped before the PHP process launched.") |
For Run Selection, the selection's start line is added to runner line numbers to map them back to the editor. On the selection's first line, the selection's start column is added too (RunRequest.editorColumn(forSnippetLine:column:)); later lines keep their columns.
Applying Events (#82)
AppModel.startRun reads a run's events through a RunEventFeed and applies them to the tab in batches, paced by OutputPacer: the first batch at once, then at most every 100 ms, and while the output is slow to lay out and draw, three times as long as the main thread stayed busy (up to 1 s). A run that prints thousands of lines or dumps therefore updates the tab a few times a second, and the UI keeps most of the main thread. finished is applied as soon as it arrives. The run's observer (an MCP client's run report, #43), the session hints, and what Runlet learns about the target get every event, in order, whatever the Output setting.
Output: Realtime or At once (Settings ▸ General ▸ Output, AppSettings.outputDelivery, Realtime by default; read when Run is pressed, for the whole run). TabModel.apply passes each event through the run's RunEventGate. In At once mode the gate holds the output (printed output, dumps, the result, errors, notices, inspector records, magic-comment values) and releases it, in arrival order, with finished, so a run that completes, fails, calls dd() or exit, is stopped, or loses its process or SSH connection shows everything that arrived (the engine always ends a run with finished). The status (running, elapsed time, Stop) and the Run Log stay live. Holding happens in the app, as #10 did for magic-comment values, so every target behaves the same and the PHP process holds nothing. Clear Output during a run drops what was held, as it drops what was shown in Realtime. Settings files from before #82 decode with Realtime, or At once when they had #10's Show values while the code runs turned off (streamInlineValues: false); that key is no longer saved.
Large output. Measured before #82 with a Debug build (local PHP 8.4 and the SSH fixture), the output fell minutes behind a run and froze the window: 5,000 dump() calls had 3,400 cards after 90 s, 200,000 echoed lines (2.3 MB) had 29 KB after 60 s, and output near the 8 MiB limit had 195 KB after 60 s, with the main thread busy the whole time. The causes were one SwiftUI update per event, a printed-output card laid out again in full for every chunk (one Text holding all of it), and scrolling to the last card after each one, which places every card above it. Now: events are batched and paced as above; printed output is a ChunkedText whose pieces are separate lazy rows; the Structured view shows the last 5,000 lines of a printed output and the last 1,000 cards (Show All lifts that; Plain, Raw, Copy and Save Output always have everything); and Plain and Raw are a native text view that appends. The same runs now finish in the tab within a few hundred milliseconds of the process (the PR for #82 has the numbers).
Magic Comments (#10)
//?, /*?*/, /*?->projection*/, and /*?.*/ show values in the editor while the code runs. compatibility.md lists what each form shows and the placements Runlet refuses.
Compiler (MagicComments, called by SnippetCompiler only when the code contains //? or /*?):
token_get_allfindsT_COMMENTtokens of the four forms, so text in strings, heredocs, other comments, doc comments, and inline HTML never counts. Each comment is resolved against the token before it (whitespace and comments skipped): (1) an expression ending there, the outermost first, looking inside parentheses, and inside an arrow function's body,yield,print, orthrow; (2) for//?and projections, a statement ending there on the same line (expression statements,return,echo;$i++;shows$iafterwards); (3) for//?and/*?.*/, a statement position between statements (in a statement list region found from the braces or alternative-syntax colons), whereProbe::hit(id);orProbe::mark(id);is inserted (before areturn,break,continue,throw, orexitthat ends right before the comment; around the final expression when it becomes the result).- A walk over the php-parser tree records each expression's context: read, written, taken by reference, checked by
isset/empty/??, a constant expression, the start of a{$…}interpolation, a slot that can't hold a call, or a call argument. An argument's by-reference-ness comes from the callee: functions and classes the snippet declares (useimports and namespaces resolved by hand), and loaded functions and classes through reflection (classes are never autoloaded for this); methods on objects are unknown. Read contexts get\RunletRunner\Probe::at(id, <expr>); by-reference ones whose expression is a variable, element, or property getProbe::ref(…)(a by-reference parameter and return, so=&,foreach (… as &$v), by-reference returns and yields, and by-reference arguments keep their references); projections getProbe::tap(id, <expr>, fn ($__runletProbe) => $__runletProbe<chain>), timingsProbe::time(id, <expr>). A wrap that would end a nullsafe chain's short-circuit (a?->chain followed by->) is refused. Everything else is refused with a reason and a short label. - Nothing is pretty-printed. Inserted text has no line breaks (a projection is re-tokenized onto one line without comments), and the instrumented source must parse with the same parser and keep its line count; otherwise every probe is dropped with a notice and the snippet runs as written.
Runtime (Probe, armed by Runner with the compiled probes after the probes event; begin() right before eval):
- Each hit counts. The first
maxInlineHits(100) hits of a probe are sent with a value (aValueNormalizerlimited to depth 5, 100 children, 8 KiB strings, 2,500 nodes, 256 KiB); later hits are sent about every 250 ms withsampled, andfinish()(fromRunner::finish, beforerunnerFinished) sendsfinalcounts for probes whose last hit wasn't sent. AftermaxInlineBytesof values, hits are sent without values (omitted: bytes). - A projection runs only for hits whose values are sent; one that throws is reported as
errorand the value passes on./*?.*/measures from the previous mark of the run (any/*?.*/), or frombegin(). Probe methods never throw and call no user code besides projections. - Events go through
Channellike every other frame, so they stream on every transport.
Settings (General ▸ Magic Comments, AppSettings.magicComments, on by default; read when Run is pressed, for the whole run):
- Show values of magic comments off:
RunRequest.magicCommentsis false, soRunnerBundle.scriptsendsmagicComments: falseandSnippetCompilerskipsMagicCommentsentirely. There is one script per run for every adapter, so no target gets probes. The editor stops highlighting them (they color as ordinary comments) and shows no values or gutter marks, also for events of a run that was already going. Profile Run always sendsmagicComments: false, so its flame graph shows only the code as written. - When values appear follows Settings ▸ General ▸ Output (#82, see Applying events), which replaced #10's Show values while the code runs switch: At once holds inline events with the rest of the output. Holding them in the app rather than batching in the runner keeps one code path for every target, keeps the values already received when a run is stopped (SIGKILL), crashes, or loses its connection, and costs the PHP process no memory. Runner-side batching would save little: each value has to be captured when its line runs anyway (objects change later), so only the small per-hit frame writes would move to the end.
Editor: TabModel passes .inline events to the tab's InlineValueOverlay with RunRequest.editorLine(forSnippetLine:), so Run Selection maps back to the selected lines. Values are drawn only for visible lines, and the hover panel and the gutter markers read the same InlineValues. The next run clears them; edited lines lose theirs. Values never start a run.
Profile Run
Run ▸ Profile Run (⌥⌘R, and in the Command Palette; #41) runs the tab like Run, through the same production confirmation and the same target resolution, with request.profile set.
- Detection. Every run's
startedframe lists the PHP's profiler extensions, and so do PHP discovery (the sandbox's and local projects' PHP, with their php.ini), the Docker profile's Test, and SSH Test Connection (PHPProfilers). The app keeps them per target (TargetFacts.profilers). Settings ▸ PHP shows a badge per installation, and both probes show a Profilers row. - Availability.
ProfileRunAvailability: enabled when the target's PHP loads Excimer, or when nothing is known yet (a container or server not run or probed since); disabled with the reason otherwise ("Profile Run needs the Excimer extension, which PHP 8.4.25 (Herd) doesn't load."). The Command Palette lists the disabled command with its reason; the Run menu item shows it as its tooltip. - In the runner. Without Excimer, the runner reports
ProfilerUnavailableright afterstartedand stops before the project boots, so nothing runs. OtherwiseProfilerstarts anExcimerProfiler(wall time, 1 ms period) just beforeRunner::evaluate()and stops it when the snippet returns, throws, or exits; the application's bootstrap is not profiled. A flush callback every 2,000 log entries keeps memory bounded. Each sample's trace is cut atRunner::evaluate(), so the runner's frames never show; the snippet's top level becomessnippet:<line>, closures{closure:<file or snippet>:<line>}, methodsClass::method, included files their path. The record keeps the 4,000 most sampled stacks (the rest fold into[other stacks]), at most 200 frames deep ([deeper frames]), and at most 2 MiB of collapsed text, plus each frame's file and most sampled line. It is sent fromfinish(), before the inspector finishes, so completed, failed,exit, and fatal runs all report it. - SPX is detected and named, but not used: it profiles only processes started with
SPX_ENABLED=1(an environment variable read when PHP starts) and writes its reports to files inspx.data_diror to stderr, with no API that returns them to the script (php-spx README). - In the app. The
profilerecord adds a line to the output and selects the Profile section (unless the run failed).FlameGraphbuilds the tree off the main thread;FlameGraphCanvasdraws it.
Cancellation (Stop)
ExecutionEngine.cancel(runId:) marks the run as cancelled, then calls the adapter's stop operation. An SQL tab's run that reported its database session first has its statement cancelled on the server (#144). It returns a CancelOutcome, where confirmed is true only when Runlet saw the PHP process end.
| Adapter | Mechanism |
|---|---|
Local (LocalAdapter) | The runner leads its own process group. Stop sends SIGTERM to the group, waits 1.5 s, sends SIGKILL to the group, and gives up after 5 s in total. Child processes started by the snippet are in the same group and stop too. |
Existing container (DockerExecAdapter) | See below. |
Docker sandbox (DockerSandboxAdapter) | The run uses docker run --rm -i --init --name runlet-sandbox-<first 8 of runId> --label dev.runlet.owned=sandbox. --init makes a small init PID 1 that forwards signals, so PHP is never PID 1 (which would ignore SIGTERM). Because Runlet owns that container, Stop kills the whole container, including any children of the snippet. Stop can arrive before docker run has created the container, so docker kill is retried every 100 ms until it succeeds or the client exits, for up to 4 s. Runlet then waits up to 4 s for the client. If that fails, it runs docker rm -f, waits 2 s, and finally terminates the local client and reports the stop as unconfirmed. |
For an existing container, Stop works as follows:
- Wait up to 2 s for the runner PID from the
startedevent. - Run a separate
docker exec [--user U] <container> <php> -r <helper> -- <pid> <runId> <signal>. - The helper reads
/proc/<pid>/statand treats a missing or zombie process as gone. - The helper requires
RUNLET_RUN_ID=<runId>in/proc/<pid>/environ. On a mismatch it does not send a signal. - The helper signals with
posix_kill, falls back toexec('kill …'), and otherwise reportsunsupported. - The sequence is
SIGTERM, a 1.5 s wait,SIGKILL, a 3 s wait, then a signal-0 check.
Runlet never uses docker stop or pkill, so the application container keeps running.
For an SSH host (SSHExecAdapter), Stop runs RemoteSignal.wholeRunHelper over a second SSH session through the shared connection. It signals the runner and every process whose /proc/<pid>/environ carries the run's RUNLET_RUN_ID (what the snippet started, even after setsid), with the same SIGTERM, 1.5 s, SIGKILL, 3 s, check sequence. Killing the local ssh alone would not stop PHP: without a pty the remote side gets no SIGHUP, and a shared connection keeps the session open. Hosts without /proc are not signalled, and the stop is reported as unconfirmed.
Known limitations, also listed in compatibility.md:
- Docker Stop signals only the runner PID, so processes a snippet spawns inside a container may survive.
- In-container Stop needs Linux
/proc, plus eitherposix_killor/bin/shwithexec(). Without them the outcome is reported as unconfirmed.
Cancelling an SQL Statement on the Server (#144)
Stopping the runner doesn't stop the database. MySQL and MariaDB keep executing a long statement until they write to the closed connection, and PostgreSQL usually finishes it (unless client_connection_check_interval is set). Locks stay held meanwhile. So an SQL tab's run (Run, Run All, Load Next, Explain) cancels its statement on the server before the adapter's stop:
- The session. Right after the runner resolves the connection, and before the statement runs,
SqlCancel::report()(Resources/Runner/src/SqlCancel.php) emits ansqlSessionevent: the PDO driver, the server-side id (CONNECTION_ID(),pg_backend_pid(),@@SPID), the tab's application connection name (none for a saved connection), whether Run All runs in a transaction, and a 16-character hash of what identifies the server (MySQL's@@hostnameand@@port, PostgreSQL'spg_postmaster_start_time()andinet_server_port(), SQL Server's@@SERVERNAME). It never carries credentials. SQLite and callable connections ($wpdb, Doctrine without PDO, a driver's callable) report none.RunSessionkeeps it inRunControl.sqlSessionand adds a Run Log line ("Database session 4711 (mysql): Stop cancels its statement with KILL QUERY 4711"). - The plan.
SQLCancel.plan(for:)(RunletCore) gives the dialect's statement:KILL QUERY <id>(MySQL, MariaDB),SELECT pg_cancel_backend(<pid>)(PostgreSQL),KILL <spid>(SQL Server, which ends the whole session; untested live). Other drivers have none. - The second runner. On the first
cancel(runId:)of a run with a session and a plan,ExecutionEngine.cancelOnServer(SQLServerCancel.swift) holds the run'sfinishedevent (RunControl.holdFinish), logs "Stop: cancelling the statement on the server with …", and launches a second runner withSQLCancel.code: on the target the run launched on (ActiveRun.target), with the run's request's saved connection (its password read from theCredentialStoreagain, on stdin) or hints (an application connection: the application boots again and opens the same connection through the same driver). It takes no run slot (launch(…, usesSlot: false)), so it never waits behind other runs. Docker targets get a seconddocker execinto the same container; SSH targets a second session over the shared connection. A saved connection that opens from this Mac (#142) ran withLocalConnectionLaunch's local snapshot, so the second runner uses the same PHP in<data>/LocalConnections(andLocalConnectionLaunch.checkrefuses any other target). Its launch line goes to the run's Run Log ("Second runner: …"). SqlTab::cancel()resolves the connection like a run (no read-only refusal: the statement is Runlet's, andKILL QUERYandpg_cancel_backendon the user's own session work in a read-only session, #139) and callsSqlCancel::cancel(), which checks, in order: the connection is a PDO of the same dialect; the statement is exactly the dialect's own for the session; its own session isn't the target; the server fingerprint matches (a list of hosts, a load balancer, or a failover could reach another server, where the id is someone else's); the session (information_schema.PROCESSLIST,pg_stat_activity) exists, belongs to the same database user, and runs something. Then it sends the statement and watches the session for up to 1.5 s until the statement ends (PostgreSQL'squery_startor MySQL's statement text and time change, or the session goes idle or away). It emitssqlCancel:cancelled(withverified),stillRunning(with the session's state, e.g. MySQL rolling back a large change),alreadyEnded(MySQL error 1094,pg_cancel_backendfalse),idle,refused(MySQL 1095, PostgreSQL 42501, SQL Server 6102, or one of the checks), orfailed.- The database's answer. While the cancel runs (
RunControl.beginServerCancel),RunSessionholds anerrorframe thatSQLCancel.isCancellationErrorreads as the dialect's cancellation error (MySQL/MariaDB 1317, PostgreSQL 57014 "due to user request"). When the server took the cancel (cancelled,stillRunning), the engine gives the runner up to 0.5 s to end on its own, then yields the held errors withRunErrorInfo.interruptedByStop(settleServerCancel); a later one is marked as it arrives. The output draws such an error as an info line (SQLCancel.interruptedText: "Interrupted by Stop.", with Run All's statement and rollback notes) instead of anErrorCard; itsplainTextand the Run Log keep the database's message. A failed cancel releases the errors unmarked, and errors without Stop are never held. - The report and the stop. The engine yields
.sqlCancel(SQLCancelReport)into the run (interruptedwhen an error was marked, so its message leaves Run All's rollback to that line; the output showsreport.messageas a notice, or a warning when it didn't succeed; the Run Log logs it; Load Next's card adds it to "Stopped."), releasesfinished, and then runs the adapter's stop as before. The whole step is bounded bySQLCancel.timeout(8 s): a second runner that doesn't answer is stopped and reported astimedOut.CancelOutcome.servercarries the report.
Stop never asks, on production either: it only stops what already ran with confirmation. cancelAll() (quit) stops every run at the same time, each with its server cancel. If the runner is still alive when the database cancels the statement, the statement fails as for any error: Run All stops there and, in a transaction, rolls back. If the runner was already gone, the database rolls back the open transaction when it notices the closed connection.
Behind a transaction-pooling proxy (PgBouncer in transaction mode, ProxySQL), the reported id belongs to a pooled server connection that may serve another client by the time Stop cancels; the activity and user checks make that unlikely, not impossible. Session-pooling proxies and direct connections are not affected.
Docker Targets
CLI. The Docker CLI is found through
ExecutableLocator.AppSettings.dockerExecutableoverrides it. The CLI uses the machine's selected Docker context.Discovery. Running containers are listed with
docker ps -q --no-truncanddocker inspect --type container. Runlet-owned containers (labeldev.runlet.owned) are excluded. A container can exit betweenpsandinspect(for example another application's container or one of Runlet's--rmsandbox containers).docker inspectthen exits 1 but still prints the others, so Runlet accepts that exit when stderr only reports missing containers, and skips them.Profiles.
DockerProfilestores:- a
ContainerIdentity: Compose project and service labels, the container name as a fallback, and the last container ID and image - the working directory
- the PHP executable (default
php) - an optional user and the temporary directory (default
/tmp) - an optional local source path and language PHP version
- an optional strict-types override (nil follows the global setting)
autoResolveand a revision number
validate()rejects relative paths, malformed users, and PHP values that start with-.- a
Resolution (
DockerProfileResolver):- With a Compose identity: one running match resolves, flagged as recreated if its ID changed. Several matches are ambiguous, and the user must choose. No match means not running.
- Without Compose labels: the same container ID resolves. The same name with a new ID needs confirmation.
- The resolver never picks a different container silently.
Launch. The snapshotted container ID is inspected again right before launch. Runlet refuses to run if that container was removed or is not running. The command is
docker exec -i --env RUNLET_RUN_ID=<id> --workdir <dir> [--env TMPDIR=<tmp>] [--user <user>] <containerId> <php> -d …. It uses no TTY and keeps the container's environment.Probe.
DockerCLI.probeuses onlyphp -rinside the container, so no shell utilities are needed. It reports:- PHP version and binary
- user and uid
- whether the working directory exists and is readable
- framework
- whether the temporary directory is writable
- tokenizer availability
- how Stop can signal (
posix,shell, ornone) - candidate application directories
Working-directory suggestions. These come from the container's
WorkingDir, its mount destinations, and common paths.Temporary directory. The profile's temporary directory is checked by the probe and passed as
TMPDIR, sosys_get_temp_dir()andtempnam()work in read-only containers. Runlet itself writes nothing there.
SSH Targets
The full user guide is ssh.md; the design is in done-next-release-ideas.md §3.
- Client. The system
/usr/bin/ssh, never a library and never-F(Debug builds'RUNLET_SSH_CONFIGaside), so~/.ssh/config(Include,Match,ProxyJump), agents including 1Password's,known_hosts, andUseKeychainbehave as in the user's terminal. Runlet stores no keys or passwords and handles no secret. - Runs.
ssh -T -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o LogLevel=ERROR -o RemoteCommand=none -o PermitLocalCommand=no -o ClearAllForwardings=yes -S <socket> -o ControlMaster=auto|no [-C] [-l] [-p] [-J] -- <host> "/bin/sh -c '<script>'". The script enters the directory (printing a marker when it can't), exportsRUNLET_RUN_ID, andexecs PHP with the usual-darguments, so PHP leads the SSH session's process group. The runner arrives on stdin; nothing is written on the server except, withSSHProfile.keepCompiledPHP(on for new profiles, off for profiles saved without the key), PHP's opcode file cache in~/.cache/runlet/opcacheand the runner in~/.cache/runlet/runner/<sha256>.php(0700; not with a container step). With the runner kept there, stdin carries a small loader and the request;php -nruns the loader, which checks the kept runner and pipes the runner and the request to the runner's PHP, and a miss is sent again with the runner (SSHRunnerCache, #48; details in ssh.md). - Shared connection. Every call for a profile uses one OpenSSH control socket. Automatic (agent or key) profiles use
ControlMaster=autowithControlPersist=<minutes>m(oryes); interactive (password/2FA) profiles useControlMaster=no, andAppModel.sshSnapshotrefuses to run until Connect… has opened the socket. - Failures.
PreparedLaunch.explainFailuremapssshexit 255 and its message (unknown or changed host key, rejected keys, unresolvable host, timeouts, refused connections, a lost connection), the missing-directory marker, and exit 127 to plain language throughSSHFailure. - Status.
SSHControlSocketconnects to the socket on this Mac and closes it at once: connected, disconnected (no file), or expired (a stale file). Checking never startsssh, so it never reads~/.ssh/configor reaches the network. Tabs check when they appear and when Runlet becomes active; runs, Connect…, and Disconnect check afterwards. - Connect… (password, keyboard-interactive, 2FA, unknown host keys).
AppModel.connectSSHremoves a stale socket and opens a terminal tab (TerminalRequest.executable, not a command tab) runningssh -M -S <socket> -o ControlPersist=yes -o BatchMode=no -o StrictHostKeyChecking=ask … -N -f <host>. OpenSSH prompts in the pty; Runlet never reads the input. After authentication-fdaemonizes the master (detached from the pty, stdio on/dev/null) and the tab's process exits 0, so the tab closes;AppModel+Terminal's exit hook callssshTerminalExited, which re-reads the status. The master outlives Runlet: a restart finds it through the socket. Disconnect isssh -O exit, after asking when runs on that profile are in progress.SSHRunTests.connectLogsInThroughAPseudoTerminalAndDisconnectEndsItdrives the same argv throughscript(1)against the fixture's password account. - Local folder.
TargetLibrary.localFolder(for:)gives an SSH profile's checkout on this Mac to the same places a Docker profile's local source goes: the language workspace (else the basic workspace),projectRoot(project snippets),hostDirectory(host commands),projectFolder(Open in Editor), the terminal's start folder, and static facts.EditorPathMapping.remote(roots:localRoot:host:)maps server paths under the profile's directory and the run's reported working directory (its real path), and a root ending in/currentalso covers everyreleases/<id>/beside it. - Commands and shells (SSH-5).
ProjectCommandLauncherruns a listed command asssh -t(purpose.terminal: a pty, otherwise the batch options) with/bin/sh -lc 'cd <dir> || explain; <command>', so~/.profileadds to PATH as in a login; commands that need input openexec "$SHELL" -lin the directory and type the command without Return.AppModel.openSSHShell(terminal "+", target menu, Commands panel, palette) resolves like a run and opens the same login shell. Production hosts ask before every command, listing, and shell (GuardedAction.shell); the snippet grace never covers them. Open REPL uses the samessh -tpath withProjectREPL.selectionScript(the server picks Tinker, PsySH, orphp -a), asks every time on production (GuardedAction.repl), and connects only on that click, under the same rules (a password host must be connected first). The Tests group (#40) uses the same path withProjectTests.selectionScript(the server picksphp artisan test, Pest, or PHPUnit, and explains when there is none) and is disabled on production hosts.SSHRunTestsruns these argvs underscript(1)against the fixture. - Docker on the host (SSH-6).
SSHProfile.container(RemoteContainerStep: Compose identity or name, working directory, PHP, user, TMPDIR, Docker command) makesAppModel.sshSnapshotlist the server's containers through aDockerCLIwith the SSH transport and resolve them withDockerProfileResolver(ambiguity or a replaced name-only container opensContainerChoiceSheetvia a target-agnosticContainerChoice; a chosen replica stays chosen while it runs). The snapshot is.sshwith the Docker fields set plusdockerCommandandlocalFolderRoot(the container path of the server directory's bind mount), andExecutionEnginesends it throughDockerExecAdapterwith the remoteDockerCLI(re-check before launch,docker exec -i, runner-only Stop).EditorPathMapping.forSnapshotmaps container paths throughlocalFolderRoot(else the container directory and the run's real directory). Recording the resolved container ID is not an edit (no revision bump; a production grace stays). Test Connection adds aRemoteContainerCheck(resolution plusContainerProbe); a host without PHP is reported as fine. Tests useTests/Fixtures/docker/ssh/fake-dockeron the SSH fixture. - Never on its own. Opening, restoring, or switching to an SSH tab, and launching Runlet, never connect. Facts come from the local folder; the server's PHP version from runs and Test Connection. The Commands panel lists an SSH host's commands only when asked.
Laravel Sandbox
Template.
Resources/Sandbox/laravelholds Laravel 13.34.0, which requires PHP^8.3.scripts/build-sandbox.shinstalls its locked dependencies and builds a pre-migrated SQLite database on the build machine.runlet-sandbox.jsonrecordslaravelVersion,phpConstraint,minimumPHP(8.3), anddockerImage(php:8.4-cli).Install.
SandboxManager.ensureInstalled()copies the immutable template from the bundle intoSandbox/laravel-<version>/under Application Support, using a staging directory.install-<uuid>. It then:- writes a
.envwith a randomAPP_KEY, SQLite, array sessions, a file cache, a sync queue, and thelogmailer - creates the storage directories
- writes a
.runlet-installedmarker
reset()deletes only that directory and reinstalls it.- writes a
Runtime choice.
chooseRuntimefollows the sandbox runtime preference in Settings (automatic,localPHP, ordocker). Unless the preference isdocker, it picks the first match from this list:- The preferred PHP, if it is version 8.3 or newer and has the tokenizer.
- Otherwise, the first compatible discovered PHP (
PHPDiscovery.preferred: discovery order, so thephponPATHcomes first; stable releases before prereleases). Runlet's own PHP, when downloaded, is last in that list (#2). - Otherwise, with the
localPHPpreference,unavailable.
Then (or directly with the
dockerpreference) it uses Docker if the CLI exists and its engine responds.imagePresent: falsemeans the first use needs an image download, which the app offers as Download Docker Image. Otherwise the result isunavailable, with an explanation.The Docker sandbox mounts the installed copy at
/sandbox.
Production Guard (N14)
- Model. Every target has an environment (development, staging, production) and an optional colour (
TargetLibrary.environment(for:),color(for:),isProduction(_:)). The sandbox is always development. - Badges.
EnvironmentBadgenext to the toolbar's target menu, on vertical tab cards (a PRODUCTION chip and a coloured stripe) and horizontal tabs, in the target menu (" — PRODUCTION"), ⌘P subtitles, and Settings ▸ Targets. The status bar has a red tint for production and a 2 pt stripe in the target's colour. - Confirmation.
AppModel.runcaptures the code and selection, thenguardProduction(.run, …)(SQL tabs:.sqland Explain's.sqlExplain, which always ask; see SQL tabs); on confirmationstartRuntakes the usual snapshot path.ProductionGraceskips the question for snippet runs within 10 minutes of a granted grace (per target, in memory, revoked bytargetEditedon every save). Listing commands, running project or host commands, opening a shell or a REPL, and loading App Info always ask; confirming them never grants the grace. The Tests group doesn't ask on production: it is disabled there (ProjectTests.isAllowed(on:)), because test suites often reset or migrate the database;runTestsrefuses production targets too. A REPL is excluded from the grace on purpose: once it is open, every line typed into it runs without another question, so the one confirmation must happen each time it opens. The sheet is presented by the window whose tab asked. - Stricter defaults.
listsCommandsAutomatically(for:)is false for production (and SSH) targets;detectFactsreads only a production Docker profile's local source, never the container. - The application's environment (#12). The runner calls the driver's
environment()aftername()and sends the cleaned name asbootstrapped.environment(BootstrappedInfo.environment; absent for plain and Composer projects and from older runners).learnFactskeeps it asTargetFacts.appEnvironment(persisted infacts.json).AppEnvironmentNotice.decide(target × marking × reported name × dismissed kinds) givesreportsProductionwhen the name is one ofAppEnvironment.productionNames(production,prod,prd,live) and the target isn't marked production, or the informationalreportsLocalwhen a production target's app sayslocal,development, ordev; never for the sandbox.AppEnvironmentBannershows it above the tab. Mark as Production callsmarkAsProduction, which saves the target throughsaveProject/saveDockerProfile/saveSSHProfile(sotargetEditedrevokes the grace); Dismiss adds the kind toTargetFacts.dismissedEnvironmentNotices. Nothing changes a marking automatically or runs code. - Saved connections' own marking (#139).
TargetLibrary.marking(for:connection:)gives the stricter of the target's environment and the saved connection's (TargetEnvironment.stricter, bystrictness),fromConnectionwhen the connection's is stricter, and the connection's colour (else the target's).guardProduction(…, savedConnection:)decides with it for.sqland.sqlSchema, andProductionConfirmation.markedConnectionmakes the sheet say the connection is marked as production and show it with its badge (andReadOnlyBadge).wantsSQLSchema, the schema explorer's hint, the SQL bar (SavedConnectionBadges), and the run's history entry use the same marking. Test Connection doesn't ask (#138's choice). - History snapshot.
startRunreads the target's environment and colour when the run starts (for a saved connection,marking(for:connection:)); with the reported environment they go into the run'sHistoryEntry(targetEnvironment,targetColor,appEnvironment, all optional, so older history decodes with nil). The History row draws the badge and colour from the entry, never from the target's current settings.
Persistence
AppPaths.standard is ~/Library/Application Support/<folder>, where the folder is the app's RunletDataFolder Info.plist key: Runlet Dev for Runlet Dev, a Debug build from Xcode, and Runlet for every other build (#267). A key that's missing or isn't a plain name means Runlet.
- How a build gets its identity: only
PRODUCT_BUNDLE_IDENTIFIERdiffers per configuration.project.ymllooks the name, icon, and folder up from the bundle id (#269):RUNLET_APP_NAME = $(RUNLET_APP_NAME_$(PRODUCT_BUNDLE_IDENTIFIER:identifier):default=Runlet), and the same forRUNLET_DATA_FOLDERandASSETCATALOG_COMPILER_APPICON_NAME. Onlydev.runlet.Runlet.devhas entries, so screenshot builds (.prshots,.websiteshots) get Runlet's name and icon with no extra flags. - What follows the folder: the Keychain services of saved connections and of AI keys (
KeychainCredentialStore.service(for:base:)adds a hash for any folder other than the release's) and the MCP socket, so Runlet Dev never shares them with the installed app. - Runlet Dev's other differences: its own bundle id (
dev.runlet.Runlet.dev), so its own UserDefaults; its name; and its icon,AppIconDev, whichscripts/app-icon/make-dev-icon.swiftwrites in both forms, like the release'sAppIcon:Runlet/AppIconDev.icon, an Icon Composer icon:AppIcon.iconplus a top layer with the DEV pill. macOS 26 and later draw this one; the Dock doesn't draw a classic icon set on its own (#269).AppIconDev.appiconset, the classic icon, for macOS 15.
- The
runletcommand uses the data folder of the app it's bundled in (AppPaths.standard(appBundle:),CommandLineTool.isRunletApp). RUNLET_DATA_DIR, when set and not empty, replaces the folder in every build.
<root>/
State/
settings.json AppSettings
targets.json TargetLibrary (local projects, Docker profiles, SSH profiles,
saved database connections' definitions; never passwords)
snippets.json personal snippets (optional description; legacy entries omit it)
history.json execution history (SQL runs with their connection's name, #149)
session.json SessionState (tabs, selected tab, the Quick Run panel's code
and target, #25)
onboarding.json OnboardingState: new user, the tour's status, last What's New seen (#232)
command-usage.json CommandUsage: command id → uses, last use, decayed weight (#328)
shortcut-tips.json ShortcutTipRecord: command id → uses by source, last tip, Don't Show Again (#345)
<name>.last-good.json previous valid copy
<name>.corrupt-<timestamp>.json preserved unreadable file
Sandbox/laravel-<version>/ writable sandbox install (see above)
LanguageService/
config-<hash>/phpantom_lsp/.phpantom.toml per-workspace PHPantom global config
basic-workspace/ root for workspaces without local source
ShellIntegration/ zsh/.zshenv … .zlogin and bash/runlet.bash for command tabs (rewritten on use)
SSH/ OpenSSH control sockets, <first 8 hex of the profile id>.sock (0700)
LocalConnections/ empty working folder of saved connections opened from this Mac (#142; 0700), through SSH tunnels too (#143)
Runs/ declared in AppPaths; unused (the runner writes no files)
Logs/ declared in AppPaths; unused by package codeSaved database connections' passwords (#138) are not in this folder: they are generic passwords in the login keychain (service dev.runlet.Runlet.database, account = the connection's id, not synchronizable). A data folder other than the standard one uses the service dev.runlet.Runlet.database.<8 hex of a hash of its path>, and Debug builds with a scratch RUNLET_DATA_DIR keep them in memory instead.
How JSONDocumentStore works:
- Envelope. Each file is
{ "schemaVersion": 1, "savedAt": …, "data": … }. - Save. If the current file still decodes, it is first copied to
<name>.last-good.json. The new data is written to a temporary file in the same directory and moved into place withrename(). - Load when missing. If the file is missing, the last-good copy is used, or the default value if there is none.
- Load when unreadable. A file that cannot be decoded, or that has a
schemaVersionnewer than this build supports, is moved aside to<name>.corrupt-<ISO 8601 timestamp>.json. The last-good copy is restored if it decodes; otherwise the default is used. - Recovery notes. Loading returns recovery notes for display.
- Settings.
AppSettingsdecoding fills missing keys with defaults.
The app layer adds the rest. AppModel debounces session and history writes by 0.5 s, saves the session when the app resigns active, and flushes both on quit. Settings, targets, and snippets are saved on every change. History is trimmed to AppSettings.historyLimit (default 1000, adjustable in Settings). Restoring a session, history entry, or snippet loads code only. Sandbox tabs can explicitly opt into auto-run for subsequent editor edits: TabModel keeps the unsaved opt-in and cancellable 800 ms task; EditorController distinguishes edit events from code loads; AppModel.run rechecks sandbox ownership and runs the whole tab. Code loads and target changes disarm auto-run, while Stop/explicit Run/close cancel pending evaluation. Sessions and workspaces omit the opt-in.
Language Service (PHPantom)
Binary.
Resources/LSP/phpantom_lspis a universal binary. It is copied toContents/Helpers/phpantom_lspin the app bundle.Launch. PHPantom runs with
--stdio, with the workspace root as its working directory. Its environment is minimal:HOME,USER,LOGNAME,TMPDIR,LANG,LC_ALL, andPATH=/usr/bin:/bin, which has no host PHP.XDG_CONFIG_HOMEpoints to an app-owned config home.One process per workspace.
LanguageServicekeeps oneLanguageServerSessionperLanguageWorkspace, keyed by kind (projectorbasic), root path, and optional PHP version. Tabs acquire and release a session by tab ID, and the server stops when the last tab releases it. Workspaces with different keys never share a process.App-owned global config.
PHPantomConfigwritesLanguageService/config-<FNV-1a of kind|root|phpVersion>/phpantom_lsp/.phpantom.toml. PHPantom reads it as its global config. It sets:- an optional
[php] version [diagnostics] workspace = false,workspace-external = false[formatting] pint,php-cs-fixer,phpcbf = ""[phpstan],[phpcs],[mago] command = ""
The user's own global PHPantom config is never modified, because PHPantom looks for its global config under
XDG_CONFIG_HOME. A project's.phpantom.tomlis read but never modified. It merges on top, so a project that sets a tool command explicitly opts back in.- an optional
Scratch documents. Each tab's URI is
file://<root>/.runlet-scratch/tab-<uuid>.php. PHPantom resolves it against the real project root, and the file is never created on disk. Every change sends the full text with an increasing version number.Model copies. On every start of a
projectsession,EloquentOverlayreads the PHP files under the project's Composerautoload.psr-4directories (orapp/) and opens adjusted in-memory copies under the files' own URIs (textDocument/didOpen) for two shapes PHPantom 0.10.0 misreads: a relation method with only a native relation return type (the type is blanked to spaces, so PHPantom infers the related model from$this->hasMany(X::class)), and acasts()array without a trailing comma (a comma is added). Nothing is written; the copies are rebuilt on restart and follow their files as they change on disk (#340), andLanguageServerSession(modelOverlays: false)turns them off. Details, limits, and the upstream report drafts are in compatibility.md (#55, retirement tracked in #117).Runlet's snippet API. On every start of any session,
RunletAPIStubopens declarations of theRunlet\functions andRunlet\Inspector's public methods as an in-memory document (<root>/.runlet-scratch/runlet-api.php, never written), so\Runlet\completes and hovers in snippets (#196).RunletAPIStubTestscompares its signatures with the built runner's by reflection.Line mapping. If the editor text has no opening tag,
ScratchDocumentMappingadds<?php\nas its own line. Editor line n maps to LSP line n+1, and columns do not change. Positions inside the synthetic line clamp to (0, 0), so importadditionalTextEditsland on editor line 0. Both sides use UTF-16 positions, and the client advertisespositionEncodings: ["utf-16"].Snippet syntax.
SnippetTextconverts PHPantom's snippet syntax to plain text plus a cursor offset. PHPantom 0.10.0 sends snippet syntax even though the client declaressnippetSupport: false. For calls (methods, functions, classes afternew),CompletionInsertiondrops placeholder text and insertsname(): the caret goes inside when the callable takes parameters (a tab stop, or a non-empty parameter list in the label) and after)when it takes none. Built-in functions and classes come without a parameter list, so the caret starts inside and the editor steps past)if signature help reports no parameters. If(already follows the word, only the name is inserted.Features used. Completion with resolve, hover, signature help, and pushed diagnostics (
publishDiagnosticswith version support); since #22 also definition, references, inlay hints, code actions withcodeAction/resolve, and folding ranges, each only wheninitializeadvertises it. Code-action edits apply to the tab's text only (navigation.md).Crash restart. After an unexpected exit, the session restarts with a backoff of 300 ms × attempt, up to 5 attempts. It reopens documents from the client's copy of their text. State is reported as
stopped,starting,ready,restarting, orfailed. A manual restart resets the attempt counter.No local source. Docker profiles without a mapped checkout use a
basicworkspace (LanguageService/basic-workspace) for core PHP completion.LanguageWorkspace.sourceLimitations()describes what is missing: an unmapped source, a missing directory, or a missingvendor/.
Terminal
The terminal panel runs the user's own login shell, untouched, for plain tabs. A tab that types a command (TerminalRequest.commandLine, e.g. a project command run locally) must not type it before the shell is ready: an rc file may ask a question first (oh-my-zsh's dotenv plugin, read -q, an oh-my-zsh update prompt), and the command's first character would answer it.
- Readiness marker. For zsh, bash, and fish, the shell writes
ESC ] 6973 ; RunletReady ; <token> BELonce, just before its first prompt, after every startup file ran. The token is random per tab.TerminalSessionfeeds output toShellIntegration.ReadyScanner(a marker split across reads is still found) and registers a no-op SwiftTerm OSC 6973 handler, so the sequence is never drawn. After the marker, it waits until the line editor puts the pty in raw mode (at most 1 s) and types the command; typed earlier, the tty would echo it above the prompt. A command is never typed into a shell that has not reported; after 15 s the panel shows Run Now / Don't Run instead. A marker that comes later (the user answered the question) still types the command. - zsh.
ZDOTDIRpoints atShellIntegration/zsh(RUNLET_USER_ZDOTDIRcarries an inheritedZDOTDIR,RUNLET_SHELL_READY_TOKENthe token; both are unset before any user file runs). Each of Runlet's files puts the user'sZDOTDIRback (value, export flag, or unset), sources the user's file of the same name from it, records any change the file made, and pointsZDOTDIRback at Runlet's directory for zsh's next startup file. So aZDOTDIRset in~/.zshenvis followed, and/etc/z*files still run in order..zshrcresetsHISTFILEwhen/etc/zshrcderived it from Runlet's directory..zlogin(or the first file after which zsh reads no more, e.g.NO_RCS) leaves the user'sZDOTDIRin place, removes every helper function and variable, and adds a one-shotprecmdhook that writes the marker to/dev/ttyand removes itself. precmd first runs after all startup files, so a question in any of them holds it back. Nothing prints during startup (Powerlevel10k's instant prompt warns about console output). - bash.
bash --rcfile ShellIntegration/bash/runlet.bash -i(non-login, because--rcfileis ignored for login shells). The rc file reads whatbash -lreads (/etc/profile, then the first of~/.bash_profile,~/.bash_login,~/.profile) and appends a one-shotPROMPT_COMMANDentry (string, or array on bash 5.1+) that removes itself wherever other hooks moved it. Differences from a real login shell, in command tabs only:shopt login_shellis off,$0isbash,logoutrefuses, and~/.bash_logoutis not read. - fish.
--init-command(runs afterconfig.fish) defines afish_prompthandler that writes the marker and erases itself; fish stays a login shell. - Fallback. Other shells (sh, tcsh, nu, …), direct executables with a command line, and zsh/bash when the scripts cannot be written use output heuristics: type once the line editor announced itself (bracketed paste
ESC[?2004hor keypad modeESC[?1hwhile the pty is raw) and output settled for 0.3 s, or, for shells that never announce it, after 3 s of quiet (1.5 s for direct executables). zsh and fish are never typed into blindly; the 15 s notice applies. - Command tabs. A tab with
isCommand(every project command, includingdocker exec … sh -lc <cmd>) stays open when its process exits, with the exit line, a check or warning mark in the tab strip, and Run Again / Close in the notice bar; Return, ⌘W, or × closes it. Plain and container shells that exit with 0 close as before.
SQL Tabs (#35)
The user guide is sql-tabs.md; the driver API is in drivers.md.
- Model.
TabLanguage(php,sql, andredis, #190) lives onTabModeland inTabState,WorkspaceTab, andHistoryEntry, with a tab'ssqlConnectionname (or, #138,sqlSavedConnectionid andsqlSavedConnectionName; workspaces keep only the name); missing or unknown values decode as PHP.AppModel.setLanguage(_:for:)switches a tab (no language server for SQL, auto-run off);.sqlfiles open as SQL tabs (TabLanguage.forFile). - Editor.
EditorController.syntaxpicksSQLHighlighter(fromSQLScript.tokenizein RunletCore) orPHPHighlighter, andCodeTextView.lineCommentMarkeris--.bindLanguageunbinds PHPantom for SQL tabs. - Scope and safety.
SQLScript.statementToRunpicks the selection (one statement) or the statement at the caret and refuses several;SQLScript.effectclassifies writes (best-effort).AppModel.runSQLasksguardProduction(.sql, …), which always asks on production (ProductionGracenever skips.sql) and shows the statement and the write warning.run(automatically:), the auto-run scheduler, andstartRunrefuse SQL tabs except throughrunSQL; MCP never reuses an SQL tab. - Running.
SQLTabRun.codegeneratesreturn \RunletRunner\SqlTab::run("<sql>", <connection|null>, <maxRows>);with Explain's string escaping, andstartRun(…, sql:)runs it like any snippet (snapshot,ExecutionEngine, every target kind), without strict types or magic comments. In the runner,SqlTab::runasks the booted driver'ssqlConnection()(throughRunner::callBootedDriver, so a project driver's errors name its file), then an Eloquent resolver or$wpdb, else throwsSqlUnavailable. A PDO is prepared natively (MySQL: emulation and buffering off for the run, restored after); a callable returns rows or an affected count. The result is onesqlevent (SQLResultInfo: columns, rows ofSQLCell,affectedRows, truncation, timing, driver, source, connection names), bounded to 1,000 rows (from the app), 200 columns, 8 KiB per cell, and 8 MiB per result. - Output.
OutputItem.sqlrendersSQLResultCard(the result'sValueTablein the shared table view, or the affected rows).SQLResultInfo.tableis stored, not computed: built ininit(from:), whereRunSessiondecodes thesqlevent on the pump's task, and again only whencolumnsorrowschange; it isn't encoded.ValueTableViewkeeps its search and sort in view state and computes the shown rows withValueTableQueryon a detached task when they change (.task(id:), typing debounced);ValueTableGrid(anNSViewRepresentableoverNSTableView) reloads only when the rows or columns change. Its height comes fromOutputTableLayout(RunletCore: up to 400 points), and its scroll view hands a vertical gesture it can't take to the output (OutputTableLayout.scrollGoesToOutput). The card's copy text is made on Copy (Card.copyTextProvider). While an SQL run is active,SQLRunningRowshowsTabModel.sqlActivity("on the default connection", "3 statements on …"), the elapsed time sinceRunState.running, and Stop, in all three display modes (#162). An SQL run's emptyresultis not shown, and its errors lose the runner's and generated snippet's locations. History records the statement withlanguage: sql. Connection names reported by a run fill the tab bar's picker for that target (SQLConnectionCatalog, session only). - Run All Statements (#129).
SQLScript.statementsToRunAlltakes the selection's statements (or the tab's);AppModel.runAllSQLrefuses scripts with their own transaction statements (SQLScript.transactionControl) while the tab'ssqlTransaction(inTabState, nil = on) is set, asksguardProduction(.sql, …, sqlStatements:)(the sheet lists each statement with itsSQLScript.effectwarning), and runsSQLTabRun.scriptCode:SqlTab::runAll([['sql' => …, 'line' => …, 'implicitCommit' => …], …], <connection>, <maxRows>, <transaction>). The runner resolves the connection once, emits onesqlevent per statement withstatement {index, count, line, text}, stops at the first error withSqlStatementFailed(which names the statement and says what was rolled back and what didn't run), and commits at the end.implicitCommit(SQLScript.commitsImplicitly) matters on MySQL and Oracle only: a notice before the first statement, and a new transaction after each such statement.SQLRunInfocarries the statements, the transaction flag, and the history text. - Bound parameters (#145) and the parameters drawer (#168).
SQLParameters.scan(RunletCore,SQLParameters.swift) reads the run's statements withSQLScript.tokenize(with the saved connection's or the schema's driver when known: MySQL's backslash escapes, PostgreSQL's#;#is a comment only for MySQL, SQLite, and an unknown driver, not for SQL Server's#temptables or a custom DSN) into anSQLParameterScan: oneSQLParameterper:name(shared by every statement of a Run All) and per?of each statement (.positional(statement:index:)), each statement's keys in binding order, per-statement name counts, and the firstSQLParameterProblem(mixed kinds,$1, a name PDO reads differently), whichrunSQL/runAllSQLshow as an alert before anything else.??is skipped as PDO's escape. The values come from the tab's drawer:SQLParameterDrawerModel(RunletCore,SQLParameterDrawer.swift) turns the tab's text, selection, and driver intoContent(rows, problem,-- @paramproblems, statement count) for its scope (.statement:SQLScript.statementToRun;.all:statementsToRunAll), skipping the work when nothing changed. EachSQLParameterRowcomes fromSQLParameterRows.make: the tab'sSQLParameterMemory(SQLParameterDrafts, i.e. type, text, and flag, set in the drawer, keyed:nameor?Nplus the statement's trimmed text), elseSQLParameters.presets(-- @paramcomments), else not set. On an edit, a statement at the same place in a tab with as many statements carries its?drafts to its new text and a:namerenamed in place to its new name. In the app,SQLParameterDrawer(AppModel+SQLParameters.swift,@Observable, one per tab inSQLParameterStore, in memory only) holds the model and republishescontentonly when it changed;TabModel.onChange(edits and caret moves of SQL tabs) schedulesrefreshSQLParametersafter 150 ms plus three times the last update's duration, so typing stays smooth.SQLParameterDrawerView(Features/) sits under theCodeEditorViewin SQL tabs only, with layout priority so a short pane shrinks its list (fixed 28 pt rows in aLazyVStack, at most 5.5 visible) rather than the editor.withSQLParameterValues(used byrunSQL,runAllSQL, andexplainSQL) refreshes the drawer at once (so an edit just before the run has carried its values), readsSQLParameterDrawerModel.rows(for:statements:text:)for the run's own scan, and either calls the continuation with the values or switches the drawer's scope, sets its note, and focuses the first missing or invalid row (SQLParameterDrawer.focusRequest); nothing runs. The continuation builds[[SQLBinding]](SQLParameterScan.bindings), setsSQLRunInfo.values(the output's note) andhistoryCode(SQLParameters.historyCode:-- @paramlines before the text,?lines before their statement), then asksguardProduction(…, sqlValues:)and runsSQLTabRun.code(…, bindings:)/scriptCode(…, bindings:). The values travel as PHP literals in an array argument ([['name' => 'id', 'type' => 'int', 'value' => 42, 'uses' => 2], ['position' => 1, …]]; a Run All statement'sparams), inside the request on stdin; never in the SQL text. In the runner,SqlTab::refuseUnbindablethrowsSqlParametersRefusedafter resolving the connection and before anything runs, for a callable connection with values or, on MySQL, a name used more than once in a statement (uses);runPdoturns emulated prepares off when there are values, thenSqlTab::bindcallsbindValuewithPARAM_STR(text and decimals),PARAM_INT,PARAM_BOOL, orPARAM_NULL. Write as @param Comments:SQLParameterDrawerModel.writingDeclarations()(SQLParameters.writingDeclarations) rewrites a placeholder's own--@paramline or inserts lines before the statement that uses it, andEditorController.replaceChangedPart(with:actionName:)applies the difference as one named undo group (FormattingEdit, as Format Code does).SQLScript.statements(in:)counts statement lines on from the previous statement (LineCounter), so splitting stays linear in the text's length. - Explain Statement (#147).
AppModel.explainSQL(_:mode:)(AppModel+SQLExplain.swift) takes the statement like Run, refuses EXPLAIN/DESCRIBE/ANALYZE statements, and, for Explain Analyze, refuses what the known driver (a saved connection's, or the schema's) or a read-only connection would refuse; placeholders take their values from the parameters drawer (withSQLParameterValues, #168). It then asksguardProduction(.sqlExplain(analyze:), …)on production (withSQLExplain.productionWarning), or, off production,SQLExplainUI.pendingAnalyze(an alert on the tab's window, Cancel the default) before Explain Analyze of a write, and starts the run withSQLRunInfo.explainset (the note, the running label, and Run History's first line say what it does).SqlTab::explain()resolves the connection asrun()does, refuses unbindable values, and hands it toSqlExplain::explain(), which picks the dialect from the PDO driver (MariaDB from the server version;$wpdbis MySQL; SQL Server and callables are refused), prefixes the dialect's EXPLAIN, prepares it natively (a second statement is refused, never run), binds the values throughSqlTab::bind(), and emits ansqlPlanevent with the raw JSON or text (at most 4 MiB) or SQLite's rows. Explain Analyze refuses SQLite, and writes on MySQL/MariaDB (SqlReadOnly::refusalas the write check) and on read-only connections before connecting; on PostgreSQL it runs insidebeginTransaction()/rollBack().RunSessiondecodes the event intoRunEvent.Kind.sqlPlan;OutputItem.sqlPlanrendersSQLPlanCard(SQLPlanViews.swift): a tree of at most 300 visible rows with collapsible steps, full-scan highlights, estimate and actual columns, and a Raw toggle. - The inspector's Explain (#4, #170).
QueryExplain.code(for:style:)(RunletCore) generates the idle Explain #N PHP tab for a captured query and connection style (Laravel's DB facade, an Eloquent resolver, Symfony's Doctrine registry, a DBAL or PDO template,$wpdb);AppModel.explain(_:from:index:)opens it on the capture's target without running it. By the captured driver it asks forEXPLAIN FORMAT=JSON(MySQL, MariaDB),EXPLAIN (FORMAT JSON)(PostgreSQL), orEXPLAIN QUERY PLAN(SQLite), runs it through the captured connection with the captured bindings, and returns\Runlet\explainPlan($plan, <connection>, $connectionName)when the function exists (else the rows). A capture without a driver keeps a plainEXPLAINand returns its rows. In the runner,explainPlan()hands the rows toSqlExplain::fromRows(), which reads the dialect and server version from the connection object without a query (a PDO's attributes; an Illuminate connection's driver name and read PDO; a DBAL connection's platform class and native PDO;$wpdb->db_server_info(); or a dialect name; else the rows' column:detail,QUERY PLAN,EXPLAIN), so MariaDB is told from MySQL as in Explain Statement. Rows it reads (SQLite's withdetail; one column of JSON, or MySQL's->tree) become the samesqlPlanpayloadSqlExplain::explain()builds (shared 4 MiB and row limits), emitted withChannel::emit, and the function returnsNoResult, so the card replaces the dump; other rows come back unchanged. The app needs no change for it: any tab'ssqlPlanevent rendersSQLPlanCard. - Stop (#144). An SQL run reports its database session (
sqlSession), and Stop cancels its statement on the server from a second runner before the process stops: see Cancelling an SQL statement on the server.TabModelshows the.sqlCancelreport as a notice (or a warning) and logs it;SQLResultPager.stopcancels the page's run through the engine (not its task), so the card can add the report to "Stopped.". - Load Next (#146).
SQLPaging(RunletCore,SQLPaging.swift) decides whether a cut result's statement pages (plan(for:driver:):SQLScript.effectmust read it as a read starting withSELECT/WITH/TABLE/VALUES, with no locking clause) and how:Mode.appendaddsLIMIT <n+1> OFFSET <shown>(SQLite, MySQL/MariaDB, PostgreSQL) orOFFSET … ROWS FETCH NEXT … ROWS ONLYafter a top-levelORDER BY(SQL Server) on a line after the statement;Mode.skipruns it as written for statements with their own top-levelLIMIT/OFFSET/FETCH/TOP, SQL Server withoutORDER BYor withFOR XML/JSON, and unknown dialects (callables, other PDO drivers). The statement is never wrapped in a derived table (MariaDB drops itsORDER BY; MySQL refuses duplicate column names).Plan.page(offset:size:)gives thePage(SQL,skip,size, the driver it was written for, the added clause), andSQLTabRun.pageCodethe PHP:SqlTab::page(<sql>, <connection>, <size>, <skip>, <driver|null>, <bindings>, <added|null>), which refuses a read-only connection's write, resolves the connection asrun()does, throwsSqlPageFailedwhen the PDO driver isn't the one the page was written for or when the database refuses the added clause (naming it), andrunPdo/runCallablefetch and discardskiprows before keeping at mostsize. Everysqlevent now carriesbytes(the runner's cell count). In the app,SQLRunInfo.bindingskeeps each statement's[SQLBinding];startRunhands the run'sSQLRunInfotoTabModel.beginRun(…, sqlRun:), andTabModel.apply(.sql)creates anSQLResultPager(AppModel+SQLPaging.swift,@Observable) per truncated result, keyed by output item id (TabModel.sqlPagers);beginRunandclearOutputdetach them (stopping a page that loads).AppModel.loadNextSQLPage(_:item:)checks the tab's target and the saved connection's definition are unchanged, asksguardProduction(.sql, …, sqlPage:)(the sheet's "next page of a previous statement"), and runs the page throughExecutionEngine.startwith a pseudo tab id (the same snapshot, saved connection, hints, and inspector options as a run), collecting itssqlevent, errors, andfinished; Stop cancels the task, whose cancellation handler cancels the run. It records aHistoryEntry(the statement's history text plus-- Load Next: rows …), buildsSQLResultInfo.appending(_:)on a detached task (only the page's rows are added to theValueTable, in an initializer sorows'didSetdoesn't rebuild it; nil when the columns differ), replaces the output item (TabModel.replaceSQLResult, which bumpsoutputGenerationso the text transcripts start over), and refreshes result windows (ResultWindows.refresh(pager:table:title:);ResultDocument.tableandtitleare mutable and itspageris weak).SQLPaging.nextPageSizekeeps a card under 50,000 rows, 500,000 cells, and 64 MiB.SQLResultCardshowsSQLPagerControls(the button, progress and Stop, the refusal, the limit, the end, errors, the stability note), as does the result window's footer (compact).ValueTableGrid.updateNSViewreloads instead of rebuilding columns when only rows were added (keeping widths, widening the row-number column), andResultDocumentfilters and sorts on a detached task (refreshShownRows,.task(id: shownRowsKey)).AppSettings.sqlRowsPerPage(SQLPaging.pageSizes) is the first run'smaxRowsand each page's size. - Completion (#128).
SQLCompletion.suggestions(RunletCore) reads the statement at the caret withSQLScript.tokenize: no items inside strings, comments, or quoted names; table references with aliases; tables afterFROM/JOIN/UPDATE/INTO/TABLE; columns of the referenced tables first; keywords, phrases, and functions; names quoted per driver.EditorController.sqlCompletion(set fromTabModel.sqlCompletionProvider, whichbindLanguagepoints at the tab's schema) feeds the sameCompletionPopupas PHPantom, synchronously. The schema is asqlSchemaevent (SQLSchemaInfo: tables with columns and types,how,error):SqlTab::schema()for Load Schema (ExecutionEngine.loadSQLSchema, a fresh runner apart from the tab, afterguardProduction(.sqlSchema, …)), orrun/runAllwithschema: trueafter the statements succeed, whichAppModel.wantsSQLSchemaasks for only on non-production targets with nothing cached. The runner asks the driver'ssqlSchema(), else the catalog for the PDO driver (or each catalog in turn for a callable), bounded to 2,000 tables and 50,000 columns; a failure in a run is the event'serror, never the run's.SQLSchemaStorekeeps schemas per target and connection in memory (forgotten bytargetEdited). - Every result set (#154).
SqlTab::runPdoSetsruns a statement and reads its result sets withreadSetunder one row and byte budget ($budget), moving on withnextRowset()(nextRowset: never asked on SQLite and PostgreSQL; SQLSTATEIM001means none; any other error is the procedure's and propagates). A later set whosegetColumnMeta(0)is false has no columns (MySQL repeats the previous column count) and reportsrowCount(); a last such set that changed nothing, after others, is theCALL's status and is dropped.run()andrunAll()emit them throughemitResults, addingresultSet(index, andcountunless a later set failed, when the sets read so far are emitted before the error is rethrown).runPdo(Load Next'spage()) reads the first set only.SQLResultInfo.resultSet(ResultSetInfo) givestitle("Statement 2 of 3 · Result 1 of 2"), used bySQLResultCard,windowTitle,plainText, andmarkdown;SQLResultPagerrefuses one of several results (SQLPaging.Refusal.resultSets). - CSV export and import (#152). RunletCore's
SQLCSV.swift:CSVText(RFC 4180field(_:delimiter:), whichValueTable.csvnow uses;records(in:delimiter:limit:)with each record's line;detectDelimiter),SQLCSVExportOptions,SQLExportFrame(thesqlExportevent),SQLCSVExport(text/line/headerper the options;code(statement:connection:bindings:);refusal(of:)fromSQLPaging.plan),SQLCSVExportWriter(writes frames to.<name>.runlet-exportbeside the destination,finish()moves it into place,abandon()deletes it),SQLCSVImport(parse, header detection,autoMapping,values,insertPrefix/rowPlaceholders,batches()of JSON,code(connection:),historyCode, the 8 MiB and 100,000-row limits), andSQLImportReport(thesqlImportevent).RunEvent.Kindgains.sqlExportand.sqlImport(RunSessiondecodes them;TabModel.applyignores them).RunRequest.sqlBatches(not encoded) reaches the runner's request assqlBatches(RunnerBundle.script), whichRunner::mainmoves toRunner::takeSqlBatches()before anything runs, so the snippet compiler never parses the rows. The runner'sSqlCsvusesSqlTab::openFor(the run's refusals and connection) andSqlTab::bindValues:exportemits columns, frames of 1,000 rows or 256 KB, anddone(unbuffered MySQL; a PostgreSQLDECLARE … CURSORin a transaction it rolls back; whole values, binary as hex;SqlExportRefusedfor a statement without rows);importchecks the INSERT's parts are what the app builds, begins a transaction, inserts chunks of up to 500 rows / 999 placeholders withSAVEPOINT runlet_importaround each (SQLite, MySQL, PostgreSQL) so a failing chunk is retried row by row to name the row (SqlImportRowFailed), emits progress after each batch, and on an error rolls back, emitsfailedRow/rolledBack, and throwsSqlImportFailed. In the app,AppModel+SQLCSV.swiftholdsSQLCSVStorewithSQLCSVExportJobandSQLCSVImportJob, presented bySQLCSVSheetModifier(Runlet/Features/SQLCSVSheets.swift), which shows a pending production confirmation inside the sheet (guardProduction(…, sqlExportFile:)/sqlImport: SQLImportCheck). Both run like Load Next:ExecutionEngine.startwith a pseudo tab id onsqlSnapshot(for:saved:), Stop throughengine.cancel(runId:)(#144),DatabaseWork.Purpose.csvin the Connection Manager, and oneHistoryEntry; the export's frames are written bywriteExporton a detached task with throttled progress. Entry points: therun.sqlExportCSVcommand,SQLPagerControls' Export All Rows… (exportResultToCSV, the run's own values), and the explorer's Import CSV… (importCSV(into:schema:from:)). DEBUG steps:SQLCSVDebugStepsandcsv-wait. - Schema explorer and result window (#21).
SqlSchema.php(the runner's catalog reader, split out ofSqlTab.php) reads, per dialect, a detailed columns query (nullable, default, primary key, kind, row estimate; falling back to #128's plain one), then indexes and foreign keys as soft extras (SchemaBuildernotes what failed). A driver'ssqlSchema()may use the detailed per-table form.SQLSchemaInfogainsColumn.nullable/defaultValue/primaryKey/references,Table.kind/rows/indexes, andnotes, all optional. The Library'sInspectorPane.databaseshowsSchemaExplorerPane(Runlet/Features/SchemaExplorer.swift) fromSQLSchemaStorefor the tab's target andexplorerConnection(for:)(an SQL tab's connection, else the default). It is filtered bySQLSchemaExplorer.filter, with expansion and filter inSchemaExplorerState. Its actions (openSchemaTable,openSchemaTableAsPHP,insertSchemaName) only open tabs or insert text.ValueTableView's Open in Window registers aResultDocumentinResultWindowsand opens theresultWindowGroup(Runlet/Features/ResultWindow.swift):ValueTableQuery(RunletCore: search,ValueTableFilterrules, number-aware sort) picks the rows, and the output'sValueTableGrid(#162) shows them with resizable, sortable columns, ⌘C, and a filter-by-value menu (onFilter). An output table opens its window with its own search and sort (ResultWindows.open(…, query:)). Documents live only while their window is open. - Show Definition (#148).
SchemaTableRow's context menu and document button callAppModel.showSchemaDefinition(_:schema:from:)(AppModel+SchemaExplorer.swift):guardProduction(.sqlDefinition, …)with the saved connection's marking (as.sqlSchema), then aSchemaDefinitionSheet(an@Observablerequest inSchemaExplorerState.definitionSheet, scoped to the tab's window and never saved) whose task runsExecutionEngine.loadSQLDefinition(target:table:connection:saved:)(SQLDefinitionLoader.swift) onsqlSnapshot(for:saved:)(this Mac'sLocalConnectionLaunchsnapshot for a saved connection that opens there, #142; the engine refuses to send one anywhere else), a fresh runner likeloadSQLSchemathat runsSQLDefinition.code(SqlTab::definition()) and picks thesqlDefinitionframe up throughRunSession'sotherFrames(no newRunEventcase).SqlDefinition.phpresolves the dialect (the PDO driver; for a callable, MySQL, PostgreSQL, then SQLite, unless the booted driver overridessqlSchema()), binds the name on PDO (literals for callables), and returnstable,kind,how,sql,server, andreconstructed/notes;SqlDefinition::postgres()builds PostgreSQL's DDL from catalog rows so it can be tested from recorded ones.SQLDefinitionInfodecodes it andSQLDefinition.documentadds the header.SchemaDefinitionSheetModifier(Runlet/Features/SchemaDefinitionSheet.swift, mounted inMainWindow) presentsSchemaDefinitionSheetView: a spinner, the error, or a read-onlyNSTextViewcoloured bySQLHighlighterwithEditorThemeandEditorPreferences;ResizableSheetmakes the sheet window resizable and keeps its 720 × 480 minimum (SwiftUI resets the window's minimum as it lays out). Copy (copySchemaDefinition), Open in SQL Tab (openSchemaDefinitionInTab: an SQL tab on the same connection with the output pane dismissed, #60's per-tab flag), and Done (closeSchemaDefinition, which cancels a read under way). - Relations diagram (#153).
SqlSchema.php's foreign key queries also return the constraint (its name; SQLite'spragma_foreign_key_listid), andSchemaBuilder::addForeignKeysgroups a table's rows intoforeignKeys(name,columns,references,referencedColumns, left out when the catalog doesn't name them), alongside each column'sreferences; PostgreSQL's rows keep the key's order (unnest … WITH ORDINALITY).SQLSchemaInfo.Table.foreignKeysdecodes them (nil from a driver'ssqlSchema()).SchemaTableRow's context menu and diagram button callAppModel.showSchemaRelations(_:from:)(AppModel+Relations.swift), which registers aRelationsDocument(the focus history for Back and Forward, hops, all columns, expanded groups, the selected line, zoom; never saved) inRelationsWindowsand opens therelationsWindowGroup(Runlet/Features/RelationsDiagram.swift, like the result window). The window reads the schema fromSQLSchemaStoreon every draw (relationsSchema), so it follows a reload and offers Load Schema (loadSQLSchema(for:connection:), production asks) when it's forgotten;RelationsDocument.layout(for:token:)caches the relations and layout per schema read and options.RelationsCanvasdrawsSQLRelationsLayout(lines under the boxes, labels on top) withRelationsPalette's explicit colours, soRelationsExport's PNG (ImageRendererat 2x) matches the window; the SVG comes fromSQLRelationsSVG. Copy Join and Insert Join (relationJoin,copyRelationJoin,insertRelationJoin) quote withSQLSchemaExplorer.quoted; Open in SQL Tab and Show Definition reuse the explorer's paths. DEBUG steps are inRelationsDebugSteps. - Server section (#150).
SchemaExplorerPaneswitches between Tables and Server (DatabaseServerStore.section,AppModel+DatabaseServer.swift);DatabaseServerView(Runlet/Features/DatabaseServerPanel.swift) shows aDatabaseServerStateperSQLSchemaStore.key(in memory only).readDatabaseServer(for:parts:)asksguardProduction(.sqlServer, …)with the saved connection's marking, then runsExecutionEngine.loadSQLServerInfo(target:parts:connection:saved:)(SQLServerLoader.swift) onsqlSnapshot(for:saved:): a fresh runner withSQLServerPanel.code(SqlTab::server()), whosesqlServerframe arrives throughRunSession's other frames.SqlServerInfo::read()(Resources/Runner/src/SqlServerInfo.php) refuses callables and SQL Server, identifies the session and server withSqlCancel::identify()(#144's fingerprint), and readsoverview,sizes, andsessionseach in its owntry(a failure becomeserrors[part]): statuses,information_schema.TABLES/pg_*_size()/dbstat, andPROCESSLIST(withINNODB_TRXand lock waits)/pg_stat_activity(withpg_blocking_pids(); other roles' sessions, whosebackend_typeis hidden, count as clients when they have a database and a role), at most 20 tables and 200 sessions, statements cut to 4 KB within 1 MB, plus notes on what the user's privileges hide.SQLServerInfo.merged(with:)lays a sessions-only refresh over the report, taking that read'ssessionIdandserver. Refresh:setServerRefresh(_:for:)loops a task that reads the sessions every 5/10/30 s only while the tab is selected in its window, the Server section shows, the connection is the same, and it isn't production (SQLServerPanel.refreshIntervals(isProduction:));stopServerRefresh(reason:)turns it off from the view'sonDisappear, tab and connection changes, andNSApplication.didResignActive/didHide. Actions:requestServerActionturns a row into anSQLServerActionPlan(SQLServerPanel.plan: refuses the panel's own session, databases without the statements, a report without a fingerprint, and Cancel on an idle session) and always setsDatabaseServerStore.confirmation, whichServerActionConfirmationModifier(mounted inMainWindow) presents;confirmServerActionlogs to the tab's Run Log (TabModel.appendRunLog) and runsrunSQLServerActionwithSQLServerPanel.code(_:connection:)(SqlTab::serverAction()).SqlServerInfo::act()checks the statement is its own, the dialect, the listed and its own session, the fingerprint, and the listed user (and PostgreSQL'sbackend_start), sendsKILL QUERY/KILL/pg_cancel_backend/pg_terminate_backend, maps MySQL 1094/1095 and PostgreSQL 42501, and watches up to 2 s;SQLServerActionReport(RunletCore) carries the outcome and its message. - Browse Table (#151).
SchemaTableRow's context menu and grid button callAppModel.browseSchemaTable(_:schema:from:)(AppModel+TableBrowser.swift), which picks the dialect from the schema (SQLTableBrowse.Dialect; refused for others), decidesSQLTableEdits.refusal(views, no primary key, a read-only saved connection, a callable, a binary key, a key past 200 columns), makes aTableBrowser, and opens it in a result window (ResultWindows.openBrowser;ResultDocument.browserswitchesResultWindowViewtoTableBrowserView). Every read builds aSQLTableBrowse.Queryfrom the browser's page size, sort, and filter rules (names from the schema only, values as positionalSQLBindings typed byColumnKind), asksguardProduction(.sql, …, sqlTable: .browse)with the SELECT and its values, and runsSQLTabRun.browseCode(SqlTab::browse(<sql>, <connection>, <pageSize>, <driver|null>, <bindings>)) throughExecutionEngine.startlike Load Next:sqlSnapshot(for:saved:), a pseudo tab id, the saved connection's definition on the request, Stop throughengine.cancel(runId:)(#144's server cancel), andtrackDatabaseWork(.browse)(#180).SqlTab::browse()resolves the connection,SqlTable::checkBrowse()refuses anything but one reading SELECT and a dialect other than the one it was written for,refuseUnbindablerefuses a callable's values, andrunPdo/runCallablekeeppageSizerows of thepageSize + 1asked for, sotruncatedmeans another page. Editing keepsSQLTableEdits.Changes(cells by page row and column, deleted rows, new rows);TableBrowser.displayandmarks(SQLTableEdits.display) feedValueTableGrid'smarks(orange, struck-through red, green),onDoubleClick(the cell editor),editMenu(Edit, Set to NULL, Revert, Delete/Restore Row), andonSelection; the grid keeps its columns' widths whilemarksis set, and.id(pageLoads)remakes it for each page. Review Changes showsSQLTableEdits.statements(DELETE, then UPDATESET … WHERE <key> = ? AND <changed column> = ?|IS NULLfor columns thatcomparesExactly, with MySQL'sverifySQLcount, then INSERT, orDEFAULT VALUES/() VALUES ()); Apply asksguardProduction(.sql, …, sqlStatements:, sqlTransaction: true, sqlTable: .apply)(the Run All sheet;SQLStatementCheck.captionreplaces the line) and runsSQLTabRun.applyCode(SqlTab::applyEdits([...], <connection>, <driver>)).applyEditschecks each statement's kind (SqlTable::checkEdits), refuses a read-only connection before connecting (refuseOnReadOnly) and a callable, checks the dialect, reports the session for Stop, andSqlTable::apply()runs them with native prepares in one transaction: each must affect one row (MySQL'sverifycount turns an unchanged row's 0 into 1), else it rolls back and throwsSqlEditsFailednaming the change; after the commit it emits ansqlevent per change and a notice. The app records oneHistoryEntry(SQLTableEdits.historyCode, with the connection, #149) for every finished Apply, and reads the page again off production. DEBUG steps:TableBrowserDebugSteps(browse-table,browse-…,browse-waitinRunletApp); screenshots:scripts/table-browser-screenshots.py. - Saved connections (#138). Definitions live in
TargetLibrary.databaseConnections(targets.json); passwords inAppModel.credentials(KeychainCredentialStore; in Debug builds with a scratchRUNLET_DATA_DIR, or withRUNLET_CREDENTIALS=memory, anInMemoryCredentialStore), which the app hands toExecutionEngine.AppModel.sqlConnectionChoice(for:)resolves a tab to.app(name),.saved(connection)(by id on its target, else by name), or.missing(name), which refuses to run.SQLRunInfo.savedmakesstartRunsetRunRequest.sqlConnection(the definition, no password), send no hints, and learn nothing from the run but its schema (learnSQLSchema(_:for:connection:));TabModelkeeps the last run's framework facts.ExecutionEngine.startreads the password from its store inside the script closure, after the run has a slot (runnerConnection); a store that throws fails the launch before PHP starts.RunnerBundle.script(…, sqlConnection:)puts{id, name, driver, host, port, database, user, password, timeout, summary}in the request withbootstrap: plainand no hints, inspector, or profile; the engine keeps no copy of the script once the process has it.loadSQLSchema(…, saved:)andtestSQLConnection(target:connection:password:)(asqlTestframe throughRunSession's other frames) do the same. In the runner,Runner::mainseessqlConnection, setszend.exception_ignore_args, clears its argument, hands the definition toSqlConnect::configure(which registers the password withChannel::addSecret), and removes it from the request before the bootstrap.SqlTab::resolvereturnsSqlConnect::pdo()first;SqlConnect::connect()takes no arguments, checksPDO::getAvailableDrivers(), builds the DSN (rechecking host and database), and rethrows PDO's error as anSqlConnectionFailedwith the message only.Channel::emitscrubs every string of every event (errors, notices, and log lines only for passwords under 4 characters). Results carrysaved: true, the name, andsource: saved connection "…" (…). Read-only (#139): the request's connection gainsreadOnly: true;SqlConnect::connect()opens SQLite files withSQLITE_OPEN_READONLYand, for every driver, sends the read-only setting (SET SESSION TRANSACTION READ ONLY,SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY,PRAGMA query_only = ON) and checks it (@@session.transaction_read_only/tx_read_only,SHOW default_transaction_read_only,PRAGMA query_only), failing the run when the database didn't take it;SqlTab::run/runAllrefuse withSqlReadOnlyRefused(SqlReadOnly::refusal) before resolving the connection, andrunAllcallsSqlConnect::enforceReadOnly()before each statement after the first. The app refuses first, inrunSQL/runAllSQL(SQLScript.readOnlyRefusal, the whole script for Run All).origin()andSQLConnectionTestInfo.readOnlysay "read-only session". Options (#140): the request's connection gainssocket,charset,tls(mode,ca,cert,key),init,options([key, value]pairs), anddsn, each only when set.SqlConnect::plan()turns the definition into a DSN and PDO attributes per driver, rechecking everything the app validated (hosts, paths, charsets, option keys and values, the custom DSN's password patterns anduri:): MySQLunix_socket=,charset=, and the SSL attributes (Pdo\Mysql::ATTR_*on 8.4+, elsePDO::MYSQL_ATTR_*; Require is an empty CA withSSL_VERIFY_SERVER_CERTfalse, Verify the CA file oropenssl_get_cert_locations()'s defaults with it true); PostgreSQLhost='<socket dir>',sslmode, quotedsslrootcert/sslcert/sslkey/client_encoding, and options; SQL Serversqlsrv:Server=host,port;Database=…;LoginTimeout=…withEncrypt/TrustServerCertificateand options and noPDO::ATTR_TIMEOUT(pdo_sqlsrv rejects it), ordblib:host=host:port;…when only pdo_dblib is there (no TLS); a custom DSN as typed, after checking that its prefix is an available PDO driver.connect()checks init statements (SqlReadOnly::initRefusal) and TLS files (is_readable) before connecting; after connecting it checksSsl_cipherfor MySQL's Require and Verify, makes a read-only session read-only, runs the init statements (queryandcloseCursor; a failure is anSqlConnectionFailed), and on a read-only connection sends and checks the setting again.test()addstls,tlsVersion,tlsCipher(SHOW SESSION STATUS,pg_stat_ssl,sys.dm_exec_connections),pdoDriver(dblib), andinitStatements. The production sheet lists a saved connection's init statements (ProductionConfirmation.sqlInitStatements). The UI:DatabaseConnectionEditor,DatabaseConnectionsList(project options, Docker and SSH profile forms),DatabaseConnectionsSheet, and the SQL bar'sSQLConnectionPickerpopover;SQLSchemaStorekeys onSQLConnectionRef; Open as PHP is hidden for saved connections (offersQueryBuilder). Removing a target (removeProject,removeDockerProfile,removeSSHProfile) removes its connections and Keychain items; the Profiles window's Duplicate copies connections without passwords. - From this Mac, and for all targets (#142).
DatabaseConnection.connectFrom(DatabaseConnectFrom:.target,.thisMac) andscope: TargetRef?(nil: all targets). On disk an all-targets connection has"allTargets": trueand noscope(a Runlet before #142 leaves it out, throughLossyList), and one from this Mac"connectFrom": "mac"; both keys are written only when used, so other definitions are unchanged.opensOnThisMacis true for either;normalizedsets.thisMacfor all targets and expands~in this Mac's SQLite, socket, and TLS paths;validaterefuses a relative SQLite path from this Mac and checks names per scope.TargetLibrary.allTargetsDatabaseConnections,databaseConnections(scope:), anddatabaseConnection(id:name:on:)(by id whenisAvailable(on:), else by name: the target's own, then all targets') serve every target, the sandbox too;marking(for:connection:)is unchanged, so the stricter of the tab's target and the connection wins. In the app (AppModel+LocalConnections.swift),localConnectionPHPreadsrunletPHPState, the default PHP, and the discovered installations;sqlSnapshot(for:saved:)returnslocalConnectionSnapshot(for:)for a connection that opens from this Mac, elsesnapshot(for:), andstartRun,loadSQLSchema, andstartSQLPageuse it (so Explain, Run All, parameters, and the schema explorer follow);testDatabaseConnectiondoes the same and setsSQLConnectionTestInfo.openedFrom.startRunreports to the SSH status only for SSH snapshots.RunnerBundle.scriptaddsplace: "mac"to the request's connection;SqlConnectthen says "This Mac's PHP" and "on this Mac" in its messages, and names Runlet's PHP for a missingpdo_mysql,pdo_pgsql, orpdo_sqlite;test()reportspdoDrivers.SQLSchemaStore.keydrops the target forsaved:refs, so a schema is shared by every tab of the connection (forgetSQLSchemas(for:)also forgets the target's own saved connections). The UI: the editor's Available on and Connect From (DatabaseConnectionDraft.homeTarget), Choose… for this Mac's paths, Download Runlet's PHP… after a failed test (showPHPSettings), the picker's Saved connections (all targets),DatabaseConnectionsList(allTargets:),DatabaseConnectionsSheet's two lists, and Settings ▸ Databases (DatabaseSettingsView). Production sheets say "from this Mac" (ProductionConfirmation.sqlFromThisMac, the destination). - Through an SSH tunnel (#143).
DatabaseConnectFrom.sshTunnelwithDatabaseConnection.sshProfile(the profile's id); on disk"connectFrom": "sshTunnel"and"sshProfile", written only for tunnelled connections (a Runlet before #143 leaves them out throughLossyList, rather than connecting directly).opensOnThisMacis true for it andusesSSHTunneltells it apart;normalizeddrops the socket (and the profile id from connections that don't tunnel);validaterefuses SQLite, custom DSNs, no profile, and a PostgreSQLhostaddroption.TargetLibrary.tunnelProfile(of:)andtunnelProblem(of:)(no profile, or a removed one: never retargeted) serve the editor and runs;marking(for:connection:)passes the profile's environment toEnvironmentMarking(…tunnel:), which setsfromTunnelwhen the profile is the strictest.TargetSnapshot.sqlTunnel(SQLTunnelRoute: local port, the server's host and port, the profile, the-O forwardline, reused, and the app's lease token) rides with the run, so Stop's cancel runner (ActiveRun.target) goes through the same forward.ExecutionEngine.launchlogs the forward (tunnelsource) in the Run Log;runnerConnection(…tunnel:)hands the route toRunnerBundle.script, which addstunnel: {port, via}and " through SSH “<profile>”" to the summary;SqlConnect::address()then connects PostgreSQL withhost=<server>;hostaddr=127.0.0.1;port=<local>(verify-full checks the server's name) and MySQL and SQL Server to127.0.0.1:<local>,checkTunnel()refuses sockets, other drivers, andhostaddr, and a connect error adds that the SSH server connects to the database. In the app (AppModel+SQLTunnels.swift),SQLTunnelStore(@Observable) holds theSSHTunnelManager(idle time fromRUNLET_SQL_TUNNEL_IDLEin Debug), the leases of runs in progress, the tabs each forward served, andactive(SQLTunnelInfo: connection id and name, SSH profile, ports and remote host, opened and last-used times fromSSHTunnelManager.Forward, and the holds, soinUse), refreshed after every acquire, release, and manager event;closeSQLTunnel(_:force:)cancels one (.closed;forceeven while runs hold it) for a connection manager (#180). The Database pane's Server section (#150) reads and acts throughsqlSnapshotand releases the forward too; its refresh ticks passaskToConnect: false.ExecutionEngine.runnerConnectiontakes the tunnel as a required argument, so every loader (runs, schema, definition, server panel) passestarget.sqlTunnel.sqlSnapshot(for:saved:)returnstunnelSnapshot(for:tab:): the profile's checks, this Mac's PHP (localConnectionSnapshot),ensureTunnelConnected(anNSAlert; Connect opens an agent or key profile's master withopenSharedConnection, Connect… opens a password or 2FA profile's terminal and ends the run), thenacquire.startRun,loadSQLSchema,showSchemaDefinition,startSQLPage, andtestDatabaseConnectioncallreleaseSQLTunnelwhen they end (Test Connection withcancelWhenUnusedunless a tab uses the connection).closeTab,windowDidClose,setSQLConnection, andsetSQLSavedConnectioncallcancelUnusedSQLTunnels; saving or removing a connectioncancelSQLTunnel(for:);disconnectSSHandremoveSSHProfilecancelSQLTunnels(on:)beforessh -O exit;shutdowncancelAllSQLTunnels.sqlTunnelEventlogs to the unified log (ssh-tunnel) and appends cancel lines to the Run Log of the tabs that used the forward (TabModel.appendRunLog). The editor's Connect From has the third choice, an SSH profile picker with its connection's state and a Missing profile state that blocks Save, and the driver'stunnelTLSNote;savedConnectionPlace/savedConnectionPlaceDetailandSQLRunInfo.tunnelProfileput "through bastion" in pickers, lists, notes, and confirmations (ProductionConfirmation.markedTunnel). - Import from TablePlus (#188). Behind
FeatureFlag.tablePlusImport(#187: the registry inRunletCore/FeatureFlags.swift,AppSettings.featureFlagsandshowAdvancedSettingsinsettings.json, checked throughAppModel.isEnabled(_:);RUNLET_FEATURE_FLAGSin Debug builds; Settings ▸ Advanced isAdvancedSettingsView, revealed byAdvancedSettingsTrigger's ⌥⌘, key monitor or ⌥ held when the Settings window becomes key).TablePlusParser(RunletCore/TablePlus.swift) readsConnections.plistandConnectionGroups.plistdefensively intoTablePlusConnections;TablePlusImportPlan(TablePlusImport.swift) maps each to aTablePlusImportRow(a normalizedDatabaseConnectionfrom this Mac withimportedFrom: "tableplus:<id>", or the reason it can't be imported, plus notes), finds duplicates (byimportedFrom, then by name in the scope), matches SSH profiles (TablePlusSSHServer: host, port, user) and plans one newSSHProfileper server (identityFilefor key logins,.interactivefor passwords, the least strict environment of its connections, folder/).TablePlusImport.readPasswordsasks aTablePlusKeychainReader(SecurityTablePlusKeychainReader: servicecom.tableplus.TablePlus, account<id>_database;FakeTablePlusKeychainReaderin tests and Debug runs withRUNLET_TABLEPLUS_DIRor scratch data) off the main thread, andTablePlusImport.applyadds the profiles and connections to the library and the found passwords to theCredentialStore, returning aTablePlusImportSummarythat holds no secret. The app side isAppModel+TablePlus.swift(reading the default location, Choose File…,performTablePlusImport) andTablePlusImportSheet.swift; Debug steps are inTablePlusDebugSteps.SSHProfile.identityFilebecomesSSHEndpoint.identityFileandssh -i(never on-Ocalls). MongoDB rows (#209):TablePlusParser.readMongo(TablePlusMongo.swift) reads amongodb:///mongodb+srv://string (MongoConnectionString: hosts, user, aSensitiveStringpassword, database, options; secret-bearing option values and unreadable strings keep nothing) or the fields intoTablePlusConnection.mongo;TablePlusImportRow.mapMongofillsMongoConnectionOptionsand TLS; SRV rows don't use SSH;applycopies the string's password only with the opt-in, andpasswordRequestsskips those rows. - Snippets (#130).
Snippet.language(nil = PHP;tabLanguage) andProjectSnippet.language(.sqlfiles, with-- @labelmetadata parsed byProjectSnippets.parseSQL) open throughopenLibraryCode(…, language:); SQL snippets have no inputs.SnippetDraft.languagecomes from the tab, so Save as Snippet writes SQL snippets and.sqlproject files. MCP's snippet tools reportlanguage, andadd_snippetaccepts it. - Connections in history and snippets (#149).
SQLConnectionReference(RunletCore,SQLConnectionReference.swift):.application(String?)(nil: the default connection),.saved(name:id:allTargets:), and.named(String)(a bare project-snippet name), encoded as{"kind", "name", "id"?, "allTargets"?}; only names and a saved connection's id.HistoryEntry.connection(SQL runs only;SQLRunInfo.historyConnection, recorded bystartRunand Load Next) andSnippet.connection(forSnippet: no id, nothing for the default connection) decode withtry?, so older files and unknown kinds load without one.ProjectSnippet.connectioncomes from@connection(ProjectSnippets.parseConnection:.named, or.savedwith the(saved)marker;connectionLinewrites it).TargetLibrary.resolve(_:on:)givesSQLConnectionResolution(.application,.saved,.missing) withdatabaseConnection(id:name:on:).HistoryLog.recordingkeeps an entry per connection (identity: a saved connection's id, else kind and name; an entry without one is replaced), andconnections(in:)/filtered(_:connection:)back the History pane's Connection filter. In the app,applyLibraryConnection(_:to:from:)(AppModel+LibraryConnections.swift) runs afterrestore,openLibraryCode, and the snippetopenmethods put code in a tab;.missingsets the default connection andTabModel.sqlConnectionNote(not saved; cleared bysetSQLConnection/setSQLSavedConnection), whichSQLTabBarshows.SnippetDraft.connectioncomes fromsnippetConnection(for:); MCP'sget_snippetreturns the name only.
Redis Tabs (#190)
The user guide is redis.md; the driver API is in drivers.md. Redis tabs reuse the SQL tab machinery wherever it isn't SQL-specific, so MongoDB tabs (#191) can follow the same seams.
- Language and families.
TabLanguage.redis(unknown languages still decode as PHP).TabLanguage.connectionFamilyandDatabaseDriverKind.family(DatabaseFamily:sql,redis) gate every connection list:TargetLibrary.databaseConnections(for:family:),allTargetsDatabaseConnections(family:),databaseConnection(id:name:on:family:), andresolve(_:on:family:).AppModel.sqlConnectionChoice(for:)resolves only the tab's family, so SQL features (pickers, the schema explorer, Explain, Load Next, the table browser) never see a Redis connection, and Redis tabs never see an SQL one. A Redis tab keeps its connection in the sameTabStatefields as an SQL tab (sqlConnection,sqlSavedConnection,sqlSavedConnectionName), plusredisTransaction; switching between families clears it. - Connection kind.
DatabaseDriverKind.redis: host, port (6379), the database number indatabase, an ACL user, the Keychain password, TLS (disable,require,verify-full, with CA, certificate, and key files), a Unix socket, Connect From, read-only, and the environment; no charset, options, or init statements (normalizeddrops them). Older Runlets leave a"driver": "redis"connection out (LossyList). Test Connection runsRedisTabRun.testCode. - Commands.
RedisScript(RunletCore) splits lines and arguments likeredis-cli(bytes, so\xHHsurvives), picks what Run and Run All send, redacts typed passwords (secretArguments,redacted), and tokenizes forRedisHighlighter.RedisCommandsis the command table (reads, connection, transaction, streaming, writes, dangerous, blocking; container commands per subcommand; unknown is a write).RedisTabRungenerates the PHP: each argument is a byte-exact PHP string literal, with its line, its element cap, and which arguments are secrets. - Running.
AppModel.runRedis/runAllRedis(AppModel+Redis.swift) refuse streaming commands, read-only writes, and typed transaction control in a transaction; dangerous commands confirm in the sharedDatabaseDangerSheet(always; its model,DatabaseDangerConfirmation, is in RunletCore and MongoDB uses it too); thenguardProduction(.redis, …); thenstartRunwith anSQLRunInfowhoselanguageis.redis(its statements are the redacted lines,redisCommandsthe arguments), so production marking,sqlSnapshot(target, this Mac, SSH tunnel), Stop, Run History, and the Connection Manager work unchanged. - Runner.
RedisTab.php:Runner::mainhands a saved connection whose driver isredistoRedisConnect::configure(instead ofSqlConnect).RedisClientis a RESP2/RESP3 client onstream_socket_client(tcp://,tls://with the connection's CA, certificate, key, and peer name,unix://), with AUTH and SELECT; replies become trees ({"t": …}) with caps (RedisReplyBudget). Application connections go through the booted driver'sredisConnection()andRedisAppConnection(phpredisrawCommand, PredisexecuteRaw, Laravel'sclient(), callables). A read-only connection refuses non-reads byRedisCommandsand by the server'sCOMMAND INFOflags before anything is sent. Run All stops at an error reply (RedisCommandFailed);MULTI/EXECreports each queued command's reply. Each reply is aredisevent; the client id is ansqlSessionevent with driverredis(no server cancel plan: closing the socket ends a blocked command). - Replies.
RedisReplyInfodecodes the event;RedisReplyView.make(arguments:reply:)picks the shape from the command (hash, zset, set, list, stream, keys with a cursor, value, error) and builds aValueTableonce.RedisReplyCardshows it (tables withValueTableView, strings and nested replies withValueContentView);RedisReplyPagerandRedisPaging.nextgive Load More for SCAN cursors and cut index ranges. - Database pane. For a Redis tab,
SchemaExplorerPaneshowsRedisDatabasePane: the key browser (RedisKeyBrowserState;RedisTab::keysSCANs one page with TYPE and PTTL,valuereads by type,keyInfoMEMORY USAGE) and the server panel (RedisServerState;RedisTab::serverINFO and CLIENT LIST, parsed byRedisInfoSectionandRedisClientInfo;killchecks run_id, the panel's and its own client, and the listed address before CLIENT KILL ID).ExecutionEngine.runRedisPanelruns each in a fresh runner (RedisLoader.swift);guardProduction(.redisKeys|.redisServer, …)asks on production;DatabaseWork.Purpose.otherlists them in the Connection Manager. - Command Builder (#218).
RedisCommandSpecs(RunletCore,RedisCommandSpec.swift) holds each command's syntax as a tree ofRedisArguments modelled onCOMMAND DOCS(typed values, pure tokens, one-of, blocks; optional, repeated, token before each row,numkeyscounts, column-wiseSTREAMS key … id …), with its group and its class as Redis's flags have it (RedisCommandBuilderTestschecks it againstRedisCommands).RedisCommandForm(RedisCommandForm.swift) is the form's state (RedisArgumentValue, the same tree), renders the exact argument list and quoted line with blocking and non-blocking issues, and reads a line back with a backtracking matcher (options in any order, repeated arguments up to the next option word, missing words at the end; the best match places the most words, the rest stay raw inextra), also giving each word'sRedisArgumentRolefor completion (#206).RedisBuilderTextcomputes Insert and Replace Line edits and reads the caret's line. In the app,RedisBuilderState/RedisBuilderStore(AppModel+RedisBuilder.swift, per tab, in memory) andRedisCommandBuilderPanel(RedisCommandBuilder.swift, beside the editor inTabContent) write throughEditorController.apply(_:actionName:), one undo group; key fields complete fromRedisKeyBrowserState.keys. The key browser's Insert Command items come fromRedisKeyEntry.builderCommands. - Completion (#206).
RedisCompletion(RunletCore) reads the caret's line withRedisScript.tokenize(the word at the caret, open quotes unescaped) and offers commands fromRedisCompletion.commands(everyRedisCommandSpecsspec, then the rest ofRedisCommands' table but streaming, containers by subcommand, with WRITE/DANGEROUS/BLOCKS fromRedisCommands.classify). After a command it asks the builder's matcher (RedisCommandForm.parseWithRoles) where a probe word would go (expectation(after:)): a key, a value (itssuggestions), and which option words some way of finishing the line reads as tokens. Key names come only fromKnownKeys the app passes: the key browser's last scan of the tab's connection and database, Load Keys for Completion (RedisCompletionStore,AppModel+RedisCompletion.swift), and the tab'sSCAN/KEYS/RANDOMKEYreplies. It returns anSQLCompletion.Result, so the editor's local-completion path (EditorController.sqlCompletion) shows it; items may carry a filter text, documentation, badges, an action (Load Keys for Completion, throughEditorController.completionAction), or reopen the list after inserting (a container). Load Keys for Completion runsRedisTab::keys(…, false)(one SCAN, names only) afterguardProduction(.redisKeys, …). Hover isRedisCompletion.hoverthroughEditorController.textHover. - Safety of secrets. As for SQL saved connections (#138): Keychain only, stdin only, forgotten after connecting, scrubbed (
Channel::addSecret) from every event; typed passwords are marked by the app, echoed as•••, and scrubbed too. History, confirmations, and MCP snippet tools showRedisScript.redactedtext.
MongoDB Tabs (#191)
RunletCore/MongoQuery.swift validates structured JSON without evaluating JavaScript, preserves compound-key order, and classifies operations. MongoConnection.swift adds optional settings without changing old SQL connection encodings. TabLanguage.mongodb and DatabaseFamily.mongodb isolate pickers. AppModel+Mongo.swift, MongoTabViews.swift and MongoExplorer.swift reuse execution, production guards, credential transport, history and result views. LocalConnectionLaunch picks the local PHP by the extensions discovery read (#184). Resources/Runner/src/MongoTab.php uses the low-level extension, repeats safety checks and emits bounded tables and Extended JSON trees. Saved runs use plain bootstrap and stdin-only passwords; application runs use Laravel or a project-driver hook. Metadata and completion caches are in-memory, keyed by target/connection/revision. See MongoDB tabs for supported operations and remaining #191 scope.
The remaining scope (#207):
- Load More.
MongoUI.Page(AppModel+Mongo.swift) keeps the captured query, connection, target and loaded count;loadMoreMongoruns the next page in a fresh runner (like SQL'sstartSQLPage) and merges it withMongoPaging.appending(RunletCore): table rows with columns unioned, the Extended JSON tree keyed by document position and renumbered (TabModel.mongoResultItems,replaceSQLResult,replaceDump). - Cells.
MongoTab::cellformats canonical Extended JSON for the table (mongosh-like text for nested values);MongoTab::typeNamegives sampled fields short BSON names. - Stop on the server.
MongoTab::runexecutes on one selectedMongoDB\Driver\Server, tags every command withcomment: runlet:<run id>(Runner::runId(), MongoDB 4.4+), and emitssqlSessionwithdriver: mongodb,tag, and a hashedtopologyVersion.processId.SQLCancel.plan(for:)makes akillOpplan from the tag;SQLCancel.codecallsMongoTab::cancelin the second runner (ExecutionEngine.cancelOnServer, unchanged), which matches the server, finds the user's tagged operations withcurrentOp $ownOps, refuses another user's, and kills them.SQLCancel.isCancellationErrorreadsDriver code: 11601. - Server section.
MongoServer.swift(MongoServerReport,MongoKillReport,MongoServerPanel,DatabaseDangerConfirmation.mongoKill),RunletExecution/MongoLoader.swift(runMongoPanel,loadMongoServer,killMongoOperation),AppModel+MongoServer.swift(MongoServerState, production guard.sqlServer, refresh, Kill Op), andMongoServerPanel.swift; runnerMongoTab::server(serverStatus,hello,$currentOptagged with the panel's comment) andMongoTab::killOp. - dropDatabase.
MongoQuery.databaseandsubject; the runner checks the name against the connection's database. - TLS and X.509.
DatabaseDriverKind.supportsTLSFilesandtlsModesinclude MongoDB (Verify CA istlsAllowInvalidHostnames);MongoConnectionOptions.x509Problem;MongoTab::clientOptionsbuilds the URI and options without connecting (and combines a separate key into a private temporary PEM). - Snippets.
DatabaseSnippetHeader(RunletCoreDatabaseSnippets.swift) parses the leading line-comment metadata block (//for.mongodb,#for.redis, #205);MongoSnippets.substitutefills{"$input": "name"}placeholders with JSON literals;ProjectSnippets.parseMongo,fileContents,fileExtension(for:);Snippet.openingCode;SnippetInputRequest.languagepicks the substitution.RedisSnippets(#205,RedisSnippets.swift) fills$name/${name}in unquoted arguments by re-lexing each line asRedisScript.parsedoes and writing the filled argument back withRedisScript.quoted;ProjectSnippets.parseRedis.
The query builder (#217):
- Model.
MongoJSON(RunletCore) parses strict JSON keeping member order and number literals, and pretty-prints the builder's style (80 columns, two-space indents,pipelineanddocumentsone element per line).MongoQueryBuilderreads a query object into typed parts and writes it back:MongoFilterGroup(All/Any/None,implicitfor an$orbranch with several members) ofMongoFilterRules and raw members (MongoBuilderFilter.swift);MongoFieldValuelists (projection, sort,$project,$addFields),MongoStagecards,MongoUpdateNodes (MongoBuilderStages.swift);MongoValuetyped inputs and their Extended JSON (MongoBuilderValue.swift). Every part that doesn't write back exactly as read becomes aMongoRawMember(or a JSON stage, or the query'sextras), sojson()of a read query equals the input. Fields the operation doesn't take are kept, written only for operations that take them (MongoQuery.fields(for:)). - Text.
MongoBuilderTextfinds the tab's top-level queries (blocks), the selected one or the one at the caret (target), and computes a minimalrewriteor aninsert;MongoBuilderScheduledebounces writes (0.4 s). - App.
MongoBuilderState/MongoBuilderStore(AppModel+MongoBuilder.swift, per tab, in memory):readMongoBuilder,mongoBuilderEdited(the model'sdidSet),flushMongoBuilder(EditorController.apply(_:actionName:), one undo group; the query's range and the synced text tell its own writes from the user's edits, whichmongoBuilderEditorChanged, called fromTabModel.onChange, reads again).MongoQueryBuilderPanel(MongoQueryBuilder.swift) sits beside the editor inTabContent, in theBuilderPanelframe it shares with Redis's Command Builder (BuilderPanel.swift, withBuilderHeader,BuilderPreviewBox,BuilderBadge, and the sharedview.buildercommand'stoggleBuilder). Filter by This Value:ValueTableGrid'scellMenu,SQLResultCard.cellMenu,mongoResultCellMenu,MongoValue(resultCell:).
MCP Server (N44)
The user guide is mcp.md.
- Processes. An AI client starts
Runlet.app/Contents/Helpers/runlet mcpand talks MCP to it over stdio. The tool answers the protocol itself (MCPServer) and sends each tool call over a Unix socket to the running app (MCPAppClient→MCPSocketListener). It starts Runlet in the background when nothing listens; starting never runs code. The app listens only while Settings ▸ AI Clients is on (AppSettings.mcpServerEnabled, off by default). - Approvals. Every
run_phpgoes throughMCPApprovalPolicyin the app. Only a run on a target the user allowed for that connection ("Allow runs on <target> from <client> for this session", offered on every target but production, #326) runs without the sheet, and an SSH host only while connected. The allowance remembers the target's settings, so editing or removing the target, or marking it production, makes its next run ask again. The approval and the allowances belong to the app's connection object, never to message fields. The sheet doubles as the production confirmation:guardProductionandProductionGracearen't consulted for MCP runs, so the grace never skips one and approving one never grants it. - Runs. An approved run uses the same
startRunpath as the Run button (snapshot,ExecutionEngine, history), in a tab named after the client, with a note in the output saying who asked. ARunObserverstreams events intoMCPRunReport, which answers the call when the run ends. - Never on its own. Listing, reading, and saving snippets run nothing; a waiting request is withdrawn when the client cancels or disconnects, when it expires, or when the server is turned off; an SSH profile's state is read from its local control socket only.
Guided Tour and What's New (#232)
- Manifest.
Runlet/WhatsNew.json(a bundle resource) holds the first-launch tour's steps and one entry per version and build (WhatsNewRelease: label, date, release notes, features with Show Me steps, Also lines).WhatsNewManifest(RunletCoreWhatsNew.swift) decodes it, aggregates the releases after the last build seen up to the running one (releases(after:through:), grouped by version insections), and listsproblems(versions, ids, anchors, preparations, flags, and, given the catalog, commands).AppVersionordersCFBundleShortVersionStringnumerically, thenCFBundleVersion. - Anchors.
TourAnchoris the registry of ids steps may name; views mark themselves with.tourAnchor(_:), a backgroundNSViewRepresentable(TourAnchorView: no hit testing, no accessibility element) that registers inTourAnchors, which finds the on-screen one and its screen frame (clipped to its bounds and window, since toolbar items report avisibleRectas large as the window).TourPreparationlists the only UI a step may open first. - Coach marks.
TourController(Features/CoachMarks.swift) runs one tour over a main window: aCoachMarkPanel(borderless, transparent, non-activating child panel, SwiftUI card with its own arrow and shadow) placed byCoachMarkLayoutbelow, above, beside, or inside the anchor within the window (else the screen), a click-throughCoachMarkRingPanelaround the anchor, or the card centred with an illustration when the anchor isn't on screen. A 0.25 s timer follows the anchor. The card takes key status only whenTypingMonitor(a local key-down monitor) saw no typing for 2 s; Return and Esc are SwiftUI default and cancel shortcuts; each stop posts a VoiceOver announcement. - Launch.
OnboardingStorereadsState/onboarding.jsoninAppModel.initbefore anything else is written (a missing file plus earlier data means someone who updated).WhatsNew.presentIfNeeded(model:)(AppModel+Onboarding.swift) returns at once forOnboardingLaunch.blocker(self-test,--launched-by-mcp, UI tests, scratch data), waits whileOnboardingActivity.waitReasonsays so (runs, sheets, the palette, typing, Runlet in the background, no main window), then appliesOnboardingPolicy.decideand saves the state it returns. Show Me hidesWhatsNewWindow(an AppKit-managed window, so no scene is declared) and brings it back when the mini-tour ends. See whats-new.md.
In-App Updates (#233)
Runlet updates itself from GitHub Releases with Sparkle 2 (app target only) and code of its own around it. The release side (keys, appcast, signing) is in releasing.md.
Why Sparkle. It does the hard parts and works with ad-hoc signed apps when updates are EdDSA-signed.
scripts/update-e2e.pychecks it end to end on two local builds. Sparkle handles:- the download, then the EdDSA check of the archive before extraction (
SUVerifyUpdateBeforeExtraction); - the signed feed (
SURequireSignedFeed); - extraction, the code-signature validity check, the downgrade check (
CFBundleVersion), and quarantine release; - the administrator prompt for a folder you can't write to, and the atomic swap (
renamex_npwithRENAME_SWAP).
Without Developer ID, Sparkle can't compare signing teams, so the update key is the only proof of origin. The app refuses to install anything when it has no key.
- the download, then the EdDSA check of the archive before extraction (
Configuration.
project.ymlsets these Info.plist keys:SUFeedURL: theappcastbranch on raw.githubusercontent.com.SUPublicEDKey:$(RUNLET_UPDATE_PUBLIC_KEY), empty until the owner sets it.SUEnableAutomaticChecks = NO: Sparkle never schedules or asks; Runlet does.- The version:
$(MARKETING_VERSION),$(CURRENT_PROJECT_VERSION), andRunletPrerelease = $(RUNLET_PRERELEASE). Test builds override them on thexcodebuildcommand line.
AppUpdaterstarts Sparkle (SPUUpdater) on the first check. It sends a plain request: user agentRunlet, no system profile.- Channels.
allowedChannels(for:)returns[]for Stable and["beta"]for Beta. - Choosing the update.
bestValidUpdate(in:for:)maps items toUpdateCandidates (sparkle:shortVersionStringis the full semantic version,sparkle:versionthe build) and returnsUpdateSelection.best. - Skipped versions. Sparkle filters them for automatic checks; Check for Updates shows them again.
- Feed override. In Debug builds,
feedURLString(for:)honoursRUNLET_UPDATE_FEED_URL.
- Channels.
When it checks (
UpdateCheckPolicy): 5 s after launch, then on hourly ticks once 24 hours have passed, and when Runlet first becomes active.- It never checks automatically in self-tests, in a
runlet mcplaunch (--launched-by-mcp, until the user brings Runlet forward), in Debug builds (UI tests, Xcode runs;RUNLET_UPDATE_AUTOMATIC=1turns it on for tests), or in scripted sessions (RUNLET_DEBUG_STEPS,RUNLET_SNAPSHOT_DIR). - An offer an automatic check finds waits until no tab runs code.
- A failed automatic check stays quiet: Settings ▸ General ▸ Updates shows it.
- It never checks automatically in self-tests, in a
The user driver.
AppUpdaterimplementsSPUUserDriver, so Sparkle's own windows never appear.UpdateWindowshows the phases.- Install and Relaunch replies
.installto the offer. After the download and extraction,showReady(toInstallAndRelaunch:)runsinstallAndQuit(or waits, inreadyToInstall, while code runs). installAndQuitclones the bundle toUpdates/Backup/Runlet.backupand writesUpdates/pending.json(UpdateRecord).- It then starts the watchdog with
DetachedProcess(/bin/sh, a new session,PATHandHOMEonly) and replies.dismiss. Sparkle's installer, already waiting, installs when the app exits and doesn't relaunch it. - Then it calls
NSApp.terminatefrom a run-loop timer: from a main-queue block,.terminateLaterwould wait forever for its own reply.
- Install and Relaunch replies
The watchdog (
UpdateWatchdog.script, unit-tested against fake bundles) runs these steps:- It waits for Runlet's process to end, then up to 120 s for the bundle's
CFBundleVersionto be the new build. If that doesn't happen, it reportsnotInstalledand starts the old version. - It checks the bundle identifier and removes any quarantine left.
- It starts the bundle with
open -n, passingRUNLET_DATA_DIRon. Debug builds also pass onRUNLET_RELAUNCH_*asRUNLET_*, plus the stderr and background options. - It waits 60 s for
Updates/launched, whichAppUpdater.startwrites at every launch (build, then pid). - Without the marker, it stops processes running from the new bundle and moves that bundle to
Updates/Failed.backup. It puts the backup back, writesUpdates/result.json(rolledBack), and starts the old version. - With the marker, it removes the backup.
- It waits for Runlet's process to end, then up to 120 s for the bundle's
After the relaunch. The new version finds
pending.jsonwith its own build: that setsinstalledUpdate, anddidLaunchAfterUpdateis the hook for #232's What's New. The old version finds its own build there with aresult.json, and shows what happened (rolled back, not installed, or couldn't restore).Where it can't install.
UpdateInstallLocationruns before each check.- A translocated copy (
SecTranslocateIsTranslocatedURL, looked up withdlsym) or a read-only volume (the DMG) gets "Move Runlet to Applications first". - A folder or bundle you can't write to keeps the offer, and the window explains macOS's administrator prompt, which Sparkle's installer raises.
- A translocated copy (
Rollback limits. If the folder needed an administrator, the watchdog (running as the user) can't put the backup back. It reports
restoreFailed, and the old version, if it still runs, says to download Runlet again.Runlet's PHP (Settings ▸ PHP, #212) updates separately; the updater only replaces the app.
Dependency Versions
| Component | Version | Pinned by |
|---|---|---|
| nikic/php-parser | 5.9.0 | Resources/Runner/composer.json (^5.6, Composer platform PHP 7.4.33) and composer.lock. Bundled as RunletVendor\PhpParser in dist/runlet-runner.php. |
| Laravel (sandbox) | laravel/framework 13.34.0 | Resources/Sandbox/laravel/composer.json (^13.17), composer.lock, and runlet-sandbox.json |
| Sandbox PHP requirement | ^8.3 (minimum 8.3) | runlet-sandbox.json |
| Docker sandbox fallback image | php:8.4-cli | runlet-sandbox.json (dockerImage) |
| PHPantom LSP | 0.10.0 | scripts/fetch-phpantom.sh. Release tarballs are checksum-verified and combined with lipo. |
| PHPantom aarch64 SHA-256 | 2f445d9708ed15e1714b48271db43741d13a70c674ef5b3ff1a423e51b4f663b | scripts/fetch-phpantom.sh |
| PHPantom x86_64 SHA-256 | b1c8fffbbc34cba2edc42013f4010794179506eab04a49ee15a07364985bd596 | scripts/fetch-phpantom.sh |
| Mago (formatter) | 1.51.2, MIT OR Apache-2.0 (Resources/Formatter/LICENSE-mago.txt, the MIT text, bundled as Contents/Resources/Licenses/Mago-LICENSE.txt) | scripts/fetch-mago.sh. Release tarballs are checksum-verified and combined with lipo into Resources/Formatter/mago. |
| Mago aarch64 SHA-256 | 13125481a4a039b92d520c04d4f395c90dd0223284c976c1c3e447b0bbdbda07 | scripts/fetch-mago.sh |
| Mago x86_64 SHA-256 | bcbac16d24ef6c6b7df951ecfac2064954bd900292ae7c8afb5222cdd7fe753d | scripts/fetch-mago.sh |
| Runner target PHP | 7.4 – 8.5 | compatibility.md |
Swift packages (RunletKit) | none | Package.swift |
| Sparkle | 2.10.0, MIT (Resources/Licenses/LICENSE-Sparkle.txt, bundled as Contents/Resources/Licenses/Sparkle-LICENSE.txt); the binary Sparkle.xcframework from its Swift package, with Autoupdate and Updater.app inside, re-signed ad-hoc with the app. Its bin/ (sign_update, generate_appcast, generate_keys) is used by scripts/appcast.py. | project.yml exactVersion |
| SwiftTerm | 1.11.2, MIT (Resources/Licenses/LICENSE-SwiftTerm.txt, bundled as Contents/Resources/Licenses/SwiftTerm-LICENSE.txt) | project.yml exactVersion. Newer releases need extra build setup: 1.12.0+ compiles a Metal shader, which requires Xcode's separately downloaded Metal Toolchain (xcodebuild -downloadComponent MetalToolchain), and 1.19.0 (latest stable on 2026-10-02) also adds a build-tool plugin that xcodebuild runs only with -skipPackagePluginValidation. |
| Docker test fixtures | php:8.4-cli, php:7.4-cli, php:8.2-cli-alpine, and runlet-fixtures-ssh:1 (built from php:8.4-cli with Debian's openssh-server, published on 127.0.0.1:2222 only) | Tests/Fixtures/docker/compose.yml, Tests/Fixtures/docker/ssh/ |
The build machine needs these tools; end users do not:
- Composer
- PHP 8.0+ for
scripts/build-runner.php - PHP 8.3+ for
scripts/build-sandbox.shandscripts/setup-fixtures.sh, which runartisan curl,shasum, andlipoforscripts/fetch-phpantom.shandscripts/fetch-mago.sh
Runlet never runs Composer in a user's project.
Build and Distribution
- Bundle settings (
project.yml): bundle IDdev.runlet.Runlet, version fromMARKETING_VERSION,CURRENT_PROJECT_VERSION, andRUNLET_PRERELEASE(#233), category developer tools. The app declares that it can openpublic.php-scriptfiles as an editor, withAlternaterank, and folders (public.folder) with rankNone, so a folder dropped on the Dock icon opens as a project while Runlet is never offered as a folder's default app. - App icon (
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon):Runlet/AppIcon.icon, an Icon Composer icon (Liquid Glass, with default, dark, tinted, and clear styles), and a classicRunlet/Assets.xcassets/AppIcon.appiconset(16–512 pt, @1x and @2x). When both exist, actool compiles the.iconintoAssets.carandAppIcon.icnsand addsCFBundleIconNameandCFBundleIconFileto the built Info.plist; the classic set is the fallback if the.iconis removed.scripts/app-icon/make-app-icon.swiftgenerates both fromwebsite/assets/favicon.svg, andscripts/app-icon/export-web-icons.swiftrenders the README's and the website's images of the icon (website/assets/app-icon-*.png, favicons, logo,apple-touch-icon.png) from the.iconwith Icon Composer'sictool. Run both from the repository root and commit the result. - No App Sandbox (
ENABLE_APP_SANDBOX = NO). Runlet launches user-selected PHP and Docker executables and reads existing projects, and subprocesses would inherit sandbox restrictions. Runlet's Laravel sandbox is an execution workspace, not an OS security boundary. - Hardened runtime is enabled (
ENABLE_HARDENED_RUNTIME = YES).- The app has
com.apple.security.cs.disable-library-validation(Runlet/Runlet.entitlements, fromproject.yml). It's needed because an ad-hoc signature has no Team ID, so library validation refuses every embedded dynamic framework. Re-signing Sparkle ad hoc doesn't help: a hardened, ad-hoc signed probe can'tdlopenit, and without the entitlement the packaged app stops at launch. - The rest of the hardened runtime stays, such as refusing
DYLD_*injection. package.shstops an ad-hoc build without it.- Remove it with Developer ID signing (#24), when the app and Sparkle share a Team ID.
- Debug builds signed ad hoc run without the hardened runtime (Xcode turns it off).
- The app has
- Signing.
CODE_SIGN_STYLE = Manual,CODE_SIGN_IDENTITY = "-"(ad-hoc), and no development team. Local builds need no signing credentials. - Post-build phase (
scripts/embed-resources.sh):- Copies the runner to
Contents/Resources/Runner/runlet-runner.php. - Copies the sandbox template to
Contents/Resources/Sandbox/laravel. It excludes.env,node_modules,.git,tests, logs, compiled views, sessions, cache data, andbootstrap/cache/*.php. The phase fails if the sandboxvendor/is missing. - Copies PHPantom to
Contents/Helpers/phpantom_lsp, runningscripts/fetch-phpantom.shfirst if the binary is missing. - Copies Mago to
Contents/Helpers/mago, runningscripts/fetch-mago.shfirst if the binary is missing. - (Before this phase, the app target's "Embed Dependencies" copy phase puts the
RunletCLIproduct,runlet, inContents/Helpersand signs it on copy; it carries an Info.plist section, so its signing identifier isdev.runlet.cli.) - Signs the helpers (PHPantom and Mago) with
codesign --force --options runtime --timestamp=none --sign "$EXPANDED_CODE_SIGN_IDENTITY"(ad-hoc-by default), unlessCODE_SIGNING_ALLOWED=NO. - Copies license notices to
Contents/Resources/Licenses/:PHPantom-LICENSE.txt,Mago-LICENSE.txt,PHP-Parser-LICENSE.txt,SwiftTerm-LICENSE.txt,Sparkle-LICENSE.txt, andLaravel-LICENSE.md.
- Copies the runner to
Packaging (scripts/package.sh)
- Builds the sandbox if its
vendor/is missing, fetches PHPantom and Mago, and regenerates the project with XcodeGen. - Builds the Release configuration for
arm64andx86_64(ONLY_ACTIVE_ARCH=NO) intobuild/Release-DerivedDataand copiesRunlet.apptodist/. - Verifies the package:
codesign --verify --deep --strict,lipo -infoon the app executable,Contents/Helpers/phpantom_lsp,Contents/Helpers/mago(both architectures checked), andContents/Helpers/runlet(all universal),runlet --version,mago --version, the bundled runner, sandbox manifest, sandboxvendor/autoload.php, and the PHPantom, Mago, and SwiftTerm licenses, and that no sandbox.envis bundled. It also checks that Sparkle's framework and itsAutoupdateare universal and validly signed, and thatUpdater.appand the Sparkle license are there (#233). - Runs the packaged self-test,
Runlet --self-test(with--dockerwhenRUNLET_SELFTEST_DOCKERis set), against a temporaryRUNLET_DATA_DIR, and writes the report todist/self-test.json. Itsupdatercheck confirms Sparkle loads, its installer tools are there, and the feed settings (HTTPS, signed feed, verification before extraction, no Sparkle-scheduled checks); it reports whether the build has an update key. - Creates
dist/Runlet.zip(ditto) anddist/Runlet.dmg(hdiutil, UDZO).
Signing is ad-hoc by default (RUNLET_SIGN_IDENTITY defaults to -). With a Developer ID identity in RUNLET_SIGN_IDENTITY and a notarytool keychain profile in RUNLET_NOTARY_PROFILE, the script also submits the DMG for notarization and staples it.
Tracked in #24: Developer ID signing and notarization have not been run. They need a Developer ID Application identity and a notarytool keychain profile. Until then, builds are ad-hoc signed: they run on the build machine (natively and under Rosetta, see validation.md), but Gatekeeper is expected to block a downloaded copy on other Macs.
For developers
The overview at the top of this page is #293. The reference was first recorded on 2026-10-02 and described the code in this repository on that date; plan.md asks for package versions, the deployment target, and module boundaries to be recorded here. Sections were added as features landed. When it was recorded:
- The execution, persistence, and language-service layers (
Packages/RunletKit) are implemented and covered by 458 package tests. - The native app target (
Runlet/) is implemented. XCUITests inRunletUITests/drive the rendered app, andscripts/package.shbuilds, verifies, and self-tests an installable universal app. - Measurements, prototype-gate results, and Laravel completion coverage are in compatibility.md.
- Requirement-to-evidence status is in validation.md.