Skip to content

Re-architecture: one modular package, explicit composition root, typed seams - #2192

Open
matthijseikelenboom wants to merge 336 commits into
CodeEditApp:mainfrom
matthijseikelenboom:refactor/architecture
Open

Re-architecture: one modular package, explicit composition root, typed seams#2192
matthijseikelenboom wants to merge 336 commits into
CodeEditApp:mainfrom
matthijseikelenboom:refactor/architecture

Conversation

@matthijseikelenboom

@matthijseikelenboom matthijseikelenboom commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Suggested title: Re-architecture: one modular package, explicit composition root, typed seams

Summary

This branch is a large instalment of an ongoing re-architecture, not its completion. The app target
is decomposed into a single multi-target Swift package, dependency injection is consolidated into one
explicit composition root, every cross-feature signal is typed, and settings, themes and panel tabs
each gained a seam that a feature package can use without reaching back into the app.

What it deliberately does not settle is listed under Still open: the search
feature needs a rebuild rather than a refactor, two targets have unresolved questions about whether
they should exist in their current form, and the target names themselves are still under discussion.

321 commits, 955 files, +19,423 / −12,270. It is large, and squashing loses the step-by-step history,
so the reasoning behind the non-obvious decisions is recorded in ARCHITECTURE.md and in doc
comments on the types themselves rather than only in commit messages.

Everything below describes the difference between main and this branch. The route taken to get here
included stages that no longer exist. They are not described, because nothing in the diff shows
them.

Why, and why not just "split it into packages"

CodeEdit tried modularisation in 2022 and reverted it after cyclic dependencies. That history shaped
this attempt, because the post-mortem does not say what people remember it saying: the cycles were
not caused by having packages. They were caused by two specific things: there was no dependency
sink, so any two features that needed to share a type had nowhere downward to put it, and two edges
pointed the wrong way.

So the fix is not a packaging shape but a rule set:

  • CodeEditCore has zero local dependencies. That makes it a sink, so cross-feature sharing
    always resolves downward. This is the direct fix for the 2022 failure.
  • CodeEditCore imports no UI framework. This keeps the placement question answerable. Without
    it, Core becomes the new AppPreferences, a bag everything drifts into.
  • CodeEditUI depends on no local target. Presentation atoms stay atoms.

There are currently zero feature→feature imports, and Core is reachable from everything without a
cycle being expressible.

Package topology

One package, twelve library targets:

CodeEdit.xcworkspace
├── CodeEdit.xcodeproj      — app shell: lifecycle, windows, menus, composition
└── CodeEditModules/
    ├── CodeEditCore        — domain types, EventBus, command interfaces (zero deps, no UI)
    ├── CodeEditUI          — presentation atoms (→ CodeEditSymbols only)
    ├── CodeEditDocument    — CodeFileDocument + editor-framework bridging
    ├── CodeEditSettings    — settings model, store and seam
    ├── ShellClient, CEWorkspaceFileManager        — platform adapters
    └── CEEditor, CESearch, CENotifications,
        CELSP, CESourceControl, CETerminal          — one target per feature

main has no Package.swift at all: it is a single app target, with features as folders under
CodeEdit/Features/. One package with many targets was chosen over many packages so that the
dependency edges are declared in one readable file, and so adding a target is a three-line change.

Package targets are Swift 6 strict-concurrency (except CEEditor, pinned to Swift 5 language mode).
The app target is still Swift 5; full migration is deliberately out of scope.

What changed

Dependency injection. On main, services are reached through singletons. Settings.shared
alone is named in 22 files. This branch introduces AppDependencies, a single app-scope composition
root: objects take what they need by initialiser, SwiftUI views get environment keys, and only
composition roots hold the whole dependency object. Settings.shared is deleted. Several other
pre-existing singletons remain and are listed rather than quietly kept (see Still open below).

Cross-feature communication is typed. main broadcasts some cross-feature signals through
untyped NotificationCenter names (TaskNotificationHandler declares its own). Here there are
zero custom Notification.Names. Features
communicate through the Workspace aggregate, a typed EventBus for facts, or command interfaces in
Core for requests with one handler (WorkspaceNavigator, WorkspaceFileOpener, FileRelocator).
Remaining NotificationCenter use observes platform notifications only.

Settings. On main every consumer reads the Settings.shared singleton and addresses fields
through one app-wide aggregate. Here, a feature package owns its own settings sections and reads them
through an injected seam, one section at a time, so it never names the aggregate and never reaches a
singleton. The store is an observed object injected into the view tree; a subtree that never receives
it fails loudly instead of reading plausible defaults and silently discarding writes.

Panel tabs. NavigatorTab, InspectorTab and UtilityAreaTab are deleted. A panel's tabs are a
[any WorkspacePanelContribution] assembled at the composition root, so first-party tabs, app shell
chrome and extension tabs are the same kind of value. Previously extensions had a privileged dynamic
case that features could not use.

Themes. The active theme moved into Core and is delivered as an observed object.

Folder conventions. Grouping is by purpose, never by kind: no Models/, Views/,
ViewModels/, Services/ or UseCases/ folders. Six package targets have been regrouped on that
basis (CEEditor, CESourceControl, CENotifications, CodeEditCore, CETerminal, CELSP),
each a pure rename with no content change. CodeEditCore keeps its Domain/ and Infrastructure/
split as a stated exception: there the layer is the purpose.

Documentation.docc is deleted (34 files). It was untouched since January 2025, and of the 34
symbols its landing page linked, 9 no longer existed and 13 had moved into package targets, DocC
documents one module, so an app-target catalog could no longer resolve them. Its section names were
the retired Features/ folders, and AppPreferences/ was nine files of tutorial for the god object
this re-architecture exists to have removed. Nothing referenced it: no inbound links, absent from
Package.swift and CI, while compiled in the app target's Sources phase. This also matches what the
CodeEditApp org does: all five libraries it publishes ship a catalog, because a published
library has readers who never open its source; none of these twelve internal targets does. A catalog
is optional in any case: DocC generates symbol documentation from doc comments without one.

App target layout is now scope-first (App/, WorkspaceWindow/, AuxiliaryWindows/, Utils/),
mapping onto the scenes the app actually declares, and grouped by purpose rather than by kind, so there
are no Models//Views//Services/ folders.

The constraint that kept paying off

The most useful result was not a module boundary; it was that the boundaries kept forcing better
APIs. Four times, something "obviously" needed a forbidden import and the constraint produced a
better design instead:

  • FileIcon is keyed on URL, not on a domain type, so it needs only SwiftUI and
    UniformTypeIdentifiers, and lives in CodeEditUI with no charter change. Deduplicating it also
    retired an 80-case FileType enum and fixed a silent bug where every unenumerated file extension
    reported itself as "text".
  • WorkspacePanelContribution vends AnyView, so it needs SwiftUI only and cannot live in Core.
  • Color+HEX is keyed on a String, so the colour conversion needs nothing from Core. Splitting
    it revealed that its file was always two unrelated things: the app used only the SwiftUI accessor,
    CEEditor only the AppKit one.
  • ActiveTheme is an ObservableObject, and ObservableObject is Combine, not SwiftUI, so an
    observable theme is legal in a zero-UI target.

Each of these was a placement argument that looked like it needed a rule change and did not.

Enforcement

Three automated checks, run on every build:

  1. no_ui_in_core (SwiftLint, error severity): CodeEditCore may not import SwiftUI/AppKit/Cocoa.
  2. ui_package_purity: CodeEditUI may not import a local target.
  3. audit_package_imports.py: import honesty across all 12 library targets.

The audit is load-bearing rather than belt-and-braces: swift build cannot validate 7 of the 12
targets standalone (an external dependency lacks a resources: declaration, so Bundle.module is
unavailable), which makes Xcode the build gate and the audit the only structural check.

Bugs on main that this fixes

Each of these is present on main today and was found by moving the code that contained it:

  • Terminal font changes never reached open terminals. updateNSView never set view.font, so a
    font change applied only to terminals opened afterwards.
  • File-extension visibility preferences never matched. The FileType enum's raw value for .txt
    is "text", and every unenumerated extension also reported itself as "text", so "show/hide these
    extensions" silently compared the wrong strings. Retiring the enum in favour of matching
    url.pathExtension fixed it.
  • Rename targeted the last-revealed file rather than the selected one — found while renaming a
    type whose name obscured what it actually held.
  • A modal alert presented from an arbitrary thread. SearchState.replaceRange built an NSAlert
    and called runModal() from a synchronous nonisolated method. It had no callers anywhere in the
    repo, so it is deleted rather than fixed.

Eight further pre-existing defects were concurrency errors rather than behavioural ones.
Moving a file into CodeEditModules is not only a move: the app target is Swift 5 with minimal
concurrency checking, the package is Swift 6, so code that compiled silently for years arrives with
hard errors.
The failing lines are byte-identical on main.
The toolchain detail is in a comment on this PR.

Several other defects were introduced and fixed within this branch as the seams were built. They
are not listed here: they are not on main, so they are not fixes a reviewer can verify.

One behaviour is new rather than a fix: an unreadable settings.json is now copied aside as
settings.json.corrupt-<timestamp> before anything writes over it, and a section this build cannot
decode is re-emitted verbatim instead of being replaced by defaults.

Still open

Deliberately out of scope here, and the reason each waits:

Open design questions. These may change the package layout again.

  • CodeEditSettings needs a rename, or a split. Its dependency structure is sound, but its
    name is not: it reads as the home for all settings, while a feature's own settings belong in
    that feature and settings pages are app-side. Three homes, and one of them advertises itself as
    the answer.
  • CodeEditDocument may dissolve into CEEditor. Only CEEditor and CELSP depend on it, and
    CELSP already declares its own LanguageServerDocument protocol, but it still names
    CodeFileDocument in 15 places, so this is a design slice, not a move.
  • Target naming is unsettled. CodeEdit* marks substrate and CE* marks features, but
    ShellClient and CEWorkspaceFileManager fit neither, and whether platform adapters deserve
    their own signal is undecided.
  • CESearch needs a rebuild, not a refactor. Its index does no matching, never updates, and has
    no reachable cancellation. Restructuring it now would move files a rewrite will move again.
    Its indexing also has no completion signal, so tests poll indexStatus with a timeout, which is
    the mechanical cause of the known flakiness there.
  • CESourceControl/Accounts/ is 58 of that target's 133 files with three call sites, and
    BitBucket is unreferenced outside its own subtree. What is dead needs settling before anything is
    reorganised.

Known work, no open questions.

  • App-target Swift 6 migration. Measured at 224 warnings / 80 hard errors. Isolating
    SettingsAccessing is unblocked by this branch but waits for it.
  • SettingsData / @AppSettings still exist (40 declarations, 24 files). The seam is in place
    and preferred for new code; the cutover is mechanical and its own change.
  • Seven process-scope singletons (ThemeModel, FeedbackModel, ExtensionManager, …) should
    move onto AppDependencies. TerminalCache is separate: it is a scope error, holding
    workspace-window-scoped views in a process-global cache with no eviction on workspace close.
  • Eight kind-grouped folders remain in the packages, of which three are the excluded
    Accounts/. Six targets have been regrouped by purpose; CESearch, CodeEditSettings and
    CodeEditUI have not.
  • Section versioning / settings migrations. Pre-1.0 is when key changes are cheap; building
    migration machinery before there is a migration to run would be guessing.
  • Untyped settings access for extensions. Unknown and undecodable sections already survive a
    round-trip, which is the guarantee that matters; the accessor waits on the extension manifest
    schema.
  • CodeFileDocument's isolation is bridged, not solved. NSDocument is main-actor isolated but
    declares read(from:ofType:) and presentedItemDidChange() nonisolated. Four sites now branch on
    Thread.isMainThread and assume isolation only on the main-thread side; two must block with
    DispatchQueue.main.sync, which rests on an invariant nothing enforces. The proper fix separates
    main-actor UI state from the I/O lifecycle: decode to a Sendable value, install it in one
    main-actor step. Recorded in docs/architecture-decisions.md.
  • CEWorkspaceFile keeps its CE prefix despite no name collision: 294 references, mechanical
    but across eight targets.

Verification

  • Build, SwiftLint --strict, and the package import audit are green.
  • CodeEditTestPlan passes locally: exit 0, ** TEST SUCCEEDED **, 175 XCTest cases plus 110
    swift-testing cases across 28 suites.
  • The plan runs six more test targets than main. main runs CodeEditTests and
    CodeEditUITests; this branch adds CodeEditCoreTests, CodeEditSettingsTests, CESearchTests,
    CodeEditUIUnitTests, CELSPTests and CESourceControlTests. CI runtime rises accordingly.
  • .github/scripts/test_app.sh gains -workspace CodeEdit.xcworkspace. main has no workspace at
    all; this branch introduces it, and without the flag bare xcodebuild picks the .xcodeproj and
    fails with "Missing package product" once per package.
  • Manual smoke tests were run at each risky step, since nothing automated covers SwiftUI updates,
    environment injection or colour fidelity. The last full pass covered every window, panel, sheet and
    popover after settings delivery changed to a trapping mechanism.
  • One pre-existing test failure, not from this branch.
    CodeEditUITests/ProjectNavigatorFileManagementUITests/testCreateNewFiles fails on macOS 26,
    including on main, verified by running it against main in a separate worktree: byte-identical
    failure. CI does not see it because its runner predates macOS 26 and takes the other branch of
    WorkspacePanelView's availability check.

This branch also merges main, including the macOS Tahoe panel redesign (#2126). That redesign
touched the same three panels restructured here; its visuals and its per-tab bottom toolbars are
ported onto the contribution seam, where the toolbar became a protocol requirement each tab vends
rather than a switch every new tab must be added to.

How to review this

Reading it as one diff will not work. Suggested order:

  1. ARCHITECTURE.md: the rules, the 2022 post-mortem, and "where does my code go?". Then
    docs/architecture-decisions.md for the rulings and the accepted bridges.
  2. CodeEditModules/Package.swift: the twelve targets and their edges.
  3. CodeEdit/App/AppDependencies.swift: the composition root.
  4. One seam end to end, e.g. WorkspacePanelContribution plus PanelContributions.swift and one
    package-vended conformer.
  5. The rest is largely mechanical: file moves, import fixes, call-site updates.

The doc comments carry the reasoning that a squashed history will not. Where something looks
gratuitously indirect, the comment usually explains which failure it prevents.

Racing a fresh SearchState's background indexing against the next test's
directory teardown/recreate on the same path caused intermittent host
process crashes. Poll-until-timeout also replaces fixed sleeps for the
sync assertions themselves.
The question 'why is this a target for one file' has an answer that was
nowhere written down: 19 files across CESourceControl and CELSP depend on
ShellClientProtocol and none on the implementation, which only the app target
composes.

States what the boundary actually buys — a manifest edit is needed to reach the
implementation, which is visible in review — rather than claiming the import
audit forbids it. It does not: import honesty checks that imports are declared,
so a feature that declared the dependency would pass. Records the coherent
alternative too, so the question is settled rather than merely answered once.
Two files in Styles/ were not styles. View+actionBar is a plain extension View
providing a modifier, and MenuWithButtonStyle declares a View — a menu drawn to
resemble a bordered button — not a MenuStyle conformer. Both move to Views/.

MenuWithButtonStyle is renamed ButtonStyledMenu: the old name reads as a style
type, which is what put it in the wrong folder. One consumer.

CodeEditUI is otherwise left alone. Grouping by kind is wrong inside a feature,
but this target is a component library with no feature semantics by charter, so
Styles/, Views/ and EnvironmentKeys/ are the subject — the same terms SwiftUI
itself is documented in. Imposing subjects here would mean several two-file
folders; SplitView/ stays the one genuine subsystem.
Its Styles/Views/EnvironmentKeys grouping looks like the kind-grouping this
rule forbids. It is not: the rule targets layering inside a feature, and this
target is a component library with no feature semantics by charter, so kind is
the subject a consumer browses by.

Also reframes the remaining count. Of the eight folders, only two are actual
work — CESearch's, pending its rebuild. The rest are the excluded Accounts/,
the two stated exceptions, and CodeEditSettings waiting on its naming question.
Names the disposition of all twelve so the question 'did anyone look at X' has
an answer. CodeEditDocument and CEWorkspaceFileManager needed nothing — both
are flat and under the size where the convention asks for groups — which is
why they never appeared in the kind-folder counts.
Both stay, and the conclusions depend on each other. CEEditor and CELSP
reference each other zero times in either direction; what keeps them apart is
LanguageServicesProvider, declared in CodeEditDocument, implemented in CELSP and
consumed by CEEditor through an environment key — with a no-op implementation so
the editor works with no language service at all. CodeEditDocument is therefore
the contract between two independent features, not a leftover holding a document
type, and CodeFileDocument's AppKit/SwiftUI imports bar it from Core.

CELSP is not editor-internal either: its consumers are the settings UI, the
utility area and app lifecycle, and 28 of its 78 files install language servers
rather than edit text.

Also removes an unused 'import CodeEditDocument' from CELSP's
LanguageServerDocument.swift — its only mention of CodeFileDocument is a doc
comment. Import honesty checks that imports are declared, not that they are
used, so nothing flagged it.
Untouched since 2025-01-05. Of the 34 symbols its landing page linked, 9 no
longer exist and 13 moved into package targets — DocC documents one module, so
an app-target catalog cannot resolve CEWorkspaceFile, FileIcon or ShellClient
any more. Ten of the 34 were still app-target symbols.

Repair could not have succeeded. The catalog's model — one app target, one
documented module — stopped matching a codebase of twelve library targets and a
thin shell, and the only thing it could still document is the shell, which is
the part least needing an external-audience explainer. Its section names were
the retired Features/ folders, and AppPreferences/ was nine files of tutorial
for the god object ARCHITECTURE.md names as a cause of the 2022 collapse.

Nothing referenced it: no inbound links, absent from Package.swift and CI, 19
months stale while compiled in the app target's Sources phase.

This matches what the CodeEditApp org actually does. All five libraries it
publishes — CodeEditSourceEditor, CodeEditTextView, CodeEditKit,
CodeEditSymbols, CodeEditLanguages — ship a catalog at
Sources/<Target>/Documentation.docc, because a published library has readers who
never open its source. None of CodeEditModules' twelve targets has an external
consumer, so none needs one; a catalog is optional anyway, since DocC generates
symbol docs from doc comments without it. If a target is ever extracted for
publication, that is when it gains a catalog.

Four project.pbxproj entries removed with it: the catalog was an explicit file
reference in the Sources build phase, not a synchronized group, so deleting the
folder alone would have broken the build.

Also fixes ThemeSettingsView's header, which still named the deleted
ThemePreferencesView.
67 headers named a file they were not, all fossils of renames the header
comment never followed. Most record the old vocabulary directly:
ThemePreferences, KeybindingsPreferences and AccountsPreferences for the
retired Settings names; CommandPaletteView for QuickActionsView; TabManager for
EditorManager; OutlineViewController for ProjectNavigatorViewController;
SourceControlModel for SourceControlManager. Two were plain typos
(AccoundSelectionView, OutlintViewController).

Header-only: 67 files, 67 insertions, 67 deletions, and every changed line
begins '//  '.

Found while researching the DocC catalog — ThemeSettingsView's header still
named the deleted ThemePreferencesView, which suggested the pattern was wider.

Note for anyone re-running the check: scope it to CodeEdit/,
CodeEditModules/Sources/, CodeEditModules/Tests/, CodeEditTests/ and
CodeEditUITests/. A bare find over CodeEditModules/ walks into .build/checkouts
and reports dependency sources — that inflated the first count from 67 to 139.
…spaceFileManager

The norm was justified in the abstract, so it read as decorative. It now carries
what it actually prevents: merging CEWorkspaceFileManager into Core. That target
holds 50 FileManager calls and a full FSEvents implementation with a C callback
and its own dispatch queue. Without the norm the merge looks reasonable, since
that target depends on nothing but Core and folding it in removes a target. With
the norm it is obviously wrong, because it would put a live filesystem event
stream in the sink all twelve targets rest on.

The testability half is now empirical rather than asserted: CodeEditCoreTests is
five files with zero FileManager, temporaryDirectory or Data(contentsOf:) use.

Records CEWorkspaceFileManager alongside ShellClient. Core declares
WorkspaceFileProviding, CEWorkspaceFileManager.swift:263 conforms to it, four
CEEditor files depend on the protocol, and no package imports the
implementation. Contract in Core, adapter in its own target, app composes.

Also fixes a typo from the em-dash pass ('organized an,' to 'organized and,').
86 down to 14. The remainder are the two cases worth keeping: 10 columns in the
topology diagram, where they align target names with their descriptions, and 4
separators between a bold label and its explanation in list items.

Done in two passes, because a mechanical substitution produces bad prose. The
first replaced the dashes; the second fixed what that broke, roughly 35 places.
Comma splices became colons or full stops ('the acyclicity guarantee, because it
makes Core a sink', 'not prevention: import honesty checks'). Paired dashes that
had been holding a parenthetical became actual parentheses, which mattered most
where a list would otherwise read wrong: 'across CESourceControl and CELSP
(GitClient, SourceControlManager, ...)' had briefly read as though GitClient were
a package.

73 lines changed, structure untouched: 19 headings, 4 code fences, 44 table rows
before and after.
It was hard-wrapped at 100 columns, which nothing in the repo asks for. README.md
has a median line length of 142 and CONTRIBUTING.md 85, so this file was the odd
one out, and every Markdown renderer soft-wraps regardless.

The cost was real: hard wrapping reflows a whole paragraph when one sentence
changes, so the em dash pass touched 73 lines to make mostly single-word edits.
One sentence per line means a sentence edit changes one line.

Verified content-preserving rather than assumed. Prose hashes identically with
whitespace collapsed, code blocks are byte-identical, word count is 6484 before
and after, and the sequence of block-start indents hashes identically so every
list continuation keeps its nesting. The first attempt did not: it flattened 21
indented continuation paragraphs to column zero, which would have re-rendered
them as siblings of their list items instead of part of them.
…ion log

The repo had two files called ARCHITECTURE.md. The root one, an untracked
Architecture Vision file excluded via .git/info/exclude, was last touched on
26 July and described the tiered Packages/ layout that the consolidation
replaced. The tracked docs/ARCHITECTURE.md was the maintained one. Reading the
stale file while I edited the other is what surfaced this.

The guide now sits at the root, where a newcomer looks alongside README and
CONTRIBUTING, and is tracked normally with the exclude entry removed. Three
sections were merged from the vision file first, each verified against the code:
the design principles (point 8 rewritten, since it described the retired
Packages/ tiers), state ownership (reworded to stop overloading the word
service, because git status, tasks and LSP sessions live in feature targets
rather than service targets), and the rejected-options list.

Two claims in that list were wrong and are corrected. The @observable entry
justified itself with a macOS 13 minimum, but the declared target is 14 in both
the pbxproj and the manifest, so the blocker is team agreement rather than code.
The Combine entry claimed the EventBus could swap to AsyncStream without
touching call sites; subscribe(_:) returns AnyPublisher and all five subscribers
use sink and AnyCancellable, so it would rewrite every one.

The guide dropped from 557 to 435 lines by moving decision records to
docs/architecture-decisions.md: the four target-separation rulings, what the I/O
norm prevents, the panel seam detail, the Core charter evidence, and the
singletons inventory. What stayed is the rule plus its reasoning. Panel
contributions shrank from 41 lines to the reusable pattern, since it is the
first of several seams and logging each in full would bloat the guide. Folder
conventions lost its per-target history, which is the only content deleted
rather than moved: 170 words.

Also removes the em dash separator from CONTRIBUTING.md prose, per the writing
style rule.
CI failed to compile CodeEditSettings with four errors in SettingsValue.swift:
EnvironmentObject's init and its wrapped value are @mainactor in the SDK, so a
nonisolated property wrapper touching them is rejected under Swift 6 strict
concurrency.

It compiled locally because this machine runs Xcode 26.6 while the runner uses
Xcode 16.4, and newer SwiftUI carries @preconcurrency annotations that soften
the isolation. The app-side @appsettings wrapper has the same shape and is
unaffected, because the app target is still Swift 5.

The isolation is correct on the merits, not just a way to satisfy the compiler.
SettingsValue is documented as valid only inside a View, and
PersistentSettingsStore.setValue already asserts the main thread. SwiftUI's own
@StateObject and @ObservedObject are main-actor wrappers used the same way.

Nothing in the branch needs a newer SDK, so the runner is adequate: the 37
#available(macOS 26) guards are version checks that compile against any SDK,
GlassEffectView is our own NSViewRepresentable, and the only Apple glass API
reference in the tree is commented out. Upgrading the runner would have hidden
this rather than fixed it.

Cannot be verified locally, since Xcode 26.6 does not produce the error. CI is
the check.
It crosses GitClientProtocol, which is Sendable, and holds only
[GitChangedFile], which is Sendable too. Every other type returned by
that protocol already declares the conformance, so Status was the lone
outlier.

Xcode 26 accepts the call site without this because its region-based
isolation analysis can prove the value is disconnected there. Xcode
16.4, which the CI runner uses, cannot, and asks for the conformance
instead.
These values are produced by an AsyncSequence and consumed on the main
actor, so they cross an isolation boundary. The struct holds a Double
and a payload-free internal enum, so it was already Sendable in fact
and only lacked the declaration.

Same toolchain split as GitClient.Status: Xcode 26 proves the value is
disconnected at the call site, while the CI runner's Xcode 16.4 asks for
the conformance.
The method had no callers anywhere in the repo, was internal to CESearch
so nothing outside could reach it, witnessed no protocol requirement,
and was not @objc, so no dynamic dispatch could find it either.

It also built an NSAlert and called runModal() from a synchronous
nonisolated method on a nonisolated class, which presents a modal alert
from an arbitrary thread. Xcode 26 accepts this; the CI runner's Xcode
16.4 rejects it, correctly.

The three other NSAlert sites in the packages all sit on @mainactor
types and are unaffected. Removing this one leaves the file with no
AppKit dependency, so that import goes too: its only other NS use,
NSString.CompareOptions, is Foundation.
Both coordinators write a main-actor binding on their NSViewRepresentable
parent from inside a Combine sink on NSMenu.didSendActionNotification.
AppKit posts that notification on the main thread, so the write was
always main-thread in practice, but nothing said so.

This mirrors the fix already used by FindNavigatorResultList's
coordinator in the same package: annotate the coordinator, then state
the invariant at the mutation with MainActor.assumeIsolated, which traps
if it is ever violated.

CESearch is the one CI reported, since it is Swift 6. CEEditor has the
identical construct and is silent only because it declares
swiftLanguageMode(.v5), so it is fixed here too rather than left to fail
when that exception is lifted.
The class is @mainactor, but both methods override nonisolated
declarations on XCTestCase, and an override cannot add isolation the
superclass lacks. So they stayed nonisolated while the property they
assign and the initialiser they call are main-actor.

XCTest runs setUp and tearDown on the main thread for synchronous test
cases, so MainActor.assumeIsolated states that invariant instead of
weakening the isolation of the view model.

Swept the other seventeen test files with these overrides: no other
package test is affected. The remaining @mainactor ones live in
CodeEditTests, which is Swift 5.
Supersedes the previous attempt on this file. MainActor.assumeIsolated
was the wrong tool: its closure captures self, the XCTestCase, which is
not Sendable, so sending it into a main-actor closure from a nonisolated
override is itself a data race the compiler rejects.

A lazy property needs no escape hatch. Its getter is main-actor because
the class is, and XCTest creates a fresh test-case instance per test
method, so each test still gets its own view model. tearDown only nilled
the property, which per-test instances already handle.
NSDocument is main-actor isolated but declares read(from:ofType:) and
presentedItemDidChange() nonisolated, since AppKit may call them off the
main thread. Both touch main-actor document state, which was silent in
the Swift 5 app target and is six errors now the file lives in a Swift 6
package target. The failing lines are byte-identical on main.

Three changes, none of them a behaviour change:

1. canConcurrentlyReadDocuments(ofType:) is overridden to false. That is
   already AppKit's default; stating it pins the invariant the read path
   relies on, which was previously unwritten.
2. read(from:ofType:) states its main-actor isolation, and
   registerContentChangeUndo takes a String rather than an NSString so
   nothing non-Sendable is captured.
3. presentedItemDidChange() consults its main-actor state through the
   same Thread.isMainThread branch notifyLSPDidOpen() already uses. An
   unconditional DispatchQueue.main.sync deadlocks, because the tests
   call this on the main thread while NSFileCoordinator does not.

This is a bridge, not a resolution: assumeIsolated states what the
compiler cannot check, and the runtime branch stands in for a static
guarantee. Recorded as deferred in docs/architecture-decisions.md and in
a doc comment, with the redesign sketched. The external-changes section
moved to its own file to stay under the 400-line lint limit.
The timer block optional-chained self on every access, including inside
a MainActor.assumeIsolated closure. Region-based isolation cannot treat
self as disconnected there, because the escaping timer block still
shares it, so the isolated call reads as sending self.

Binding once with a guard gives the analysis a local value and changes
no semantics: the capture stays weak, so there is still no retain cycle,
and the strong binding lasts one firing, which repeated self? accesses
already amounted to.

This surfaced only after the type errors in this file were cleared:
the sending diagnostic is a SIL pass that runs after type checking
succeeds, so it never reached this code before.
read(from:ofType:) used a bare MainActor.assumeIsolated, justified by
canConcurrentlyReadDocuments(ofType:) being pinned to false. That
justification was wrong: the pin constrains AppKit's own reads and says
nothing about an in-process caller constructing a document off the main
actor. Commit 1985839 records that exact shape trapping here before and
taking twenty unit tests down with it.

All four nonisolated-override sites now branch on Thread.isMainThread and
assume isolation only on the main-thread side. read and
presentedItemDidChange block with .sync because both must complete before
returning; the LSP notifications and undo registration hop with .async as
they already did.

docs/architecture-decisions.md is corrected too. It previously described
the pin as what made the read path sound, which overstated it.
Five build configs carry ENABLE_APP_SANDBOX = NO and the entitlements
file has no app-sandbox key, with nothing in the repo saying why. A
reviewer had no way to tell a deliberate decision from a leftover.

The sandbox blocks Process from spawning subprocesses, which is what
ShellClient, CETerminal, and every git path depend on, so this is the
project's long-standing configuration rather than a workaround. PR CodeEditApp#2147
enabled it by accident in December as part of an unrelated fix and broke
git, LSP, the terminal, and package installs; commit a2fff0c reverted
that. Recording the history so the next person does not repeat it.

Also notes the two consequences: App Store distribution is out of scope,
and the security-scoped bookmark handling is a deliberate no-op kept for
the case where this is revisited.
@matthijseikelenboom

Copy link
Copy Markdown
Contributor Author

Toolchain note for reviewers: Xcode 16.4 versus 26

Worth knowing before you build this branch, because it will affect your own results.

The CI runner has Xcode 16.4 (Swift 6.1); current local development is Xcode 26 (Swift 6.3). The package targets are Swift 6 strict-concurrency, so they are the first code in this project where that gap matters, and it produced nine consecutive CI failures on this branch. The diagnostics split two ways:

Same analysis, different severity. Xcode 26 reports the isolation errors at the identical lines, as warnings. Judging a local build by its exit code hides them completely. To see what CI will reject:

xcodebuild -workspace CodeEdit.xcworkspace -scheme CodeEdit \
  -testPlan CodeEditTestPlan -destination 'platform=macOS,arch=arm64' \
  build-for-testing 2>&1 | grep 'warning:' | grep -Ei 'isolat|sendable|concurren|actor|data race'

Warnings carrying the suffix "this is an error in the Swift 6 language mode" live in the Swift 5 app target and stay warnings. Ones without it, in package code, are the CI blockers.

Different analysis, invisible locally. Where 16.4 demands an explicit Sendable conformance, Xcode 26's region-based isolation can prove the value disconnected at the call site and reports nothing at all. Confirmed by reverting one such fix and rebuilding: zero diagnostics. Only CI catches these. They also surface after type errors in the same file are cleared, because the check runs as a later pass, so fixing one error can reveal another in untouched code.


Why CI is not green right now. All nine compile failures are fixed and the twelve library targets plus the test targets build on the runner. The first run to get that far then sat on the "Testing App" step for 45 minutes with no output, against a 4-to-5-minute historical baseline for a passing run, and was cancelled. That is one observation and it is undiagnosed. The same invocation (clean test, no skips) completes locally in about ten minutes, so it does not reproduce here. The obvious suspect is that this branch adds six test targets to CodeEditTestPlan, taking it from two to eight, and CI had never executed any of the six before; the cheapest experiment is a run with those six temporarily excluded, which separates "the new targets" from "the runner had a bad day" without touching product code. Until that is settled, treat the local run as the evidence of correctness and this as an open CI question rather than a code defect.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

architecture documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant