• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

TommyWoodley / NotchUtility / 20693799772

04 Jan 2026 01:42PM UTC coverage: 33.657% (-0.05%) from 33.707%
20693799772

Pull #66

github

web-flow
Merge 9dafdf6bf into 65a8ccfeb
Pull Request #66: NU-27-4: Implement ToolWindowController for presenting tools in a seperate window

343 of 1133 branches covered (30.27%)

Branch coverage included in aggregate %.

104 of 107 new or added lines in 3 files covered. (97.2%)

57 existing lines in 3 files now uncovered.

1078 of 3089 relevant lines covered (34.9%)

5.16 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

43.9
/NotchUtility/Views/Components/DevToolModals.swift
1
//
2
//  DevToolModals.swift
3
//  NotchUtility
4
//
5
//  Created by thwoodle on 27/07/2025.
6
//
7

8
import SwiftUI
9

10
// MARK: - Tool Modal View
11
struct ToolModalView: View {
12
    let tool: DevTool
13
    @Environment(\.dismiss)
14
    private var dismiss
15
    
UNCOV
16
    var body: some View {
×
UNCOV
17
        VStack(spacing: 0) {
×
UNCOV
18
            // Modal Header
×
UNCOV
19
            HStack {
×
UNCOV
20
                Image(systemName: tool.icon)
×
UNCOV
21
                    .foregroundColor(tool.color)
×
UNCOV
22
                    .font(.title2)
×
UNCOV
23
                
×
UNCOV
24
                Text(tool.name)
×
UNCOV
25
                    .font(.title2)
×
UNCOV
26
                    .fontWeight(.semibold)
×
UNCOV
27
                
×
UNCOV
28
                Spacer()
×
UNCOV
29
                
×
UNCOV
30
                Button(action: { dismiss() }, label: {
×
UNCOV
31
                    Image(systemName: "xmark.circle.fill")
×
UNCOV
32
                        .foregroundColor(.secondary)
×
UNCOV
33
                        .font(.title2)
×
UNCOV
34
                })
×
UNCOV
35
                .buttonStyle(PlainButtonStyle())
×
UNCOV
36
            }
×
UNCOV
37
            .padding(.horizontal, 20)
×
UNCOV
38
            .padding(.top, 20)
×
UNCOV
39
            .padding(.bottom, 16)
×
UNCOV
40
            
×
UNCOV
41
            Divider()
×
UNCOV
42
            
×
UNCOV
43
            // Tool Content
×
UNCOV
44
            ScrollView {
×
UNCOV
45
                switch tool {
×
UNCOV
46
                case .base64:
×
UNCOV
47
                    Base64Tool()
×
UNCOV
48
                }
×
UNCOV
49
            }
×
NEW
50
            .scrollContentBackground(.hidden)
×
NEW
51
            .background(Color(nsColor: .windowBackgroundColor))
×
UNCOV
52
            .frame(maxWidth: .infinity, maxHeight: .infinity)
×
UNCOV
53
        }
×
UNCOV
54
        .frame(width: 600, height: 450)
×
UNCOV
55
        .background(Color(nsColor: .windowBackgroundColor))
×
NEW
56
        .presentationBackground(Color(nsColor: .windowBackgroundColor))
×
UNCOV
57
    }
×
58
}
59

60
// MARK: - Conversion Tool Protocol
61
protocol ConversionMode: CaseIterable, Identifiable {
62
    var title: String { get }
63
    var icon: String { get }
64
    var inputLabel: String { get }
65
    var outputLabel: String { get }
66
}
67

68
protocol ConversionService: ObservableObject {
69
    associatedtype Mode: ConversionMode
70
    associatedtype ConversionError: LocalizedError
71
    
72
    func convert(_ input: String, mode: Mode) -> Result<String, ConversionError>
73
}
74

75
// MARK: - Generic Conversion Tool View
76
struct ConversionToolView<Mode: ConversionMode, Service: ConversionService>: View where Service.Mode == Mode {
77
    @State private var inputText: String = ""
1✔
78
    @State private var outputText: String = ""
1✔
79
    @State private var mode: Mode
80
    @State private var showingError: Bool = false
1✔
81
    @State private var errorMessage: String = ""
1✔
82

83
    @StateObject private var service: Service
84
    
85
    init(defaultMode: Mode, service: Service) {
1✔
86
        self._mode = State(initialValue: defaultMode)
1✔
87
        self._service = StateObject(wrappedValue: service)
1✔
88
    }
1✔
89

90
    var body: some View {
1✔
91
        VStack(spacing: 16) {
1✔
92
            // Mode Toggle
1✔
93
            HStack {
1✔
94
                ForEach(Array(Mode.allCases), id: \.id) { toggleMode in
4✔
95
                    Button(
4✔
96
                        action: {
4✔
97
                            mode = toggleMode
×
98
                            convertText()
×
99
                        },
×
100
                        label: {
4✔
101
                            HStack(spacing: 6) {
4✔
102
                                Image(systemName: toggleMode.icon)
4✔
103
                                    .font(.caption)
4✔
104
                                Text(toggleMode.title)
4✔
105
                                    .font(.callout)
4✔
106
                            }
4✔
107
                            .padding(.vertical, 8)
4✔
108
                            .padding(.horizontal, 16)
4✔
109
                            .background(
4✔
110
                                RoundedRectangle(cornerRadius: 6)
4✔
111
                                    .fill(mode.id == toggleMode.id ? Color.accentColor : Color(nsColor: .controlBackgroundColor))
4✔
112
                            )
4✔
113
                            .foregroundColor(mode.id == toggleMode.id ? .white : .primary)
4✔
114
                        }
4✔
115
                    )
4✔
116
                    .buttonStyle(PlainButtonStyle())
4✔
117
                }
4✔
118

1✔
119
                Spacer()
1✔
120
            }
1✔
121

1✔
122
            // Input Field
1✔
123
            VStack(alignment: .leading, spacing: 8) {
1✔
124
                HStack {
1✔
125
                    Text(mode.inputLabel)
1✔
126
                        .font(.callout)
1✔
127
                        .fontWeight(.medium)
1✔
128

1✔
129
                    Spacer()
1✔
130

1✔
131
                    if showingError {
1✔
132
                        Text(errorMessage)
×
133
                            .font(.caption)
×
134
                            .foregroundColor(.red)
×
135
                    } else if !inputText.isEmpty {
1✔
136
                        Button(action: clearAll) {
×
137
                            Label("Clear", systemImage: "trash")
×
138
                                .font(.callout)
×
139
                        }
×
140
                        .buttonStyle(PlainButtonStyle())
×
141
                    }
×
142
                }
×
143

1✔
144
                TextEditor(text: $inputText)
1✔
145
                    .font(.system(.body, design: .monospaced))
1✔
146
                    .scrollContentBackground(.hidden)
1✔
147
                    .background(
1✔
148
                        RoundedRectangle(cornerRadius: 8)
1✔
149
                            .fill(Color(nsColor: .textBackgroundColor))
1✔
150
                            .overlay(
1✔
151
                                RoundedRectangle(cornerRadius: 8)
1✔
152
                                    .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
1✔
153
                            )
1✔
154
                    )
1✔
155
                    .frame(minHeight: 100, maxHeight: 120)
1✔
156
                    .onChange(of: inputText) {
1✔
157
                        convertText()
×
158
                    }
×
159
            }
1✔
160

1✔
161
            // Output Field
1✔
162
            VStack(alignment: .leading, spacing: 8) {
1✔
163
                HStack {
1✔
164
                    Text(mode.outputLabel)
1✔
165
                        .font(.callout)
1✔
166
                        .fontWeight(.medium)
1✔
167

1✔
168
                    Spacer()
1✔
169

1✔
170
                    if !outputText.isEmpty {
1✔
171
                        Button(action: copyOutput) {
×
172
                            Label("Copy", systemImage: "doc.on.clipboard")
×
173
                                .font(.callout)
×
174
                        }
×
175
                        .buttonStyle(PlainButtonStyle())
×
176
                    }
×
177
                }
×
178

1✔
179
                TextEditor(text: .constant(outputText))
1✔
180
                    .font(.system(.body, design: .monospaced))
1✔
181
                    .scrollContentBackground(.hidden)
1✔
182
                    .background(
1✔
183
                        RoundedRectangle(cornerRadius: 8)
1✔
184
                            .fill(Color(nsColor: .controlBackgroundColor))
1✔
185
                            .overlay(
1✔
186
                                RoundedRectangle(cornerRadius: 8)
1✔
187
                                    .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
1✔
188
                            )
1✔
189
                    )
1✔
190
                    .frame(minHeight: 100, maxHeight: 120)
1✔
191
                    .disabled(true)
1✔
192
            }
1✔
193
        }
1✔
194
        .padding(20)
1✔
195
        .frame(maxWidth: .infinity, maxHeight: .infinity)
1✔
196
        .background(Color(nsColor: .windowBackgroundColor))
1✔
197
    }
1✔
198

199
    private func convertText() {
×
200
        showingError = false
×
201
        errorMessage = ""
×
202

×
203
        guard !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
×
204
            outputText = ""
×
205
            return
×
206
        }
×
207

×
208
        let result = service.convert(inputText, mode: mode)
×
209

×
210
        switch result {
×
211
        case .success(let convertedText):
×
212
            outputText = convertedText
×
213
        case .failure(let error):
×
214
            outputText = ""
×
215
            showError(error.localizedDescription)
×
216
        }
×
217
    }
×
218

219
    private func showError(_ message: String) {
×
220
        errorMessage = message
×
221
        showingError = true
×
222

×
223
        // Auto-hide error after 3 seconds
×
224
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
×
225
            withAnimation(.easeInOut(duration: 0.3)) {
×
226
                showingError = false
×
227
            }
×
228
        }
×
229
    }
×
230

231
    private func clearAll() {
×
232
        inputText = ""
×
233
        outputText = ""
×
234
        showingError = false
×
235
    }
×
236

237
    private func copyOutput() {
×
238
        let pasteboard = NSPasteboard.general
×
239
        pasteboard.clearContents()
×
240
        pasteboard.setString(outputText, forType: .string)
×
241
    }
×
242
}
243

244
// MARK: - Base64 Protocol Conformances
245
extension Base64Mode: ConversionMode {}
246

247
extension Base64ToolService: ConversionService {
248
    typealias Mode = Base64Mode
249
    typealias ConversionError = Base64Error
250
}
251

252
// MARK: - Base64 Tool
253
struct Base64Tool: View {
254
    var body: some View {
1✔
255
        ConversionToolView(
1✔
256
            defaultMode: Base64Mode.encode,
1✔
257
            service: Base64ToolService()
1✔
258
        )
1✔
259
    }
1✔
260
}
261

262
// MARK: - Placeholder Tool
263
struct PlaceholderTool: View {
264
    let name: String
265
    let description: String
266
    
267
    var body: some View {
×
268
        VStack(spacing: 16) {
×
269
            Image(systemName: "hammer.fill")
×
270
                .font(.system(size: 50))
×
271
                .foregroundColor(.secondary)
×
272
            
×
273
            Text("Coming Soon")
×
274
                .font(.title2)
×
275
                .fontWeight(.semibold)
×
276
            
×
277
            Text("\(name) - \(description)")
×
278
                .font(.body)
×
279
                .foregroundColor(.secondary)
×
280
                .multilineTextAlignment(.center)
×
281
        }
×
282
        .padding(40)
×
283
        .frame(maxWidth: .infinity, maxHeight: .infinity)
×
284
    }
×
285
}
286

287
// MARK: - Previews
288
#Preview("Base64 Tool Modal") {
289
    ToolModalView(tool: .base64)
290
        .preferredColorScheme(.dark)
291
}
292

293
#Preview("Base64 Tool - Standalone") {
294
    Base64Tool()
295
        .background(Color(nsColor: .windowBackgroundColor))
296
        .preferredColorScheme(.dark)
297
        .frame(width: 600, height: 500)
298
}
299

300
#Preview("Base64 Tool - Light Mode") {
301
    Base64Tool()
302
        .background(Color(nsColor: .windowBackgroundColor))
303
        .preferredColorScheme(.light)
304
        .frame(width: 600, height: 500)
305
} 
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc