From 51ad414681484f5c443ce2d08f3fca84d7e0ecd1 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 14 May 2026 22:56:56 +0800 Subject: [PATCH] feat: add run profile feature --- .../RxCodeCore/Models/BashRunConfig.swift | 57 ++ .../RxCodeCore/Models/RunProfile.swift | 39 ++ .../Sources/RxCodeCore/Models/RunStep.swift | 17 + .../RunProfile/RunEnvFileParser.swift | 83 +++ .../RunProfile/RunTaskExecutor.swift | 131 +++++ Packages/Sources/RxCodeCore/WindowState.swift | 10 + .../RunEnvFileParserTests.swift | 77 +++ .../RunTaskExecutorTests.swift | 270 +++++++++ RxCode/App/AppState.swift | 30 + RxCode/Services/PersistenceService.swift | 18 + RxCode/Services/RunProfile/RunService.swift | 274 +++++++++ .../Inspector/InspectorContentView.swift | 4 + .../Views/Inspector/RightInspectorPanel.swift | 1 + RxCode/Views/MainView.swift | 7 + .../RunProfile/RunConfigurationsView.swift | 530 ++++++++++++++++++ .../RunProfile/RunOutputInspectorView.swift | 192 +++++++ .../RunProfile/RunProfileToolbarGroup.swift | 179 ++++++ RxCode/Views/Toolbar/RxCodeToolbar.swift | 8 +- 18 files changed, 1925 insertions(+), 2 deletions(-) create mode 100644 Packages/Sources/RxCodeCore/Models/BashRunConfig.swift create mode 100644 Packages/Sources/RxCodeCore/Models/RunProfile.swift create mode 100644 Packages/Sources/RxCodeCore/Models/RunStep.swift create mode 100644 Packages/Sources/RxCodeCore/RunProfile/RunEnvFileParser.swift create mode 100644 Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift create mode 100644 Packages/Tests/RxCodeCoreTests/RunEnvFileParserTests.swift create mode 100644 Packages/Tests/RxCodeCoreTests/RunTaskExecutorTests.swift create mode 100644 RxCode/Services/RunProfile/RunService.swift create mode 100644 RxCode/Views/RunProfile/RunConfigurationsView.swift create mode 100644 RxCode/Views/RunProfile/RunOutputInspectorView.swift create mode 100644 RxCode/Views/RunProfile/RunProfileToolbarGroup.swift diff --git a/Packages/Sources/RxCodeCore/Models/BashRunConfig.swift b/Packages/Sources/RxCodeCore/Models/BashRunConfig.swift new file mode 100644 index 00000000..736f0554 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/BashRunConfig.swift @@ -0,0 +1,57 @@ +import Foundation + +public struct BashRunConfig: Codable, Sendable, Hashable { + public var command: String + public var workingDirectory: String + public var environments: [EnvironmentPreset] + public var activePresetId: UUID? + + public init( + command: String = "", + workingDirectory: String = "", + environments: [EnvironmentPreset] = [], + activePresetId: UUID? = nil + ) { + self.command = command + self.workingDirectory = workingDirectory + self.environments = environments + self.activePresetId = activePresetId + } +} + +public struct EnvironmentPreset: Identifiable, Codable, Sendable, Hashable { + public var id: UUID + public var name: String + public var loadFromFile: Bool + public var envFilePath: String? + public var useManualKV: Bool + public var manualVars: [EnvVar] + + public init( + id: UUID = UUID(), + name: String, + loadFromFile: Bool = false, + envFilePath: String? = nil, + useManualKV: Bool = true, + manualVars: [EnvVar] = [] + ) { + self.id = id + self.name = name + self.loadFromFile = loadFromFile + self.envFilePath = envFilePath + self.useManualKV = useManualKV + self.manualVars = manualVars + } +} + +public struct EnvVar: Identifiable, Codable, Sendable, Hashable { + public var id: UUID + public var key: String + public var value: String + + public init(id: UUID = UUID(), key: String = "", value: String = "") { + self.id = id + self.key = key + self.value = value + } +} diff --git a/Packages/Sources/RxCodeCore/Models/RunProfile.swift b/Packages/Sources/RxCodeCore/Models/RunProfile.swift new file mode 100644 index 00000000..710c1f1b --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/RunProfile.swift @@ -0,0 +1,39 @@ +import Foundation + +public enum RunProfileType: String, Codable, Sendable, CaseIterable, Hashable { + case bash +} + +public struct RunProfile: Identifiable, Codable, Sendable, Hashable { + public var id: UUID + public var projectId: UUID + public var name: String + public var type: RunProfileType + public var bash: BashRunConfig + public var beforeSteps: [RunStep] + public var afterSteps: [RunStep] + public var createdAt: Date + public var updatedAt: Date + + public init( + id: UUID = UUID(), + projectId: UUID, + name: String, + type: RunProfileType = .bash, + bash: BashRunConfig = BashRunConfig(), + beforeSteps: [RunStep] = [], + afterSteps: [RunStep] = [], + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.projectId = projectId + self.name = name + self.type = type + self.bash = bash + self.beforeSteps = beforeSteps + self.afterSteps = afterSteps + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/Packages/Sources/RxCodeCore/Models/RunStep.swift b/Packages/Sources/RxCodeCore/Models/RunStep.swift new file mode 100644 index 00000000..c4bf9600 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/RunStep.swift @@ -0,0 +1,17 @@ +import Foundation + +public enum RunStepType: String, Codable, Sendable, CaseIterable, Hashable { + case bash +} + +public struct RunStep: Identifiable, Codable, Sendable, Hashable { + public var id: UUID + public var type: RunStepType + public var command: String + + public init(id: UUID = UUID(), type: RunStepType = .bash, command: String = "") { + self.id = id + self.type = type + self.command = command + } +} diff --git a/Packages/Sources/RxCodeCore/RunProfile/RunEnvFileParser.swift b/Packages/Sources/RxCodeCore/RunProfile/RunEnvFileParser.swift new file mode 100644 index 00000000..d1eda90d --- /dev/null +++ b/Packages/Sources/RxCodeCore/RunProfile/RunEnvFileParser.swift @@ -0,0 +1,83 @@ +import Foundation + +/// Parses `.env`-style files. Each non-empty, non-comment line is `KEY=VALUE`. +/// Quoted values (single or double) are unwrapped. Backslash escapes inside +/// double-quoted values are honored for `\n`, `\r`, `\t`, `\\`, `\"`. Inline +/// `#` inside an unquoted value is preserved (matches `dotenv`). +public enum RunEnvFileParser { + + /// Parse the raw text contents of a `.env` file. + /// Returns ordered (key, value) tuples. Last duplicate key wins when + /// merged into a dictionary. + public static func parse(_ contents: String) -> [(key: String, value: String)] { + var out: [(String, String)] = [] + for rawLine in contents.split(omittingEmptySubsequences: false, whereSeparator: { $0 == "\n" || $0 == "\r\n" }) { + let line = String(rawLine) + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { continue } + if trimmed.hasPrefix("#") { continue } + let body: String + if trimmed.hasPrefix("export ") { + body = String(trimmed.dropFirst("export ".count)).trimmingCharacters(in: .whitespaces) + } else { + body = trimmed + } + guard let eq = body.firstIndex(of: "=") else { continue } + let key = String(body[.. [String: String] { + var dict: [String: String] = [:] + for (k, v) in parse(contents) { dict[k] = v } + return dict + } + + private static func unquote(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + guard trimmed.count >= 2 else { return trimmed } + let first = trimmed.first + let last = trimmed.last + if first == "\"" && last == "\"" { + let inner = String(trimmed.dropFirst().dropLast()) + return applyDoubleQuoteEscapes(inner) + } + if first == "'" && last == "'" { + return String(trimmed.dropFirst().dropLast()) + } + return trimmed + } + + private static func applyDoubleQuoteEscapes(_ s: String) -> String { + var out = "" + var iter = s.makeIterator() + while let c = iter.next() { + if c == "\\" { + if let next = iter.next() { + switch next { + case "n": out.append("\n") + case "r": out.append("\r") + case "t": out.append("\t") + case "\\": out.append("\\") + case "\"": out.append("\"") + default: + out.append("\\") + out.append(next) + } + } else { + out.append("\\") + } + } else { + out.append(c) + } + } + return out + } +} diff --git a/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift b/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift new file mode 100644 index 00000000..67e19423 --- /dev/null +++ b/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift @@ -0,0 +1,131 @@ +import Foundation + +/// Pure helpers used by `RunService` to assemble subprocess inputs from a +/// `RunProfile`. No I/O except `FileManager.fileExists` / `Data(contentsOf:)` +/// for resolving the `.env` file path, which keeps the helper testable. +public enum RunTaskExecutor { + + // MARK: - Working directory + + /// Resolve a user-entered working directory string against a project root. + /// - Absolute paths (and `~`-prefixed) are returned standardized. + /// - Empty or whitespace-only strings fall back to the project path. + /// - Anything else is joined onto the project path. + public static func resolveWorkingDirectory(_ raw: String, projectPath: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return (projectPath as NSString).standardizingPath } + let expanded = (trimmed as NSString).expandingTildeInPath + if expanded.hasPrefix("/") { + return (expanded as NSString).standardizingPath + } + let joined = (projectPath as NSString).appendingPathComponent(expanded) + return (joined as NSString).standardizingPath + } + + // MARK: - Environment + + /// Resolve a preset (optionally loading from a .env file, optionally + /// overlaying manual KV pairs) on top of a base environment. + /// + /// Precedence (low → high): base < file < manualVars. + /// Missing files are silently ignored (no throw) so a typo in the path + /// doesn't block the run — the user will see the missing keys in the + /// terminal output and can fix the path. + public static func resolveEnvironment( + preset: EnvironmentPreset?, + projectPath: String, + baseEnvironment: [String: String], + fileLoader: (String) -> String? = { try? String(contentsOfFile: $0, encoding: .utf8) } + ) -> [String: String] { + var env = baseEnvironment + guard let preset else { return env } + + if preset.loadFromFile, let raw = preset.envFilePath { + let resolved = resolveWorkingDirectory(raw, projectPath: projectPath) + if let contents = fileLoader(resolved) { + for (k, v) in RunEnvFileParser.parse(contents) { env[k] = v } + } + } + + if preset.useManualKV { + for v in preset.manualVars { + let key = v.key.trimmingCharacters(in: .whitespaces) + guard !key.isEmpty else { continue } + env[key] = v.value + } + } + + return env + } + + // MARK: - Wrapper script + + /// Build the bash script that runs before-steps, the main command, and + /// after-steps in sequence. The trap is installed *before* `cd` so a + /// missing working directory still prints the exit footer — otherwise the + /// terminal goes blank with no indication of what happened. + /// + /// Note: banner text is intentionally plain ASCII without SGR styling. + /// SwiftTerm renders SGR 2 (dim) as low-contrast on dark backgrounds, + /// which made the banners invisible in earlier versions. + public static func buildWrapperScript(profile: RunProfile, projectPath: String) -> String { + let cwd = resolveWorkingDirectory(profile.bash.workingDirectory, projectPath: projectPath) + + let afterSteps = profile.afterSteps.filter { !$0.command.trimmingCharacters(in: .whitespaces).isEmpty } + + var lines: [String] = [] + lines.append("#!/usr/bin/env bash") + // macOS apps launched from Finder get the bare system PATH + // (/usr/bin:/bin:/usr/sbin:/sbin), so user-installed tools like + // `bun`, `pnpm`, `pyenv` aren't visible to subprocesses. Most users + // configure these in ~/.zshrc, which is only sourced by interactive + // zsh. We can't run our wrapper directly under `zsh -ic` because of + // a zsh quirk (`set -e` + a builtin like `cd` exits with code 1 and + // skips the EXIT trap). Workaround: run interactive zsh in a + // *subshell* purely to capture PATH — subshell state (traps, set -e + // weirdness) can't leak back here. + lines.append("__rxcode_user_path=$(/bin/zsh -ic 'printf %s \"$PATH\"' 2>/dev/null)") + lines.append("[ -n \"$__rxcode_user_path\" ] && export PATH=\"$__rxcode_user_path\"") + lines.append("") + // Trap is registered first so it fires for any subsequent failure — + // including a failing `cd` into a stale working directory. + lines.append("__rxcode_after() {") + lines.append(" local __rxcode_rc=$?") + lines.append(" set +e") + for step in afterSteps { + lines.append(" \(step.command)") + } + lines.append(" printf '\\n[rxcode] exited with code %d\\n' \"$__rxcode_rc\"") + lines.append(" return $__rxcode_rc") + lines.append("}") + lines.append("trap __rxcode_after EXIT") + lines.append("") + lines.append("printf '[rxcode] starting in %s\\n' \(shellEscape(cwd))") + lines.append("set -e") + lines.append("cd \(shellEscape(cwd))") + lines.append("") + + let beforeSteps = profile.beforeSteps.filter { !$0.command.trimmingCharacters(in: .whitespaces).isEmpty } + if !beforeSteps.isEmpty { + lines.append("# --- before launch ---") + for step in beforeSteps { + lines.append(step.command) + } + lines.append("") + } + + let main = profile.bash.command.trimmingCharacters(in: .whitespaces) + if !main.isEmpty { + lines.append("# --- main ---") + lines.append(main) + } + + return lines.joined(separator: "\n") + "\n" + } + + /// Escapes a path for safe inclusion as a single-quoted bash literal. + /// `'` inside the path is encoded as `'\''`. + public static func shellEscape(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} diff --git a/Packages/Sources/RxCodeCore/WindowState.swift b/Packages/Sources/RxCodeCore/WindowState.swift index ba61a7d3..a8f9a59c 100644 --- a/Packages/Sources/RxCodeCore/WindowState.swift +++ b/Packages/Sources/RxCodeCore/WindowState.swift @@ -6,11 +6,13 @@ import SwiftUI public enum InspectorTab: String, CaseIterable { case memo = "Memo" case terminal = "Terminal" + case run = "Run" public var icon: String { switch self { case .terminal: "apple.terminal" case .memo: "note.text" + case .run: "play.fill" } } } @@ -119,6 +121,14 @@ public final class WindowState { public var showInspector: Bool = false public var inspectorMode: InspectorMode = .review public var inspectorTab: InspectorTab = .memo + /// Currently-selected run profile in the toolbar picker. Persisted only in + /// memory — re-selecting on relaunch is fine. Per-window so two windows + /// can target different profiles in the same project. + public var selectedRunProfileId: UUID? + /// Whether the run-configurations editor sheet is open. + public var showRunConfigurations: Bool = false + /// Which active run task the Run inspector tab is currently displaying. + public var selectedRunTaskId: UUID? public var inspectorReviewTab: InspectorReviewTab = .thisThread public var inspectorFile: PreviewFile? public var diffFile: PreviewFile? diff --git a/Packages/Tests/RxCodeCoreTests/RunEnvFileParserTests.swift b/Packages/Tests/RxCodeCoreTests/RunEnvFileParserTests.swift new file mode 100644 index 00000000..b07d0842 --- /dev/null +++ b/Packages/Tests/RxCodeCoreTests/RunEnvFileParserTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing +@testable import RxCodeCore + +@Suite(".env file parser") +struct RunEnvFileParserTests { + + @Test("Parses KEY=VALUE") + func basicKeyValue() { + let pairs = RunEnvFileParser.parse("FOO=bar\nBAZ=qux") + #expect(pairs.count == 2) + #expect(pairs[0].key == "FOO") + #expect(pairs[0].value == "bar") + #expect(pairs[1].key == "BAZ") + #expect(pairs[1].value == "qux") + } + + @Test("Skips blank lines and comments") + func skipsBlanksAndComments() { + let input = """ + # a comment + FOO=1 + + # another + BAR=2 + """ + let pairs = RunEnvFileParser.parse(input) + #expect(pairs.map(\.key) == ["FOO", "BAR"]) + } + + @Test("Unwraps double-quoted values and applies escapes") + func doubleQuoted() { + let input = #"GREETING="hello\nworld"\#nPATH="\"quoted\"""# + let dict = RunEnvFileParser.parseAsDictionary(input) + #expect(dict["GREETING"] == "hello\nworld") + #expect(dict["PATH"] == "\"quoted\"") + } + + @Test("Single-quoted values are taken verbatim (no escapes)") + func singleQuoted() { + let input = "X='literal \\n value'" + let dict = RunEnvFileParser.parseAsDictionary(input) + #expect(dict["X"] == "literal \\n value") + } + + @Test("Inline # in unquoted value is preserved") + func inlineHashKept() { + let dict = RunEnvFileParser.parseAsDictionary("URL=http://example.com/#anchor") + #expect(dict["URL"] == "http://example.com/#anchor") + } + + @Test("export prefix is stripped") + func exportPrefix() { + let dict = RunEnvFileParser.parseAsDictionary("export FOO=bar") + #expect(dict["FOO"] == "bar") + } + + @Test("Trims surrounding whitespace") + func trimsWhitespace() { + let dict = RunEnvFileParser.parseAsDictionary(" FOO = bar ") + #expect(dict["FOO"] == "bar") + } + + @Test("Last duplicate key wins in dictionary form") + func duplicateKeyLastWins() { + let dict = RunEnvFileParser.parseAsDictionary("X=1\nX=2\nX=3") + #expect(dict["X"] == "3") + } + + @Test("Returns ordered pairs preserving duplicates") + func orderedPairsKeepDuplicates() { + let pairs = RunEnvFileParser.parse("X=1\nX=2") + #expect(pairs.count == 2) + #expect(pairs[0].value == "1") + #expect(pairs[1].value == "2") + } +} diff --git a/Packages/Tests/RxCodeCoreTests/RunTaskExecutorTests.swift b/Packages/Tests/RxCodeCoreTests/RunTaskExecutorTests.swift new file mode 100644 index 00000000..920feed9 --- /dev/null +++ b/Packages/Tests/RxCodeCoreTests/RunTaskExecutorTests.swift @@ -0,0 +1,270 @@ +import Foundation +import Testing +@testable import RxCodeCore + +@Suite("RunTaskExecutor helpers") +struct RunTaskExecutorTests { + + // MARK: - Working directory + + @Test("Absolute path is standardized and returned as-is") + func absolutePathPassesThrough() { + let result = RunTaskExecutor.resolveWorkingDirectory("/tmp/foo/../foo", projectPath: "/var/project") + #expect(result == "/tmp/foo") + } + + @Test("Tilde-prefixed path expands to home directory") + func tildeExpands() { + let result = RunTaskExecutor.resolveWorkingDirectory("~/Desktop", projectPath: "/var/project") + let home = NSHomeDirectory() + #expect(result == "\(home)/Desktop") + } + + @Test("Relative path joins onto project path") + func relativeJoinsOnProject() { + let result = RunTaskExecutor.resolveWorkingDirectory("./scripts", projectPath: "/Users/me/repo") + #expect(result == "/Users/me/repo/scripts") + } + + @Test("Empty string falls back to project path") + func emptyFallsBackToProject() { + let result = RunTaskExecutor.resolveWorkingDirectory("", projectPath: "/Users/me/repo") + #expect(result == "/Users/me/repo") + } + + @Test("Whitespace-only string falls back to project path") + func whitespaceFallsBackToProject() { + let result = RunTaskExecutor.resolveWorkingDirectory(" ", projectPath: "/Users/me/repo") + #expect(result == "/Users/me/repo") + } + + // MARK: - Environment resolution + + @Test("Nil preset returns base environment unchanged") + func nilPresetReturnsBase() { + let result = RunTaskExecutor.resolveEnvironment( + preset: nil, + projectPath: "/p", + baseEnvironment: ["PATH": "/usr/bin"] + ) + #expect(result == ["PATH": "/usr/bin"]) + } + + @Test("Manual KV preset overlays on top of base") + func manualKVOverlays() { + let preset = EnvironmentPreset( + name: "dev", + useManualKV: true, + manualVars: [ + EnvVar(key: "NODE_ENV", value: "development"), + EnvVar(key: "API_URL", value: "http://localhost"), + ] + ) + let result = RunTaskExecutor.resolveEnvironment( + preset: preset, + projectPath: "/p", + baseEnvironment: ["PATH": "/usr/bin", "NODE_ENV": "stale"] + ) + #expect(result["PATH"] == "/usr/bin") + #expect(result["NODE_ENV"] == "development") + #expect(result["API_URL"] == "http://localhost") + } + + @Test("File-only preset loads values via injected loader") + func fileOnlyLoadsFromInjectedLoader() { + let preset = EnvironmentPreset( + name: "dev", + loadFromFile: true, + envFilePath: ".env", + useManualKV: false + ) + let loader: (String) -> String? = { path in + #expect(path == "/p/.env") + return "FROM_FILE=yes\nNODE_ENV=production" + } + let result = RunTaskExecutor.resolveEnvironment( + preset: preset, + projectPath: "/p", + baseEnvironment: ["PATH": "/usr/bin"], + fileLoader: loader + ) + #expect(result["FROM_FILE"] == "yes") + #expect(result["NODE_ENV"] == "production") + #expect(result["PATH"] == "/usr/bin") + } + + @Test("Both: manual KV overrides values loaded from file") + func manualOverridesFile() { + let preset = EnvironmentPreset( + name: "dev", + loadFromFile: true, + envFilePath: ".env", + useManualKV: true, + manualVars: [EnvVar(key: "NODE_ENV", value: "test")] + ) + let loader: (String) -> String? = { _ in "NODE_ENV=development\nOTHER=1" } + let result = RunTaskExecutor.resolveEnvironment( + preset: preset, + projectPath: "/p", + baseEnvironment: [:], + fileLoader: loader + ) + #expect(result["NODE_ENV"] == "test") + #expect(result["OTHER"] == "1") + } + + @Test("Missing .env file is silently ignored — no throw") + func missingFileIsIgnored() { + let preset = EnvironmentPreset( + name: "dev", + loadFromFile: true, + envFilePath: "nope.env", + useManualKV: false + ) + let result = RunTaskExecutor.resolveEnvironment( + preset: preset, + projectPath: "/p", + baseEnvironment: ["PATH": "/usr/bin"], + fileLoader: { _ in nil } + ) + #expect(result == ["PATH": "/usr/bin"]) + } + + @Test("Manual var with empty key is dropped") + func emptyKeyDropped() { + let preset = EnvironmentPreset( + name: "dev", + useManualKV: true, + manualVars: [ + EnvVar(key: "", value: "x"), + EnvVar(key: " ", value: "y"), + EnvVar(key: "GOOD", value: "z"), + ] + ) + let result = RunTaskExecutor.resolveEnvironment( + preset: preset, + projectPath: "/p", + baseEnvironment: [:] + ) + #expect(result == ["GOOD": "z"]) + } + + // MARK: - Wrapper script + + @Test("Script for minimal profile is cd + lifecycle banners + main") + func minimalScript() { + let profile = RunProfile( + projectId: UUID(), + name: "test", + bash: BashRunConfig(command: "echo hi") + ) + let script = RunTaskExecutor.buildWrapperScript(profile: profile, projectPath: "/Users/me/repo") + #expect(script.contains("#!/usr/bin/env bash")) + #expect(script.contains("set -e")) + #expect(script.contains("cd '/Users/me/repo'")) + #expect(script.contains("echo hi")) + // Trap is always installed so users see a final exit-code banner. + #expect(script.contains("trap __rxcode_after EXIT")) + #expect(script.contains("[rxcode] starting")) + #expect(script.contains("[rxcode] exited with code")) + #expect(!script.contains("# --- before launch ---")) + } + + @Test("Before steps appear before main; after steps wrapped in trap") + func beforeAndAfterOrdering() { + let profile = RunProfile( + projectId: UUID(), + name: "test", + bash: BashRunConfig(command: "MAIN_CMD"), + beforeSteps: [ + RunStep(command: "BEFORE_1"), + RunStep(command: "BEFORE_2"), + ], + afterSteps: [RunStep(command: "AFTER_1")] + ) + let script = RunTaskExecutor.buildWrapperScript(profile: profile, projectPath: "/p") + let mainIdx = script.range(of: "MAIN_CMD")!.lowerBound + let b1 = script.range(of: "BEFORE_1")!.lowerBound + let b2 = script.range(of: "BEFORE_2")!.lowerBound + let aFunc = script.range(of: "AFTER_1")!.lowerBound + let trap = script.range(of: "trap __rxcode_after EXIT")!.lowerBound + #expect(b1 < b2) + #expect(b2 < mainIdx) + #expect(aFunc < trap, "after-step body lives inside __rxcode_after before trap is registered") + #expect(trap < mainIdx, "trap is registered before main so it fires even on main failure") + } + + @Test("Empty before/after commands are filtered out — trap still present") + func emptyStepsFiltered() { + let profile = RunProfile( + projectId: UUID(), + name: "test", + bash: BashRunConfig(command: "main"), + beforeSteps: [RunStep(command: ""), RunStep(command: " ")], + afterSteps: [RunStep(command: "")] + ) + let script = RunTaskExecutor.buildWrapperScript(profile: profile, projectPath: "/p") + // Trap stays installed so the exit-code banner still prints. + #expect(script.contains("trap __rxcode_after EXIT")) + #expect(!script.contains("# --- before launch ---")) + } + + @Test("Commands are emitted verbatim — user owns their own quoting") + func commandsEmittedVerbatim() { + let profile = RunProfile( + projectId: UUID(), + name: "test", + bash: BashRunConfig(command: "echo \"$HOME\" && ls -la") + ) + let script = RunTaskExecutor.buildWrapperScript(profile: profile, projectPath: "/p") + #expect(script.contains("echo \"$HOME\" && ls -la")) + } + + @Test("Working directory with single quote is properly escaped") + func cwdWithSingleQuoteEscaped() { + let profile = RunProfile( + projectId: UUID(), + name: "test", + bash: BashRunConfig(workingDirectory: "/tmp/it's a path") + ) + let script = RunTaskExecutor.buildWrapperScript(profile: profile, projectPath: "/p") + #expect(script.contains("cd '/tmp/it'\\''s a path'")) + } + + // MARK: - Model round-trip + + @Test("RunProfile JSON round-trips") + func runProfileRoundTrip() throws { + // ISO8601 strategy truncates sub-second precision; use a whole-second + // timestamp so the round-trip is exact. + let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) + let original = RunProfile( + projectId: UUID(), + name: "Dev", + bash: BashRunConfig( + command: "pnpm dev", + workingDirectory: "./web", + environments: [ + EnvironmentPreset( + name: "dev", + loadFromFile: true, + envFilePath: ".env.local", + useManualKV: true, + manualVars: [EnvVar(key: "DEBUG", value: "1")] + ) + ] + ), + beforeSteps: [RunStep(command: "pnpm install")], + afterSteps: [RunStep(command: "echo done")], + createdAt: fixedDate, + updatedAt: fixedDate + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(original) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(RunProfile.self, from: data) + #expect(decoded == original) + } +} diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 2d5bc1fa..09ea8dc9 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -690,6 +690,36 @@ final class AppState { let marketplace = MarketplaceService() let mcp: MCPService let threadStore: ThreadStore + let runService = RunService() + + // MARK: - Run Profiles + + /// Loaded lazily per project. Keyed by `Project.id`. + var runProfilesByProject: [UUID: [RunProfile]] = [:] + + func runProfiles(for projectId: UUID) -> [RunProfile] { + runProfilesByProject[projectId] ?? [] + } + + /// Load this project's run profiles from disk if we haven't already. + /// No-op if already loaded. + func ensureRunProfilesLoaded(for projectId: UUID) async { + if runProfilesByProject[projectId] != nil { return } + let loaded = await persistence.loadRunProfiles(projectId: projectId) + runProfilesByProject[projectId] = loaded + } + + /// Replace the in-memory list and persist atomically. + func setRunProfiles(_ profiles: [RunProfile], for projectId: UUID) { + runProfilesByProject[projectId] = profiles + Task { [persistence] in + do { + try await persistence.saveRunProfiles(profiles, projectId: projectId) + } catch { + logger.error("Failed to save run profiles: \(error.localizedDescription, privacy: .public)") + } + } + } /// Disk-persisted draft queues loaded at init. Hydrated into each /// `WindowState.draftQueues` when the window is initialized so messages diff --git a/RxCode/Services/PersistenceService.swift b/RxCode/Services/PersistenceService.swift index bdd5ec13..354b15c8 100644 --- a/RxCode/Services/PersistenceService.swift +++ b/RxCode/Services/PersistenceService.swift @@ -199,6 +199,24 @@ actor PersistenceService { return try? decoder.decode(ChatSession.self, from: data) } + // MARK: - Run Profiles + + func saveRunProfiles(_ profiles: [RunProfile], projectId: UUID) throws { + let url = runProfilesURL(projectId: projectId) + try encode(profiles, to: url) + } + + func loadRunProfiles(projectId: UUID) -> [RunProfile] { + let url = runProfilesURL(projectId: projectId) + return decode([RunProfile].self, from: url) ?? [] + } + + private func runProfilesURL(projectId: UUID) -> URL { + baseURL + .appendingPathComponent("run_profiles") + .appendingPathComponent("\(projectId.uuidString).json") + } + // MARK: - GitHub User Cache func saveGitHubUser(_ user: GitHubUser) throws { diff --git a/RxCode/Services/RunProfile/RunService.swift b/RxCode/Services/RunProfile/RunService.swift new file mode 100644 index 00000000..08247ac5 --- /dev/null +++ b/RxCode/Services/RunProfile/RunService.swift @@ -0,0 +1,274 @@ +import AppKit +import Foundation +import Observation +import RxCodeCore +import SwiftTerm +import os + +/// Status of a `RunTask`. Drives the status badge in the inspector dropdown +/// and the toolbar stop-button visibility. +enum RunTaskStatus: Sendable, Equatable { + case running + case succeeded + case failed(Int32) + case signaled(Int32) + case stopped + + var isTerminal: Bool { + switch self { + case .running: return false + case .succeeded, .failed, .signaled, .stopped: return true + } + } + + var label: String { + switch self { + case .running: return "running" + case .succeeded: return "done" + case .failed(let code): return "exit \(code)" + case .signaled(let sig): return "signal \(sig)" + case .stopped: return "stopped" + } + } +} + +/// Decode the raw `waitpid(2)` status that SwiftTerm passes through. POSIX +/// status is a packed 16-bit value: bits 8–15 are the exit code when the +/// process exited normally; bits 0–6 hold the signal when it was killed. +/// Without this decode, a normal `exit 1` shows up as `256`, etc. +enum WaitStatus: Equatable { + case exited(Int32) + case signaled(Int32) + + init(rawStatus: Int32) { + let signal = rawStatus & 0x7F + if signal == 0 { + self = .exited((rawStatus >> 8) & 0xFF) + } else if signal == 0x7F { + // Stopped (WIFSTOPPED) — surface as exit 0 since we don't pause. + self = .exited(0) + } else { + self = .signaled(signal) + } + } +} + +/// One in-flight (or recently-finished) execution of a `RunProfile`. The +/// `terminalView` is created up-front when the task starts so output keeps +/// buffering even when the inspector tab is not visible. +@MainActor +@Observable +final class RunTask: Identifiable { + let id: UUID + let profile: RunProfile + let project: Project + let startedAt = Date() + var status: RunTaskStatus = .running + var exitCode: Int32? + + /// Re-parentable SwiftTerm view. Created once at start; the inspector + /// view picks it up via `RunTaskTerminalView` representable. + let terminalView: LocalProcessTerminalView + + let wrapperScript: String + let resolvedCwd: String + let resolvedEnvironment: [String: String] + + private let delegate: RunTaskTerminalDelegate + + init( + id: UUID = UUID(), + profile: RunProfile, + project: Project, + wrapperScript: String, + resolvedCwd: String, + resolvedEnvironment: [String: String], + onTerminated: @escaping @MainActor (UUID, Int32) -> Void + ) { + self.id = id + self.profile = profile + self.project = project + self.wrapperScript = wrapperScript + self.resolvedCwd = resolvedCwd + self.resolvedEnvironment = resolvedEnvironment + + let tv = LocalProcessTerminalView(frame: .zero) + Self.applyTheme(to: tv) + self.terminalView = tv + + let capturedId = id + self.delegate = RunTaskTerminalDelegate { code in + onTerminated(capturedId, code) + } + tv.processDelegate = delegate + } + + func start() { + let envPairs = resolvedEnvironment.map { "\($0.key)=\($0.value)" } + // Login but *not* interactive: `-i` makes `set -e` + builtins (e.g. + // `cd`) exit with code 1 and skip the EXIT trap in zsh — a quirk that + // left users staring at a silent "exit 1" with no diagnostics. + // `-l` still sources /etc/zprofile and ~/.zprofile, which is where + // macOS and Homebrew configure PATH. The wrapper script also + // tolerantly sources ~/.zshrc for users who stash PATH there. + terminalView.startProcess( + executable: "/bin/zsh", + args: ["-lc", wrapperScript], + environment: envPairs, + currentDirectory: resolvedCwd + ) + } + + func terminate() { + guard !status.isTerminal else { return } + terminalView.terminate() + // Pre-empt the status so the UI updates immediately. The delegate + // will fire shortly after with the real exit code; we keep the + // `.stopped` label rather than overwriting it. + status = .stopped + } + + private static func applyTheme(to tv: LocalProcessTerminalView) { + let isDark = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + tv.nativeBackgroundColor = isDark + ? NSColor(red: 0x1A/255.0, green: 0x1A/255.0, blue: 0x18/255.0, alpha: 1) + : NSColor(red: 0xE8/255.0, green: 0xE5/255.0, blue: 0xDC/255.0, alpha: 1) + tv.nativeForegroundColor = isDark + ? NSColor(red: 0xCC/255.0, green: 0xC9/255.0, blue: 0xC0/255.0, alpha: 1) + : NSColor(red: 0x3C/255.0, green: 0x39/255.0, blue: 0x29/255.0, alpha: 1) + } +} + +private final class RunTaskTerminalDelegate: NSObject, LocalProcessTerminalViewDelegate { + nonisolated(unsafe) let onTerminated: (Int32) -> Void + + init(onTerminated: @escaping (Int32) -> Void) { + self.onTerminated = onTerminated + } + + nonisolated func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {} + nonisolated func setTerminalTitle(source: LocalProcessTerminalView, title: String) {} + nonisolated func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} + + nonisolated func processTerminated(source: TerminalView, exitCode: Int32?) { + onTerminated(exitCode ?? -1) + } +} + +extension RunService { + /// Test seam so unit tests can exercise status decoding without spawning + /// a real subprocess. + static func statusFromRawWaitpid(_ raw: Int32) -> RunTaskStatus { + switch WaitStatus(rawStatus: raw) { + case .exited(let code): + return code == 0 ? .succeeded : .failed(code) + case .signaled(let sig): + return .signaled(sig) + } + } +} + +/// Owns running tasks for the whole app. One instance lives on +/// `AppState.runService` so the toolbar, inspector, and dropdown all read +/// the same source of truth. +@MainActor +@Observable +final class RunService { + + private let logger = Logger(subsystem: "com.claudework", category: "RunService") + + /// Most-recent task first. Includes finished tasks so the inspector + /// dropdown can show their output until cleared. + private(set) var tasks: [RunTask] = [] + + /// Tasks still alive — drives toolbar stop-button visibility. + var activeTasks: [RunTask] { + tasks.filter { !$0.status.isTerminal } + } + + @discardableResult + func start(profile: RunProfile, project: Project) -> RunTask { + // Replace any previous task for the same profile+project so a re-run + // doesn't accumulate stale terminal sessions in the dropdown. Active + // tasks (the run button is normally disabled in that case, but be + // defensive) are terminated first so their subprocess isn't orphaned. + for existing in tasks where existing.profile.id == profile.id && existing.project.id == project.id { + if !existing.status.isTerminal { + existing.terminate() + } + } + tasks.removeAll { $0.profile.id == profile.id && $0.project.id == project.id } + + let cwd = RunTaskExecutor.resolveWorkingDirectory( + profile.bash.workingDirectory, + projectPath: project.path + ) + let preset = profile.bash.environments.first { $0.id == profile.bash.activePresetId } + ?? profile.bash.environments.first + let env = RunTaskExecutor.resolveEnvironment( + preset: preset, + projectPath: project.path, + baseEnvironment: ProcessInfo.processInfo.environment + ) + let script = RunTaskExecutor.buildWrapperScript(profile: profile, projectPath: project.path) + + let task = RunTask( + profile: profile, + project: project, + wrapperScript: script, + resolvedCwd: cwd, + resolvedEnvironment: env, + onTerminated: { [weak self] id, rawStatus in + Task { @MainActor in + self?.taskTerminated(id: id, rawStatus: rawStatus) + } + } + ) + tasks.insert(task, at: 0) + task.start() + return task + } + + func stop(taskId: UUID) { + tasks.first { $0.id == taskId }?.terminate() + } + + func stopAll() { + for task in activeTasks { task.terminate() } + } + + func task(id: UUID) -> RunTask? { + tasks.first { $0.id == id } + } + + /// Remove a finished task from the list. No-op while running. + func remove(taskId: UUID) { + guard let idx = tasks.firstIndex(where: { $0.id == taskId }), + tasks[idx].status.isTerminal else { return } + tasks.remove(at: idx) + } + + /// Remove every finished task. Active tasks are left in place. + func clearFinished() { + tasks.removeAll { $0.status.isTerminal } + } + + private func taskTerminated(id: UUID, rawStatus: Int32) { + guard let task = tasks.first(where: { $0.id == id }) else { + logger.warning("processTerminated for unknown task id=\(id, privacy: .public) raw=\(rawStatus, privacy: .public)") + return + } + let decoded = WaitStatus(rawStatus: rawStatus) + switch decoded { + case .exited(let code): + task.exitCode = code + case .signaled(let sig): + task.exitCode = 128 + sig + } + // If the user explicitly stopped, preserve `.stopped`. Otherwise pick + // based on the decoded waitpid status. + if task.status != .stopped { + task.status = Self.statusFromRawWaitpid(rawStatus) + } + } +} diff --git a/RxCode/Views/Inspector/InspectorContentView.swift b/RxCode/Views/Inspector/InspectorContentView.swift index 749499f3..c7a503c8 100644 --- a/RxCode/Views/Inspector/InspectorContentView.swift +++ b/RxCode/Views/Inspector/InspectorContentView.swift @@ -36,6 +36,10 @@ struct InspectorContentView: View { focusTrigger: memoFocusID) .frame(maxHeight: windowState.inspectorTab == .memo ? .infinity : 0) .clipped() + + RunOutputInspectorView() + .frame(maxHeight: windowState.inspectorTab == .run ? .infinity : 0) + .clipped() } } } diff --git a/RxCode/Views/Inspector/RightInspectorPanel.swift b/RxCode/Views/Inspector/RightInspectorPanel.swift index 13537d8e..63f35e50 100644 --- a/RxCode/Views/Inspector/RightInspectorPanel.swift +++ b/RxCode/Views/Inspector/RightInspectorPanel.swift @@ -21,6 +21,7 @@ struct RightInspectorPanel: View { switch tab { case .terminal: terminalFocusID = UUID() case .memo: memoFocusID = UUID() + case .run: break } } diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index a6797df2..589c17d8 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -715,6 +715,13 @@ struct ChatDetailModifiers: ViewModifier { .sheet(item: Bindable(windowState).interactiveTerminal) { terminal in InteractiveTerminalPopup(state: terminal) } + .sheet(isPresented: Bindable(windowState).showRunConfigurations) { + if let project = windowState.selectedProject { + RunConfigurationsView(project: project) + .environment(appState) + .environment(windowState) + } + } } } diff --git a/RxCode/Views/RunProfile/RunConfigurationsView.swift b/RxCode/Views/RunProfile/RunConfigurationsView.swift new file mode 100644 index 00000000..dfbe4b17 --- /dev/null +++ b/RxCode/Views/RunProfile/RunConfigurationsView.swift @@ -0,0 +1,530 @@ +import AppKit +import RxCodeCore +import SwiftUI +import UniformTypeIdentifiers + +/// Modal sheet for editing run profiles — JetBrains "Run/Debug Configurations" +/// equivalent. Left list of profiles, right form pinned to the selected one. +struct RunConfigurationsView: View { + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + @Environment(\.dismiss) private var dismiss + + let project: Project + + @State private var draft: [RunProfile] = [] + @State private var selectedId: UUID? + + private var selectedIndex: Int? { + guard let id = selectedId else { return nil } + return draft.firstIndex { $0.id == id } + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + HSplitView { + profileList + .frame(minWidth: 220, idealWidth: 240, maxHeight: .infinity) + detailPane + .frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity) + } + Divider() + footer + } + .frame(minWidth: 760, minHeight: 560, idealHeight: 620) + .onAppear { + draft = appState.runProfiles(for: project.id) + selectedId = windowState.selectedRunProfileId ?? draft.first?.id + } + } + + // MARK: - Sections + + private var header: some View { + HStack { + Text("Run/Debug Configurations") + .font(.headline) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private var profileList: some View { + VStack(spacing: 0) { + HStack(spacing: 4) { + Button { + addProfile() + } label: { + Image(systemName: "plus") + } + .help("Add Profile") + Button { + deleteSelected() + } label: { + Image(systemName: "minus") + } + .disabled(selectedId == nil) + .help("Delete Profile") + Button { + duplicateSelected() + } label: { + Image(systemName: "doc.on.doc") + } + .disabled(selectedId == nil) + .help("Duplicate Profile") + Spacer() + } + .buttonStyle(.borderless) + .padding(8) + + Divider() + + if draft.isEmpty { + VStack { + Spacer() + Text("No profiles yet") + .foregroundStyle(.secondary) + .font(.system(size: 12)) + Text("Click + to add one.") + .foregroundStyle(.secondary) + .font(.system(size: 11)) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(selection: $selectedId) { + Section("Bash") { + ForEach(draft) { profile in + HStack { + Image(systemName: "terminal") + .foregroundStyle(ClaudeTheme.accent) + Text(profile.name.isEmpty ? "Untitled" : profile.name) + } + .tag(profile.id) + } + } + } + .listStyle(.sidebar) + } + } + .background(ClaudeTheme.surfaceSecondary.opacity(0.4)) + } + + @ViewBuilder + private var detailPane: some View { + if let idx = selectedIndex { + RunProfileDetailForm( + profile: $draft[idx], + project: project + ) + .id(draft[idx].id) + } else { + VStack { + Spacer() + Text("Select or add a profile to edit") + .foregroundStyle(.secondary) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private var footer: some View { + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Apply") { + applyAndDismiss() + } + .keyboardShortcut(.defaultAction) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + // MARK: - Actions + + private func addProfile() { + let now = Date() + let new = RunProfile( + projectId: project.id, + name: "New Bash Configuration", + bash: BashRunConfig(), + createdAt: now, + updatedAt: now + ) + draft.append(new) + selectedId = new.id + } + + private func deleteSelected() { + guard let idx = selectedIndex else { return } + let removed = draft.remove(at: idx) + if selectedId == removed.id { + selectedId = draft.indices.contains(idx) ? draft[idx].id : draft.last?.id + } + } + + private func duplicateSelected() { + guard let idx = selectedIndex else { return } + var copy = draft[idx] + copy.id = UUID() + copy.name = copy.name + " (copy)" + copy.createdAt = Date() + copy.updatedAt = Date() + draft.insert(copy, at: idx + 1) + selectedId = copy.id + } + + private func applyAndDismiss() { + // Stamp updatedAt on whatever changed. + let now = Date() + let stamped = draft.map { profile -> RunProfile in + var p = profile + p.updatedAt = now + return p + } + appState.setRunProfiles(stamped, for: project.id) + if let sel = selectedId, stamped.contains(where: { $0.id == sel }) { + windowState.selectedRunProfileId = sel + } else { + windowState.selectedRunProfileId = stamped.first?.id + } + dismiss() + } +} + +// MARK: - Detail form + +private struct RunProfileDetailForm: View { + @Binding var profile: RunProfile + let project: Project + + var body: some View { + Form { + Section { + TextField("Name", text: $profile.name) + Picker("Type", selection: $profile.type) { + ForEach(RunProfileType.allCases, id: \.self) { type in + Text(type.rawValue.capitalized).tag(type) + } + } + } header: { + Text("Configuration") + } footer: { + Text("Only Bash is supported for now.") + } + + Section { + TextEditor(text: $profile.bash.command) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 100) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(ClaudeTheme.borderSubtle, lineWidth: 0.5) + ) + HStack { + TextField("Working Directory", text: $profile.bash.workingDirectory, prompt: Text(project.path)) + Button("Browse…") { + pickDirectory { picked in + profile.bash.workingDirectory = picked + } + } + Button { + profile.bash.workingDirectory = "" + } label: { + Image(systemName: "arrow.uturn.backward") + } + .help("Reset to project root") + } + } header: { + Text("Command") + } footer: { + Text("Absolute or project-relative path. Leave empty to use the project root.") + } + + environmentsSection + + stepsSection( + title: "Before Launch", + steps: Binding(get: { profile.beforeSteps }, set: { profile.beforeSteps = $0 }) + ) + + stepsSection( + title: "After Launch", + steps: Binding(get: { profile.afterSteps }, set: { profile.afterSteps = $0 }) + ) + } + .formStyle(.grouped) + } + + // MARK: - Environments + + @State private var newPresetName: String = "" + + private var activePresetIndex: Int? { + guard let id = profile.bash.activePresetId ?? profile.bash.environments.first?.id else { return nil } + return profile.bash.environments.firstIndex { $0.id == id } + } + + @ViewBuilder + private var environmentsSection: some View { + Section { + LabeledContent("Preset") { + HStack { + Picker("", selection: Binding( + get: { profile.bash.activePresetId ?? profile.bash.environments.first?.id }, + set: { profile.bash.activePresetId = $0 } + )) { + if profile.bash.environments.isEmpty { + Text("None").tag(UUID?.none) + } else { + ForEach(profile.bash.environments) { preset in + Text(preset.name.isEmpty ? "Untitled" : preset.name) + .tag(UUID?.some(preset.id)) + } + } + } + .labelsHidden() + .frame(width: 200) + Button { + addPreset() + } label: { + Image(systemName: "plus") + } + .help("Add Preset") + Button { + deleteActivePreset() + } label: { + Image(systemName: "minus") + } + .disabled(profile.bash.environments.isEmpty) + .help("Delete Preset") + } + } + if let idx = activePresetIndex { + presetEditor(idx: idx) + } + } header: { + Text("Environments") + } footer: { + if activePresetIndex == nil { + Text("Add a preset (e.g. \"dev\", \"prod\") to configure environment variables.") + } + } + } + + @ViewBuilder + private func presetEditor(idx: Int) -> some View { + let preset = Binding( + get: { profile.bash.environments[idx] }, + set: { profile.bash.environments[idx] = $0 } + ) + + TextField("Preset Name", text: preset.name, prompt: Text("dev / prod / beta")) + + Toggle("Load from .env file", isOn: preset.loadFromFile) + + if preset.wrappedValue.loadFromFile { + LabeledContent("Env File") { + HStack { + TextField(".env", text: Binding( + get: { preset.wrappedValue.envFilePath ?? "" }, + set: { preset.wrappedValue.envFilePath = $0 } + )) + Button("Browse…") { + pickFile { picked in + preset.wrappedValue.envFilePath = picked + } + } + } + } + } + + Toggle("Manual key/value pairs", isOn: preset.useManualKV) + + if preset.wrappedValue.useManualKV { + manualKVTable(preset: preset) + } + } + + private func addPreset() { + let preset = EnvironmentPreset( + name: profile.bash.environments.isEmpty ? "dev" : "preset \(profile.bash.environments.count + 1)" + ) + profile.bash.environments.append(preset) + profile.bash.activePresetId = preset.id + } + + private func deleteActivePreset() { + guard let idx = activePresetIndex else { return } + let removed = profile.bash.environments.remove(at: idx) + if profile.bash.activePresetId == removed.id { + profile.bash.activePresetId = profile.bash.environments.first?.id + } + } + + @ViewBuilder + private func manualKVTable(preset: Binding) -> some View { + VStack(alignment: .leading, spacing: 8) { + // Header row with column titles and add button + HStack(spacing: 8) { + Text("Name") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 140, alignment: .leading) + Text("Value") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + Button { + preset.wrappedValue.manualVars.append(EnvVar()) + } label: { + Image(systemName: "plus.circle.fill") + .foregroundStyle(ClaudeTheme.accent) + } + .buttonStyle(.plain) + .help("Add Variable") + } + + if preset.wrappedValue.manualVars.isEmpty { + HStack { + Spacer() + Text("No environment variables defined") + .foregroundStyle(.tertiary) + .font(.system(size: 12)) + Spacer() + } + .padding(.vertical, 12) + } else { + // Variable rows + ForEach(preset.wrappedValue.manualVars.indices, id: \.self) { i in + HStack(spacing: 8) { + TextField("API_KEY", text: Binding( + get: { preset.wrappedValue.manualVars[i].key }, + set: { preset.wrappedValue.manualVars[i].key = $0 } + )) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + .frame(width: 140) + + TextField("value", text: Binding( + get: { preset.wrappedValue.manualVars[i].value }, + set: { preset.wrappedValue.manualVars[i].value = $0 } + )) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + + Button { + preset.wrappedValue.manualVars.remove(at: i) + } label: { + Image(systemName: "trash") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Remove Variable") + } + } + } + } + .padding(.vertical, 4) + } + + // MARK: - Before / after steps + + @ViewBuilder + private func stepsSection(title: String, steps: Binding<[RunStep]>) -> some View { + Section { + if steps.wrappedValue.isEmpty { + Text("There are no tasks to run \(title.lowercased()).") + .foregroundStyle(.secondary) + } else { + ForEach(steps.wrappedValue.indices, id: \.self) { i in + HStack(spacing: 6) { + Picker("", selection: Binding( + get: { steps.wrappedValue[i].type }, + set: { steps.wrappedValue[i].type = $0 } + )) { + ForEach(RunStepType.allCases, id: \.self) { t in + Text(t.rawValue.capitalized).tag(t) + } + } + .labelsHidden() + .frame(width: 80) + TextField("command", text: Binding( + get: { steps.wrappedValue[i].command }, + set: { steps.wrappedValue[i].command = $0 } + )) + .font(.system(.body, design: .monospaced)) + Button { + if i > 0 { steps.wrappedValue.swapAt(i, i - 1) } + } label: { Image(systemName: "arrow.up") } + .disabled(i == 0) + Button { + if i < steps.wrappedValue.count - 1 { steps.wrappedValue.swapAt(i, i + 1) } + } label: { Image(systemName: "arrow.down") } + .disabled(i == steps.wrappedValue.count - 1) + Button { + steps.wrappedValue.remove(at: i) + } label: { Image(systemName: "xmark.circle") } + .buttonStyle(.borderless) + } + } + } + } header: { + HStack { + Text(title) + Spacer() + Menu { + Button("Bash") { + steps.wrappedValue.append(RunStep(type: .bash, command: "")) + } + } label: { + Image(systemName: "plus") + } + .menuStyle(.borderlessButton) + .fixedSize() + } + } + } + + // MARK: - File pickers + + private func pickDirectory(onPick: @escaping (String) -> Void) { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: project.path) + if panel.runModal() == .OK, let url = panel.url { + onPick(displayPath(for: url)) + } + } + + private func pickFile(onPick: @escaping (String) -> Void) { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: project.path) + if panel.runModal() == .OK, let url = panel.url { + onPick(displayPath(for: url)) + } + } + + /// If the picked URL is inside the project root, return a project-relative + /// path; otherwise return the absolute path. + private func displayPath(for url: URL) -> String { + let absolute = url.path + let root = (project.path as NSString).standardizingPath + let std = (absolute as NSString).standardizingPath + if std.hasPrefix(root + "/") { + return "./" + String(std.dropFirst(root.count + 1)) + } + return std + } +} diff --git a/RxCode/Views/RunProfile/RunOutputInspectorView.swift b/RxCode/Views/RunProfile/RunOutputInspectorView.swift new file mode 100644 index 00000000..506c079e --- /dev/null +++ b/RxCode/Views/RunProfile/RunOutputInspectorView.swift @@ -0,0 +1,192 @@ +import AppKit +import RxCodeCore +import SwiftTerm +import SwiftUI + +/// Inspector body for the "Run" tab. Header row holds the task dropdown plus +/// per-task action icons; the body re-parents the selected task's +/// `LocalProcessTerminalView` so output keeps buffering when hidden. +struct RunOutputInspectorView: View { + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + + private var tasks: [RunTask] { appState.runService.tasks } + + private var selectedTask: RunTask? { + if let id = windowState.selectedRunTaskId, let match = tasks.first(where: { $0.id == id }) { + return match + } + return tasks.first + } + + var body: some View { + VStack(spacing: 0) { + taskBar + Divider() + if let task = selectedTask { + RunTaskTerminalHost(view: task.terminalView) + .padding(8) + .background(ClaudeTheme.codeBackground) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + emptyState + } + } + .onAppear { + if windowState.selectedRunTaskId == nil { + windowState.selectedRunTaskId = tasks.first?.id + } + } + .onChange(of: tasks.map(\.id)) { _, newIds in + // If the currently-selected task was removed, reselect the newest. + if let id = windowState.selectedRunTaskId, !newIds.contains(id) { + windowState.selectedRunTaskId = newIds.first + } else if windowState.selectedRunTaskId == nil { + windowState.selectedRunTaskId = newIds.first + } + } + } + + private var taskBar: some View { + HStack(spacing: 8) { + Menu { + if tasks.isEmpty { + Text("No tasks").foregroundStyle(.secondary) + } else { + ForEach(tasks) { task in + Button { + windowState.selectedRunTaskId = task.id + } label: { + HStack { + statusDot(for: task.status) + Text("\(task.profile.name) · \(task.status.label)") + if selectedTask?.id == task.id { + Image(systemName: "checkmark") + } + } + } + } + } + } label: { + HStack(spacing: 4) { + if let task = selectedTask { + statusDot(for: task.status) + Text("\(task.profile.name) · \(task.status.label)") + .lineLimit(1) + } else { + Text("No tasks") + .foregroundStyle(.secondary) + } + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.secondary) + } + .font(.system(size: 12, weight: .medium)) + } + .menuStyle(.borderlessButton) + .fixedSize() + + Spacer() + + if let task = selectedTask { + if !task.status.isTerminal { + Button { + appState.runService.stop(taskId: task.id) + } label: { + Image(systemName: "stop.fill") + } + .buttonStyle(.borderless) + .help("Stop \(task.profile.name)") + } else { + Button { + _ = appState.runService.start(profile: task.profile, project: task.project) + windowState.selectedRunTaskId = appState.runService.tasks.first?.id + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .help("Re-run \(task.profile.name)") + + Button { + appState.runService.remove(taskId: task.id) + } label: { + Image(systemName: "xmark") + } + .buttonStyle(.borderless) + .help("Clear task") + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(ClaudeTheme.surfaceElevated) + } + + private func statusDot(for status: RunTaskStatus) -> some View { + let color: SwiftUI.Color = { + switch status { + case .running: return ClaudeTheme.statusWarning + case .succeeded: return ClaudeTheme.statusSuccess + case .failed, .signaled: return ClaudeTheme.statusError + case .stopped: return SwiftUI.Color.secondary + } + }() + return Circle() + .fill(color) + .frame(width: 8, height: 8) + } + + private var emptyState: some View { + VStack(spacing: 8) { + Spacer() + Image(systemName: "play.rectangle") + .font(.system(size: 32)) + .foregroundStyle(.secondary) + Text("No active runs") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.secondary) + Text("Pick a profile in the toolbar and press Run.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +/// Wraps an externally-owned `LocalProcessTerminalView` so SwiftUI can host +/// it without taking ownership. SwiftTerm reuses the same view across mounts; +/// when the view re-parents we just keep the same instance. +struct RunTaskTerminalHost: NSViewRepresentable { + let view: LocalProcessTerminalView + + func makeNSView(context: Context) -> NSView { + let container = NSView() + container.addSubview(view) + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: container.topAnchor), + view.bottomAnchor.constraint(equalTo: container.bottomAnchor), + view.leadingAnchor.constraint(equalTo: container.leadingAnchor), + view.trailingAnchor.constraint(equalTo: container.trailingAnchor), + ]) + DispatchQueue.main.async { + view.window?.makeFirstResponder(view) + } + return container + } + + func updateNSView(_ nsView: NSView, context: Context) { + if view.superview !== nsView { + view.removeFromSuperview() + nsView.addSubview(view) + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: nsView.topAnchor), + view.bottomAnchor.constraint(equalTo: nsView.bottomAnchor), + view.leadingAnchor.constraint(equalTo: nsView.leadingAnchor), + view.trailingAnchor.constraint(equalTo: nsView.trailingAnchor), + ]) + } + } +} diff --git a/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift b/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift new file mode 100644 index 00000000..8b6f5d3d --- /dev/null +++ b/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift @@ -0,0 +1,179 @@ +import RxCodeCore +import SwiftUI + +/// Single combined toolbar pill containing: profile picker · run button · +/// stop button (the stop button is hidden entirely when no task is active). +/// Wrapped in its own subview so its body re-renders independently of the +/// rest of the toolbar. +struct RunProfileToolbarGroup: View { + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + + private var project: Project? { windowState.selectedProject } + + private var profiles: [RunProfile] { + guard let project else { return [] } + return appState.runProfiles(for: project.id) + } + + private var selectedProfile: RunProfile? { + guard let id = windowState.selectedRunProfileId else { return profiles.first } + return profiles.first { $0.id == id } ?? profiles.first + } + + private var activeTasks: [RunTask] { + appState.runService.activeTasks + } + + private var isSelectedProfileRunning: Bool { + guard let selectedProfile, let project else { return false } + return activeTasks.contains { + $0.profile.id == selectedProfile.id && $0.project.id == project.id + } + } + + var body: some View { + HStack(spacing: 2) { + profilePicker + .padding(.leading, 4) + + Divider().frame(height: 14) + + runButton + + if !activeTasks.isEmpty { + Divider().frame(height: 14) + stopButton + } + } + .task(id: project?.id) { + if let project { await appState.ensureRunProfilesLoaded(for: project.id) } + } + } + + // MARK: - Profile picker + + private var profilePicker: some View { + Menu { + if profiles.isEmpty { + Text("No profiles for this project") + .foregroundStyle(.secondary) + } else { + ForEach(profiles) { profile in + Button { + windowState.selectedRunProfileId = profile.id + } label: { + HStack { + Text(profile.name.prefix(150) + (profile.name.count > 150 ? "…" : "")) + if selectedProfile?.id == profile.id { + Image(systemName: "checkmark") + } + } + } + } + Divider() + } + Button("Edit Configurations…") { + windowState.showRunConfigurations = true + } + .disabled(project == nil) + } label: { + HStack(spacing: 4) { + Image(systemName: "play.rectangle") + .font(.system(size: 11)) + Text(selectedProfile?.name ?? "Run") + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + .frame(maxWidth: 200) + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Select Run Profile") + } + + // MARK: - Run button + + private var runButton: some View { + Button { + guard let project, let profile = selectedProfile else { return } + _ = appState.runService.start(profile: profile, project: project) + openRunInspector() + } label: { + Image(systemName: "play.fill") + .font(.system(size: 12, weight: .semibold)) + .frame(width: 22, height: 22) + .contentShape(Rectangle()) + } + .help(isSelectedProfileRunning + ? "\(selectedProfile?.name ?? "") is already running" + : "Run \(selectedProfile?.name ?? "")") + .disabled(selectedProfile == nil || project == nil || isSelectedProfileRunning) + } + + // MARK: - Stop button + + @ViewBuilder + private var stopButton: some View { + if activeTasks.count == 1, let only = activeTasks.first { + Button { + appState.runService.stop(taskId: only.id) + } label: { + stopIcon + } + .help("Stop \(only.profile.name)") + } else { + Menu { + ForEach(activeTasks) { task in + Button { + appState.runService.stop(taskId: task.id) + } label: { + Text("Stop '\(task.profile.name)'") + } + } + Divider() + Button("Stop All") { + appState.runService.stopAll() + } + } label: { + stopIcon + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Stop running tasks") + .padding(.trailing, 4) + } + } + + private var stopIcon: some View { + ZStack(alignment: .topTrailing) { + Image(systemName: "stop.fill") + .font(.system(size: 12, weight: .semibold)) + .frame(width: 22, height: 22) + if activeTasks.count > 1 { + Text("\(activeTasks.count)") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 3) + .frame(minWidth: 12, minHeight: 12) + .background(Capsule().fill(.red)) + .offset(x: 6, y: -4) + } + } + .contentShape(Rectangle()) + } + + private func openRunInspector() { + windowState.inspectorMode = .inspector + windowState.inspectorTab = .run + windowState.showInspector = true + windowState.selectedRunTaskId = appState.runService.activeTasks.first?.id + } +} diff --git a/RxCode/Views/Toolbar/RxCodeToolbar.swift b/RxCode/Views/Toolbar/RxCodeToolbar.swift index 9c538fa9..2dbac5c1 100644 --- a/RxCode/Views/Toolbar/RxCodeToolbar.swift +++ b/RxCode/Views/Toolbar/RxCodeToolbar.swift @@ -27,6 +27,12 @@ struct RxCodeToolbarContent: ToolbarContent { .padding(.horizontal) } ToolbarItemGroup(placement: .primaryAction) { + RunProfileToolbarGroup() + } + + ToolbarItemGroup(placement: .primaryAction) { + ExternalEditorMenu() + Button { appState.startNewChat(in: windowState) } label: { @@ -34,8 +40,6 @@ struct RxCodeToolbarContent: ToolbarContent { } .help("New Chat") - ExternalEditorMenu() - Button { if NSEvent.modifierFlags.contains(.option) { let path = windowState.selectedProject?.path ?? ""