Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,4 @@ web/public/pagefind/
# Local scratch (screenshots, etc.)
tmp/
tmp-*/
.iter-logs/*.png
68 changes: 68 additions & 0 deletions .iter-logs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Xcode project pane — autonomous iteration log

Three reviewers per pass: design/UX, product/PM, technical/Swift. Their prioritized findings drove the per-iteration edits below. All builds green, tests green, screenshots under `iterN-{files,targets,buildSettings,schemes}.png`.

## Iteration 0 — baseline
Foundation slice from earlier in the session: `CMUXProjectModel` package with `XcodeProjectAdapter` (XcodeProj-backed), `ProjectPanel` + four SwiftUI tab views, `project.open` and `project.set_*` / `project.get_state` debug RPCs, session persistence, CLI subcommand. Build green.

## Iteration 1 — top findings applied
- Fix T1 crash: `ProjectTargetsTabView.metadata(_:_:)` returned `Text(...).foregroundStyle(.secondary) as! Text`, a force-cast that crashes the first time it renders. Now `@ViewBuilder some View`.
- Honesty T3: renamed Build Settings "Resolved" column to "Effective" and added disclaimer that xcconfig + platform defaults are not yet folded in.
- Empty states S5: extracted shared `ProjectEmptyDetailView` (icon + title + hint), replacing 12pt floating-secondary "Select a file" / "target" / "scheme" in three tabs.
- Chrome S1: collapsed two-row chrome into one row (project title + scheme + config + reload), tab strip on its own line, path moved to tooltip on title.

## Iteration 2 — segmentation + cleanup
- Tab strip S2: turned from underline-style text into segmented control (selected = filled accent, others = neutral pill).
- Default expansion: auto-collapse Files tree past depth 1 so the top-level navigator opens to a usable summary instead of an alphabetical wall.
- Build Settings P3: added "Customized only" checkbox toggle.
- Targets P5 / S4: dropped the dead Configurations subsection that duplicated Build Settings data.
- Persistence: lifted `collapsedNodeIDs` + `settingsCustomizedOnly` from view `@State` into `ProjectPanel`, so reload doesn't drop tree expansion state.
- Reload race fix: `ProjectPanel.reload()` now cancels in-flight `Task.detached` on re-entry; error path keeps previous model loaded instead of dumping the user back to a status screen.

## Iteration 3 — table polish + filter
- Files P1 / P3: file filter bar at top of Files tab (magnifier + clear button + match count). Filter mode auto-expands matching groups.
- Files P2: single-pane layout when nothing selected (no dead 220pt right-rail). Only splits when a file is selected.
- Targets / Schemes detail polish: replaced the dead Configurations subsection with a count summary + "Open in Build Settings" jump button.
- Bug C4: dropped the hardcoded "Debug" fallback in Build Settings; now uses `module.configurationNames.first ?? ""`.
- Bug C3: Build Settings now resolves the owning module of the selected target (was wrong on multi-module workspaces — was reading module 0's settings while the picker showed module 0's targets).
- `lastLoadError` stale: clears on next successful `applyLoaded` (was lingering as phantom failure).

## Iteration 4 — visual differentiation of overrides
- Build Settings S3: target overrides now stand out with three combined cues — leading 3pt accent bar, accent-colored Target column value, semibold setting name. (Toned down in iter5 — see below.)
- Build Settings hot path C1/C2: hoisted `let rows = ...` at top of body so `Text("\(rows.count) settings")` and `ForEach(rows)` share one evaluation. Same pattern applied to Files tab `flattenedRows`.

## Iteration 5 — bug sweep + tone-down overrides
- Chrome T1: scheme + configuration pickers now `flatMap` across all `model.modules` and dedupe by name (was reading only `model.modules.first`, silently dropping workspace-wide schemes).
- `applyLoaded` T3: validates persisted selections (`selectedSchemeName`, `selectedConfigurationName`, `selectedTargetID`, `selectedFilePath`) against the freshly loaded model; clears + reseeds anything stale to avoid silent wrong-data display.
- Scheme T4: `XcodeProjectAdapter.schemeSummary.resolve(...)` no longer fabricates `TargetID`s from `blueprintIdentifier` when that UUID isn't in the known target set. Returns nil (which the Schemes view now treats as "not in this module's targets") instead of synthesizing 6-char-hash-rendered fake IDs.
- Visual reduce: dropped the semibold-name + accent-Effective combo (too many override signals stacked); kept just the leading bar + accent Target value.

## Iteration 6 — collapse + dedup
- Default expansion (initial): tightened to depth 0 (only root open). Reverted in iter7 after PM agent flagged it as "Files tab is empty."
- Restored: Files tab default expansion back to depth 1.
- Schemes dedup: switched `ForEach(model.modules)` / `ForEach(module.schemes)` to a single `ForEach` over `[(module, scheme, compositeID)]` keyed on `"\(module.id)|\(scheme.name)"` so two same-named schemes from different modules don't collide on SwiftUI identity.

## Iteration 7 — xcconfig parsing
- New package leaf: `XcconfigParser.swift` (~110 LOC) — handles simple assignments, trailing `//` comments, relative + optional `#include` / `#include?`, cycle detection.
- Adapter integration: `XcodeProjectAdapter.collectTargets(...)` now merges xcconfig settings at both project and target scope via `baseConfiguration` resolution, then falls back into pbxproj's `buildSettings` dict. Bundle id, deployment target, and platforms now actually populate on cmux (was blank because those live in `.xcconfig`).
- Targets pane: `detailGrid` now always shows Product / Platforms / Deploy min / Bundle ID, falling back to `—` when truly absent. No more silent omission.

## Iteration 8 — chrome polish + load-warnings surface
- Chrome load-error pill: when `loadState` is `.loaded` and `lastLoadError != nil` (i.e. successful reload preserved previous model but had warnings), shows a dismissible orange pill above the tab strip.
- Schemes detail polish: header now has the scheme name + inline shared/personal badge instead of a separate "Visibility" row below.
- Targets detail polish: target detail header now has the product-type SF Symbol at 18pt in accent color + name + product type subtitle, instead of a single `Label`.

## Iteration 9 — test coverage
- New `XcconfigParserTests` suite (6 tests, all green): simple assignments, trailing line comments, relative `#include`, optional `#include?` for missing file, multi-file `parseChain` ordering, cycle detection.
- New adapter tests: `bundleIdentifierIsEitherResolvedOrExplicitlyNilNotFabricated` (guards against `$(...)` leakage), `unresolvableSchemeTargetsReturnNilNotFabricatedID` (guards against T4 regression), `loadReportsAtLeastOneBuildConfigurationPerKnownTarget`.
- 15/15 tests green in 58ms total. Adapter and xcconfig parser have behavior-level coverage that catches the silent-wrong-data bugs flagged in iter1–iter5.

## Iteration 10 — final
- All builds green; test suite 15/15 green; all four tabs render against `cmux.xcodeproj` end-to-end.

## Open backlog (deferred, not in this run)
- `xcodebuild -showBuildSettings -json` integration to produce a true Resolved column (currently uses local effective-or-fallback heuristic).
- Per-module load-warning aggregation across workspace projects (currently uses `try?` in `loadWorkspace`, swallowing per-module errors silently).
- Edit affordances: target membership toggle, settings edit, scheme env edit (all wired through the data model but no write-back yet).
- File watcher / reactive sync via `DispatchSource.makeFileSystemObjectSource` (currently manual reload).
- Fixture-driven adapter tests (currently tests run against the live cmux project; brittle to project shape changes).
59 changes: 59 additions & 0 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4523,6 +4523,10 @@ struct CMUXCLI {
case "browser":
try runBrowserCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)

// Project pane
case "project":
try runProjectCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)

// Legacy aliases shimmed onto the v2 browser command surface.
case "open-browser":
try runBrowserCommand(commandArgs: ["open"] + commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
Expand Down Expand Up @@ -4711,6 +4715,61 @@ struct CMUXCLI {
}
}

private func runProjectCommand(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat
) throws {
var args = commandArgs
let (workspaceOpt, argsAfterWorkspace) = parseOption(args, name: "--workspace")
let (windowOpt, argsAfterWindow) = parseOption(argsAfterWorkspace, name: "--window")
let (focusOpt, argsAfterFocus) = parseOption(argsAfterWindow, name: "--focus")
args = argsAfterFocus

// Treat first token as subcommand if it's "open", else require it.
guard let first = args.first?.lowercased() else {
throw CLIError(message: "project requires a subcommand. Usage: cmux project open <path-to-.xcodeproj-or-.xcworkspace>")
}
let subArgs: [String]
if first == "open" {
subArgs = Array(args.dropFirst())
} else if args.count == 1 {
subArgs = args
} else {
throw CLIError(message: "Unknown project subcommand: \(first). Usage: cmux project open <path>")
}

guard let rawPath = subArgs.first, !rawPath.isEmpty else {
throw CLIError(message: "project open requires a path. Usage: cmux project open <path-to-.xcodeproj-or-.xcworkspace>")
}
Comment on lines +4734 to +4745

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Lone non-open token is silently treated as a path, masking mistyped subcommands.

The else if args.count == 1 branch accepts any single token as the path, so cmux project status (or any typo'd one-word subcommand) becomes a bogus path and surfaces a confusing "file not found"-style error from project.open instead of a clear usage error. Multi-token typos are already caught by the final else. The looksLikePath(_:) helper right below (Line 4774) can gate this cleanly.

🛠️ Proposed fix to reject non-path single tokens
         let subArgs: [String]
         if first == "open" {
             subArgs = Array(args.dropFirst())
-        } else if args.count == 1 {
+        } else if args.count == 1, looksLikePath(rawFirst) {
             subArgs = args
         } else {
             throw CLIError(message: "Unknown project subcommand: \(first). Usage: cmux project open <path>")
         }

This requires capturing the original-cased first token (e.g. let rawFirst = args[0]) since looksLikePath should run against the un-lowercased value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLI/cmux.swift` around lines 4734 - 4745, The single-token branch currently
treats any one-word token as a path and should instead validate it using the
looksLikePath(_:) helper: capture the original-cased first token (e.g. rawFirst
= args[0]) before any lowercasing, then when args.count == 1 call
looksLikePath(rawFirst) and only accept it as subArgs if it returns true;
otherwise throw the existing CLIError usage message for unknown project
subcommands (keep the existing throw path for multi-token cases), and ensure
subArgs is set from the validated rawFirst so the later guard uses the correct
rawPath.

let absolutePath = resolvePath(rawPath)
var params: [String: Any] = ["path": absolutePath]
let workspaceRaw = workspaceOpt ?? (windowOpt == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
if let workspaceRaw {
if let workspace = try normalizeWorkspaceHandle(workspaceRaw, client: client) {
params["workspace_id"] = workspace
}
}
if let windowRaw = windowOpt {
if let window = try normalizeWindowHandle(windowRaw, client: client) {
params["window_id"] = window
}
}
try applyFocusOption(focusOpt, defaultValue: true, to: &params)

let payload = try client.sendV2(method: "project.open", params: params)

if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
} else {
let surfaceText = formatHandle(payload, kind: "surface", idFormat: idFormat) ?? "unknown"
let paneText = formatHandle(payload, kind: "pane", idFormat: idFormat) ?? "unknown"
let path = (payload["path"] as? String) ?? absolutePath
print("OK surface=\(surfaceText) pane=\(paneText) project=\(path)")
}
}

/// Returns true if the argument looks like a filesystem path rather than a CLI command.
private func looksLikePath(_ arg: String) -> Bool {
if arg == "." || arg == ".." { return true }
Expand Down
42 changes: 42 additions & 0 deletions Packages/CMUXProjectModel/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions Packages/CMUXProjectModel/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
name: "CMUXProjectModel",
platforms: [
.macOS(.v14),
],
products: [
.library(
name: "CMUXProjectModel",
targets: ["CMUXProjectModel"]
),
.executable(
name: "cmux-project-dump",
targets: ["CMUXProjectDump"]
),
],
dependencies: [
.package(
url: "https://github.com/tuist/XcodeProj.git",
from: "9.0.0"
),
],
targets: [
.target(
name: "CMUXProjectModel",
dependencies: [
.product(name: "XcodeProj", package: "XcodeProj"),
]
),
.executableTarget(
name: "CMUXProjectDump",
dependencies: ["CMUXProjectModel"]
),
.testTarget(
name: "CMUXProjectModelTests",
dependencies: ["CMUXProjectModel"]
),
]
)
64 changes: 64 additions & 0 deletions Packages/CMUXProjectModel/Sources/CMUXProjectDump/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import CMUXProjectModel
import Foundation

/// Manual verification entrypoint for ``XcodeProjectAdapter``.
///
/// Usage:
///
/// swift run cmux-project-dump <path to .xcworkspace or .xcodeproj>
///
/// Prints a hierarchical summary of the parsed ``ProjectModel`` so changes to
/// the adapter can be eyeballed against a real project without standing up the
/// SwiftUI navigator pane.

@main
struct CMUXProjectDump {
static func main() {
var arguments = CommandLine.arguments.dropFirst()
let rawPath = arguments.popFirst() ?? FileManager.default.currentDirectoryPath
let url = URL(fileURLWithPath: rawPath, isDirectory: false).standardizedFileURL

let adapter = XcodeProjectAdapter()
let model: ProjectModel
do {
model = try adapter.load(at: url)
} catch {
FileHandle.standardError.write(Data("error: \(error)\n".utf8))
exit(1)
}
printModel(model)
}

private static func printModel(_ model: ProjectModel) {
print("Project: \(model.displayName) [\(model.adapter.rawValue)]")
print(" root: \(model.rootURL.path)")
print(" modules: \(model.modules.count)")
for module in model.modules {
print(" - module: \(module.displayName)")
print(" root: \(module.rootURL.path)")
print(" targets: \(module.targets.count)")
for target in module.targets {
print(" - \(target.displayName) [\(target.productType.rawValue)] platforms=\(target.platforms.joined(separator: ",")) bundle=\(target.bundleIdentifier ?? "-") deploy=\(target.deploymentTarget ?? "-") deps=\(target.dependencies.count)")
}
print(" tree:")
printNode(.group(module.rootGroup), indent: " ")
}
}

private static func printNode(_ node: ProjectNodeKind, indent: String) {
switch node {
case let .group(group):
let style = group.style.rawValue
print("\(indent)\u{1F4C1} \(group.displayName) [\(style)]")
for child in group.children {
printNode(child, indent: indent + " ")
}
case let .file(file):
let warn = file.existsOnDisk ? "" : " (missing)"
let members = file.memberships.isEmpty
? ""
: " targets=\(file.memberships.map { $0.targetID.rawValue.prefix(8) }.joined(separator: ","))"
print("\(indent)\u{1F4C4} \(file.displayName)\(warn)\(members)")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Foundation

/// Where a ``BuildConfigSummary`` is attached.
///
/// Build settings live in two places in an Xcode project: at the project
/// level (inherited by every target) and at the target level (overrides). The
/// scope identifies which side of the inheritance stack a particular config
/// row belongs to so the Levels view can render it in the correct column.
public enum BuildConfigScope: Sendable, Hashable {
case project
case target(TargetID)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Foundation

/// One named build configuration ("Debug", "Release", custom names) attached
/// to either a project or a target.
///
/// ``rawSettings`` is the literal `buildSettings` dictionary from the
/// underlying `XCBuildConfiguration` object. Keys may carry conditional
/// suffixes such as `OTHER_LDFLAGS[sdk=iphoneos*][config=Debug]`; the
/// Build Settings view is responsible for parsing those suffixes when it
/// computes the Levels rows.
public struct BuildConfigSummary: Sendable, Hashable, Identifiable {
public let id: BuildConfigID
public let name: String
public let scope: BuildConfigScope
public let baseConfigurationPath: URL?
public let rawSettings: [String: String]

public init(
id: BuildConfigID,
name: String,
scope: BuildConfigScope,
baseConfigurationPath: URL?,
rawSettings: [String: String]
) {
self.id = id
self.name = name
self.scope = scope
self.baseConfigurationPath = baseConfigurationPath
self.rawSettings = rawSettings
}
Comment on lines +12 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Document the public properties and initializer.

The public stored properties (Lines 12–16) and the memberwise initializer (Lines 18–24) lack the required Swift-DocC /// comments.

As per coding guidelines: "Every public symbol in any new Swift package under Packages/ must be documented with a Swift-DocC triple-slash comment at the time of writing."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/CMUXProjectModel/Sources/CMUXProjectModel/BuildConfigSummary.swift`
around lines 12 - 30, Add Swift-DocC triple-slash comments for each public
stored property (id, name, scope, baseConfigurationPath, rawSettings) and the
public initializer in BuildConfigSummary so every public symbol is documented;
for the initializer (init(id:name:scope:baseConfigurationPath:rawSettings:))
include a brief overview plus /// - Parameters: entries describing each
parameter and their meanings, and for optional baseConfigurationPath note it can
be nil, keeping all comments concise and formatted with /// so they appear in
generated docs.

}
Loading
Loading