Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
51dc235
Fix build on newer Swift 6 compilers
jondkinney Aug 16, 2026
636398f
Fix the test target under Swift 6 concurrency
jondkinney Aug 17, 2026
9333f31
Show workflow name in CI/CD run rows
jondkinney Aug 16, 2026
2baf36b
Collapse CI/CD rows to latest run per workflow
jondkinney Aug 16, 2026
a0bbc72
Offer currently shown repositories in the CI/CD hide list
jondkinney Aug 16, 2026
3ed2066
Allow 1-row sizes for glanceable widgets
jondkinney Aug 16, 2026
001eeeb
Optimize widget layouts for density and small sizes
jondkinney Aug 16, 2026
e7bd06d
Keep identifying labels at small widget sizes
jondkinney Aug 16, 2026
a900725
Render rate pairs through one shared component
jondkinney Aug 16, 2026
8b911cb
Fix stub config dropdowns; wire Sort By and add Rows
jondkinney Aug 16, 2026
8af8d8e
Match Network titles to Disk I/O; grow Day Bar day strip
jondkinney Aug 16, 2026
6e885ae
Raise the process list capacity to 16 rows
jondkinney Aug 16, 2026
28dddb8
Center Network's 1-row layout like Disk I/O's
jondkinney Aug 16, 2026
b66b868
Stretch the Day Bar time to the width of the day strip
jondkinney Aug 16, 2026
3242585
Justify Day Bar chips so strip and clock edges align
jondkinney Aug 16, 2026
9f52822
Center the Day Bar chips above the clock
jondkinney Aug 16, 2026
f97102c
Center the Day Bar group vertically
jondkinney Aug 16, 2026
99a067b
Hold rate-pair type at a constant size
jondkinney Aug 16, 2026
7db1211
Sort Top Processes by tapping its column headers
jondkinney Aug 16, 2026
a1a9f3f
Keep Disk I/O and Network titles at 2-column widths
jondkinney Aug 16, 2026
2ef5cb0
Allow 1-column Disk I/O and Network placements
jondkinney Aug 16, 2026
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
4 changes: 2 additions & 2 deletions Sources/EdgeControl/Services/ProcessMonitorService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,8 @@ public final class ProcessMonitorService: ObservableObject {
}

// Sort, take top 5, resolve icons
let top5 = Array(results.sorted { $0.cpuPercent > $1.cpuPercent }.prefix(5))
resolveIcons(for: top5)
let top = Array(results.sorted { $0.cpuPercent > $1.cpuPercent }.prefix(16))
resolveIcons(for: top)
}

/// Resolve app icons on MainActor.
Expand Down
89 changes: 43 additions & 46 deletions Sources/EdgeControl/UI/Components/RadialGaugeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,63 +36,60 @@ struct RadialGaugeView: View {
// Dampened ratio (0.5x) — gauge already has large proportional sizes, full ratio overshoots.
let valueRatio = 1.0 + (ts.fontSizeValue / 28.0 - 1.0) * 0.5
let captionRatio = 1.0 + (ts.fontSizeCaption / 11.0 - 1.0) * 0.5
let titleRatio = 1.0 + (ts.fontSizeTitle / 18.0 - 1.0) * 0.5
let valueFontSize = (isCompact ? minDim * 0.28 : minDim * 0.20) * scale * valueRatio
let unitFontSize = (isCompact ? minDim * 0.10 : minDim * 0.07) * scale * captionRatio
let labelFontSize = (isCompact ? minDim * 0.10 : minDim * 0.08) * scale * titleRatio
let labelFontSize = (isCompact ? minDim * 0.09 : minDim * 0.065) * scale * captionRatio
let lwScaled = isCompact ? lineWidth * 0.7 : lineWidth
let design = ts.fontFamily.design

VStack(spacing: isCompact ? 2 : 6) {
ZStack {
Circle()
.fill(Theme.glowGradient(gaugeColor))
.scaleEffect(1.3)
.opacity(0.4)
// Label lives inside the ring so the arc fills min(width, height) with no caption row below.
ZStack {
Circle()
.fill(Theme.glowGradient(gaugeColor))
.scaleEffect(1.3)
.opacity(0.4)

ArcShape(startAngle: startAngle, endAngle: endAngle)
.stroke(Color.white.opacity(0.08), style: StrokeStyle(lineWidth: lwScaled, lineCap: .round))
ArcShape(startAngle: startAngle, endAngle: endAngle)
.stroke(Color.white.opacity(0.08), style: StrokeStyle(lineWidth: lwScaled, lineCap: .round))

ArcShape(startAngle: startAngle, endAngle: startAngle + (endAngle - startAngle) * progress)
.stroke(
AngularGradient(
stops: [
.init(color: accentColor, location: 0.0),
.init(color: gaugeColor, location: 1.0)
],
center: .center,
startAngle: .degrees(startAngle),
endAngle: .degrees(startAngle + (endAngle - startAngle) * progress)
),
style: StrokeStyle(lineWidth: lwScaled, lineCap: .round)
)
ArcShape(startAngle: startAngle, endAngle: startAngle + (endAngle - startAngle) * progress)
.stroke(
AngularGradient(
stops: [
.init(color: accentColor, location: 0.0),
.init(color: gaugeColor, location: 1.0)
],
center: .center,
startAngle: .degrees(startAngle),
endAngle: .degrees(startAngle + (endAngle - startAngle) * progress)
),
style: StrokeStyle(lineWidth: lwScaled, lineCap: .round)
)

VStack(spacing: 2) {
Text(displayValue)
.font(.system(size: valueFontSize, weight: .bold, design: design))
.foregroundStyle(Theme.text1(ts))
.contentTransition(.numericText())
.minimumScaleFactor(0.5)
if !unit.isEmpty && !isCompact {
Text(unit)
.font(.system(size: unitFontSize, weight: .semibold, design: design))
.foregroundStyle(Theme.text2(ts))
.textCase(.uppercase)
.minimumScaleFactor(0.4)
.lineLimit(1)
}
VStack(spacing: 2) {
Text(displayValue)
.font(.system(size: valueFontSize, weight: .bold, design: design))
.foregroundStyle(Theme.text1(ts))
.contentTransition(.numericText())
.minimumScaleFactor(0.5)
if !unit.isEmpty {
Text(unit)
.font(.system(size: unitFontSize, weight: .semibold, design: design))
.foregroundStyle(Theme.text2(ts))
.textCase(.uppercase)
.minimumScaleFactor(0.4)
.lineLimit(1)
}
if showLabel {
Text(label)
.font(.system(size: labelFontSize, weight: .bold, design: design))
.foregroundStyle(Theme.text3(ts))
.textCase(.uppercase)
.lineLimit(1)
}
}
.aspectRatio(1, contentMode: .fit)

if showLabel {
Text(label)
.font(.system(size: labelFontSize, weight: .bold, design: design))
.foregroundStyle(Theme.text2(ts))
.textCase(.uppercase)
.lineLimit(1)
}
}
.aspectRatio(1, contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
Expand Down
67 changes: 67 additions & 0 deletions Sources/EdgeControl/UI/Components/WidgetHeader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,70 @@ struct WidgetHeader: View {
}
}
}

// MARK: - Rate Pair

/// Two labeled rates (down/up, read/write) rendered identically wherever a
/// widget shows them, so equal box sizes produce equal layouts across
/// Network, Disk I/O and friends.
struct RatePairView: View {
struct Entry {
let icon: String
let label: String
let value: String
let color: Color
}

let first: Entry
let second: Entry
var compact: Bool = false
/// One-column cells stack the groups; anything wider sits side by side.
var vertical: Bool = false

@Environment(\.themeSettings) private var ts

var body: some View {
if vertical {
VStack(alignment: .leading, spacing: 8) {
group(first)
group(second)
}
} else {
HStack(spacing: 12) {
group(first)
group(second)
}
}
}

private func group(_ entry: Entry) -> some View {
VStack(alignment: .leading, spacing: compact ? 2 : 4) {
HStack(spacing: 4) {
Image(systemName: entry.icon)
.font(.system(size: (compact ? 12 : 18) * ts.fontScale))
.foregroundStyle(entry.color)
Text(entry.label)
.font(Theme.label(ts))
.foregroundStyle(Theme.text3(ts))
}
Text(Self.padded(entry.value))
.font(Theme.value(ts))
.foregroundStyle(Theme.text1(ts))
.monospacedDigit()
.minimumScaleFactor(0.5)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}

/// Live values arrive at varying widths ("3.5 KB/s" vs "216.5 KB/s"), and
/// letting each width pick its own scale made the type pulse with the
/// data. Padding every value to the formatters' widest possible output
/// ("1023.9 KB/s", 11 figures) with figure spaces holds the measured
/// width — and therefore the rendered size — perfectly still.
private static func padded(_ value: String) -> String {
let target = 11
guard value.count < target else { return value }
return value + String(repeating: "\u{2007}", count: target - value.count)
}
}
8 changes: 6 additions & 2 deletions Sources/EdgeControl/UI/DashboardShell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,12 @@ struct DashboardShell: View {
activeColor: accent,
registry: model.touchService.zoneRegistry
) {
withAnimation(.easeInOut(duration: 0.2)) {
editMode.toggle()
// The zone registry runs actions on the main actor, but the
// closure itself is @Sendable, so hop explicitly for Swift 6.
Task { @MainActor in
withAnimation(.easeInOut(duration: 0.2)) {
editMode.toggle()
}
}
}
.overlay {
Expand Down
3 changes: 3 additions & 0 deletions Sources/EdgeControl/UI/GridPageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ struct GridPageView: View {
.padding(CGFloat(layoutEngine.document.globalSettings.theme.widgetGap))
}
.frame(width: w, height: h)
// Compact layouts at large font scales can overflow a
// 1-row cell; never let a widget paint over neighbors.
.clipped()
.opacity(isDragging ? 0.5 : isResizing ? 0.7 : 1)
.overlay {
if editMode {
Expand Down
33 changes: 33 additions & 0 deletions Sources/EdgeControl/UI/Settings/CICDSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ struct CICDSettingsView: View {
@State private var importError: String?

private var service: CICDService { model.cicdService }

/// Repositories currently contributing runs to the widget, ready to hide.
/// run.id is "<accountID>/<owner>/<name>/<run number>".
private var shownRepositories: [CIRepositoryRef] {
let hidden = service.settings.hiddenRepositories
var seen = Set<CIRepositoryRef>()
var result: [CIRepositoryRef] = []
for run in service.runs {
let parts = run.id.split(separator: "/")
guard parts.count >= 4,
let accountID = UUID(uuidString: String(parts[0])),
let host = store.accounts.first(where: { $0.id == accountID })?.host else { continue }
let ref = CIRepositoryRef(host: host, fullName: parts.dropFirst().dropLast().joined(separator: "/"))
if !hidden.contains(ref), seen.insert(ref).inserted { result.append(ref) }
}
return result.sorted()
}
private var store: CIAccountStore { model.accountStore }

private var cliAvailable: Bool {
Expand Down Expand Up @@ -246,6 +263,22 @@ struct CICDSettingsView: View {
host: $hiddenHost,
text: $hiddenDraft
)

// The typed entry above requires knowing the exact owner/name;
// this menu offers exactly what the widget is showing right now.
if !shownRepositories.isEmpty {
Menu {
ForEach(shownRepositories, id: \.self) { ref in
Button(ref.displayName) {
service.settings.hiddenRepositories.insert(ref)
}
}
} label: {
Label("Hide a repository currently shown…", systemImage: "eye.slash")
.font(Theme.label(ts))
}
.frame(maxWidth: 320)
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/EdgeControl/UI/Settings/WidgetConfigEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ struct WidgetConfigEditor: View {
set: { config[entry.key] = .string($0) }
)) {
ForEach(options, id: \.self) { option in
Text(option.capitalized.replacingOccurrences(of: "Daybar", with: "Day Bar").replacingOccurrences(of: "Dotmatrix", with: "Dot Matrix"))
Text(option.capitalized.replacingOccurrences(of: "Daybar", with: "Day Bar").replacingOccurrences(of: "Dotmatrix", with: "Dot Matrix").replacingOccurrences(of: "Cpu", with: "CPU"))
.tag(option)
}
}
Expand Down
49 changes: 47 additions & 2 deletions Sources/EdgeControl/Widgets/DevTools/CICDRunsWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,41 @@ private struct CICDRunsWidgetView: View {
)
}

/// Latest run per (account, repository, workflow). The widget is a status
/// board, not an activity log: re-runs and repeat deployments (Pages runs
/// all share one title) collapse to the workflow's current state.
private func latestPerWorkflow(_ runs: [CIRun]) -> [CIRun] {
var latest: [String: CIRun] = [:]
for run in runs {
// run.id is "<accountID>/<repo full name>/<run number>"; dropping
// the run number yields a key that survives same-named repos in
// different orgs, which repositoryName (the short name) would not.
let repoKey = run.id[..<(run.id.lastIndex(of: "/") ?? run.id.endIndex)]
let key = "\(repoKey)|\(run.workflowName)"
if let existing = latest[key], existing.startedAt >= run.startedAt { continue }
latest[key] = run
}
return latest.values.sorted { $0.startedAt > $1.startedAt }
}

/// The header badge counts what the list shows: distinct workflows, not
/// raw runs, once collapsing is in effect.
private var headerCount: Int {
if case .runs(let runs, _) = state { return latestPerWorkflow(runs).count }
return service.runs.count
}

/// Compact age label ("2h"). Empty for runs whose start time is unknown
/// (the provider falls back to .distantPast).
private func relativeAge(_ date: Date) -> String {
guard date != .distantPast else { return "" }
let seconds = max(0, Date().timeIntervalSince(date))
if seconds < 60 { return "now" }
if seconds < 3600 { return "\(Int(seconds / 60))m" }
if seconds < 86400 { return "\(Int(seconds / 3600))h" }
return "\(Int(seconds / 86400))d"
}

var body: some View {
VStack(spacing: 6) {
header
Expand Down Expand Up @@ -123,7 +158,7 @@ private struct CICDRunsWidgetView: View {
Task { @MainActor in SettingsWindowController.shared.show() }
}
}
Text("\(service.runs.count)")
Text("\(headerCount)")
.font(Theme.body(ts))
.foregroundStyle(Theme.text3(ts))
}
Expand Down Expand Up @@ -152,7 +187,7 @@ private struct CICDRunsWidgetView: View {
// the list to 50.
TouchScrollView {
VStack(spacing: 4) {
ForEach(runs) { run in
ForEach(latestPerWorkflow(runs)) { run in
runRow(run)
}
}
Expand Down Expand Up @@ -190,13 +225,23 @@ private struct CICDRunsWidgetView: View {
.font(Theme.label(ts))
.foregroundStyle(Theme.text3(ts).opacity(0.6))
}
// One commit can fan out to several workflows, and some runs
// share a constant title ("pages build and deployment"), so
// without the workflow name such rows are indistinguishable.
Text("· \(run.workflowName)")
.font(Theme.label(ts))
.foregroundStyle(Theme.text3(ts).opacity(0.6))
.lineLimit(1)
}
Text(run.title)
.font(Theme.body(ts))
.foregroundStyle(Theme.text1(ts))
.lineLimit(1)
}
Spacer()
Text(relativeAge(run.startedAt))
.font(Theme.label(ts))
.foregroundStyle(Theme.text3(ts).opacity(0.7))
Text(statusLabel(run))
.font(Theme.label(ts))
.foregroundStyle(statusColor(run))
Expand Down
Loading