diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7f3bd1f..bb261e7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,3 +41,13 @@ jobs:
with:
# `inputs` is empty on push, pull_request and schedule; only a dispatch sets it.
version-bump: ${{ inputs.version-bump || 'auto' }}
+
+ # Regenerates docs/gallery once the pipeline has released, and commits it back to main. Only for
+ # a push to main, so nothing from a pull request, and so nothing from a fork, is ever built with
+ # this job's write access. See editor-gallery.yml.
+ editor-gallery:
+ needs: ci
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ uses: ./.github/workflows/editor-gallery.yml
+ permissions:
+ contents: write
diff --git a/.github/workflows/editor-gallery.yml b/.github/workflows/editor-gallery.yml
new file mode 100644
index 0000000..bb0b291
--- /dev/null
+++ b/.github/workflows/editor-gallery.yml
@@ -0,0 +1,148 @@
+name: Editor Gallery
+
+# Regenerates docs/gallery after CI finishes on main, and commits the pictures back to main when they
+# differ. Modelled on ImGuiApp's widget-gallery.yml and ImageGui's feature-gallery.yml; the reasoning
+# there holds here too, and is repeated where it decides something.
+#
+# The pictures come from the Gallery tests in tests/Coder.Editor.UITests, run with CODER_GALLERY_OUT
+# pointing at docs/gallery. Pull requests already run those tests in the Linux UI test leg, writing
+# to a temporary directory, so a stage that no longer works fails the pull request; this workflow
+# only ever builds main, and only to publish the result.
+#
+# Nothing is committed when the output is byte-identical, which it is run to run: the editor draws
+# on the CPU rasterizer at a pinned display size, opens nothing from disk, starts from the document
+# built in code, steps its layout simulation at the harness's fixed frame time, and draws in the font
+# ImGuiApp ships. The only thing that moves a picture is a change to the code that draws it. Always
+# on the same runner image, because the pictures are compared with the last ones committed.
+#
+# ci.yml calls this as a job that needs its pipeline, only for a push to main; it can also be run by
+# hand on main. It is deliberately not a workflow_run workflow. workflow_run runs with the
+# repository's own token and secrets whatever started the run it follows, including a pull request
+# from a fork, so building checked-out code there is the pattern githubactions:S7631 exists to catch.
+# Here the only things that can start a run are a push to main, which needs write access, and a
+# dispatch by someone with write access, so nothing a fork wrote is ever checked out or built with
+# this job's permissions.
+#
+# Why after CI rather than beside it: CI's release job commits VERSION.md and friends back to main
+# with a plain `git push` from the commit it was started on. A gallery commit landing on main first
+# makes that push non-fast-forward, and the release fails. Needing the pipeline means main already
+# carries the release commit. A failed pipeline skips this job.
+#
+# Why the commit back cannot loop or cut a release:
+# - It is pushed with the workflow's own GITHUB_TOKEN, and GitHub starts no workflow run for a
+# push made with that token. Neither CI nor, through it, this workflow sees it.
+# - It carries `[bot][skip ci]`, the prefix KtsuBuild's own metadata commits use, so KtsuBuild
+# leaves it out of the version calculation and the changelog either way.
+#
+# Why it can push to a protected main: the main ruleset lists github-actions[bot] (the identity
+# behind GITHUB_TOKEN) as an always-bypass actor, which is also how KtsuBuild pushes its metadata
+# commits. If it ever stops doing so, the push step fails and says so.
+
+on:
+ workflow_call:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ DOTNET_VERSION: "10.0"
+
+jobs:
+ regenerate:
+ name: Regenerate editor gallery
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ if: github.repository == 'ktsu-dev/Coder' && github.ref == 'refs/heads/main'
+ # A newer run supersedes an older one: both regenerate from the current main. On the job rather
+ # than the workflow, because a called workflow's github.workflow is the caller's name.
+ concurrency:
+ group: editor-gallery
+ cancel-in-progress: true
+ permissions:
+ contents: write # To push the regenerated pictures to main
+
+ steps:
+ # LFS content is not fetched: nothing the gallery builds from is in LFS, and every picture it
+ # writes replaces the pointer that was there. The LFS filters are installed below instead,
+ # which is what makes git compare and commit the pictures as LFS objects.
+ - name: Checkout Repository
+ uses: actions/checkout@v7
+ with:
+ # The current main, never the triggering event's ref or SHA.
+ ref: main
+ fetch-depth: 1
+ lfs: false
+ persist-credentials: true
+
+ - name: Setup .NET SDK ${{ env.DOTNET_VERSION }}
+ uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}.x
+ cache: true
+ cache-dependency-path: |
+ **/*.csproj
+ **/Directory.Packages.props
+ **/global.json
+
+ - name: Install Git LFS filters
+ run: git lfs install --local
+
+ - name: Build the UI tests
+ run: dotnet build tests/Coder.Editor.UITests/Coder.Editor.UITests.csproj -c Release
+
+ # The old pictures are removed first so that one whose entry was renamed or removed goes with
+ # it. A stage that fails fails the job before anything is committed.
+ - name: Regenerate the gallery
+ env:
+ CODER_GALLERY_OUT: ${{ github.workspace }}/docs/gallery
+ run: |
+ rm -f docs/gallery/*.png docs/gallery/README.md
+ tests/Coder.Editor.UITests/bin/Release/net10.0/ktsu.Coder.Editor.UITests \
+ --filter "FullyQualifiedName~ktsu.Coder.Editor.UITests.Gallery"
+
+ # Only docs/gallery is staged. The build also rewrites some tracked files (ktsu.Sdk syncs
+ # .gitignore and friends from its own copy), and none of that belongs in this commit.
+ - name: Commit and push the pictures
+ env:
+ GIT_LFS_SKIP_SMUDGE: "1"
+ run: |
+ source_sha=$(git rev-parse HEAD)
+ git add --all docs/gallery
+
+ if git diff --cached --quiet; then
+ echo "The editor gallery is up to date."
+ exit 0
+ fi
+
+ git diff --cached --stat
+
+ # A PNG committed as a blob rather than as an LFS pointer would bloat the history for good.
+ for path in $(git diff --cached --name-only --diff-filter=AM -- '*.png'); do
+ header=$(git cat-file blob ":$path" | head -c 42 | tr -d '\0' || true)
+ if [[ "$header" != "version https://git-lfs.github.com/spec/v1" ]]; then
+ echo "::error file=$path::Staged as a raw blob rather than an LFS pointer."
+ exit 1
+ fi
+ done
+
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git commit --quiet --message "[bot][skip ci] Regenerate the editor gallery" \
+ --message "Rendered by .github/workflows/editor-gallery.yml from $source_sha."
+
+ # Drop what the build rewrote and was left unstaged, which a rebase refuses to run over.
+ git reset --hard --quiet HEAD
+
+ for attempt in 1 2 3 4 5; do
+ if git push origin HEAD:main; then
+ exit 0
+ fi
+ echo "Push rejected (attempt $attempt); rebasing onto the current main."
+ sleep $((attempt * 5))
+ git fetch --depth=50 origin main
+ git rebase FETCH_HEAD
+ done
+
+ echo "::error::Could not push the regenerated gallery to main. If main's ruleset does not let github-actions[bot] bypass it, that is why."
+ exit 1
diff --git a/Coder.sln b/Coder.sln
index 6278f54..7da5c1d 100644
--- a/Coder.sln
+++ b/Coder.sln
@@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coder.Graph", "Coder.Graph\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coder.Editor", "Coder.Editor\Coder.Editor.csproj", "{90A3A9A1-F2ED-46EB-934C-1EF4632E2C1D}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coder.Editor.UITests", "tests\Coder.Editor.UITests\Coder.Editor.UITests.csproj", "{1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -83,10 +87,25 @@ Global
{90A3A9A1-F2ED-46EB-934C-1EF4632E2C1D}.Release|x64.Build.0 = Release|Any CPU
{90A3A9A1-F2ED-46EB-934C-1EF4632E2C1D}.Release|x86.ActiveCfg = Release|Any CPU
{90A3A9A1-F2ED-46EB-934C-1EF4632E2C1D}.Release|x86.Build.0 = Release|Any CPU
+ {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Debug|x64.Build.0 = Debug|Any CPU
+ {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Debug|x86.Build.0 = Debug|Any CPU
+ {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Release|x64.ActiveCfg = Release|Any CPU
+ {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Release|x64.Build.0 = Release|Any CPU
+ {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Release|x86.ActiveCfg = Release|Any CPU
+ {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {449DC655-FEAD-4DD8-9AD1-5F731B7A8DFA}
EndGlobalSection
diff --git a/README.md b/README.md
index 3612db6..e07a4e4 100644
--- a/README.md
+++ b/README.md
@@ -191,6 +191,8 @@ keyboard focus is.
dotnet run --project Coder.Editor
```
+Pictures of the editor as it draws today are in the [editor gallery](docs/gallery/README.md).
+
Documents are `.coder.yaml` files — the same YAML the serializer already round-trips, so anything the
library can write, the editor can open. The code pane follows the document live and lists what is
outstanding instead of generating while an operand is unfilled; clicking one of those selects the
diff --git a/tests/Coder.Editor.UITests/AssemblyInfo.cs b/tests/Coder.Editor.UITests/AssemblyInfo.cs
new file mode 100644
index 0000000..8d47424
--- /dev/null
+++ b/tests/Coder.Editor.UITests/AssemblyInfo.cs
@@ -0,0 +1,6 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+// ImGui keeps its context in global state, so only one harness can exist per process and two
+// tests running at once would corrupt each other. Sequential execution is a correctness
+// requirement here, not a performance preference.
+[assembly: DoNotParallelize]
diff --git a/tests/Coder.Editor.UITests/Coder.Editor.UITests.csproj b/tests/Coder.Editor.UITests/Coder.Editor.UITests.csproj
new file mode 100644
index 0000000..a850ed4
--- /dev/null
+++ b/tests/Coder.Editor.UITests/Coder.Editor.UITests.csproj
@@ -0,0 +1,32 @@
+
+
+
+
+
+ true
+ net10.0
+
+
+ ktsu.Coder.Editor.UITests
+ $(RootNamespace)
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Coder.Editor.UITests/Gallery/EditorGallery.cs b/tests/Coder.Editor.UITests/Gallery/EditorGallery.cs
new file mode 100644
index 0000000..1de843a
--- /dev/null
+++ b/tests/Coder.Editor.UITests/Gallery/EditorGallery.cs
@@ -0,0 +1,136 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Editor.UITests.Gallery;
+
+using ktsu.Coder.Editor;
+using ktsu.Coder.Languages;
+using ktsu.ImGui.App.Testing;
+
+using Microsoft.Extensions.DependencyInjection;
+
+///
+/// Photographs the editor for docs/gallery: one picture per
+/// entry, and an index that captions them.
+///
+///
+///
+/// These run as ordinary tests, so every pull request proves each picture can still be staged: a
+/// stage that reaches for a node, a pane or a language that has gone fails here rather than at the
+/// next regeneration on main. Pictures are written to a temporary directory unless
+/// CODER_GALLERY_OUT names one, which is how the gallery workflow, and anyone regenerating by
+/// hand, sends them to docs/gallery.
+///
+///
+/// Each entry builds its own application from , the container the
+/// real editor runs with, and opens nothing from disk, so the status bar never shows a path from the
+/// machine that drew it. The layout simulation steps at the harness's fixed frame time, which is what
+/// makes a settled graph come out the same on every run.
+///
+///
+[TestClass]
+public sealed class EditorGallery
+{
+ /// The environment variable naming the directory the gallery is written to.
+ internal const string OutputVariable = "CODER_GALLERY_OUT";
+
+ /// The display the gallery is drawn at.
+ internal static readonly (int Width, int Height) Display = (1600, 900);
+
+ ///
+ /// How many frames the layout runs before the picture is staged: about four seconds of the
+ /// simulation, which is long enough for the default document to come to rest.
+ ///
+ private const int SettleFrames = 240;
+
+ private static readonly Lazy TemporaryOutput = new(() =>
+ Path.Combine(Path.GetTempPath(), $"coder-gallery-{Guid.NewGuid():N}"));
+
+ /// Gets or sets the test context, for reporting where each picture went.
+ public TestContext TestContext { get; set; } = null!;
+
+ /// Gets every entry's name, one test case each.
+ public static IEnumerable