diff --git a/.gitignore b/.gitignore index bb47e7bd8b26..47f7e6e539e4 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,4 @@ web/public/pagefind/ # Local scratch (screenshots, etc.) tmp/ tmp-*/ +.iter-logs/*.png diff --git a/.iter-logs/CHANGELOG.md b/.iter-logs/CHANGELOG.md new file mode 100644 index 000000000000..0064aba2b0dd --- /dev/null +++ b/.iter-logs/CHANGELOG.md @@ -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). diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 39301c56d4c1..f69378163e29 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -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) @@ -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 ") + } + 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 ") + } + + guard let rawPath = subArgs.first, !rawPath.isEmpty else { + throw CLIError(message: "project open requires a path. Usage: cmux project open ") + } + 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: ¶ms) + + 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 } diff --git a/Packages/CMUXProjectModel/Package.resolved b/Packages/CMUXProjectModel/Package.resolved new file mode 100644 index 000000000000..df497d4bfd2c --- /dev/null +++ b/Packages/CMUXProjectModel/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "4fe6623e036119a08becc1b42b54014290fa31db422535f2f4d47c97865aa763", + "pins" : [ + { + "identity" : "aexml", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tadija/AEXML.git", + "state" : { + "revision" : "db806756c989760b35108146381535aec231092b", + "version" : "4.7.0" + } + }, + { + "identity" : "pathkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kylef/PathKit.git", + "state" : { + "revision" : "3bfd2737b700b9a36565a8c94f4ad2b050a5e574", + "version" : "1.0.1" + } + }, + { + "identity" : "spectre", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kylef/Spectre.git", + "state" : { + "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7", + "version" : "0.10.1" + } + }, + { + "identity" : "xcodeproj", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tuist/XcodeProj.git", + "state" : { + "revision" : "fb79d68bc758cdb00079d72b6c8b899d99f5f15e", + "version" : "9.12.0" + } + } + ], + "version" : 3 +} diff --git a/Packages/CMUXProjectModel/Package.swift b/Packages/CMUXProjectModel/Package.swift new file mode 100644 index 000000000000..cf0aae1a5371 --- /dev/null +++ b/Packages/CMUXProjectModel/Package.swift @@ -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"] + ), + ] +) diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectDump/main.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectDump/main.swift new file mode 100644 index 000000000000..ea215bc821f2 --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectDump/main.swift @@ -0,0 +1,64 @@ +import CMUXProjectModel +import Foundation + +/// Manual verification entrypoint for ``XcodeProjectAdapter``. +/// +/// Usage: +/// +/// swift run cmux-project-dump +/// +/// 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)") + } + } +} diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectModel/BuildConfigScope.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/BuildConfigScope.swift new file mode 100644 index 000000000000..d92c90eea860 --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/BuildConfigScope.swift @@ -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) +} diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectModel/BuildConfigSummary.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/BuildConfigSummary.swift new file mode 100644 index 000000000000..bcfef38cf109 --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/BuildConfigSummary.swift @@ -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 + } +} diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectModel/IdentifierTypes.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/IdentifierTypes.swift new file mode 100644 index 000000000000..abb841dbb232 --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/IdentifierTypes.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Stable identifier for a parsed ``ProjectModel``. +/// +/// The raw value is a content-derived hash (typically the absolute path of the +/// root `.xcworkspace` or `.xcodeproj`) so the identifier survives reloads even +/// when the underlying file is rewritten by the IDE. +public struct ProjectModelID: Sendable, Hashable, RawRepresentable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } +} + +/// Identifier for a ``ProjectModule`` within a ``ProjectModel``. +/// +/// For the Xcode adapter this is the `.xcodeproj` bundle path scoped under the +/// owning workspace. For other adapters (Cargo, Gradle, etc.) it is the +/// canonical module path declared by that ecosystem. +public struct ProjectModuleID: Sendable, Hashable, RawRepresentable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } +} + +/// Identifier for a node (group or file) inside a ``ProjectModule``'s +/// navigator tree. +/// +/// Content-derived: for the Xcode adapter this combines the module ID, the +/// parent path inside the navigator, and the node's display name. UI state +/// (expansion, selection, scroll position) keyed on ``ProjectNodeID`` must +/// therefore survive a `pbxproj` rewrite that does not change the visible +/// tree, even when Xcode regenerates the underlying object UUIDs. +public struct ProjectNodeID: Sendable, Hashable, RawRepresentable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } +} + +/// Identifier for a build target inside a ``ProjectModule``. +public struct TargetID: Sendable, Hashable, RawRepresentable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } +} + +/// Identifier for a build configuration (e.g. "Debug" or "Release") attached +/// to either a project or a target. +public struct BuildConfigID: Sendable, Hashable, RawRepresentable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } +} + +/// Identifier for a scheme inside a ``ProjectModel``. +public struct SchemeID: Sendable, Hashable, RawRepresentable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } +} diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectAdapter.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectAdapter.swift new file mode 100644 index 000000000000..4f037e2056da --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectAdapter.swift @@ -0,0 +1,37 @@ +import Foundation + +/// The protocol every per-ecosystem adapter implements. +/// +/// An adapter is responsible for one and only one thing: turning a root URL +/// (e.g. a `.xcworkspace`, a `Cargo.toml`, a `settings.gradle.kts`, or a +/// plain directory) into a ``ProjectModel`` snapshot. Watching, caching, +/// diffing, and resolution of expensive secondary information (e.g. +/// `xcodebuild -showBuildSettings`) live above this protocol. +/// +/// Implementations must be `Sendable` because the project pane loads them +/// off the main actor. +public protocol ProjectAdapter: Sendable { + /// Which ecosystem this adapter handles. + var kind: ProjectAdapterKind { get } + + /// Lightweight check used by the registry to decide whether to try this + /// adapter on a given root URL before paying the cost of ``load(at:)``. + /// + /// - Parameter url: A file URL pointing at a candidate project root. + /// - Returns: `true` when this adapter is likely to be able to load + /// ``url``. Implementations should rely on cheap signals (file + /// extension, presence of a manifest file in the directory) and avoid + /// parsing. + func canLoad(_ url: URL) -> Bool + + /// Parse the project rooted at ``url`` into an immutable + /// ``ProjectModel`` snapshot. + /// + /// - Parameter url: A file URL pointing at a project root. For the Xcode + /// adapter this may be either a `.xcworkspace` bundle or a + /// `.xcodeproj` bundle, or a directory that contains one of those. + /// - Returns: A populated ``ProjectModel``. + /// - Throws: ``ProjectLoadError`` when the URL cannot be read, is not + /// supported by this adapter, or fails to parse. + func load(at url: URL) throws -> ProjectModel +} diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectAdapterKind.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectAdapterKind.swift new file mode 100644 index 000000000000..fa1388840d6f --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectAdapterKind.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Identifies which ecosystem adapter produced a ``ProjectModel``. +/// +/// Each adapter populates the shared model from its native metadata source: +/// the Xcode adapter parses `.xcodeproj` and `.xcworkspace`; future adapters +/// shell out to `cargo metadata`, the Gradle Tooling API, `pnpm m ls --json`, +/// or fall back to a plain filesystem walk. +/// +/// UI code that wants to render adapter-specific affordances (e.g. a Cargo +/// features toggle) discriminates on this value. +public enum ProjectAdapterKind: String, Sendable, Hashable, Codable { + case xcode + case cargo + case gradle + case node + case filesystem +} diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectFileNode.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectFileNode.swift new file mode 100644 index 000000000000..38563a825f42 --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectFileNode.swift @@ -0,0 +1,33 @@ +import Foundation + +/// A leaf file in a ``ProjectGroup`` navigator tree. +/// +/// Carries the file's resolved on-disk path (or `nil` for files whose +/// `sourceTree` resolves to a runtime variable that we cannot evaluate +/// statically, e.g. `BUILT_PRODUCTS_DIR`), an `existsOnDisk` flag for the +/// "missing file" warning glyph, and the list of target memberships that +/// drive the membership chips in the detail strip. +public struct ProjectFileNode: Sendable, Hashable, Identifiable { + public let id: ProjectNodeID + public let displayName: String + public let resolvedPath: URL? + public let fileType: String? + public let existsOnDisk: Bool + public let memberships: [TargetMembership] + + public init( + id: ProjectNodeID, + displayName: String, + resolvedPath: URL?, + fileType: String?, + existsOnDisk: Bool, + memberships: [TargetMembership] + ) { + self.id = id + self.displayName = displayName + self.resolvedPath = resolvedPath + self.fileType = fileType + self.existsOnDisk = existsOnDisk + self.memberships = memberships + } +} diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectGroup.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectGroup.swift new file mode 100644 index 000000000000..8ee3c77123bc --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectGroup.swift @@ -0,0 +1,31 @@ +import Foundation + +/// A folder node in the project navigator tree. +/// +/// A group corresponds to one row in Xcode's left sidebar above its files. +/// Groups may be purely virtual (no on-disk directory), backed by a real +/// directory (``ProjectGroupStyle/folderRef``), or backed by an Xcode 16+ +/// synchronized root (``ProjectGroupStyle/synchronized``). The UI does not +/// distinguish these by default; see ``ProjectGroupStyle`` for the cases +/// surfaced behind a "Raw" toggle. +public struct ProjectGroup: Sendable, Hashable, Identifiable { + public let id: ProjectNodeID + public let displayName: String + public let resolvedPath: URL? + public let style: ProjectGroupStyle + public let children: [ProjectNodeKind] + + public init( + id: ProjectNodeID, + displayName: String, + resolvedPath: URL?, + style: ProjectGroupStyle, + children: [ProjectNodeKind] + ) { + self.id = id + self.displayName = displayName + self.resolvedPath = resolvedPath + self.style = style + self.children = children + } +} diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectGroupStyle.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectGroupStyle.swift new file mode 100644 index 000000000000..91695b33aba9 --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectGroupStyle.swift @@ -0,0 +1,29 @@ +import Foundation + +/// How a ``ProjectGroup`` is materialized on disk. +/// +/// The visual UI collapses ``logical``, ``folderRef``, ``variant``, and +/// ``synchronized`` into a single tree node by default; the style is exposed +/// to power-user / "Raw" inspectors and to writers that need to round-trip +/// the underlying object correctly. +public enum ProjectGroupStyle: String, Sendable, Hashable, Codable { + /// Classic Xcode "yellow folder" (`PBXGroup`). The group does not have to + /// correspond to a directory on disk. + case logical + + /// Xcode "blue folder reference". The group is a thin shell around a real + /// on-disk directory; children are derived from the directory contents at + /// open time and cannot be reordered. + case folderRef + + /// Localized variant group (`PBXVariantGroup`). Children are the + /// locale-specific siblings of the same logical resource (e.g. + /// `Main.storyboard/en.lproj`, `Main.storyboard/ja.lproj`). + case variant + + /// Xcode 16+ file-system-synchronized root group + /// (`PBXFileSystemSynchronizedRootGroup`). Membership is derived by + /// walking the filesystem at open time and applying per-target exception + /// sets. + case synchronized +} diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectLoadError.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectLoadError.swift new file mode 100644 index 000000000000..48634419bd8f --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectLoadError.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Errors that ``ProjectAdapter`` implementations can raise when loading a +/// project from disk. +public enum ProjectLoadError: Error, Sendable, Equatable { + /// The supplied URL does not exist or is not readable. + case unreadable(URL) + + /// The supplied URL exists but is not the kind of artifact this adapter + /// can parse (e.g. a directory passed to the Xcode adapter that contains + /// no `.xcodeproj` or `.xcworkspace`). + case unsupported(URL) + + /// The artifact exists and is the right kind, but parsing failed. + /// + /// The underlying reason is rendered as a string because adapter + /// implementations wrap third-party errors (XcodeProj, libxml2, etc.) + /// whose types are not part of this package's public API surface. + case parseFailure(URL, reason: String) +} diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectModel.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectModel.swift new file mode 100644 index 000000000000..7938f080cc37 --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectModel.swift @@ -0,0 +1,45 @@ +import Foundation + +/// A parsed snapshot of a project that can be rendered by the project pane. +/// +/// ``ProjectModel`` is an immutable value type. Reactive updates are +/// expressed by replacing the snapshot wholesale, which lets the UI diff two +/// snapshots cheaply (`Hashable` on every nested type) without reasoning +/// about partial mutation. Build a new model with ``ProjectAdapter`` on each +/// underlying file change. +/// +/// Example: +/// ```swift +/// let model = try XcodeProjectAdapter().load( +/// at: URL(fileURLWithPath: "/path/to/cmux.xcworkspace") +/// ) +/// for module in model.modules { +/// print(module.displayName, module.targets.count) +/// } +/// ``` +public struct ProjectModel: Sendable, Hashable, Identifiable { + public let id: ProjectModelID + public let displayName: String + public let rootURL: URL + public let adapter: ProjectAdapterKind + public let modules: [ProjectModule] + + public init( + id: ProjectModelID, + displayName: String, + rootURL: URL, + adapter: ProjectAdapterKind, + modules: [ProjectModule] + ) { + self.id = id + self.displayName = displayName + self.rootURL = rootURL + self.adapter = adapter + self.modules = modules + } + + /// Look up a module by id. + public func module(for id: ProjectModuleID) -> ProjectModule? { + modules.first(where: { $0.id == id }) + } +} diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectModule.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectModule.swift new file mode 100644 index 000000000000..31f9238cdefd --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectModule.swift @@ -0,0 +1,54 @@ +import Foundation + +/// One project inside a ``ProjectModel``. +/// +/// For the Xcode adapter a module is one `.xcodeproj`. A ``ProjectModel`` +/// based on a `.xcworkspace` may contain many modules; a model loaded +/// directly from a single `.xcodeproj` always contains exactly one. +public struct ProjectModule: Sendable, Hashable, Identifiable { + public let id: ProjectModuleID + public let displayName: String + public let rootURL: URL + public let rootGroup: ProjectGroup + public let targets: [TargetSummary] + public let configurations: [BuildConfigSummary] + public let schemes: [SchemeSummary] + + public init( + id: ProjectModuleID, + displayName: String, + rootURL: URL, + rootGroup: ProjectGroup, + targets: [TargetSummary], + configurations: [BuildConfigSummary], + schemes: [SchemeSummary] + ) { + self.id = id + self.displayName = displayName + self.rootURL = rootURL + self.rootGroup = rootGroup + self.targets = targets + self.configurations = configurations + self.schemes = schemes + } + + /// Look up a target by id within this module. + /// + /// Convenience used by the navigator detail strip to translate a + /// ``TargetMembership/targetID`` back into a display name. + public func target(for id: TargetID) -> TargetSummary? { + targets.first(where: { $0.id == id }) + } + + /// All distinct build configuration names ("Debug", "Release", ...) that + /// appear at either project or target scope. + public var configurationNames: [String] { + var seen: Set = [] + var ordered: [String] = [] + for config in configurations where !seen.contains(config.name) { + seen.insert(config.name) + ordered.append(config.name) + } + return ordered + } +} diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectNodeKind.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectNodeKind.swift new file mode 100644 index 000000000000..8539f07f241e --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/ProjectNodeKind.swift @@ -0,0 +1,44 @@ +import Foundation + +/// The two kinds of nodes that can appear in a ``ProjectGroup``'s children. +/// +/// Modeled as an `indirect enum` so a group can recursively contain other +/// groups without forcing the model to use reference types. SwiftUI +/// `OutlineGroup` consumes this directly via a children key path that +/// projects ``ProjectGroup/children`` when the case is ``group``. +public indirect enum ProjectNodeKind: Sendable, Hashable, Identifiable { + case group(ProjectGroup) + case file(ProjectFileNode) + + public var id: ProjectNodeID { + switch self { + case let .group(group): + return group.id + case let .file(file): + return file.id + } + } + + public var displayName: String { + switch self { + case let .group(group): + return group.displayName + case let .file(file): + return file.displayName + } + } + + /// Children of this node, projected for SwiftUI ``OutlineGroup``. + /// + /// Returns `nil` for a file leaf so the outline renders it without a + /// disclosure chevron. Returns an empty array for an empty group so it + /// still renders with the chevron. + public var children: [ProjectNodeKind]? { + switch self { + case let .group(group): + return group.children + case .file: + return nil + } + } +} diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectModel/SchemeSummary.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/SchemeSummary.swift new file mode 100644 index 000000000000..b98954c81137 --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/SchemeSummary.swift @@ -0,0 +1,41 @@ +import Foundation + +/// One Xcode scheme parsed from `xcshareddata/xcschemes/*.xcscheme` or the +/// per-user `xcuserdata/.../xcschemes/*.xcscheme`. +/// +/// The shared-vs-personal distinction is collapsed to ``isShared`` rather +/// than exposed as a path so the UI can render a single badge instead of +/// leaking on-disk file locations. +public struct SchemeSummary: Sendable, Hashable, Identifiable { + public let id: SchemeID + public let name: String + public let isShared: Bool + public let runTargetIDs: [TargetID] + public let testTargetIDs: [TargetID] + public let profileTargetID: TargetID? + public let archiveTargetID: TargetID? + public let launchArguments: [String] + public let environmentVariables: [String: String] + + public init( + id: SchemeID, + name: String, + isShared: Bool, + runTargetIDs: [TargetID], + testTargetIDs: [TargetID], + profileTargetID: TargetID?, + archiveTargetID: TargetID?, + launchArguments: [String], + environmentVariables: [String: String] + ) { + self.id = id + self.name = name + self.isShared = isShared + self.runTargetIDs = runTargetIDs + self.testTargetIDs = testTargetIDs + self.profileTargetID = profileTargetID + self.archiveTargetID = archiveTargetID + self.launchArguments = launchArguments + self.environmentVariables = environmentVariables + } +} diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectModel/TargetMembership.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/TargetMembership.swift new file mode 100644 index 000000000000..c0a4ee36f982 --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/TargetMembership.swift @@ -0,0 +1,24 @@ +import Foundation + +/// The participation of one file in one target's build. +/// +/// The same ``ProjectFileNode`` can carry multiple ``TargetMembership`` values +/// when it is built into more than one target (common for shared sources and +/// test helpers). Compiler flags are per-file-per-target Xcode build settings +/// stored on the `PBXBuildFile`'s `settings.COMPILER_FLAGS` array; the +/// navigator surfaces them as a small badge on the target chip. +public struct TargetMembership: Sendable, Hashable { + public let targetID: TargetID + public let role: TargetMembershipRole + public let compilerFlags: [String] + + public init( + targetID: TargetID, + role: TargetMembershipRole, + compilerFlags: [String] = [] + ) { + self.targetID = targetID + self.role = role + self.compilerFlags = compilerFlags + } +} diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectModel/TargetMembershipRole.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/TargetMembershipRole.swift new file mode 100644 index 000000000000..3a996534ee83 --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/TargetMembershipRole.swift @@ -0,0 +1,17 @@ +import Foundation + +/// The build phase a file participates in for a given target. +/// +/// Derived from the Xcode build-phase isa (`PBXSourcesBuildPhase`, +/// `PBXResourcesBuildPhase`, etc.). Used by the navigator to render +/// target-membership chips that distinguish "compiled source" from "copied +/// resource", and by the future editing path to know which phase to mutate +/// when toggling membership. +public enum TargetMembershipRole: String, Sendable, Hashable, Codable { + case compile + case resource + case copy + case framework + case header + case script +} diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectModel/TargetProductType.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/TargetProductType.swift new file mode 100644 index 000000000000..ed70098b66b8 --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/TargetProductType.swift @@ -0,0 +1,60 @@ +import Foundation + +/// The kind of artifact a ``TargetSummary`` produces. +/// +/// Modeled after Xcode's `productType` field on `PBXNativeTarget`. Adapters +/// for other ecosystems map their native target kinds (Cargo `lib`/`bin`, +/// Gradle `application`/`library`/`androidTest`, npm `app`/`lib`) into this +/// enum, falling back to ``other`` when no direct mapping exists. +public enum TargetProductType: String, Sendable, Hashable, Codable { + case application + case framework + case staticLibrary + case dynamicLibrary + case bundle + case unitTest + case uiTest + case commandLineTool + case appExtension + case watchApp + case watchExtension + case xcFramework + case other + + /// Parse the raw Xcode product-type string into a ``TargetProductType``. + /// + /// Returns ``other`` when the value is not recognized so the UI can still + /// render a target row instead of failing the whole load. + public static func fromXcodeProductType(_ raw: String) -> TargetProductType { + switch raw { + case "com.apple.product-type.application": + return .application + case "com.apple.product-type.framework": + return .framework + case "com.apple.product-type.library.static": + return .staticLibrary + case "com.apple.product-type.library.dynamic": + return .dynamicLibrary + case "com.apple.product-type.bundle": + return .bundle + case "com.apple.product-type.bundle.unit-test": + return .unitTest + case "com.apple.product-type.bundle.ui-testing": + return .uiTest + case "com.apple.product-type.tool": + return .commandLineTool + case "com.apple.product-type.app-extension": + return .appExtension + case "com.apple.product-type.application.watchapp", + "com.apple.product-type.application.watchapp2": + return .watchApp + case "com.apple.product-type.watchkit-extension", + "com.apple.product-type.watchkit2-extension": + return .watchExtension + case "com.apple.product-type.xcframework": + return .xcFramework + default: + return .other + } + } +} diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectModel/TargetSummary.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/TargetSummary.swift new file mode 100644 index 000000000000..ef94edec2d24 --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/TargetSummary.swift @@ -0,0 +1,35 @@ +import Foundation + +/// A buildable target inside a ``ProjectModule``. +/// +/// The summary fields are everything the Targets tab and the navigator's +/// membership chips need without paying the cost of running +/// `xcodebuild -showBuildSettings`. Resolved settings are populated lazily +/// when the user opens the Build Settings tab. +public struct TargetSummary: Sendable, Hashable, Identifiable { + public let id: TargetID + public let displayName: String + public let productType: TargetProductType + public let platforms: [String] + public let bundleIdentifier: String? + public let deploymentTarget: String? + public let dependencies: [TargetID] + + public init( + id: TargetID, + displayName: String, + productType: TargetProductType, + platforms: [String], + bundleIdentifier: String?, + deploymentTarget: String?, + dependencies: [TargetID] + ) { + self.id = id + self.displayName = displayName + self.productType = productType + self.platforms = platforms + self.bundleIdentifier = bundleIdentifier + self.deploymentTarget = deploymentTarget + self.dependencies = dependencies + } +} diff --git a/Packages/CMUXProjectModel/Sources/CMUXProjectModel/XcconfigParser.swift b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/XcconfigParser.swift new file mode 100644 index 000000000000..0b16ee56b4e3 --- /dev/null +++ b/Packages/CMUXProjectModel/Sources/CMUXProjectModel/XcconfigParser.swift @@ -0,0 +1,108 @@ +import Foundation + +/// A minimal `.xcconfig` parser that flattens key/value assignments, +/// following `#include` / `#include?` directives and stripping line +/// comments. +/// +/// This is intentionally small and lossy: conditional keys +/// (`OTHER_LDFLAGS[sdk=iphoneos*]`) are stored under their literal +/// dotted name, `$(inherited)` is preserved verbatim, and no variable +/// substitution is performed. The goal is just to surface the static +/// values the Targets pane and Build Settings Levels view need for +/// `PRODUCT_BUNDLE_IDENTIFIER`, `*_DEPLOYMENT_TARGET`, +/// `SUPPORTED_PLATFORMS`, and friends. +public enum XcconfigParser { + /// Parse the file at ``url`` and merge it, plus every transitive + /// include, into a single `[key: value]` dictionary. + /// + /// Later assignments override earlier ones; `#include`d files are + /// merged before the including file's own assignments so the + /// including file wins on conflicts. + public static func parse(at url: URL) throws -> [String: String] { + var visited: Set = [] + return try parseRecursive(at: url, visited: &visited) + } + + /// Convenience: merge a stack of xcconfig URLs into one map. The last + /// URL in the array wins on conflicts. + public static func parseChain(_ urls: [URL]) -> [String: String] { + var merged: [String: String] = [:] + for url in urls { + guard let resolved = try? parse(at: url) else { continue } + for (key, value) in resolved { + merged[key] = value + } + } + return merged + } + + private static func parseRecursive(at url: URL, visited: inout Set) throws -> [String: String] { + let canonical = url.standardizedFileURL + if visited.contains(canonical) { return [:] } + visited.insert(canonical) + let raw = try String(contentsOf: canonical, encoding: .utf8) + var out: [String: String] = [:] + let directory = canonical.deletingLastPathComponent() + for rawLine in raw.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) { + let line = stripComment(rawLine).trimmingCharacters(in: .whitespaces) + if line.isEmpty { continue } + if let include = parseInclude(line, relativeTo: directory) { + let nested = (try? parseRecursive(at: include.url, visited: &visited)) ?? [:] + if !include.optional && (try? Data(contentsOf: include.url)) == nil { + continue + } + for (k, v) in nested { out[k] = v } + continue + } + if let (key, value) = parseAssignment(line) { + out[key] = value + } + } + return out + } + + private static func stripComment(_ line: S) -> String { + if let range = line.range(of: "//") { + return String(line[.. ParsedInclude? { + let optional: Bool + let body: String + if line.hasPrefix("#include?") { + optional = true + body = String(line.dropFirst("#include?".count)) + } else if line.hasPrefix("#include") { + optional = false + body = String(line.dropFirst("#include".count)) + } else { + return nil + } + let trimmed = body.trimmingCharacters(in: .whitespaces) + guard trimmed.first == "\"", trimmed.last == "\"" else { return nil } + let path = String(trimmed.dropFirst().dropLast()) + let resolved: URL + if path.hasPrefix("/") { + resolved = URL(fileURLWithPath: path) + } else { + resolved = URL(fileURLWithPath: path, relativeTo: directory).standardizedFileURL + } + return ParsedInclude(url: resolved, optional: optional) + } + + private static func parseAssignment(_ line: String) -> (String, String)? { + guard let equalsIndex = line.firstIndex(of: "=") else { return nil } + let left = line[.. Bool { + let ext = url.pathExtension.lowercased() + if ext == "xcworkspace" || ext == "xcodeproj" { return true } + return Self.findFirstProjectArtifact(in: url) != nil + } + + public func load(at url: URL) throws -> ProjectModel { + let resolved = try Self.resolveRoot(url) + switch resolved.pathExtension.lowercased() { + case "xcworkspace": + return try loadWorkspace(at: resolved) + case "xcodeproj": + return try loadStandaloneProject(at: resolved) + default: + throw ProjectLoadError.unsupported(resolved) + } + } + + // MARK: - Workspace and project loading + + private func loadWorkspace(at workspaceURL: URL) throws -> ProjectModel { + let workspace: XCWorkspace + do { + workspace = try XCWorkspace(path: Path(workspaceURL.path)) + } catch { + throw ProjectLoadError.parseFailure(workspaceURL, reason: String(describing: error)) + } + let workspaceDir = workspaceURL.deletingLastPathComponent() + let projectURLs = Self.collectProjectURLs( + from: workspace.data.children, + workspaceDir: workspaceDir + ) + var modules: [ProjectModule] = [] + modules.reserveCapacity(projectURLs.count) + for projectURL in projectURLs { + if let module = try? loadModule(at: projectURL) { + modules.append(module) + } + } + return ProjectModel( + id: ProjectModelID(rawValue: workspaceURL.standardizedFileURL.path), + displayName: workspaceURL.deletingPathExtension().lastPathComponent, + rootURL: workspaceURL, + adapter: .xcode, + modules: modules + ) + } + + private func loadStandaloneProject(at projectURL: URL) throws -> ProjectModel { + let module = try loadModule(at: projectURL) + return ProjectModel( + id: ProjectModelID(rawValue: projectURL.standardizedFileURL.path), + displayName: projectURL.deletingPathExtension().lastPathComponent, + rootURL: projectURL, + adapter: .xcode, + modules: [module] + ) + } + + private func loadModule(at projectURL: URL) throws -> ProjectModule { + let xcodeProj: XcodeProj + do { + xcodeProj = try XcodeProj(path: Path(projectURL.path)) + } catch { + throw ProjectLoadError.parseFailure(projectURL, reason: String(describing: error)) + } + let pbxproj = xcodeProj.pbxproj + guard let rootObject = pbxproj.rootObject else { + throw ProjectLoadError.parseFailure(projectURL, reason: "missing rootObject") + } + let sourceRoot = Path(projectURL.deletingLastPathComponent().path) + let targets = Self.collectTargets(from: rootObject, sourceRoot: sourceRoot) + let memberships = Self.buildMembershipIndex(targets: rootObject.targets) + let moduleID = ProjectModuleID(rawValue: projectURL.standardizedFileURL.path) + let rootGroup = try Self.buildGroup( + from: rootObject.mainGroup, + moduleID: moduleID, + parentPath: nil, + displayPath: "", + sourceRoot: sourceRoot, + memberships: memberships + ) + let configurations = Self.collectBuildConfigurations( + from: rootObject, + sourceRoot: sourceRoot + ) + let schemes = Self.collectSchemes( + xcodeProj: xcodeProj, + projectURL: projectURL, + targets: rootObject.targets + ) + return ProjectModule( + id: moduleID, + displayName: projectURL.deletingPathExtension().lastPathComponent, + rootURL: projectURL, + rootGroup: rootGroup, + targets: targets, + configurations: configurations, + schemes: schemes + ) + } + + private static func collectBuildConfigurations( + from project: PBXProject, + sourceRoot: Path + ) -> [BuildConfigSummary] { + var out: [BuildConfigSummary] = [] + if let projectList = project.buildConfigurationList { + for config in projectList.buildConfigurations { + let base = config.baseConfiguration.flatMap { ref -> URL? in + guard let path = (try? ref.fullPath(sourceRoot: sourceRoot)).flatMap({ $0 }) else { + return nil + } + return URL(fileURLWithPath: path.string) + } + let raw = normalizeRawSettings(config.buildSettings) + out.append(BuildConfigSummary( + id: BuildConfigID(rawValue: config.uuid), + name: config.name, + scope: .project, + baseConfigurationPath: base, + rawSettings: raw + )) + } + } + for target in project.targets { + guard let native = target as? PBXNativeTarget, + let configList = native.buildConfigurationList else { continue } + let targetID = TargetID(rawValue: native.uuid) + for config in configList.buildConfigurations { + let base = config.baseConfiguration.flatMap { ref -> URL? in + guard let path = (try? ref.fullPath(sourceRoot: sourceRoot)).flatMap({ $0 }) else { + return nil + } + return URL(fileURLWithPath: path.string) + } + let raw = normalizeRawSettings(config.buildSettings) + out.append(BuildConfigSummary( + id: BuildConfigID(rawValue: config.uuid), + name: config.name, + scope: .target(targetID), + baseConfigurationPath: base, + rawSettings: raw + )) + } + } + return out + } + + private static func normalizeRawSettings(_ source: [String: Any]) -> [String: String] { + var out: [String: String] = [:] + out.reserveCapacity(source.count) + for (key, value) in source { + if let stringValue = value as? String { + out[key] = stringValue + } else if let arrayValue = value as? [String] { + out[key] = arrayValue.joined(separator: " ") + } else { + out[key] = String(describing: value) + } + } + return out + } + + private static func collectSchemes( + xcodeProj: XcodeProj, + projectURL: URL, + targets: [PBXTarget] + ) -> [SchemeSummary] { + var seen: Set = [] + var out: [SchemeSummary] = [] + let targetNameToID: [String: TargetID] = Dictionary(uniqueKeysWithValues: targets.map { + ($0.name, TargetID(rawValue: $0.uuid)) + }) + for scheme in xcodeProj.sharedData?.schemes ?? [] { + if seen.insert(scheme.name).inserted { + out.append(schemeSummary(from: scheme, shared: true, targetNameToID: targetNameToID)) + } + } + for userData in xcodeProj.userData { + for scheme in userData.schemes { + if seen.insert(scheme.name).inserted { + out.append(schemeSummary(from: scheme, shared: false, targetNameToID: targetNameToID)) + } + } + } + return out + } + + private static func schemeSummary( + from scheme: XCScheme, + shared: Bool, + targetNameToID: [String: TargetID] + ) -> SchemeSummary { + let knownTargetIDValues = Set(targetNameToID.values.map(\.rawValue)) + func resolve(_ ref: XCScheme.BuildableReference) -> TargetID? { + if let direct = targetNameToID[ref.blueprintName] { + return direct + } + if let blueprintUUID = ref.blueprintIdentifier, + knownTargetIDValues.contains(blueprintUUID) { + return TargetID(rawValue: blueprintUUID) + } + return nil + } + let runTargets = scheme.launchAction + .flatMap { $0.runnable?.buildableReference } + .flatMap(resolve) + .map { [$0] } ?? [] + let testTargets: [TargetID] = scheme.testAction?.testables.compactMap { entry in + resolve(entry.buildableReference) + } ?? [] + let profileTarget = scheme.profileAction + .flatMap { $0.buildableProductRunnable?.buildableReference } + .flatMap(resolve) + let archiveTarget = scheme.archiveAction + .flatMap { $0.buildConfiguration } + .flatMap { _ in + scheme.buildAction?.buildActionEntries.first?.buildableReference + } + .flatMap(resolve) + let args = scheme.launchAction?.commandlineArguments?.arguments + .filter(\.enabled) + .map(\.name) ?? [] + var env: [String: String] = [:] + for variable in scheme.launchAction?.environmentVariables ?? [] where variable.enabled { + env[variable.variable] = variable.value + } + return SchemeSummary( + id: SchemeID(rawValue: scheme.name), + name: scheme.name, + isShared: shared, + runTargetIDs: runTargets, + testTargetIDs: testTargets, + profileTargetID: profileTarget, + archiveTargetID: archiveTarget, + launchArguments: args, + environmentVariables: env + ) + } + + // MARK: - Group tree walk + + private static func buildGroup( + from group: PBXGroup, + moduleID: ProjectModuleID, + parentPath: Path?, + displayPath: String, + sourceRoot: Path, + memberships: MembershipIndex + ) throws -> ProjectGroup { + let resolvedPath = (try? group.fullPath(sourceRoot: sourceRoot)).flatMap { $0 } + let groupName = group.name ?? group.path ?? "(group)" + let childDisplayPath = displayPath.isEmpty ? groupName : "\(displayPath)/\(groupName)" + let style = groupStyle(for: group) + var children: [ProjectNodeKind] = [] + children.reserveCapacity(group.children.count) + for child in group.children { + if let subgroup = child as? PBXGroup { + let built = try buildGroup( + from: subgroup, + moduleID: moduleID, + parentPath: resolvedPath, + displayPath: childDisplayPath, + sourceRoot: sourceRoot, + memberships: memberships + ) + children.append(.group(built)) + } else if let synchronized = child as? PBXFileSystemSynchronizedRootGroup { + let built = buildSynchronizedGroup( + from: synchronized, + moduleID: moduleID, + parentPath: resolvedPath, + displayPath: childDisplayPath, + sourceRoot: sourceRoot + ) + children.append(.group(built)) + } else if let fileRef = child as? PBXFileReference { + let file = buildFileNode( + from: fileRef, + moduleID: moduleID, + displayPath: childDisplayPath, + sourceRoot: sourceRoot, + memberships: memberships + ) + children.append(.file(file)) + } + } + let nodeID = ProjectNodeID(rawValue: nodeIdentifier( + moduleID: moduleID, + displayPath: childDisplayPath, + kind: "group" + )) + return ProjectGroup( + id: nodeID, + displayName: groupName, + resolvedPath: (resolvedPath?.string).flatMap { URL(fileURLWithPath: $0) }, + style: style, + children: children + ) + } + + private static func buildSynchronizedGroup( + from group: PBXFileSystemSynchronizedRootGroup, + moduleID: ProjectModuleID, + parentPath: Path?, + displayPath: String, + sourceRoot: Path + ) -> ProjectGroup { + let resolved = (try? group.fullPath(sourceRoot: sourceRoot)).flatMap { $0 } + let name = group.name ?? group.path ?? "(synchronized)" + let childDisplayPath = displayPath.isEmpty ? name : "\(displayPath)/\(name)" + let resolvedURL = (resolved?.string).flatMap { URL(fileURLWithPath: $0) } + var children: [ProjectNodeKind] = [] + if let resolvedURL, + let walker = try? FileManager.default.contentsOfDirectory( + at: resolvedURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) { + for url in walker.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + let isDir = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + if isDir { + children.append(.group(buildFilesystemGroup( + at: url, + moduleID: moduleID, + displayPath: childDisplayPath + ))) + } else { + let nodeID = ProjectNodeID(rawValue: nodeIdentifier( + moduleID: moduleID, + displayPath: childDisplayPath + "/" + url.lastPathComponent, + kind: "file" + )) + children.append(.file(ProjectFileNode( + id: nodeID, + displayName: url.lastPathComponent, + resolvedPath: url, + fileType: nil, + existsOnDisk: true, + memberships: [] + ))) + } + } + } + let id = ProjectNodeID(rawValue: nodeIdentifier( + moduleID: moduleID, + displayPath: childDisplayPath, + kind: "group" + )) + return ProjectGroup( + id: id, + displayName: name, + resolvedPath: resolvedURL, + style: .synchronized, + children: children + ) + } + + private static func buildFilesystemGroup( + at url: URL, + moduleID: ProjectModuleID, + displayPath: String + ) -> ProjectGroup { + let name = url.lastPathComponent + let childDisplayPath = "\(displayPath)/\(name)" + var children: [ProjectNodeKind] = [] + let contents = (try? FileManager.default.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + )) ?? [] + for child in contents.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + let isDir = (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + if isDir { + children.append(.group(buildFilesystemGroup( + at: child, + moduleID: moduleID, + displayPath: childDisplayPath + ))) + } else { + let id = ProjectNodeID(rawValue: nodeIdentifier( + moduleID: moduleID, + displayPath: childDisplayPath + "/" + child.lastPathComponent, + kind: "file" + )) + children.append(.file(ProjectFileNode( + id: id, + displayName: child.lastPathComponent, + resolvedPath: child, + fileType: nil, + existsOnDisk: true, + memberships: [] + ))) + } + } + let id = ProjectNodeID(rawValue: nodeIdentifier( + moduleID: moduleID, + displayPath: childDisplayPath, + kind: "group" + )) + return ProjectGroup( + id: id, + displayName: name, + resolvedPath: url, + style: .synchronized, + children: children + ) + } + + private static func buildFileNode( + from ref: PBXFileReference, + moduleID: ProjectModuleID, + displayPath: String, + sourceRoot: Path, + memberships: MembershipIndex + ) -> ProjectFileNode { + let resolvedPath = (try? ref.fullPath(sourceRoot: sourceRoot)).flatMap { $0 } + let name = ref.name ?? ref.path ?? "(file)" + let childDisplayPath = displayPath.isEmpty ? name : "\(displayPath)/\(name)" + let url = (resolvedPath?.string).flatMap { URL(fileURLWithPath: $0) } + let exists: Bool = { + guard let url else { return false } + return FileManager.default.fileExists(atPath: url.path) + }() + let nodeID = ProjectNodeID(rawValue: nodeIdentifier( + moduleID: moduleID, + displayPath: childDisplayPath, + kind: "file" + )) + let fileMemberships = memberships.memberships(forFileUUID: ref.uuid) + return ProjectFileNode( + id: nodeID, + displayName: name, + resolvedPath: url, + fileType: ref.lastKnownFileType ?? ref.explicitFileType, + existsOnDisk: exists, + memberships: fileMemberships + ) + } + + private static func groupStyle(for group: PBXGroup) -> ProjectGroupStyle { + if group is PBXVariantGroup { return .variant } + return .logical + } + + // MARK: - Target summaries + + private static func collectTargets( + from project: PBXProject, + sourceRoot: Path + ) -> [TargetSummary] { + let projectXcconfigSettings = mergedXcconfigSettings(from: project.buildConfigurationList, sourceRoot: sourceRoot) + return project.targets.compactMap { target -> TargetSummary? in + guard let native = target as? PBXNativeTarget else { return nil } + let productType = native.productType.flatMap { TargetProductType.fromXcodeProductType($0.rawValue) } ?? .other + let targetXcconfig = mergedXcconfigSettings(from: native.buildConfigurationList, sourceRoot: sourceRoot) + let resolved = resolveTargetMetadata( + native: native, + projectXcconfig: projectXcconfigSettings, + targetXcconfig: targetXcconfig + ) + let deps = native.dependencies.compactMap { dep -> TargetID? in + if let resolved = dep.target?.uuid { + return TargetID(rawValue: resolved) + } + if let info = dep.targetProxy?.remoteInfo { + return TargetID(rawValue: "remote:\(info)") + } + return nil + } + return TargetSummary( + id: TargetID(rawValue: native.uuid), + displayName: native.name, + productType: productType, + platforms: resolved.platforms, + bundleIdentifier: resolved.bundleIdentifier, + deploymentTarget: resolved.deploymentTarget, + dependencies: deps + ) + } + } + + private static func mergedXcconfigSettings( + from configList: XCConfigurationList?, + sourceRoot: Path + ) -> [String: String] { + guard let configs = configList?.buildConfigurations else { return [:] } + var merged: [String: String] = [:] + for config in configs { + guard let ref = config.baseConfiguration, + let path = (try? ref.fullPath(sourceRoot: sourceRoot)).flatMap({ $0 }) else { + continue + } + let url = URL(fileURLWithPath: path.string) + guard let parsed = try? XcconfigParser.parse(at: url) else { continue } + for (key, value) in parsed { + merged[key] = value + } + } + return merged + } + + private struct ResolvedTargetMetadata { + let bundleIdentifier: String? + let deploymentTarget: String? + let platforms: [String] + } + + private static func resolveTargetMetadata( + native: PBXNativeTarget, + projectXcconfig: [String: String], + targetXcconfig: [String: String] + ) -> ResolvedTargetMetadata { + let bundle = resolveSetting( + key: "PRODUCT_BUNDLE_IDENTIFIER", + native: native, + projectXcconfig: projectXcconfig, + targetXcconfig: targetXcconfig + ) + let deploymentKeys = [ + "MACOSX_DEPLOYMENT_TARGET", + "IPHONEOS_DEPLOYMENT_TARGET", + "TVOS_DEPLOYMENT_TARGET", + "WATCHOS_DEPLOYMENT_TARGET", + "VISIONOS_DEPLOYMENT_TARGET" + ] + var deployment: String? + for key in deploymentKeys { + if let value = resolveSetting( + key: key, + native: native, + projectXcconfig: projectXcconfig, + targetXcconfig: targetXcconfig + ), !value.isEmpty { + deployment = value + break + } + } + var platforms: Set = [] + if let supported = resolveSetting( + key: "SUPPORTED_PLATFORMS", + native: native, + projectXcconfig: projectXcconfig, + targetXcconfig: targetXcconfig + ) { + for piece in supported.split(separator: " ") { + platforms.insert(String(piece)) + } + } + if let sdk = resolveSetting( + key: "SDKROOT", + native: native, + projectXcconfig: projectXcconfig, + targetXcconfig: targetXcconfig + ) { + platforms.insert(sdk) + } + return ResolvedTargetMetadata( + bundleIdentifier: bundle, + deploymentTarget: deployment, + platforms: platforms.sorted() + ) + } + + private static func resolveSetting( + key: String, + native: PBXNativeTarget, + projectXcconfig: [String: String], + targetXcconfig: [String: String] + ) -> String? { + if let configs = native.buildConfigurationList?.buildConfigurations { + for config in configs { + if let raw = config.buildSettings[key], let value = stringFromAny(raw), !value.isEmpty { + return value + } + } + } + if let value = targetXcconfig[key], !value.isEmpty { return value } + if let value = projectXcconfig[key], !value.isEmpty { return value } + return nil + } + + private static func stringFromAny(_ value: Any) -> String? { + if let s = value as? String { return s } + if let a = value as? [String] { return a.joined(separator: " ") } + return nil + } + + // MARK: - Membership index + + private struct MembershipIndex { + private let table: [String: [TargetMembership]] + + init(table: [String: [TargetMembership]]) { + self.table = table + } + + func memberships(forFileUUID uuid: String) -> [TargetMembership] { + table[uuid] ?? [] + } + } + + private static func buildMembershipIndex(targets: [PBXTarget]) -> MembershipIndex { + var table: [String: [TargetMembership]] = [:] + for target in targets { + let targetID = TargetID(rawValue: target.uuid) + for phase in target.buildPhases { + let role = role(for: phase) + for buildFile in phase.files ?? [] { + guard let fileUUID = buildFile.file?.uuid else { continue } + let flags = (buildFile.settings?["COMPILER_FLAGS"] as? String) + .map { $0.split(separator: " ").map(String.init) } + ?? [] + table[fileUUID, default: []].append( + TargetMembership(targetID: targetID, role: role, compilerFlags: flags) + ) + } + } + } + return MembershipIndex(table: table) + } + + private static func role(for phase: PBXBuildPhase) -> TargetMembershipRole { + switch phase.buildPhase { + case .sources: return .compile + case .resources: return .resource + case .frameworks: return .framework + case .headers: return .header + case .copyFiles: return .copy + case .runScript: return .script + case .carbonResources: return .resource + @unknown default: return .resource + } + } + + // MARK: - URL and path resolution + + private static func resolveRoot(_ url: URL) throws -> URL { + let standardized = url.standardizedFileURL + let ext = standardized.pathExtension.lowercased() + if ext == "xcworkspace" || ext == "xcodeproj" { + guard FileManager.default.fileExists(atPath: standardized.path) else { + throw ProjectLoadError.unreadable(standardized) + } + return standardized + } + guard let candidate = findFirstProjectArtifact(in: standardized) else { + throw ProjectLoadError.unsupported(standardized) + } + return candidate + } + + private static func findFirstProjectArtifact(in url: URL) -> URL? { + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue else { + return nil + } + let contents = (try? FileManager.default.contentsOfDirectory( + at: url, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + )) ?? [] + if let ws = contents.first(where: { $0.pathExtension.lowercased() == "xcworkspace" }) { + return ws + } + return contents.first(where: { $0.pathExtension.lowercased() == "xcodeproj" }) + } + + private static func collectProjectURLs( + from elements: [XCWorkspaceDataElement], + workspaceDir: URL + ) -> [URL] { + var out: [URL] = [] + for element in elements { + switch element { + case let .file(ref): + let resolved = resolveWorkspaceLocation(ref.location, workspaceDir: workspaceDir) + if resolved.pathExtension.lowercased() == "xcodeproj" { + out.append(resolved) + } + case let .group(group): + let nested = collectProjectURLs(from: group.children, workspaceDir: workspaceDir) + out.append(contentsOf: nested) + } + } + return out + } + + private static func resolveWorkspaceLocation( + _ location: XCWorkspaceDataElementLocationType, + workspaceDir: URL + ) -> URL { + let raw = location.path + if raw.hasPrefix("/") { + return URL(fileURLWithPath: raw) + } + return URL(fileURLWithPath: raw, relativeTo: workspaceDir).standardizedFileURL + } + + private static func nodeIdentifier( + moduleID: ProjectModuleID, + displayPath: String, + kind: String + ) -> String { + "\(moduleID.rawValue)|\(kind)|\(displayPath)" + } +} diff --git a/Packages/CMUXProjectModel/Tests/CMUXProjectModelTests/XcconfigParserTests.swift b/Packages/CMUXProjectModel/Tests/CMUXProjectModelTests/XcconfigParserTests.swift new file mode 100644 index 000000000000..49b3d7c9098b --- /dev/null +++ b/Packages/CMUXProjectModel/Tests/CMUXProjectModelTests/XcconfigParserTests.swift @@ -0,0 +1,91 @@ +import Foundation +import Testing +@testable import CMUXProjectModel + +@Suite("XcconfigParser") +struct XcconfigParserTests { + @Test + func parsesSimpleAssignments() throws { + let url = try writeTempXcconfig(""" + // header comment + PRODUCT_BUNDLE_IDENTIFIER = ai.manaflow.cmux + MACOSX_DEPLOYMENT_TARGET = 14.0 + EMPTY = + """) + let parsed = try XcconfigParser.parse(at: url) + #expect(parsed["PRODUCT_BUNDLE_IDENTIFIER"] == "ai.manaflow.cmux") + #expect(parsed["MACOSX_DEPLOYMENT_TARGET"] == "14.0") + #expect(parsed["EMPTY"] == "") + try? FileManager.default.removeItem(at: url) + } + + @Test + func stripsTrailingLineComments() throws { + let url = try writeTempXcconfig(""" + SWIFT_VERSION = 6.0 // bumped 2026-05 + """) + let parsed = try XcconfigParser.parse(at: url) + #expect(parsed["SWIFT_VERSION"] == "6.0") + try? FileManager.default.removeItem(at: url) + } + + @Test + func followsRelativeIncludes() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let baseURL = dir.appendingPathComponent("Base.xcconfig") + let derivedURL = dir.appendingPathComponent("Debug.xcconfig") + try "PRODUCT_BUNDLE_IDENTIFIER = ai.manaflow.cmux\nMACOSX_DEPLOYMENT_TARGET = 14.0\n".write(to: baseURL, atomically: true, encoding: .utf8) + try "#include \"Base.xcconfig\"\nMACOSX_DEPLOYMENT_TARGET = 15.0\n".write(to: derivedURL, atomically: true, encoding: .utf8) + let parsed = try XcconfigParser.parse(at: derivedURL) + #expect(parsed["PRODUCT_BUNDLE_IDENTIFIER"] == "ai.manaflow.cmux") + #expect(parsed["MACOSX_DEPLOYMENT_TARGET"] == "15.0", "including file should override included") + try? FileManager.default.removeItem(at: dir) + } + + @Test + func optionalIncludeIsIgnoredWhenMissing() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent("Top.xcconfig") + try "#include? \"Missing.xcconfig\"\nKEY = value\n".write(to: url, atomically: true, encoding: .utf8) + let parsed = try XcconfigParser.parse(at: url) + #expect(parsed["KEY"] == "value") + try? FileManager.default.removeItem(at: dir) + } + + @Test + func parseChainMergesInDeclaredOrder() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let a = dir.appendingPathComponent("a.xcconfig") + let b = dir.appendingPathComponent("b.xcconfig") + try "KEY = a\nA_ONLY = 1\n".write(to: a, atomically: true, encoding: .utf8) + try "KEY = b\nB_ONLY = 2\n".write(to: b, atomically: true, encoding: .utf8) + let merged = XcconfigParser.parseChain([a, b]) + #expect(merged["KEY"] == "b", "later file in chain wins") + #expect(merged["A_ONLY"] == "1") + #expect(merged["B_ONLY"] == "2") + try? FileManager.default.removeItem(at: dir) + } + + @Test + func cycleDetectionAvoidsInfiniteRecursion() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let a = dir.appendingPathComponent("a.xcconfig") + let b = dir.appendingPathComponent("b.xcconfig") + try "#include \"b.xcconfig\"\nA = 1\n".write(to: a, atomically: true, encoding: .utf8) + try "#include \"a.xcconfig\"\nB = 2\n".write(to: b, atomically: true, encoding: .utf8) + let parsed = try XcconfigParser.parse(at: a) + #expect(parsed["A"] == "1") + #expect(parsed["B"] == "2") + try? FileManager.default.removeItem(at: dir) + } + + private func writeTempXcconfig(_ contents: String) throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).xcconfig") + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } +} diff --git a/Packages/CMUXProjectModel/Tests/CMUXProjectModelTests/XcodeProjectAdapterTests.swift b/Packages/CMUXProjectModel/Tests/CMUXProjectModelTests/XcodeProjectAdapterTests.swift new file mode 100644 index 000000000000..a4a2ef057a0b --- /dev/null +++ b/Packages/CMUXProjectModel/Tests/CMUXProjectModelTests/XcodeProjectAdapterTests.swift @@ -0,0 +1,127 @@ +import Foundation +import Testing +@testable import CMUXProjectModel + +@Suite("XcodeProjectAdapter against cmux.xcodeproj") +struct XcodeProjectAdapterTests { + private let workspaceURL: URL + private let projectURL: URL + + init() { + let env = ProcessInfo.processInfo.environment + if let override = env["CMUX_PROJECT_FIXTURE"] { + let base = URL(fileURLWithPath: override) + self.workspaceURL = base.pathExtension.lowercased() == "xcworkspace" ? base : base.appendingPathComponent("cmux.xcworkspace") + self.projectURL = base.pathExtension.lowercased() == "xcodeproj" ? base : base.appendingPathComponent("cmux.xcodeproj") + } else { + let here = URL(fileURLWithPath: #filePath) + let worktreeRoot = here + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + self.workspaceURL = worktreeRoot.appendingPathComponent("cmux.xcworkspace") + self.projectURL = worktreeRoot.appendingPathComponent("cmux.xcodeproj") + } + } + + @Test + func loadsCmuxXcodeprojIntoOneModule() throws { + let adapter = XcodeProjectAdapter() + let model = try adapter.load(at: projectURL) + #expect(model.adapter == .xcode) + #expect(model.modules.count == 1) + let module = try #require(model.modules.first) + #expect(!module.targets.isEmpty) + #expect(module.rootGroup.children.isEmpty == false) + } + + @Test + func findsCmuxAppTargetWithApplicationProductType() throws { + let adapter = XcodeProjectAdapter() + let model = try adapter.load(at: projectURL) + let module = try #require(model.modules.first) + let cmuxTarget = module.targets.first(where: { $0.displayName == "cmux" }) + let summary = try #require(cmuxTarget) + #expect(summary.productType == .application) + } + + @Test + func navigatorTreeHasSourcesGroup() throws { + let adapter = XcodeProjectAdapter() + let model = try adapter.load(at: projectURL) + let module = try #require(model.modules.first) + let names = topLevelGroupNames(in: module.rootGroup) + #expect(names.contains(where: { $0.lowercased().contains("source") || $0 == "Sources" })) + } + + @Test + func workspaceLoadIncludesAtLeastOneModule() throws { + guard FileManager.default.fileExists(atPath: workspaceURL.path) else { return } + let adapter = XcodeProjectAdapter() + let model = try adapter.load(at: workspaceURL) + #expect(model.adapter == .xcode) + #expect(!model.modules.isEmpty) + } + + @Test + func canLoadAcceptsXcodeprojDirectly() { + let adapter = XcodeProjectAdapter() + #expect(adapter.canLoad(projectURL)) + } + + @Test + func canLoadAcceptsDirectoryContainingProject() { + let adapter = XcodeProjectAdapter() + #expect(adapter.canLoad(projectURL.deletingLastPathComponent())) + } + + @Test + func bundleIdentifierIsEitherResolvedOrExplicitlyNilNotFabricated() throws { + let adapter = XcodeProjectAdapter() + let model = try adapter.load(at: projectURL) + let module = try #require(model.modules.first) + let cmux = try #require(module.targets.first(where: { $0.displayName == "cmux" })) + if let bundle = cmux.bundleIdentifier { + #expect(!bundle.isEmpty) + #expect(!bundle.contains("$("), "Bundle ID should be resolved, not contain unresolved $(...) variables: \(bundle)") + } + } + + @Test + func unresolvableSchemeTargetsReturnNilNotFabricatedID() throws { + let adapter = XcodeProjectAdapter() + let model = try adapter.load(at: projectURL) + let module = try #require(model.modules.first) + let knownTargetIDs = Set(module.targets.map(\.id)) + for scheme in module.schemes { + for targetID in scheme.runTargetIDs + scheme.testTargetIDs { + #expect(knownTargetIDs.contains(targetID), + "Scheme \(scheme.name) references target ID \(targetID.rawValue) that is not in the module's target list") + } + } + } + + @Test + func loadReportsAtLeastOneBuildConfigurationPerKnownTarget() throws { + let adapter = XcodeProjectAdapter() + let model = try adapter.load(at: projectURL) + let module = try #require(model.modules.first) + #expect(module.configurationNames.contains("Debug") || module.configurationNames.contains("Release")) + for target in module.targets { + let targetConfigs = module.configurations.filter { config in + if case let .target(id) = config.scope, id == target.id { return true } + return false + } + #expect(!targetConfigs.isEmpty, "Target \(target.displayName) has no build configurations") + } + } + + private func topLevelGroupNames(in group: ProjectGroup) -> [String] { + group.children.compactMap { node in + if case let .group(child) = node { return child.displayName } + return nil + } + } +} diff --git a/Sources/ClosedItemHistory.swift b/Sources/ClosedItemHistory.swift index 706c26c8f62b..a6ea3758b8d3 100644 --- a/Sources/ClosedItemHistory.swift +++ b/Sources/ClosedItemHistory.swift @@ -712,6 +712,8 @@ final class ClosedItemHistoryStore: ObservableObject { return mode.label } return String(localized: "menu.history.recentlyClosed.panel.tool", defaultValue: "Tool") + case .project: + return String(localized: "menu.history.recentlyClosed.panel.project", defaultValue: "Project") } } diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index 69ac92d017fe..e46d54cd3895 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -1813,6 +1813,7 @@ struct CmuxSurfaceDefinition: Codable, Sendable { enum CmuxSurfaceType: String, Codable, Sendable { case terminal case browser + case project } struct CmuxResolvedCommand: Sendable { diff --git a/Sources/CmuxLifecycleEventPublishing.swift b/Sources/CmuxLifecycleEventPublishing.swift index 476598485200..3e59bf2995c0 100644 --- a/Sources/CmuxLifecycleEventPublishing.swift +++ b/Sources/CmuxLifecycleEventPublishing.swift @@ -214,6 +214,8 @@ extension Workspace { return "file_preview" case .rightSidebarTool: return "right_sidebar_tool" + case .project: + return "project" } } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 6ddb15314082..35efd3db0edf 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -5822,6 +5822,8 @@ struct ContentView: View { return String(localized: "commandPalette.kind.filePreview", defaultValue: "File Preview") case .rightSidebarTool: return String(localized: "commandPalette.kind.rightSidebarTool", defaultValue: "Tool") + case .project: + return String(localized: "commandPalette.kind.project", defaultValue: "Project") } } @@ -5837,6 +5839,8 @@ struct ContentView: View { return ["file", "preview", "text", "pdf", "image", "audio", "video"] case .rightSidebarTool: return ["tool", "files", "find", "vault", "sidebar"] + case .project: + return ["project", "xcode", "build", "settings", "schemes", "targets"] } } @@ -9230,6 +9234,10 @@ struct ContentView: View { return "filePreview.mediaPlayer" case .filePreview(.quickLook): return "filePreview.quickLook" + case .project(.navigator): + return "project.navigator" + case .project(.detail): + return "project.detail" } } diff --git a/Sources/Panels/FilePreviewPanel.swift b/Sources/Panels/FilePreviewPanel.swift index 2f8e71fb2cfe..0d94d8cf7e99 100644 --- a/Sources/Panels/FilePreviewPanel.swift +++ b/Sources/Panels/FilePreviewPanel.swift @@ -1100,7 +1100,7 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan filePreviewIntent = target case .panel: filePreviewIntent = focusCoordinator.preferredIntent - case .terminal, .browser: + case .terminal, .browser, .project: return false } return focusCoordinator.focus(filePreviewIntent) diff --git a/Sources/Panels/FilePreviewWorkspaceOpenSupport.swift b/Sources/Panels/FilePreviewWorkspaceOpenSupport.swift index c4f978763f7b..1eada6ab4dd1 100644 --- a/Sources/Panels/FilePreviewWorkspaceOpenSupport.swift +++ b/Sources/Panels/FilePreviewWorkspaceOpenSupport.swift @@ -16,7 +16,15 @@ extension Workspace { for filePath in filePaths { let panel: (any Panel)? - if MarkdownPanelFileLinkResolver.isMarkdownPathLike(filePath) { + let pathExtension = (filePath as NSString).pathExtension.lowercased() + if pathExtension == "xcodeproj" || pathExtension == "xcworkspace" { + panel = newProjectSurface( + inPane: paneId, + projectPath: filePath, + focus: shouldFocusNewTabs, + targetIndex: nextIndex + ) + } else if MarkdownPanelFileLinkResolver.isMarkdownPathLike(filePath) { if reuseExisting { panel = openOrFocusMarkdownSurface( inPane: paneId, diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index cd66fb28db0a..ed89f68022e1 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -9,6 +9,7 @@ public enum PanelType: String, Codable, Sendable { case markdown case filePreview = "filepreview" case rightSidebarTool + case project public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() @@ -59,11 +60,17 @@ public enum FilePreviewPanelFocusIntent: Hashable { case quickLook } +public enum ProjectPanelFocusIntent: Hashable { + case navigator + case detail +} + public enum PanelFocusIntent: Equatable { case panel case terminal(TerminalPanelFocusIntent) case browser(BrowserPanelFocusIntent) case filePreview(FilePreviewPanelFocusIntent) + case project(ProjectPanelFocusIntent) } public enum WorkspaceAttentionFlashReason: String, Equatable, Sendable { diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index 4bf17f5f7231..a5eeea8ee82f 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -93,6 +93,14 @@ struct PanelContentView: View { onRequestPanelFocus: onRequestPanelFocus ) } + case .project: + if let projectPanel = panel as? ProjectPanel { + ProjectPanelView( + panel: projectPanel, + isFocused: isFocused, + onRequestPanelFocus: onRequestPanelFocus + ) + } } } @@ -110,7 +118,7 @@ struct PanelContentView: View { private var shouldInstallPaneDropTarget: Bool { guard isVisibleInUI else { return false } switch panel.panelType { - case .markdown, .filePreview, .rightSidebarTool: + case .markdown, .filePreview, .rightSidebarTool, .project: return true case .terminal, .browser: return false diff --git a/Sources/Panels/ProjectBuildSettingsTabView.swift b/Sources/Panels/ProjectBuildSettingsTabView.swift new file mode 100644 index 000000000000..254608b26da6 --- /dev/null +++ b/Sources/Panels/ProjectBuildSettingsTabView.swift @@ -0,0 +1,233 @@ +import CMUXProjectModel +import SwiftUI + +/// Build Settings tab inside ``ProjectPanelView``. +/// +/// Renders the "Levels" view: each row is one setting key, with columns for +/// the resolved value, the target override, and the project value. The +/// adapter does not yet shell out to `xcodebuild -showBuildSettings`, so the +/// Resolved column is computed locally as the first non-empty value in the +/// stack (target → project → empty). The cell whose value won is marked. +struct ProjectBuildSettingsTabView: View { + @ObservedObject var panel: ProjectPanel + let model: ProjectModel + + var body: some View { + let computedRows = rows + VStack(alignment: .leading, spacing: 0) { + controlsRow(rowCount: computedRows.count) + Divider() + tableView(rows: computedRows) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + @ViewBuilder + private func controlsRow(rowCount: Int) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text("Target") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + targetPicker + Spacer(minLength: 4) + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Filter settings", text: $panel.settingsSearchText) + .textFieldStyle(.plain) + .font(.system(size: 12)) + Toggle("Customized only", isOn: $panel.settingsCustomizedOnly) + .toggleStyle(.checkbox) + .font(.system(size: 11)) + Text("\(rowCount) settings") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color(NSColor.controlBackgroundColor).opacity(0.4)) + } + + @ViewBuilder + private var targetPicker: some View { + if let module = selectedModule ?? model.modules.first { + Picker( + "Target", + selection: Binding( + get: { panel.selectedTargetID ?? module.targets.first?.id ?? TargetID(rawValue: "") }, + set: { panel.selectedTargetID = $0 } + ) + ) { + ForEach(module.targets) { target in + Text(target.displayName).tag(target.id) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(maxWidth: 160) + } + } + + private static let settingColumnWidth: CGFloat = 240 + private static let valueColumnWidth: CGFloat = 170 + + @ViewBuilder + private func tableView(rows computedRows: [BuildSettingRow]) -> some View { + ScrollView([.horizontal, .vertical]) { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 0) { + Spacer().frame(width: 3) + columnHeader("Setting", weight: Self.settingColumnWidth, alignment: .leading) + columnHeader("Effective", weight: Self.valueColumnWidth, alignment: .leading) + columnHeader("Target", weight: Self.valueColumnWidth, alignment: .leading) + columnHeader("Project", weight: Self.valueColumnWidth, alignment: .leading) + } + .padding(.vertical, 6) + .padding(.horizontal, 14) + .background(Color(NSColor.controlBackgroundColor).opacity(0.25)) + Divider() + ForEach(Array(computedRows.enumerated()), id: \.element.key) { index, row in + SettingsRow( + row: row, + settingColumnWidth: Self.settingColumnWidth, + valueColumnWidth: Self.valueColumnWidth + ) + .padding(.horizontal, 14) + .padding(.vertical, 4) + .background( + index.isMultiple(of: 2) + ? Color.primary.opacity(0.025) + : Color.clear + ) + .overlay(alignment: .leading) { + if row.winner == .target { + Rectangle() + .fill(Color.accentColor) + .frame(width: 3) + } + } + } + } + } + } + + @ViewBuilder + private func columnHeader(_ title: String, weight: CGFloat, alignment: Alignment) -> some View { + Text(title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: weight, alignment: alignment) + } + + private var selectedModule: ProjectModule? { + if let targetID = panel.selectedTargetID, + let owner = model.modules.first(where: { $0.target(for: targetID) != nil }) { + return owner + } + return model.modules.first + } + + private var rows: [BuildSettingRow] { + guard let module = selectedModule else { return [] } + + let configName = panel.selectedConfigurationName ?? module.configurationNames.first ?? "" + let projectConfig = module.configurations.first { config in + config.name == configName && config.scope == .project + } + let targetConfig: BuildConfigSummary? = { + guard let targetID = panel.selectedTargetID else { return nil } + return module.configurations.first { config in + if case let .target(id) = config.scope, id == targetID, config.name == configName { + return true + } + return false + } + }() + + var keys: Set = [] + for k in projectConfig?.rawSettings.keys ?? [:].keys { keys.insert(k) } + for k in targetConfig?.rawSettings.keys ?? [:].keys { keys.insert(k) } + + let filter = panel.settingsSearchText.lowercased() + let filteredKeys = filter.isEmpty + ? Array(keys) + : keys.filter { $0.lowercased().contains(filter) } + + return filteredKeys.sorted().compactMap { key in + let projectValue = projectConfig?.rawSettings[key] + let targetValue = targetConfig?.rawSettings[key] + if panel.settingsCustomizedOnly && targetValue == nil && projectValue == nil { + return nil + } + let resolved = targetValue ?? projectValue ?? "" + let winner: BuildSettingRow.Winner = { + if targetValue != nil { return .target } + if projectValue != nil { return .project } + return .none + }() + return BuildSettingRow( + key: key, + resolvedValue: resolved, + targetValue: targetValue, + projectValue: projectValue, + winner: winner + ) + } + } +} + +private struct BuildSettingRow { + enum Winner { case target, project, none } + + let key: String + let resolvedValue: String + let targetValue: String? + let projectValue: String? + let winner: Winner +} + +private struct SettingsRow: View { + let row: BuildSettingRow + let settingColumnWidth: CGFloat + let valueColumnWidth: CGFloat + + var body: some View { + HStack(spacing: 0) { + Spacer().frame(width: 3) + Text(row.key) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .frame(width: settingColumnWidth, alignment: .leading) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(row.winner == .none ? Color.secondary : Color.primary) + .help(row.key) + valueCell(row.resolvedValue, emphasis: .normal) + .frame(width: valueColumnWidth, alignment: .leading) + valueCell(row.targetValue ?? "—", emphasis: row.winner == .target ? .accent : .dim) + .frame(width: valueColumnWidth, alignment: .leading) + valueCell(row.projectValue ?? "—", emphasis: row.winner == .project ? .normal : .dim) + .frame(width: valueColumnWidth, alignment: .leading) + } + } + + private enum CellEmphasis { case accent, normal, dim } + + @ViewBuilder + private func valueCell(_ value: String, emphasis: CellEmphasis) -> some View { + let style: Color = { + switch emphasis { + case .accent: return Color.accentColor + case .normal: return Color.primary + case .dim: return Color.secondary + } + }() + Text(value) + .font(.system(size: 11, design: .monospaced)) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(value == "—" ? Color.secondary.opacity(0.6) : style) + .help(value) + } +} diff --git a/Sources/Panels/ProjectFilesTabView.swift b/Sources/Panels/ProjectFilesTabView.swift new file mode 100644 index 000000000000..f38710e5e904 --- /dev/null +++ b/Sources/Panels/ProjectFilesTabView.swift @@ -0,0 +1,433 @@ +import AppKit +import CMUXProjectModel +import SwiftUI + +/// Files tab inside ``ProjectPanelView``. +/// +/// Renders the merged group tree from every ``ProjectModule`` in the loaded +/// ``ProjectModel`` on the left, and a detail strip showing target +/// memberships and on-disk path for the selected file on the right. +private struct FlattenedRow: Identifiable { + enum Kind { case group(ProjectGroup, isExpanded: Bool); case file(ProjectFileNode) } + let id: ProjectNodeID + let depth: Int + let module: ProjectModule + let kind: Kind +} + +struct ProjectFilesTabView: View { + @ObservedObject var panel: ProjectPanel + let model: ProjectModel + + var body: some View { + let rows = flattenedRows + VStack(alignment: .leading, spacing: 0) { + filterBar(rowCount: rows.count) + Divider() + if panel.selectedFilePath != nil { + HSplitView { + navigator(rows: rows) + .frame(minWidth: 280, idealWidth: 360, maxHeight: .infinity) + detail + .frame(minWidth: 240, maxWidth: .infinity, maxHeight: .infinity) + } + } else { + navigator(rows: rows) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + @ViewBuilder + private func filterBar(rowCount: Int) -> some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Filter files (e.g. AppDelegate)", text: $panel.filesSearchText) + .textFieldStyle(.plain) + .font(.system(size: 12)) + if !panel.filesSearchText.isEmpty { + Button { + panel.filesSearchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + Spacer() + Text("\(rowCount)") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(Color(NSColor.controlBackgroundColor).opacity(0.4)) + } + + @ViewBuilder + private func navigator(rows: [FlattenedRow]) -> some View { + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(rows, id: \.id) { row in + renderRow(row) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Color(NSColor.controlBackgroundColor).opacity(0.45)) + } + + private var flattenedRows: [FlattenedRow] { + var out: [FlattenedRow] = [] + let filter = panel.filesSearchText.lowercased() + for module in model.modules { + walk(node: .group(module.rootGroup), depth: 0, module: module, filter: filter, out: &out) + } + return out + } + + private func walk( + node: ProjectNodeKind, + depth: Int, + module: ProjectModule, + filter: String, + out: inout [FlattenedRow] + ) { + switch node { + case let .group(group): + if !filter.isEmpty { + let matches = Self.collectMatchingFiles(in: group, filter: filter) + if matches.isEmpty { return } + let presentation = Self.presentationGroup(for: group, fallbackName: module.displayName) + out.append(FlattenedRow( + id: group.id, + depth: depth, + module: module, + kind: .group(presentation, isExpanded: true) + )) + for match in matches { + out.append(FlattenedRow( + id: match.id, + depth: depth + 1, + module: module, + kind: .file(match) + )) + } + return + } + let isExpanded = !panel.collapsedNodeIDs.contains(group.id) + let presentation = Self.presentationGroup(for: group, fallbackName: module.displayName) + out.append(FlattenedRow( + id: group.id, + depth: depth, + module: module, + kind: .group(presentation, isExpanded: isExpanded) + )) + if isExpanded { + for child in group.children { + walk(node: child, depth: depth + 1, module: module, filter: filter, out: &out) + } + } + case let .file(file): + out.append(FlattenedRow( + id: file.id, + depth: depth, + module: module, + kind: .file(file) + )) + } + } + + private static func presentationGroup(for group: ProjectGroup, fallbackName: String) -> ProjectGroup { + if group.displayName.isEmpty || group.displayName == "(group)" { + return ProjectGroup( + id: group.id, + displayName: fallbackName, + resolvedPath: group.resolvedPath, + style: group.style, + children: group.children + ) + } + return group + } + + private static func collectMatchingFiles(in group: ProjectGroup, filter: String) -> [ProjectFileNode] { + var out: [ProjectFileNode] = [] + for child in group.children { + switch child { + case let .file(file): + if file.displayName.lowercased().contains(filter) { + out.append(file) + } + case let .group(subgroup): + out.append(contentsOf: collectMatchingFiles(in: subgroup, filter: filter)) + } + } + return out + } + + @ViewBuilder + private func renderRow(_ row: FlattenedRow) -> some View { + switch row.kind { + case let .group(group, isExpanded): + ProjectFilesGroupRow( + group: group, + depth: row.depth, + isExpanded: isExpanded, + onToggle: { + if isExpanded { + panel.collapsedNodeIDs.insert(group.id) + } else { + panel.collapsedNodeIDs.remove(group.id) + } + } + ) + case let .file(file): + ProjectFilesFileRow( + file: file, + depth: row.depth, + module: row.module, + isSelected: panel.selectedFilePath == file.resolvedPath?.path, + onSelect: { + panel.selectedFilePath = file.resolvedPath?.path + } + ) + } + } + + @ViewBuilder + private var detail: some View { + if let path = panel.selectedFilePath, + let file = findFile(byPath: path), + let module = findModule(forFilePath: path) { + ProjectFilesDetailStrip(file: file, module: module) + } else { + ProjectEmptyDetailView( + systemImage: "doc.text.magnifyingglass", + title: "Select a file", + hint: "Pick any file in the tree to see its target memberships and on-disk path." + ) + } + } + + private func findFile(byPath path: String) -> ProjectFileNode? { + for module in model.modules { + if let found = ProjectFilesTabView.findFile(in: module.rootGroup, path: path) { + return found + } + } + return nil + } + + private func findModule(forFilePath path: String) -> ProjectModule? { + for module in model.modules { + if ProjectFilesTabView.findFile(in: module.rootGroup, path: path) != nil { + return module + } + } + return nil + } + + private static func findFile(in group: ProjectGroup, path: String) -> ProjectFileNode? { + for child in group.children { + switch child { + case let .file(file): + if file.resolvedPath?.path == path { + return file + } + case let .group(subgroup): + if let nested = findFile(in: subgroup, path: path) { + return nested + } + } + } + return nil + } +} + +private struct ProjectFilesGroupRow: View { + let group: ProjectGroup + let depth: Int + let isExpanded: Bool + let onToggle: () -> Void + + var body: some View { + Button(action: onToggle) { + HStack(spacing: 4) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .frame(width: 12) + .foregroundStyle(.secondary) + Image(systemName: glyph(for: group.style)) + .font(.system(size: 12)) + .imageScale(.small) + .frame(width: 14, height: 14) + .foregroundStyle(.secondary) + Text(group.displayName) + .font(.system(size: 12)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } + .padding(.leading, CGFloat(depth) * 14 + 6) + .padding(.vertical, 2) + .padding(.trailing, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func glyph(for style: ProjectGroupStyle) -> String { + switch style { + case .logical: return "folder" + case .folderRef: return "folder.fill" + case .variant: return "globe" + case .synchronized: return "folder.badge.gearshape" + } + } +} + +private struct ProjectFilesFileRow: View { + let file: ProjectFileNode + let depth: Int + let module: ProjectModule + let isSelected: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 4) { + Spacer().frame(width: 12) + Image(systemName: glyph(for: file.fileType)) + .font(.system(size: 12)) + .imageScale(.small) + .frame(width: 14, height: 14) + .foregroundStyle(file.existsOnDisk ? Color.secondary : Color.orange) + Text(file.displayName) + .font(.system(size: 12)) + .lineLimit(1) + .truncationMode(.tail) + .strikethrough(!file.existsOnDisk) + Spacer(minLength: 6) + if !file.memberships.isEmpty { + HStack(spacing: 2) { + ForEach(memberships, id: \.targetID) { membership in + Text(targetLabel(for: membership.targetID)) + .font(.system(size: 9, weight: .medium)) + .lineLimit(1) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3) + .fill(Color.accentColor.opacity(0.15)) + ) + .foregroundStyle(Color.accentColor) + } + } + } + } + .padding(.leading, CGFloat(depth) * 14 + 6) + .padding(.vertical, 2) + .padding(.trailing, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(isSelected ? Color.accentColor.opacity(0.18) : .clear) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var memberships: [TargetMembership] { file.memberships } + + private func targetLabel(for id: TargetID) -> String { + guard let target = module.target(for: id) else { + return String(id.rawValue.prefix(6)) + } + return target.displayName + } + + private func glyph(for fileType: String?) -> String { + guard let fileType else { return "doc" } + if fileType.contains("swift") { return "swift" } + if fileType.contains("xcconfig") { return "doc.text" } + if fileType.contains("plist") { return "list.bullet.rectangle" } + if fileType.contains("asset") || fileType.contains("xcassets") { return "paintpalette" } + if fileType.contains("storyboard") || fileType.contains("xib") { return "rectangle.3.group" } + if fileType.contains("markdown") || fileType.contains("text") { return "doc.text" } + if fileType.contains("entitlement") { return "lock.shield" } + if fileType.contains("xcstrings") { return "globe" } + return "doc" + } +} + +private struct ProjectFilesDetailStrip: View { + let file: ProjectFileNode + let module: ProjectModule + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Image(systemName: "doc.text") + Text(file.displayName) + .font(.system(size: 13, weight: .semibold)) + Spacer() + } + if let path = file.resolvedPath?.path { + row(label: "Path", value: path) + } + if let type = file.fileType { + row(label: "Type", value: type) + } + row(label: "On disk", value: file.existsOnDisk ? "Yes" : "Missing") + if !file.memberships.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Targets") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + ForEach(file.memberships, id: \.targetID) { membership in + membershipRow(for: membership) + } + } + } + Spacer() + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private func row(label: String, value: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Text(label) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 70, alignment: .leading) + Text(value) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + } + } + + @ViewBuilder + private func membershipRow(for membership: TargetMembership) -> some View { + let target = module.target(for: membership.targetID) + HStack(spacing: 6) { + Image(systemName: "checkmark.square") + .foregroundStyle(Color.accentColor) + Text(target?.displayName ?? String(membership.targetID.rawValue.prefix(8))) + .font(.system(size: 12)) + Text("· \(membership.role.rawValue)") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + if !membership.compilerFlags.isEmpty { + Text("flags: \(membership.compilerFlags.joined(separator: " "))") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + } + Spacer() + } + } +} diff --git a/Sources/Panels/ProjectPanel.swift b/Sources/Panels/ProjectPanel.swift new file mode 100644 index 000000000000..40e7450cdff4 --- /dev/null +++ b/Sources/Panels/ProjectPanel.swift @@ -0,0 +1,265 @@ +import AppKit +import CMUXProjectModel +import Combine +import Foundation +import SwiftUI + +/// Which tab is active inside a ``ProjectPanel``. +public enum ProjectPanelTab: String, Sendable, Hashable, CaseIterable { + case files + case targets + case buildSettings + case schemes + + var displayLabel: String { + switch self { + case .files: return "Files" + case .targets: return "Targets" + case .buildSettings: return "Build Settings" + case .schemes: return "Schemes" + } + } +} + +/// Loading state of the parsed ``ProjectModel`` for a ``ProjectPanel``. +public enum ProjectPanelLoadState: Sendable, Equatable { + case idle + case loading + case loaded(ProjectModel) + case failed(String) + + public var model: ProjectModel? { + if case let .loaded(model) = self { return model } + return nil + } + + public static func == (lhs: ProjectPanelLoadState, rhs: ProjectPanelLoadState) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle), (.loading, .loading): + return true + case let (.loaded(a), .loaded(b)): + return a == b + case let (.failed(a), .failed(b)): + return a == b + default: + return false + } + } +} + +/// Runtime backing for one `project` surface. +/// +/// Holds the user's project URL, the parsed ``ProjectModel`` snapshot (loaded +/// off the main actor through ``XcodeProjectAdapter``), and the +/// currently-selected tab / scheme / configuration / node. Panel selection +/// state is plain SwiftUI ``Published`` properties so the view layer can +/// re-render without dealing with reload events. +@MainActor +public final class ProjectPanel: NSObject, Panel, ObservableObject { + public let id = UUID() + public let panelType: PanelType = .project + + @Published public private(set) var projectURL: URL + @Published public private(set) var loadState: ProjectPanelLoadState = .idle + @Published public var activeTab: ProjectPanelTab = .files + @Published public var selectedFilePath: String? + @Published public var selectedTargetID: TargetID? + @Published public var selectedSchemeName: String? + @Published public var selectedConfigurationName: String? + @Published public var settingsSearchText: String = "" + @Published public var settingsCustomizedOnly: Bool = false + @Published public var collapsedNodeIDs: Set = [] + @Published public var filesSearchText: String = "" + @Published public var lastLoadError: String? + private var reloadTask: Task? + + public var displayTitle: String { + projectURL.deletingPathExtension().lastPathComponent + } + + public var displayIcon: String? { "hammer" } + + public init(projectURL: URL) { + self.projectURL = projectURL + super.init() + } + + /// Trigger a load (or reload) of the project model. Safe to call + /// repeatedly. Loading runs off the main actor. Concurrent calls cancel + /// any in-flight reload so the latest invocation's result always wins. + public func reload() { + reloadTask?.cancel() + let previousModel = loadState.model + loadState = .loading + let url = projectURL + reloadTask = Task.detached(priority: .userInitiated) { [weak self] in + let adapter = XcodeProjectAdapter() + do { + let model = try adapter.load(at: url) + if Task.isCancelled { return } + await self?.applyLoaded(model) + } catch { + if Task.isCancelled { return } + await self?.applyLoadError(error, previousModel: previousModel) + } + } + } + + private func applyLoadError(_ error: Error, previousModel: ProjectModel?) { + lastLoadError = Self.describe(error) + if let previousModel { + loadState = .loaded(previousModel) + } else { + loadState = .failed(lastLoadError ?? "Unknown error") + } + } + + private func applyLoaded(_ model: ProjectModel) { + loadState = .loaded(model) + lastLoadError = nil + + let allSchemeNames = Set(model.modules.flatMap { $0.schemes.map(\.name) }) + if let current = selectedSchemeName, !allSchemeNames.contains(current) { + selectedSchemeName = nil + } + if selectedSchemeName == nil, + let firstScheme = model.modules.first?.schemes.first?.name { + selectedSchemeName = firstScheme + } + + let allConfigNames = Set(model.modules.flatMap { $0.configurationNames }) + if let current = selectedConfigurationName, !allConfigNames.contains(current) { + selectedConfigurationName = nil + } + if selectedConfigurationName == nil, + let firstConfigName = model.modules.first?.configurationNames.first { + selectedConfigurationName = firstConfigName + } + + let allTargetIDs = Set(model.modules.flatMap { $0.targets.map(\.id) }) + if let current = selectedTargetID, !allTargetIDs.contains(current) { + selectedTargetID = nil + } + if selectedTargetID == nil, + let firstTargetID = model.modules.first?.targets.first?.id { + selectedTargetID = firstTargetID + } + + if let path = selectedFilePath, + !ProjectPanel.fileExistsInModel(path: path, model: model) { + selectedFilePath = nil + } + + seedDefaultExpansion(for: model) + } + + private static func fileExistsInModel(path: String, model: ProjectModel) -> Bool { + for module in model.modules { + if pathExists(in: module.rootGroup, path: path) { return true } + } + return false + } + + private static func pathExists(in group: ProjectGroup, path: String) -> Bool { + for child in group.children { + switch child { + case let .file(file): + if file.resolvedPath?.path == path { return true } + case let .group(subgroup): + if pathExists(in: subgroup, path: path) { return true } + } + } + return false + } + + private func seedDefaultExpansion(for model: ProjectModel) { + guard collapsedNodeIDs.isEmpty else { return } + var collapsed: Set = [] + for module in model.modules { + collapseDeepNodes( + in: module.rootGroup, + depth: 0, + maxOpenDepth: 1, + accumulator: &collapsed + ) + } + collapsedNodeIDs = collapsed + } + + private func collapseDeepNodes( + in group: ProjectGroup, + depth: Int, + maxOpenDepth: Int, + accumulator: inout Set + ) { + if depth > maxOpenDepth { + accumulator.insert(group.id) + } + for child in group.children { + if case let .group(subgroup) = child { + collapseDeepNodes( + in: subgroup, + depth: depth + 1, + maxOpenDepth: maxOpenDepth, + accumulator: &accumulator + ) + } + } + } + + private static func describe(_ error: Error) -> String { + if let load = error as? ProjectLoadError { + switch load { + case let .unreadable(url): + return "Cannot read \(url.path)" + case let .unsupported(url): + return "Unsupported project at \(url.path)" + case let .parseFailure(url, reason): + return "Parse failed at \(url.path): \(reason)" + } + } + return String(describing: error) + } + + // MARK: Panel protocol + + public func close() {} + public func focus() { triggerFlash(reason: .navigation) } + public func unfocus() {} + + public func triggerFlash(reason: WorkspaceAttentionFlashReason) { + _ = reason + } + + public func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent { + _ = window + return .project(.navigator) + } + + public func preferredFocusIntentForActivation() -> PanelFocusIntent { + .project(.navigator) + } + + public func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) { + _ = intent + } + + @discardableResult + public func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool { + if case .project = intent { return true } + return false + } + + public func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? { + _ = responder + _ = window + return nil + } + + @discardableResult + public func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool { + _ = intent + _ = window + return false + } +} diff --git a/Sources/Panels/ProjectPanelView.swift b/Sources/Panels/ProjectPanelView.swift new file mode 100644 index 000000000000..c620a4df083a --- /dev/null +++ b/Sources/Panels/ProjectPanelView.swift @@ -0,0 +1,243 @@ +import AppKit +import CMUXProjectModel +import SwiftUI + +/// Top-level SwiftUI view for a ``ProjectPanel``. +/// +/// Renders the project chrome (project name, scheme/configuration pickers, +/// tab strip) and dispatches into the per-tab subviews. +struct ProjectPanelView: View { + @ObservedObject var panel: ProjectPanel + let isFocused: Bool + let onRequestPanelFocus: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + chrome + Divider() + content + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color(NSColor.windowBackgroundColor)) + .onAppear { + if case .idle = panel.loadState { + panel.reload() + } + } + } + + @ViewBuilder + private var chrome: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 10) { + Label(panel.displayTitle, systemImage: "hammer.fill") + .font(.system(size: 13, weight: .semibold)) + .help(panel.projectURL.path) + schemePicker + configurationPicker + Spacer(minLength: 0) + Button { + panel.reload() + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 11, weight: .semibold)) + } + .buttonStyle(.plain) + .help("Reload project") + } + if let error = panel.lastLoadError, case .loaded = panel.loadState { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10)) + Text("Reload returned errors: \(error)") + .font(.system(size: 10)) + .lineLimit(1) + .truncationMode(.tail) + Spacer() + Button { + panel.lastLoadError = nil + } label: { + Image(systemName: "xmark") + .font(.system(size: 9)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.orange.opacity(0.15)) + .foregroundStyle(Color.orange) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + tabStrip + } + .padding(.horizontal, 14) + .padding(.top, 8) + .padding(.bottom, 4) + } + + private var allSchemes: [SchemeSummary] { + guard let model = panel.loadState.model else { return [] } + var seen: Set = [] + var out: [SchemeSummary] = [] + for module in model.modules { + for scheme in module.schemes where seen.insert(scheme.name).inserted { + out.append(scheme) + } + } + return out + } + + private var allConfigurationNames: [String] { + guard let model = panel.loadState.model else { return [] } + var seen: Set = [] + var out: [String] = [] + for module in model.modules { + for name in module.configurationNames where seen.insert(name).inserted { + out.append(name) + } + } + return out + } + + @ViewBuilder + private var schemePicker: some View { + let schemes = allSchemes + if !schemes.isEmpty { + Picker( + "Scheme", + selection: Binding( + get: { panel.selectedSchemeName ?? schemes.first?.name ?? "" }, + set: { panel.selectedSchemeName = $0 } + ) + ) { + ForEach(schemes) { scheme in + Text(scheme.name).tag(scheme.name) + } + } + .pickerStyle(.menu) + .frame(maxWidth: 200) + } + } + + @ViewBuilder + private var configurationPicker: some View { + let names = allConfigurationNames + if !names.isEmpty { + Picker( + "Configuration", + selection: Binding( + get: { panel.selectedConfigurationName ?? names.first ?? "" }, + set: { panel.selectedConfigurationName = $0 } + ) + ) { + ForEach(names, id: \.self) { name in + Text(name).tag(name) + } + } + .pickerStyle(.menu) + .frame(minWidth: 180, maxWidth: 240) + } + } + + @ViewBuilder + private var pathHint: some View { + EmptyView() + } + + @ViewBuilder + private var tabStrip: some View { + HStack(spacing: 2) { + ForEach(ProjectPanelTab.allCases, id: \.self) { tab in + Button(action: { panel.activeTab = tab }) { + Text(tab.displayLabel) + .font(.system(size: 11, weight: .medium)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(panel.activeTab == tab + ? Color.accentColor + : Color.secondary.opacity(0.10)) + ) + .foregroundStyle(panel.activeTab == tab ? Color.white : Color.primary) + } + .buttonStyle(.plain) + } + Spacer(minLength: 0) + } + } + + @ViewBuilder + private var content: some View { + switch panel.loadState { + case .idle, .loading: + ProjectPanelStatusView(message: "Loading \(panel.displayTitle)") + case let .failed(reason): + ProjectPanelStatusView(message: "Failed: \(reason)") + case let .loaded(model): + tabContent(for: model) + } + } + + @ViewBuilder + private func tabContent(for model: ProjectModel) -> some View { + switch panel.activeTab { + case .files: + ProjectFilesTabView(panel: panel, model: model) + case .targets: + ProjectTargetsTabView(panel: panel, model: model) + case .buildSettings: + ProjectBuildSettingsTabView(panel: panel, model: model) + case .schemes: + ProjectSchemesTabView(panel: panel, model: model) + } + } +} + +struct ProjectPanelStatusView: View { + let message: String + + var body: some View { + VStack { + Spacer() + HStack { + Spacer() + Text(message) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + Spacer() + } + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +/// Reusable empty-state placeholder for the detail panes of each tab. +/// +/// Replaces the floating 12 pt secondary text that read as a loading bug. +/// Centered icon (tertiary), 13 pt semibold title, 11 pt secondary hint. +struct ProjectEmptyDetailView: View { + let systemImage: String + let title: String + let hint: String + + var body: some View { + VStack(spacing: 6) { + Spacer() + Image(systemName: systemImage) + .font(.system(size: 28, weight: .light)) + .foregroundStyle(.tertiary) + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + Text(hint) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 24) + .multilineTextAlignment(.center) + } +} diff --git a/Sources/Panels/ProjectSchemesTabView.swift b/Sources/Panels/ProjectSchemesTabView.swift new file mode 100644 index 000000000000..56f0a038161d --- /dev/null +++ b/Sources/Panels/ProjectSchemesTabView.swift @@ -0,0 +1,181 @@ +import CMUXProjectModel +import SwiftUI + +/// Schemes tab inside ``ProjectPanelView``. +/// +/// Renders shared and per-user schemes as a single table with a small +/// "shared / personal" badge, plus a detail panel for the selected scheme +/// listing its run / test / profile / archive targets, launch arguments, +/// and environment variables. +struct ProjectSchemesTabView: View { + @ObservedObject var panel: ProjectPanel + let model: ProjectModel + + var body: some View { + HSplitView { + schemeList + .frame(minWidth: 280, idealWidth: 360, maxHeight: .infinity) + detail + .frame(minWidth: 260, maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder + private var schemeList: some View { + let entries: [(module: ProjectModule, scheme: SchemeSummary, compositeID: String)] = model.modules.flatMap { module in + module.schemes.map { scheme in + (module: module, scheme: scheme, compositeID: "\(module.id.rawValue)|\(scheme.name)") + } + } + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(entries, id: \.compositeID) { entry in + schemeRow(entry.scheme, module: entry.module) + } + } + .padding(.vertical, 8) + } + .background(Color(NSColor.controlBackgroundColor).opacity(0.4)) + } + + @ViewBuilder + private func schemeRow(_ scheme: SchemeSummary, module: ProjectModule) -> some View { + let isSelected = panel.selectedSchemeName == scheme.name + Button(action: { panel.selectedSchemeName = scheme.name }) { + HStack(spacing: 8) { + Image(systemName: "play.rectangle") + .foregroundStyle(Color.accentColor) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text(scheme.name) + .font(.system(size: 12, weight: .semibold)) + Text(scheme.isShared ? "shared" : "personal") + .font(.system(size: 9, weight: .medium)) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3) + .fill((scheme.isShared ? Color.accentColor : Color.orange).opacity(0.18)) + ) + .foregroundStyle(scheme.isShared ? Color.accentColor : Color.orange) + } + HStack(spacing: 8) { + if !scheme.runTargetIDs.isEmpty { + Text("run: \(targetNames(for: scheme.runTargetIDs, in: module))") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + if !scheme.testTargetIDs.isEmpty { + Text("test: \(scheme.testTargetIDs.count)") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } + } + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(isSelected ? Color.accentColor.opacity(0.15) : .clear) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private var detail: some View { + if let selected = selectedScheme { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Label(selected.scheme.name, systemImage: "play.rectangle.fill") + .font(.system(size: 14, weight: .semibold)) + Text(selected.scheme.isShared ? "shared" : "personal") + .font(.system(size: 10, weight: .medium)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3) + .fill((selected.scheme.isShared ? Color.accentColor : Color.orange).opacity(0.18)) + ) + .foregroundStyle(selected.scheme.isShared ? Color.accentColor : Color.orange) + Spacer() + } + row(label: "Visibility", value: selected.scheme.isShared ? "Shared" : "Personal (current user)") + if !selected.scheme.runTargetIDs.isEmpty { + row(label: "Run target", value: targetNames(for: selected.scheme.runTargetIDs, in: selected.module)) + } + if !selected.scheme.testTargetIDs.isEmpty { + row(label: "Test targets", value: targetNames(for: selected.scheme.testTargetIDs, in: selected.module)) + } + if let profile = selected.scheme.profileTargetID { + row(label: "Profile target", value: targetNames(for: [profile], in: selected.module)) + } + if let archive = selected.scheme.archiveTargetID { + row(label: "Archive target", value: targetNames(for: [archive], in: selected.module)) + } + if !selected.scheme.launchArguments.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("Launch arguments") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + ForEach(selected.scheme.launchArguments, id: \.self) { arg in + Text(arg) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + } + } + } + if !selected.scheme.environmentVariables.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("Environment") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + ForEach(selected.scheme.environmentVariables.sorted(by: { $0.key < $1.key }), id: \.key) { entry in + Text("\(entry.key) = \(entry.value)") + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + } + } + } + Spacer() + } + .padding(14) + } + } else { + ProjectEmptyDetailView( + systemImage: "play.rectangle", + title: "Select a scheme", + hint: "Pick a scheme on the left to inspect its run / test / profile / archive targets and launch settings." + ) + } + } + + private var selectedScheme: (module: ProjectModule, scheme: SchemeSummary)? { + guard let name = panel.selectedSchemeName else { return nil } + for module in model.modules { + if let match = module.schemes.first(where: { $0.name == name }) { + return (module, match) + } + } + return nil + } + + private func targetNames(for ids: [TargetID], in module: ProjectModule) -> String { + ids.map { id in module.target(for: id)?.displayName ?? String(id.rawValue.prefix(8)) } + .joined(separator: ", ") + } + + @ViewBuilder + private func row(label: String, value: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Text(label) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 110, alignment: .leading) + Text(value) + .font(.system(size: 12)) + .textSelection(.enabled) + } + } +} diff --git a/Sources/Panels/ProjectTargetsTabView.swift b/Sources/Panels/ProjectTargetsTabView.swift new file mode 100644 index 000000000000..feb79e931cb9 --- /dev/null +++ b/Sources/Panels/ProjectTargetsTabView.swift @@ -0,0 +1,238 @@ +import CMUXProjectModel +import SwiftUI + +/// Targets tab inside ``ProjectPanelView``. +/// +/// Renders a table of targets with their product type, deployment target, +/// bundle id, and dependency count. Selecting a target shows a detail panel +/// listing per-config build settings the target overrides at its scope. +struct ProjectTargetsTabView: View { + @ObservedObject var panel: ProjectPanel + let model: ProjectModel + + var body: some View { + HSplitView { + targetList + .frame(minWidth: 320, idealWidth: 420, maxHeight: .infinity) + targetDetail + .frame(minWidth: 260, maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder + private var targetList: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(model.modules) { module in + if model.modules.count > 1 { + Text(module.displayName) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.top, 8) + } + ForEach(module.targets) { target in + targetRow(target, in: module) + } + } + } + .padding(.vertical, 8) + } + .background(Color(NSColor.controlBackgroundColor).opacity(0.4)) + } + + @ViewBuilder + private func targetRow(_ target: TargetSummary, in module: ProjectModule) -> some View { + let isSelected = panel.selectedTargetID == target.id + Button(action: { panel.selectedTargetID = target.id }) { + HStack(alignment: .top, spacing: 8) { + Image(systemName: glyph(for: target.productType)) + .foregroundStyle(Color.accentColor) + .frame(width: 16) + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(target.displayName) + .font(.system(size: 12, weight: .semibold)) + Spacer() + Text(target.productType.rawValue) + .font(.system(size: 10)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3) + .fill(Color.secondary.opacity(0.18)) + ) + .foregroundStyle(.secondary) + } + HStack(spacing: 8) { + if let deploy = target.deploymentTarget { + metadata("min", deploy) + } + if !target.platforms.isEmpty { + metadata("platforms", target.platforms.joined(separator: ",")) + } + if let bundle = target.bundleIdentifier { + metadata("bundle", bundle) + } + Text("deps: \(target.dependencies.count)") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(isSelected ? Color.accentColor.opacity(0.15) : .clear) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func metadata(_ label: String, _ value: String) -> some View { + Text("\(label): \(value)") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + + @ViewBuilder + private var targetDetail: some View { + if let selected = selectedTarget { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Image(systemName: glyph(for: selected.target.productType)) + .font(.system(size: 18)) + .foregroundStyle(Color.accentColor) + VStack(alignment: .leading, spacing: 2) { + Text(selected.target.displayName) + .font(.system(size: 14, weight: .semibold)) + Text(selected.target.productType.rawValue) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + Spacer() + } + detailGrid(for: selected.target) + if !selected.target.dependencies.isEmpty { + dependencySection(for: selected, module: selected.module) + } + configurationsSection(for: selected) + } + .padding(14) + } + } else { + ProjectEmptyDetailView( + systemImage: "shippingbox", + title: "Select a target", + hint: "Pick a target on the left to see its product type, dependencies, and configurations." + ) + } + } + + private var selectedTarget: (module: ProjectModule, target: TargetSummary)? { + guard let id = panel.selectedTargetID else { return nil } + for module in model.modules { + if let match = module.targets.first(where: { $0.id == id }) { + return (module, match) + } + } + return nil + } + + @ViewBuilder + private func detailGrid(for target: TargetSummary) -> some View { + VStack(alignment: .leading, spacing: 4) { + row(label: "Product", value: target.productType.rawValue) + row( + label: "Platforms", + value: target.platforms.isEmpty ? "—" : target.platforms.joined(separator: ", ") + ) + row(label: "Deploy min", value: target.deploymentTarget ?? "—") + row(label: "Bundle ID", value: target.bundleIdentifier ?? "—") + } + } + + @ViewBuilder + private func dependencySection(for selected: (module: ProjectModule, target: TargetSummary), module: ProjectModule) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text("Dependencies") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + ForEach(selected.target.dependencies, id: \.rawValue) { depID in + let label = module.target(for: depID)?.displayName ?? String(depID.rawValue.prefix(10)) + HStack(spacing: 6) { + Image(systemName: "link") + .foregroundStyle(.secondary) + Text(label) + .font(.system(size: 12)) + } + } + } + } + + @ViewBuilder + private func configurationsSection(for selected: (module: ProjectModule, target: TargetSummary)) -> some View { + let configCount = selected.module.configurations.filter { config in + if case let .target(targetID) = config.scope, targetID == selected.target.id { + return true + } + return false + }.count + let totalKeys = selected.module.configurations.reduce(0) { acc, config -> Int in + if case let .target(targetID) = config.scope, targetID == selected.target.id { + return acc + config.rawSettings.count + } + return acc + } + if configCount > 0 || totalKeys > 0 { + VStack(alignment: .leading, spacing: 6) { + Text("Build") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + HStack(spacing: 12) { + Label("\(configCount) configurations", systemImage: "slider.horizontal.3") + .font(.system(size: 11)) + Label("\(totalKeys) target overrides", systemImage: "wrench.and.screwdriver") + .font(.system(size: 11)) + } + .foregroundStyle(.secondary) + Button { + panel.selectedTargetID = selected.target.id + panel.activeTab = .buildSettings + } label: { + Label("Open in Build Settings", systemImage: "arrow.right.circle") + .font(.system(size: 11, weight: .medium)) + } + .buttonStyle(.plain) + .foregroundStyle(Color.accentColor) + } + } + } + + @ViewBuilder + private func row(label: String, value: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Text(label) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 80, alignment: .leading) + Text(value) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + } + } + + private func glyph(for productType: TargetProductType) -> String { + switch productType { + case .application: return "app.fill" + case .framework, .dynamicLibrary, .staticLibrary, .xcFramework: return "shippingbox" + case .bundle: return "shippingbox.fill" + case .unitTest, .uiTest: return "testtube.2" + case .commandLineTool: return "terminal" + case .appExtension: return "puzzlepiece.extension" + case .watchApp, .watchExtension: return "applewatch" + case .other: return "questionmark.square" + } + } +} diff --git a/Sources/Search/GlobalSearchDocuments.swift b/Sources/Search/GlobalSearchDocuments.swift index baa92275e4fc..6ca7bb4b11e2 100644 --- a/Sources/Search/GlobalSearchDocuments.swift +++ b/Sources/Search/GlobalSearchDocuments.swift @@ -34,7 +34,7 @@ enum GlobalSearchDocuments { kind = .browser case .markdown: kind = .markdown - case .terminal, .filePreview, .rightSidebarTool: + case .terminal, .filePreview, .rightSidebarTool, .project: kind = .title } diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 951ee703f126..48861ef14a0a 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1526,6 +1526,28 @@ struct SessionRightSidebarToolPanelSnapshot: Codable, Sendable { } } +struct SessionProjectPanelSnapshot: Codable, Sendable { + var projectPath: String + var selectedNodePath: String? + var activeTab: String? + var selectedSchemeName: String? + var selectedConfigurationName: String? + + init( + projectPath: String, + selectedNodePath: String? = nil, + activeTab: String? = nil, + selectedSchemeName: String? = nil, + selectedConfigurationName: String? = nil + ) { + self.projectPath = projectPath + self.selectedNodePath = selectedNodePath + self.activeTab = activeTab + self.selectedSchemeName = selectedSchemeName + self.selectedConfigurationName = selectedConfigurationName + } +} + struct SessionNotificationSnapshot: Codable, Sendable { var id: UUID var title: String @@ -1605,6 +1627,7 @@ struct SessionPanelSnapshot: Codable, Sendable { var markdown: SessionMarkdownPanelSnapshot? var filePreview: SessionFilePreviewPanelSnapshot? var rightSidebarTool: SessionRightSidebarToolPanelSnapshot? + var project: SessionProjectPanelSnapshot? } enum SessionSplitOrientation: String, Codable, Sendable { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index e5fe0913458b..b6e9d65d23d5 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3761,6 +3761,24 @@ class TerminalController { case "file.open": return v2Result(id: id, self.v2FileOpen(params: params)) + // Project + case "project.open": + return v2Result(id: id, self.v2ProjectOpen(params: params)) + case "project.set_tab": + return v2Result(id: id, self.v2ProjectSetTab(params: params)) + case "project.set_scheme": + return v2Result(id: id, self.v2ProjectSetScheme(params: params)) + case "project.set_configuration": + return v2Result(id: id, self.v2ProjectSetConfiguration(params: params)) + case "project.set_selected_target": + return v2Result(id: id, self.v2ProjectSetSelectedTarget(params: params)) + case "project.set_selected_file": + return v2Result(id: id, self.v2ProjectSetSelectedFile(params: params)) + case "project.set_settings_filter": + return v2Result(id: id, self.v2ProjectSetSettingsFilter(params: params)) + case "project.get_state": + return v2Result(id: id, self.v2ProjectGetState(params: params)) + case "surface.read_text": return v2Result(id: id, self.v2SurfaceReadText(params: params)) @@ -12248,6 +12266,182 @@ class TerminalController { return result } + // MARK: - Project + + private func v2ProjectOpen(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let rawPath = v2String(params, "path") else { + return .err(code: "invalid_params", message: "Missing 'path' parameter", data: nil) + } + let expanded = (rawPath as NSString).expandingTildeInPath + let resolved: String + if expanded.hasPrefix("/") { + resolved = expanded + } else { + resolved = (FileManager.default.currentDirectoryPath as NSString).appendingPathComponent(expanded) + } + guard FileManager.default.fileExists(atPath: resolved) else { + return .err(code: "not_found", message: "Project not found at \(resolved)", data: nil) + } + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to create project panel", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) + + guard let paneId = ws.bonsplitController.focusedPaneId else { + result = .err(code: "not_found", message: "No focused pane to open project in", data: nil) + return + } + + guard let panel = ws.newProjectSurface( + inPane: paneId, + projectPath: resolved, + focus: v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) + ) else { + result = .err(code: "internal_error", message: "Failed to create project panel", data: nil) + return + } + let targetPaneUUID = ws.paneId(forPanelId: panel.id)?.id + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "workspace_id": ws.id.uuidString, + "pane_id": v2OrNull(targetPaneUUID?.uuidString), + "surface_id": panel.id.uuidString, + "path": resolved + ]) + } + return result + } + + // MARK: - Project state driving (debug RPC for autonomous iteration) + + private func v2ResolveProjectPanel(params: [String: Any]) -> (Workspace, ProjectPanel)? { + guard let tabManager = v2ResolveTabManager(params: params) else { return nil } + var result: (Workspace, ProjectPanel)? + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } + let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId + guard let surfaceId, + let panel = ws.panels[surfaceId] as? ProjectPanel else { return } + result = (ws, panel) + } + return result + } + + private func v2ProjectSetTab(params: [String: Any]) -> V2CallResult { + guard let (_, panel) = v2ResolveProjectPanel(params: params) else { + return .err(code: "not_found", message: "Project surface not found", data: nil) + } + guard let raw = v2String(params, "tab"), + let tab = ProjectPanelTab(rawValue: raw) else { + return .err(code: "invalid_params", message: "tab must be one of files|targets|buildSettings|schemes", data: nil) + } + v2MainSync { panel.activeTab = tab } + return .ok(["tab": tab.rawValue]) + } + + private func v2ProjectSetScheme(params: [String: Any]) -> V2CallResult { + guard let (_, panel) = v2ResolveProjectPanel(params: params) else { + return .err(code: "not_found", message: "Project surface not found", data: nil) + } + let name = v2String(params, "name") + v2MainSync { panel.selectedSchemeName = name } + return .ok(["scheme": name ?? ""]) + } + + private func v2ProjectSetConfiguration(params: [String: Any]) -> V2CallResult { + guard let (_, panel) = v2ResolveProjectPanel(params: params) else { + return .err(code: "not_found", message: "Project surface not found", data: nil) + } + let name = v2String(params, "name") + v2MainSync { panel.selectedConfigurationName = name } + return .ok(["configuration": name ?? ""]) + } + + private func v2ProjectSetSelectedTarget(params: [String: Any]) -> V2CallResult { + guard let (_, panel) = v2ResolveProjectPanel(params: params) else { + return .err(code: "not_found", message: "Project surface not found", data: nil) + } + let name = v2String(params, "name") + var resolvedID: String? + v2MainSync { + if let name, !name.isEmpty, + let module = panel.loadState.model?.modules.first, + let target = module.targets.first(where: { $0.displayName == name }) { + panel.selectedTargetID = target.id + resolvedID = target.id.rawValue + } else { + panel.selectedTargetID = nil + } + } + return .ok(["target_name": name ?? "", "target_id": resolvedID ?? ""]) + } + + private func v2ProjectSetSelectedFile(params: [String: Any]) -> V2CallResult { + guard let (_, panel) = v2ResolveProjectPanel(params: params) else { + return .err(code: "not_found", message: "Project surface not found", data: nil) + } + let path = v2String(params, "path") + v2MainSync { panel.selectedFilePath = path } + return .ok(["selected_file": path ?? ""]) + } + + private func v2ProjectSetSettingsFilter(params: [String: Any]) -> V2CallResult { + guard let (_, panel) = v2ResolveProjectPanel(params: params) else { + return .err(code: "not_found", message: "Project surface not found", data: nil) + } + let text = v2String(params, "text") ?? "" + v2MainSync { panel.settingsSearchText = text } + return .ok(["filter": text]) + } + + private func v2ProjectGetState(params: [String: Any]) -> V2CallResult { + guard let (_, panel) = v2ResolveProjectPanel(params: params) else { + return .err(code: "not_found", message: "Project surface not found", data: nil) + } + var snapshot: [String: Any] = [:] + v2MainSync { + snapshot["surface_id"] = panel.id.uuidString + snapshot["project_url"] = panel.projectURL.path + snapshot["active_tab"] = panel.activeTab.rawValue + snapshot["selected_scheme"] = panel.selectedSchemeName ?? "" + snapshot["selected_configuration"] = panel.selectedConfigurationName ?? "" + snapshot["selected_target_id"] = panel.selectedTargetID?.rawValue ?? "" + snapshot["selected_file"] = panel.selectedFilePath ?? "" + snapshot["settings_filter"] = panel.settingsSearchText + switch panel.loadState { + case .idle: + snapshot["load_state"] = "idle" + case .loading: + snapshot["load_state"] = "loading" + case let .failed(reason): + snapshot["load_state"] = "failed" + snapshot["load_error"] = reason + case let .loaded(model): + snapshot["load_state"] = "loaded" + snapshot["module_count"] = model.modules.count + if let module = model.modules.first { + snapshot["module_name"] = module.displayName + snapshot["target_count"] = module.targets.count + snapshot["target_names"] = module.targets.map(\.displayName) + snapshot["scheme_count"] = module.schemes.count + snapshot["scheme_names"] = module.schemes.map(\.name) + snapshot["configuration_names"] = module.configurationNames + snapshot["root_group_children"] = module.rootGroup.children.count + } + } + } + return .ok(snapshot) + } + // MARK: - Browser private func v2BrowserOpenSplit(params: [String: Any]) -> V2CallResult { diff --git a/Sources/TerminalPaneDropTargetView.swift b/Sources/TerminalPaneDropTargetView.swift index 4330855d51c4..f3f3a0ec78d1 100644 --- a/Sources/TerminalPaneDropTargetView.swift +++ b/Sources/TerminalPaneDropTargetView.swift @@ -322,6 +322,8 @@ final class PaneDropTargetView: NSView { return nil case .rightSidebarTool: return nil + case .project: + return nil } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 012c3259411b..0b7d39161b1c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -532,6 +532,7 @@ extension Workspace { let markdownSnapshot: SessionMarkdownPanelSnapshot? let filePreviewSnapshot: SessionFilePreviewPanelSnapshot? let rightSidebarToolSnapshot: SessionRightSidebarToolPanelSnapshot? + let projectSnapshot: SessionProjectPanelSnapshot? switch panel.panelType { case .terminal: guard let terminalPanel = panel as? TerminalPanel else { return nil } @@ -601,6 +602,7 @@ extension Workspace { markdownSnapshot = nil filePreviewSnapshot = nil rightSidebarToolSnapshot = nil + projectSnapshot = nil case .browser: guard let browserPanel = panel as? BrowserPanel else { return nil } guard browserPanel.shouldPersistSessionSnapshot() else { return nil } @@ -619,6 +621,7 @@ extension Workspace { markdownSnapshot = nil filePreviewSnapshot = nil rightSidebarToolSnapshot = nil + projectSnapshot = nil case .markdown: guard let markdownPanel = panel as? MarkdownPanel else { return nil } terminalSnapshot = nil @@ -626,6 +629,7 @@ extension Workspace { markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: markdownPanel.filePath) filePreviewSnapshot = nil rightSidebarToolSnapshot = nil + projectSnapshot = nil case .filePreview: guard let filePreviewPanel = panel as? FilePreviewPanel else { return nil } terminalSnapshot = nil @@ -633,6 +637,7 @@ extension Workspace { markdownSnapshot = nil filePreviewSnapshot = SessionFilePreviewPanelSnapshot(filePath: filePreviewPanel.filePath) rightSidebarToolSnapshot = nil + projectSnapshot = nil case .rightSidebarTool: guard let toolPanel = panel as? RightSidebarToolPanel else { return nil } terminalSnapshot = nil @@ -640,6 +645,21 @@ extension Workspace { markdownSnapshot = nil filePreviewSnapshot = nil rightSidebarToolSnapshot = SessionRightSidebarToolPanelSnapshot(mode: toolPanel.mode) + projectSnapshot = nil + case .project: + guard let projectPanel = panel as? ProjectPanel else { return nil } + terminalSnapshot = nil + browserSnapshot = nil + markdownSnapshot = nil + filePreviewSnapshot = nil + rightSidebarToolSnapshot = nil + projectSnapshot = SessionProjectPanelSnapshot( + projectPath: projectPanel.projectURL.path, + selectedNodePath: projectPanel.selectedFilePath, + activeTab: projectPanel.activeTab.rawValue, + selectedSchemeName: projectPanel.selectedSchemeName, + selectedConfigurationName: projectPanel.selectedConfigurationName + ) } return SessionPanelSnapshot( @@ -660,7 +680,8 @@ extension Workspace { browser: browserSnapshot, markdown: markdownSnapshot, filePreview: filePreviewSnapshot, - rightSidebarTool: rightSidebarToolSnapshot + rightSidebarTool: rightSidebarToolSnapshot, + project: projectSnapshot ) } @@ -1754,6 +1775,17 @@ extension Workspace { } applySessionPanelMetadata(snapshot, toPanelId: toolPanel.id) return toolPanel.id + case .project: + guard let projectPath = snapshot.project?.projectPath, + let projectPanel = newProjectSurface( + inPane: paneId, + projectPath: projectPath, + focus: false + ) else { + return nil + } + applySessionPanelMetadata(snapshot, toPanelId: projectPanel.id) + return projectPanel.id } } @@ -2025,6 +2057,17 @@ extension Workspace { if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) } if surface.focus == true { focusPanelId = panel.id } } + + case .project: + if let panel = newProjectSurface( + inPane: paneId, + projectPath: surface.url ?? surface.cwd ?? "", + focus: false + ) { + _ = closePanel(panelId, force: true) + if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) } + if surface.focus == true { focusPanelId = panel.id } + } } } @@ -2059,6 +2102,16 @@ extension Workspace { if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) } if surface.focus == true { focusPanelId = panel.id } } + + case .project: + if let panel = newProjectSurface( + inPane: paneId, + projectPath: surface.url ?? surface.cwd ?? "", + focus: false + ) { + if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) } + if surface.focus == true { focusPanelId = panel.id } + } } } @@ -10119,6 +10172,7 @@ final class Workspace: Identifiable, ObservableObject { static let markdown = "markdown" static let filePreview = "filePreview" static let rightSidebarTool = "rightSidebarTool" + static let project = "project" } enum PanelShellActivityState: String { @@ -11052,6 +11106,8 @@ final class Workspace: Identifiable, ObservableObject { return SurfaceKind.filePreview case .rightSidebarTool: return SurfaceKind.rightSidebarTool + case .project: + return SurfaceKind.project } } @@ -13931,6 +13987,58 @@ final class Workspace: Identifiable, ObservableObject { return markdownPanel } + @discardableResult + func newProjectSurface( + inPane paneId: PaneID, + projectPath: String, + focus: Bool? = nil, + targetIndex: Int? = nil + ) -> ProjectPanel? { + guard !projectPath.isEmpty else { return nil } + let url = URL(fileURLWithPath: (projectPath as NSString).expandingTildeInPath).standardizedFileURL + let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let previousFocusedPanelId = focusedPanelId + let previousHostedView = focusedTerminalPanel?.hostedView + + let projectPanel = ProjectPanel(projectURL: url) + panels[projectPanel.id] = projectPanel + panelTitles[projectPanel.id] = projectPanel.displayTitle + + guard let newTabId = bonsplitController.createTab( + title: projectPanel.displayTitle, + icon: projectPanel.displayIcon, + kind: SurfaceKind.project, + isDirty: false, + isLoading: false, + isPinned: false, + inPane: paneId + ) else { + panels.removeValue(forKey: projectPanel.id) + panelTitles.removeValue(forKey: projectPanel.id) + return nil + } + + surfaceIdToPanelId[newTabId] = projectPanel.id + if let targetIndex { + _ = bonsplitController.reorderTab(newTabId, toIndex: targetIndex) + } + publishCmuxSurfaceCreated(projectPanel.id, paneId: paneId, kind: SurfaceKind.project, origin: "project_tab", focused: shouldFocusNewTab) + if shouldFocusNewTab { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(newTabId) + applyTabSelection(tabId: newTabId, inPane: paneId) + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: projectPanel.id, + previousHostedView: previousHostedView + ) + } + + projectPanel.reload() + return projectPanel + } + @discardableResult func openOrFocusMarkdownSurface( inPane paneId: PaneID, @@ -17214,7 +17322,7 @@ extension Workspace: BonsplitDelegate { switch intent { case .browser(.addressBar), .browser(.findField), .terminal(.findField), .terminal(.textBoxInput): return true - case .panel, .browser(.webView), .terminal(.surface), .filePreview: + case .panel, .browser(.webView), .terminal(.surface), .filePreview, .project: return false } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3e25c5fe6f7d..385138461fab 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -145,6 +145,7 @@ 2F0C07000000000000000002 /* CmuxMainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0C07000000000000000001 /* CmuxMainWindow.swift */; }; C0DE31390000000000000101 /* CMUXOpenCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000102 /* CMUXOpenCommandTests.swift */; }; 3069F1D10000000000000005 /* CMUXPasteboardFidelity in Frameworks */ = {isa = PBXBuildFile; productRef = 3069F1D10000000000000006 /* CMUXPasteboardFidelity */; }; + A5C0DE0000000000000000A3 /* CMUXProjectModel in Frameworks */ = {isa = PBXBuildFile; productRef = A5C0DE0000000000000000A2 /* CMUXProjectModel */; }; D3610C010000000000000001 /* CmuxSettingsJSONPathSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3610C010000000000000002 /* CmuxSettingsJSONPathSupport.swift */; }; E7E000000000000000000009 /* CmuxSocketEventMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E00000000000000000000A /* CmuxSocketEventMapper.swift */; }; A5354303A5354303A5354303 /* CMUXSocketPathDomain in Frameworks */ = {isa = PBXBuildFile; productRef = A5354305A5354305A5354305 /* CMUXSocketPathDomain */; }; @@ -331,6 +332,12 @@ C47110010000000000000001 /* ProcessPipeReadCrashRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C47110010000000000000002 /* ProcessPipeReadCrashRegressionTests.swift */; }; C47110020000000000000001 /* ProcessPipeReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C47110020000000000000002 /* ProcessPipeReader.swift */; }; C47110020000000000000003 /* ProcessPipeReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C47110020000000000000002 /* ProcessPipeReader.swift */; }; + A5C0DE0000000000000000BA /* ProjectBuildSettingsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0DE0000000000000000B9 /* ProjectBuildSettingsTabView.swift */; }; + A5C0DE0000000000000000B6 /* ProjectFilesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0DE0000000000000000B5 /* ProjectFilesTabView.swift */; }; + A5C0DE0000000000000000B2 /* ProjectPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0DE0000000000000000B1 /* ProjectPanel.swift */; }; + A5C0DE0000000000000000B4 /* ProjectPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0DE0000000000000000B3 /* ProjectPanelView.swift */; }; + A5C0DE0000000000000000BC /* ProjectSchemesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0DE0000000000000000BB /* ProjectSchemesTabView.swift */; }; + A5C0DE0000000000000000B8 /* ProjectTargetsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0DE0000000000000000B7 /* ProjectTargetsTabView.swift */; }; A500RG01 /* ReactGrab.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500RG00 /* ReactGrab.swift */; }; D0C0D0C0D0C0D0C0D0C00001 /* RemoteLoopbackProxyAlias.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C0D0C0D0C0D0C0D0C00002 /* RemoteLoopbackProxyAlias.swift */; }; D0C0D0C0D0C0D0C0D0C00003 /* RemoteLoopbackRuntimeBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C0D0C0D0C0D0C0D0C00004 /* RemoteLoopbackRuntimeBridge.swift */; }; @@ -927,6 +934,12 @@ A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = ""; }; C47110010000000000000002 /* ProcessPipeReadCrashRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessPipeReadCrashRegressionTests.swift; sourceTree = ""; }; C47110020000000000000002 /* ProcessPipeReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessPipeReader.swift; sourceTree = ""; }; + A5C0DE0000000000000000B9 /* ProjectBuildSettingsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ProjectBuildSettingsTabView.swift; sourceTree = ""; }; + A5C0DE0000000000000000B5 /* ProjectFilesTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ProjectFilesTabView.swift; sourceTree = ""; }; + A5C0DE0000000000000000B1 /* ProjectPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ProjectPanel.swift; sourceTree = ""; }; + A5C0DE0000000000000000B3 /* ProjectPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ProjectPanelView.swift; sourceTree = ""; }; + A5C0DE0000000000000000BB /* ProjectSchemesTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ProjectSchemesTabView.swift; sourceTree = ""; }; + A5C0DE0000000000000000B7 /* ProjectTargetsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ProjectTargetsTabView.swift; sourceTree = ""; }; A500RG00 /* ReactGrab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ReactGrab.swift; sourceTree = ""; }; D0C0D0C0D0C0D0C0D0C00002 /* RemoteLoopbackProxyAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoopbackProxyAlias.swift; sourceTree = ""; }; D0C0D0C0D0C0D0C0D0C00004 /* RemoteLoopbackRuntimeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoopbackRuntimeBridge.swift; sourceTree = ""; }; @@ -1145,6 +1158,7 @@ 5EDB6027B346C46521A93C74 /* CMUXAuthCore in Frameworks */, C0DE45000000000000000001 /* CmuxExtensionKit in Frameworks */, 3069F1D10000000000000005 /* CMUXPasteboardFidelity in Frameworks */, + A5C0DE0000000000000000A3 /* CMUXProjectModel in Frameworks */, A5354303A5354303A5354303 /* CMUXSocketPathDomain in Frameworks */, AA11BB22CC33DD44EE550001 /* CMUXWorkstream in Frameworks */, A5001006 /* GhosttyKit.xcframework in Frameworks */, @@ -1529,6 +1543,12 @@ A50014F8 /* MarkdownWebSupport.swift */, A50014F1 /* MarkdownViewerAssets.swift */, A5001423 /* FilePreviewPanel.swift */, + A5C0DE0000000000000000B1 /* ProjectPanel.swift */, + A5C0DE0000000000000000B3 /* ProjectPanelView.swift */, + A5C0DE0000000000000000B5 /* ProjectFilesTabView.swift */, + A5C0DE0000000000000000B7 /* ProjectTargetsTabView.swift */, + A5C0DE0000000000000000B9 /* ProjectBuildSettingsTabView.swift */, + A5C0DE0000000000000000BB /* ProjectSchemesTabView.swift */, CE314D9700F6F1C8D4A2FE11 /* PanelOwnedNativeViewSession.swift */, 81E3615490F822CA0C8527C9 /* FilePreviewNativeViewSessions.swift */, D5D8322021F224A1538F8988 /* FilePreviewPDFSession.swift */, @@ -1824,6 +1844,7 @@ AA11BB22CC33DD44EE550002 /* CMUXWorkstream */, C0DE45000000000000000002 /* CmuxExtensionKit */, 3069F1D10000000000000006 /* CMUXPasteboardFidelity */, + A5C0DE0000000000000000A2 /* CMUXProjectModel */, F53000A2A1B2C3D4E5F60718 /* CMUXAgentVault */, A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, A5354305A5354305A5354305 /* CMUXSocketPathDomain */, @@ -1957,6 +1978,7 @@ AA11BB22CC33DD44EE550003 /* XCLocalSwiftPackageReference "CMUXWorkstream" */, C0DE45000000000000000003 /* XCLocalSwiftPackageReference "CmuxExtensionKit" */, 3069F1D10000000000000007 /* XCLocalSwiftPackageReference "CMUXPasteboardFidelity" */, + A5C0DE0000000000000000A1 /* XCLocalSwiftPackageReference "CMUXProjectModel" */, F53000A1A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXAgentVault" */, A5B00001A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXAgentLaunch" */, A500D012A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXDebugLog" */, @@ -2263,6 +2285,12 @@ A5001540 /* PortScanner.swift in Sources */, A5001521 /* PostHogAnalytics.swift in Sources */, C47110020000000000000001 /* ProcessPipeReader.swift in Sources */, + A5C0DE0000000000000000BA /* ProjectBuildSettingsTabView.swift in Sources */, + A5C0DE0000000000000000B6 /* ProjectFilesTabView.swift in Sources */, + A5C0DE0000000000000000B2 /* ProjectPanel.swift in Sources */, + A5C0DE0000000000000000B4 /* ProjectPanelView.swift in Sources */, + A5C0DE0000000000000000BC /* ProjectSchemesTabView.swift in Sources */, + A5C0DE0000000000000000B8 /* ProjectTargetsTabView.swift in Sources */, A500RG01 /* ReactGrab.swift in Sources */, D0C0D0C0D0C0D0C0D0C00001 /* RemoteLoopbackProxyAlias.swift in Sources */, D0C0D0C0D0C0D0C0D0C00003 /* RemoteLoopbackRuntimeBridge.swift in Sources */, @@ -3027,6 +3055,10 @@ isa = XCLocalSwiftPackageReference; relativePath = Packages/CMUXPasteboardFidelity; }; + A5C0DE0000000000000000A1 /* XCLocalSwiftPackageReference "CMUXProjectModel" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/CMUXProjectModel; + }; F53000A1A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXAgentVault" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/CMUXAgentVault; @@ -3105,6 +3137,11 @@ package = 3069F1D10000000000000007 /* XCLocalSwiftPackageReference "CMUXPasteboardFidelity" */; productName = CMUXPasteboardFidelity; }; + A5C0DE0000000000000000A2 /* CMUXProjectModel */ = { + isa = XCSwiftPackageProductDependency; + package = A5C0DE0000000000000000A1 /* XCLocalSwiftPackageReference "CMUXProjectModel" */; + productName = CMUXProjectModel; + }; F53000A2A1B2C3D4E5F60718 /* CMUXAgentVault */ = { isa = XCSwiftPackageProductDependency; package = F53000A1A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXAgentVault" */; diff --git a/cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 85e1de4b0ce8..e436e9b4ce88 100644 --- a/cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "21ae962c1bd1db162e73bb73adabe7e6e4bf38fc9981b5ecc0a9ac8ac7c16f5e", + "originHash" : "0633a45f4623b034a535b63d759f91d0ab5b18af604ab29c08b5f87af8902ed4", "pins" : [ + { + "identity" : "aexml", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tadija/AEXML.git", + "state" : { + "revision" : "db806756c989760b35108146381535aec231092b", + "version" : "4.7.0" + } + }, { "identity" : "networkimage", "kind" : "remoteSourceControl", @@ -10,6 +19,15 @@ "version" : "6.0.1" } }, + { + "identity" : "pathkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kylef/PathKit.git", + "state" : { + "revision" : "3bfd2737b700b9a36565a8c94f4ad2b050a5e574", + "version" : "1.0.1" + } + }, { "identity" : "posthog-ios", "kind" : "remoteSourceControl", @@ -37,6 +55,15 @@ "version" : "2.8.1" } }, + { + "identity" : "spectre", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kylef/Spectre.git", + "state" : { + "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7", + "version" : "0.10.1" + } + }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", @@ -72,6 +99,15 @@ "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", "version" : "2.4.1" } + }, + { + "identity" : "xcodeproj", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tuist/XcodeProj.git", + "state" : { + "revision" : "fb79d68bc758cdb00079d72b6c8b899d99f5f15e", + "version" : "9.12.0" + } } ], "version" : 3