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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,11 @@ final class AppState {
}

/// Recompute stacks + standalone from the current container list and known projects.
/// Name-prefix inference for externally-launched containers is opt-in (default off) so
/// unrelated containers that merely share a prefix aren't grouped into a fake stack (#12).
private func reassemble() {
let result = registry.assemble(containers: containers)
let infer = UserDefaults.standard.bool(forKey: "groupByNamePrefix")
let result = registry.assemble(containers: containers, inferStacks: infer)
stacks = result.stacks
standalone = result.standalone
}
Expand Down
9 changes: 9 additions & 0 deletions App/ConsaiApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ enum ConsaiEntry {
exit(0)
}
app.run()
} else if let i = args.firstIndex(of: "--render-live") {
let dir = URL(fileURLWithPath: i + 1 < args.count ? args[i + 1] : "shots")
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
Task { @MainActor in
await ShotRenderer.renderLive(to: dir)
exit(0)
}
app.run()
} else if let i = args.firstIndex(of: "--render-icon") {
let out = URL(fileURLWithPath: i + 1 < args.count ? args[i + 1] : "icon.png")
let app = NSApplication.shared
Expand Down
12 changes: 12 additions & 0 deletions App/Shots/ShotRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ enum ShotRenderer {
FileHandle.standardError.write(Data("rendered shots to \(dir.path)\n".utf8))
}

/// Capture the panel backed by the REAL AppState (live daemon), for QA/authentic shots.
/// Refreshes twice with a gap so CPU% (needs two samples) populates.
static func renderLive(to dir: URL) async {
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let state = AppState()
await state.refresh()
try? await Task.sleep(for: .seconds(2.5))
await state.refresh()
await shoot(PanelView().environment(state), width: 360, height: 600, name: "live-panel", dir: dir)
FileHandle.standardError.write(Data("rendered live panel to \(dir.path)\n".utf8))
}

private static func shoot<V: View>(_ view: V, width: CGFloat, height: CGFloat, name: String, dir: URL) async {
let themed = view.frame(width: width, height: height)
.preferredColorScheme(.dark).tint(Theme.jade)
Expand Down
7 changes: 7 additions & 0 deletions App/Views/SettingsWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ struct SettingsWindow: View {
@Environment(AppState.self) private var appState
@AppStorage("pollOpen") private var pollOpen = 2.0
@AppStorage("pollClosed") private var pollClosed = 15.0
@AppStorage("groupByNamePrefix") private var groupByNamePrefix = false
@State private var working = false

var body: some View {
Expand Down Expand Up @@ -38,6 +39,12 @@ struct SettingsWindow: View {
}
}

Section("Stacks") {
Toggle("Group external containers by name prefix", isOn: $groupByNamePrefix)
Text("Off: only stacks you launch through Consai are grouped (reliable). On: containers that share a `name-` prefix are grouped as inferred stacks — can mis-group unrelated containers.")
.font(.caption).foregroundStyle(.secondary)
}

Section("Refresh cadence") {
LabeledContent("Panel open") {
Stepper("\(pollOpen, specifier: "%.0f")s", value: $pollOpen, in: 1...10)
Expand Down
50 changes: 30 additions & 20 deletions ConsaiCore/Sources/ConsaiCore/Engines/ProjectRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,16 @@ public struct ProjectRegistry: Codable, Sendable, Equatable {
// MARK: - Assembly

/// Fold containers into stacks + standalone leftovers.
public func assemble(containers: [Container]) -> (stacks: [Stack], standalone: [Container]) {
///
/// - Parameter inferStacks: when true, containers Consai didn't launch are grouped by
/// `<project>-<service>` name prefix (best-effort, marked `.inferred`). This can
/// over-group unrelated containers that merely share a prefix (e.g. `qa-web` + `qa-cache`),
/// so it defaults to **false** — only Consai-launched stacks group reliably. Callers
/// opt in via a user setting.
public func assemble(
containers: [Container],
inferStacks: Bool = false
) -> (stacks: [Stack], standalone: [Container]) {
var remaining = containers
var stacks: [Stack] = []

Expand All @@ -64,29 +73,30 @@ public struct ProjectRegistry: Codable, Sendable, Equatable {
)
}

// 2. Inferred stacks: group leftovers by candidate prefix (before the last "-").
var byCandidate: [String: [Container]] = [:]
// 2. Inferred stacks (opt-in): group leftovers by candidate prefix (before the last
// "-"). Off by default — see issue #12 — so unrelated containers sharing a prefix
// aren't grouped into a fake stack.
var standalone: [Container] = []
for container in remaining {
if let candidate = Self.inferredProject(from: container.name) {
byCandidate[candidate, default: []].append(container)
} else {
standalone.append(container)
if inferStacks {
var byCandidate: [String: [Container]] = [:]
for container in remaining {
if let candidate = Self.inferredProject(from: container.name) {
byCandidate[candidate, default: []].append(container)
} else {
standalone.append(container)
}
}
}
for (candidate, members) in byCandidate {
if members.count >= 2 {
stacks.append(
Stack(
projectName: candidate,
composeFilePath: nil,
services: members,
origin: .inferred
for (candidate, members) in byCandidate {
if members.count >= 2 {
stacks.append(
Stack(projectName: candidate, composeFilePath: nil, services: members, origin: .inferred)
)
)
} else {
standalone.append(contentsOf: members)
} else {
standalone.append(contentsOf: members)
}
}
} else {
standalone = remaining
}

// Stable ordering: stacks by name, standalone by name.
Expand Down
13 changes: 11 additions & 2 deletions ConsaiCore/Tests/ConsaiCoreTests/ProjectRegistryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ import Foundation
#expect(result.standalone.count == 1)
}

@Test func infersStackFromSharedPrefixWhenTwoOrMore() {
@Test func infersStackFromSharedPrefixWhenOptedIn() {
let result = ProjectRegistry().assemble(containers: [
c("1", "shop-web"), c("2", "shop-db"), c("3", "shop-cache"),
])
], inferStacks: true)
#expect(result.stacks.count == 1)
let stack = try! #require(result.stacks.first)
#expect(stack.projectName == "shop")
Expand All @@ -46,6 +46,15 @@ import Foundation
#expect(result.standalone.isEmpty)
}

@Test func doesNotInferStacksByDefault() {
// Default (inference off): unrelated containers sharing a prefix stay standalone (#12).
let result = ProjectRegistry().assemble(containers: [
c("1", "qa-web"), c("2", "qa-cache"),
])
#expect(result.stacks.isEmpty)
#expect(result.standalone.map(\.name).sorted() == ["qa-cache", "qa-web"])
}

@Test func singleHyphenatedContainerIsNotAStack() {
let result = ProjectRegistry().assemble(containers: [c("1", "alone-web")])
#expect(result.stacks.isEmpty)
Expand Down
Loading