import SwiftUI import UIKit struct EnhancedConsoleView: View { let lines: [ConsoleLine] var onClear: (() -> Void)? = nil @State private var filterMode: FilterMode = .all @State private var searchText = "" @State private var showSearch = false @State private var isPinned = true @State private var fontSize: CGFloat = 11 enum FilterMode: String, CaseIterable { case all, errors, warnings, info var label: String { rawValue.capitalized } var icon: String { switch self { case .all: return "list.bullet"; case .errors: return "xmark.circle" case .warnings: return "exclamationmark.triangle"; case .info: return "info.circle" } } } private var filtered: [ConsoleLine] { var result = lines switch filterMode { case .errors: result = result.filter { $0.type == .error } case .warnings: result = result.filter { $0.type == .warning } case .info: result = result.filter { $0.type == .info || $0.type == .standard } case .all: break } if !searchText.isEmpty { result = result.filter { $0.text.localizedCaseInsensitiveContains(searchText) } } return result } private var errorCount: Int { lines.filter { $0.type == .error }.count } private var warningCount: Int { lines.filter { $0.type == .warning }.count } var body: some View { VStack(spacing: 0) { // Toolbar HStack(spacing: 6) { ForEach(FilterMode.allCases, id: \.self) { mode in Button { filterMode = mode } label: { HStack(spacing: 3) { Image(systemName: mode.icon).font(.system(size: 10)) if mode == .errors && errorCount > 0 { Text("\(errorCount)").font(.system(.caption2, design: .monospaced).bold()) } if mode == .warnings && warningCount > 0 { Text("\(warningCount)").font(.system(.caption2, design: .monospaced).bold()) } } .padding(.horizontal, 7).padding(.vertical, 3) .background(filterMode == mode ? pillColor(mode).opacity(0.2) : Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: 5)) .foregroundStyle(filterMode == mode ? pillColor(mode) : .secondary) } .buttonStyle(.plain) } Spacer() Button { withAnimation { showSearch.toggle(); if !showSearch { searchText = "" } } } label: { Image(systemName: "magnifyingglass").foregroundStyle(showSearch ? Color.accentColor : Color.secondary) } .buttonStyle(.plain) Button { UIPasteboard.general.string = filtered.map(\.text).joined(separator: "\n") } label: { Image(systemName: "doc.on.doc").foregroundStyle(.secondary) }.buttonStyle(.plain) Button { isPinned.toggle() } label: { Image(systemName: isPinned ? "pin.fill" : "pin.slash").foregroundStyle(isPinned ? Color.accentColor : Color.secondary) } .buttonStyle(.plain) if let clear = onClear { Button(action: clear) { Image(systemName: "trash").foregroundStyle(.secondary) }.buttonStyle(.plain) } } .padding(.horizontal, 10).padding(.vertical, 5) .background(Color(.secondarySystemBackground)) if showSearch { HStack(spacing: 6) { Image(systemName: "magnifyingglass").foregroundStyle(.secondary).font(.caption) TextField("Filter output…", text: $searchText) .font(.system(.callout, design: .monospaced)).autocorrectionDisabled().autocapitalization(.none) if !searchText.isEmpty { Button { searchText = "" } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) }.buttonStyle(.plain) Text("\(filtered.count)").font(.caption2).foregroundStyle(.secondary) } } .padding(.horizontal, 10).padding(.vertical, 6).background(Color(.systemBackground)) Divider() } Divider() ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 0) { ForEach(filtered) { line in HStack(alignment: .top, spacing: 4) { Rectangle().fill(gutterColor(line.type)).frame(width: 2).padding(.vertical, 1) Text(line.text) .font(.system(size: fontSize, design: .monospaced)) .foregroundStyle(line.color) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } .padding(.vertical, 1) .id(line.id) } } .padding(.horizontal, 8).padding(.vertical, 4) } .background(Color(.systemBackground)) .onChange(of: filtered.count) { _, _ in if isPinned, let last = filtered.last { withAnimation(.linear(duration: 0.08)) { proxy.scrollTo(last.id, anchor: .bottom) } } } } } } private func pillColor(_ mode: FilterMode) -> Color { switch mode { case .errors: return .red; case .warnings: return .orange case .info: return .blue; case .all: return Color.accentColor } } private func gutterColor(_ type: ConsoleLine.LineType) -> Color { switch type { case .error: return .red; case .warning: return .orange case .success: return .green; case .info: return Color.accentColor; default: return .clear } } }