diff --git a/CHANGELOG.md b/CHANGELOG.md index df8a35aa..b4e4cb9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- In-app feedback form for bug reports and feature requests via Help > Report an Issue - Per-connection "Local only" option to exclude individual connections from iCloud sync - Filter operator picker shows SQL symbols alongside names for quick visual recognition - SQL autocomplete now suggests column names before a FROM clause is written, using all cached schema columns as fallback diff --git a/TablePro/Core/Services/Infrastructure/AppNotifications.swift b/TablePro/Core/Services/Infrastructure/AppNotifications.swift index ea57cf2c..2f6154d5 100644 --- a/TablePro/Core/Services/Infrastructure/AppNotifications.swift +++ b/TablePro/Core/Services/Infrastructure/AppNotifications.swift @@ -45,4 +45,8 @@ extension Notification.Name { // MARK: - Settings Window static let openSettingsWindow = Notification.Name("com.TablePro.openSettingsWindow") + + // MARK: - Feedback + + static let showFeedbackWindow = Notification.Name("com.TablePro.showFeedbackWindow") } diff --git a/TablePro/Core/Services/Infrastructure/FeedbackAPIClient.swift b/TablePro/Core/Services/Infrastructure/FeedbackAPIClient.swift new file mode 100644 index 00000000..a9ffa794 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/FeedbackAPIClient.swift @@ -0,0 +1,157 @@ +// +// FeedbackAPIClient.swift +// TablePro +// + +import Foundation +import os + +enum FeedbackType: String, Codable, CaseIterable { + case bugReport = "bug_report" + case featureRequest = "feature_request" + case general + + var displayName: String { + switch self { + case .bugReport: String(localized: "Bug Report") + case .featureRequest: String(localized: "Feature Request") + case .general: String(localized: "General Feedback") + } + } + + var iconName: String { + switch self { + case .bugReport: "ladybug" + case .featureRequest: "lightbulb" + case .general: "bubble.left" + } + } +} + +struct FeedbackSubmissionRequest: Encodable { + let feedbackType: String + let title: String + let description: String + let stepsToReproduce: String? + let expectedBehavior: String? + let appVersion: String + let osVersion: String + let architecture: String + let databaseType: String? + let installedPlugins: [String] + let machineId: String + let screenshots: [String] +} + +struct FeedbackSubmissionResponse: Decodable { + let issueUrl: String + let issueNumber: Int +} + +enum FeedbackError: LocalizedError { + case networkError(Error) + case serverError(Int, String) + case rateLimited + case submissionTooLarge + case decodingError(Error) + + var errorDescription: String? { + switch self { + case .networkError: + String(localized: "Network error. Check your connection and try again.") + case .serverError(let code, let msg): + String(format: String(localized: "Server error (%d): %@"), code, msg) + case .rateLimited: + String(localized: "Too many submissions. Please try again later.") + case .submissionTooLarge: + String(localized: "Submission too large. Try removing the screenshot.") + case .decodingError: + String(localized: "Unexpected server response.") + } + } +} + +final class FeedbackAPIClient { + static let shared = FeedbackAPIClient() + + private static let logger = Logger(subsystem: "com.TablePro", category: "FeedbackAPIClient") + + // swiftlint:disable:next force_unwrapping + private let baseURL = URL(string: "https://api.tablepro.app/v1/feedback")! + + private let session: URLSession + + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + return encoder + }() + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + }() + + private init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 30 + self.session = URLSession(configuration: config) + } + + func submitFeedback(request: FeedbackSubmissionRequest) async throws -> FeedbackSubmissionResponse { + try await post(url: baseURL, body: request) + } + + // MARK: - Private + + private func post(url: URL, body: T) async throws -> R { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try encoder.encode(body) + + let data: Data + let response: URLResponse + + do { + (data, response) = try await session.data(for: request) + } catch { + Self.logger.error("Network request failed: \(error.localizedDescription)") + throw FeedbackError.networkError(error) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw FeedbackError.networkError(URLError(.badServerResponse)) + } + + switch httpResponse.statusCode { + case 200...299: + do { + return try decoder.decode(R.self, from: data) + } catch { + Self.logger.error("Failed to decode response: \(error.localizedDescription)") + throw FeedbackError.decodingError(error) + } + + case 413: + throw FeedbackError.submissionTooLarge + + case 429: + throw FeedbackError.rateLimited + + default: + let message: String + if let errorBody = try? JSONDecoder().decode([String: String].self, from: data), + let msg = errorBody["message"] { + message = msg + } else { + message = HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode) + } + Self.logger.error("Server error \(httpResponse.statusCode): \(message)") + throw FeedbackError.serverError(httpResponse.statusCode, message) + } + } +} diff --git a/TablePro/Core/Services/Infrastructure/FeedbackDiagnosticsCollector.swift b/TablePro/Core/Services/Infrastructure/FeedbackDiagnosticsCollector.swift new file mode 100644 index 00000000..599c9575 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/FeedbackDiagnosticsCollector.swift @@ -0,0 +1,59 @@ +// +// FeedbackDiagnosticsCollector.swift +// TablePro +// + +import Foundation + +struct FeedbackDiagnostics { + let appVersion: String + let osVersion: String + let architecture: String + let activeDatabaseType: String? + let installedPlugins: [String] + let machineId: String + + var formattedSummary: String { + var parts = ["TablePro \(appVersion)", "\(osVersion) ยท \(architecture)"] + if let db = activeDatabaseType { + parts.append("Database: \(db)") + } + return parts.joined(separator: "\n") + } + + var pluginsSummary: String { + let count = installedPlugins.count + return "\(count) plugin\(count == 1 ? "" : "s") installed" + } +} + +@MainActor +enum FeedbackDiagnosticsCollector { + static func collect() -> FeedbackDiagnostics { + let version = ProcessInfo.processInfo.operatingSystemVersion + let osVersion = "macOS \(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + + let architecture: String = { + #if arch(arm64) + return "Apple Silicon" + #else + return "Intel" + #endif + }() + + let databaseType = DatabaseManager.shared.activeSessions.values + .first + .map { $0.connection.type.rawValue } + + let plugins = PluginManager.shared.plugins.map { "\($0.name) v\($0.version)" } + + return FeedbackDiagnostics( + appVersion: "\(Bundle.main.appVersion) (Build \(Bundle.main.buildNumber))", + osVersion: osVersion, + architecture: architecture, + activeDatabaseType: databaseType, + installedPlugins: plugins, + machineId: LicenseStorage.shared.machineId + ) + } +} diff --git a/TablePro/Models/UI/FeedbackDraft.swift b/TablePro/Models/UI/FeedbackDraft.swift new file mode 100644 index 00000000..e9188bbf --- /dev/null +++ b/TablePro/Models/UI/FeedbackDraft.swift @@ -0,0 +1,15 @@ +// +// FeedbackDraft.swift +// TablePro +// + +import Foundation + +struct FeedbackDraft: Codable { + var feedbackType: String + var title: String + var description: String + var stepsToReproduce: String + var expectedBehavior: String + var includeDiagnostics: Bool +} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 187d10fc..531c3047 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -2153,6 +2153,16 @@ } } }, + "%lld/%lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld/%2$lld" + } + } + } + }, "%lld%%" : { "localizations" : { "tr" : { @@ -5826,6 +5836,9 @@ } } } + }, + "Attachments" : { + }, "Auth" : { "localizations" : { @@ -6662,6 +6675,9 @@ } } } + }, + "Brief summary of the issue" : { + }, "Bring All to Front" : { "localizations" : { @@ -6750,6 +6766,9 @@ } } } + }, + "Bug Report" : { + }, "Built-in" : { "localizations" : { @@ -7215,6 +7234,9 @@ } } } + }, + "Capture Window" : { + }, "Card Background" : { "localizations" : { @@ -11118,6 +11140,9 @@ } } } + }, + "Created as GitHub issue #%d" : { + }, "Creating..." : { "localizations" : { @@ -13344,6 +13369,9 @@ } } } + }, + "Description" : { + }, "Destructive Changes" : { "localizations" : { @@ -16055,6 +16083,9 @@ } } } + }, + "Expected Behavior" : { + }, "Expired" : { "localizations" : { @@ -17989,6 +18020,9 @@ } } } + }, + "Feature Request" : { + }, "Feature Routing" : { "localizations" : { @@ -18011,6 +18045,9 @@ } } } + }, + "Feedback submitted!" : { + }, "Fetch All" : { "localizations" : { @@ -19186,6 +19223,9 @@ } } } + }, + "General Feedback" : { + }, "Generated WHERE Clause" : { "localizations" : { @@ -20811,6 +20851,9 @@ } } } + }, + "Include diagnostics" : { + }, "Include in iCloud Sync" : { @@ -20881,6 +20924,9 @@ } } } + }, + "Includes app version, macOS version, and installed plugins only." : { + }, "Incorrect passphrase" : { "localizations" : { @@ -25099,6 +25145,9 @@ } } } + }, + "Network error. Check your connection and try again." : { + }, "Network is unavailable. Changes will sync when connectivity is restored." : { "localizations" : { @@ -32998,6 +33047,12 @@ } } } + }, + "Report an Issue" : { + + }, + "Report an Issue..." : { + }, "Require confirmation or Touch ID before executing queries." : { "localizations" : { @@ -35406,6 +35461,9 @@ } } } + }, + "Select images to attach" : { + }, "Select Plugin" : { "localizations" : { @@ -38207,6 +38265,9 @@ } } } + }, + "Steps to Reproduce" : { + }, "Stop" : { "localizations" : { @@ -38384,6 +38445,18 @@ } } } + }, + "Submission too large. Try removing the screenshot." : { + + }, + "Submit" : { + + }, + "Submit Another" : { + + }, + "Submitting..." : { + }, "Success" : { "localizations" : { @@ -41094,6 +41167,9 @@ } } } + }, + "Title" : { + }, "Title 2" : { "extractionState" : "stale", @@ -41610,6 +41686,9 @@ } } } + }, + "Too many submissions. Please try again later." : { + }, "Toolbar" : { "localizations" : { @@ -42251,6 +42330,9 @@ } } } + }, + "Unexpected server response." : { + }, "Uninstall" : { "localizations" : { @@ -43700,6 +43782,9 @@ } } } + }, + "View on GitHub" : { + }, "View: %@" : { "localizations" : { diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index a86c6600..7b0494b3 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -559,6 +559,12 @@ struct AppMenuCommands: Commands { Button("GitHub Repository") { if let url = URL(string: "https://github.com/TableProApp/TablePro") { NSWorkspace.shared.open(url) } } + + Divider() + + Button(String(localized: "Report an Issue...")) { + FeedbackWindowController.shared.showFeedbackPanel() + } } } } diff --git a/TablePro/ViewModels/FeedbackViewModel.swift b/TablePro/ViewModels/FeedbackViewModel.swift new file mode 100644 index 00000000..d7b46b9d --- /dev/null +++ b/TablePro/ViewModels/FeedbackViewModel.swift @@ -0,0 +1,302 @@ +// +// FeedbackViewModel.swift +// TablePro +// + +import AppKit +import Foundation +import Observation +import os +import UniformTypeIdentifiers + +struct FeedbackAttachment: Identifiable { + let id = UUID() + let image: NSImage +} + +@MainActor @Observable +final class FeedbackViewModel { + private static let logger = Logger(subsystem: "com.TablePro", category: "FeedbackViewModel") + private static let draftKey = "com.TablePro.feedbackDraft" + private static let maxScreenshotBytes = 2 * 1_024 * 1_024 + private static let maxAttachments = 5 + + // MARK: - User-editable state + + var feedbackType: FeedbackType = .bugReport { + didSet { scheduleDraftSave() } + } + + var title = "" { + didSet { scheduleDraftSave() } + } + + var description = "" { + didSet { scheduleDraftSave() } + } + + var stepsToReproduce = "" { + didSet { scheduleDraftSave() } + } + + var expectedBehavior = "" { + didSet { scheduleDraftSave() } + } + + var includeDiagnostics = true { + didSet { scheduleDraftSave() } + } + + var attachments: [FeedbackAttachment] = [] + + var canAddAttachment: Bool { + attachments.count < Self.maxAttachments + } + + // MARK: - Submission state + + private(set) var isSubmitting = false + private(set) var submissionResult: SubmissionResult? + private(set) var diagnostics: FeedbackDiagnostics + + enum SubmissionResult { + case success(issueUrl: URL, issueNumber: Int) + case failure(FeedbackError) + } + + // MARK: - Computed + + var isValid: Bool { + !title.trimmingCharacters(in: .whitespaces).isEmpty && + !description.trimmingCharacters(in: .whitespaces).isEmpty + } + + var canSubmit: Bool { + isValid && !isSubmitting + } + + // MARK: - Draft persistence + + @ObservationIgnored private var draftSaveTask: Task? + @ObservationIgnored private var isLoadingDraft = false + @ObservationIgnored var captureTargetWindow: NSWindow? + + // MARK: - Init + + init() { + self.diagnostics = FeedbackDiagnosticsCollector.collect() + loadDraft() + } + + // MARK: - Attachments + + func addImages(_ images: [NSImage]) { + for image in images { + guard canAddAttachment else { break } + attachments.append(FeedbackAttachment(image: image)) + } + } + + func removeAttachment(_ attachment: FeedbackAttachment) { + attachments.removeAll { $0.id == attachment.id } + } + + func pasteFromClipboard() { + guard let images = NSPasteboard.general.readObjects(forClasses: [NSImage.self]) as? [NSImage] else { + return + } + addImages(images) + } + + func captureWindow() { + let window = captureTargetWindow ?? NSApp.windows.first(where: { + $0.identifier?.rawValue.hasPrefix("main") == true && $0.isVisible + }) + guard let window, let contentView = window.contentView else { return } + + let bounds = contentView.bounds + guard let bitmap = contentView.bitmapImageRepForCachingDisplay(in: bounds) else { return } + contentView.cacheDisplay(in: bounds, to: bitmap) + + let image = NSImage(size: bounds.size) + image.addRepresentation(bitmap) + addImages([image]) + } + + func browseFiles() async { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.png, .jpeg, .tiff, .bmp, .gif, .heic] + panel.allowsMultipleSelection = true + panel.message = String(localized: "Select images to attach") + let response = await panel.begin() + guard response == .OK else { return } + + let images = panel.urls.compactMap { NSImage(contentsOf: $0) } + addImages(images) + } + + // MARK: - Submission + + func submit() async { + guard canSubmit else { return } + + isSubmitting = true + submissionResult = nil + defer { isSubmitting = false } + + let encodedScreenshots = encodeScreenshots() + + let architectureString: String = { + #if arch(arm64) + return "arm64" + #else + return "x86_64" + #endif + }() + + let request = FeedbackSubmissionRequest( + feedbackType: feedbackType.rawValue, + title: title.trimmingCharacters(in: .whitespaces), + description: description.trimmingCharacters(in: .whitespaces), + stepsToReproduce: feedbackType == .bugReport && !stepsToReproduce.trimmingCharacters(in: .whitespaces).isEmpty + ? stepsToReproduce.trimmingCharacters(in: .whitespaces) : nil, + expectedBehavior: feedbackType == .bugReport && !expectedBehavior.trimmingCharacters(in: .whitespaces).isEmpty + ? expectedBehavior.trimmingCharacters(in: .whitespaces) : nil, + appVersion: diagnostics.appVersion, + osVersion: diagnostics.osVersion, + architecture: architectureString, + databaseType: diagnostics.activeDatabaseType, + installedPlugins: includeDiagnostics ? diagnostics.installedPlugins : [], + machineId: includeDiagnostics ? diagnostics.machineId : "", + screenshots: encodedScreenshots + ) + + do { + let response = try await FeedbackAPIClient.shared.submitFeedback(request: request) + if let url = URL(string: response.issueUrl) { + submissionResult = .success(issueUrl: url, issueNumber: response.issueNumber) + clearDraft() + Self.logger.info("Feedback submitted: issue #\(response.issueNumber)") + } else { + submissionResult = .failure(.decodingError(URLError(.badURL))) + } + } catch let error as FeedbackError { + submissionResult = .failure(error) + Self.logger.error("Feedback submission failed: \(error.localizedDescription)") + } catch { + submissionResult = .failure(.networkError(error)) + Self.logger.error("Feedback submission failed: \(error.localizedDescription)") + } + } + + func clearSubmissionResult() { + submissionResult = nil + } + + func resetForNewSubmission() { + feedbackType = .bugReport + title = "" + description = "" + stepsToReproduce = "" + expectedBehavior = "" + attachments = [] + submissionResult = nil + diagnostics = FeedbackDiagnosticsCollector.collect() + } + + // MARK: - Private + + private func encodeScreenshots() -> [String] { + attachments.compactMap { encodeImage($0.image) } + } + + private func encodeImage(_ image: NSImage) -> String? { + guard let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData) else { + return nil + } + + var currentImage = bitmap + var pngData = currentImage.representation(using: .png, properties: [:]) + + while let data = pngData, data.count > Self.maxScreenshotBytes { + let newWidth = Int(Double(currentImage.pixelsWide) * 0.7) + let newHeight = Int(Double(currentImage.pixelsHigh) * 0.7) + guard newWidth > 100, newHeight > 100 else { break } + + let resized = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: newWidth, + pixelsHigh: newHeight, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) + guard let resized else { break } + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: resized) + currentImage.draw( + in: NSRect(x: 0, y: 0, width: newWidth, height: newHeight), + from: .zero, + operation: .copy, + fraction: 1.0, + respectFlipped: false, + hints: [.interpolation: NSImageInterpolation.high] + ) + NSGraphicsContext.restoreGraphicsState() + + currentImage = resized + pngData = currentImage.representation(using: .png, properties: [:]) + } + + return pngData?.base64EncodedString() + } + + private func scheduleDraftSave() { + guard !isLoadingDraft else { return } + draftSaveTask?.cancel() + draftSaveTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + self?.saveDraft() + } + } + + private func saveDraft() { + let draft = FeedbackDraft( + feedbackType: feedbackType.rawValue, + title: title, + description: description, + stepsToReproduce: stepsToReproduce, + expectedBehavior: expectedBehavior, + includeDiagnostics: includeDiagnostics + ) + if let data = try? JSONEncoder().encode(draft) { + UserDefaults.standard.set(data, forKey: Self.draftKey) + } + } + + private func loadDraft() { + guard let data = UserDefaults.standard.data(forKey: Self.draftKey), + let draft = try? JSONDecoder().decode(FeedbackDraft.self, from: data) else { + return + } + isLoadingDraft = true + defer { isLoadingDraft = false } + feedbackType = FeedbackType(rawValue: draft.feedbackType) ?? .bugReport + title = draft.title + description = draft.description + stepsToReproduce = draft.stepsToReproduce + expectedBehavior = draft.expectedBehavior + includeDiagnostics = draft.includeDiagnostics + } + + private func clearDraft() { + UserDefaults.standard.removeObject(forKey: Self.draftKey) + } +} diff --git a/TablePro/Views/Feedback/FeedbackView.swift b/TablePro/Views/Feedback/FeedbackView.swift new file mode 100644 index 00000000..b781ed88 --- /dev/null +++ b/TablePro/Views/Feedback/FeedbackView.swift @@ -0,0 +1,314 @@ +// +// FeedbackView.swift +// TablePro +// + +import SwiftUI +import UniformTypeIdentifiers + +struct FeedbackView: View { + @Bindable var viewModel: FeedbackViewModel + + @FocusState private var focusedField: FocusField? + @State private var isDropTargeted = false + @State private var showDiagnosticsDetail = false + + enum FocusField { + case title, description, steps, expected + } + + var body: some View { + Group { + if case .success(let url, let number) = viewModel.submissionResult { + successView(issueUrl: url, issueNumber: number) + } else { + formView + } + } + .frame(width: 480) + } + + // MARK: - Form + + private var formView: some View { + VStack(spacing: 0) { + Picker("", selection: $viewModel.feedbackType) { + ForEach(FeedbackType.allCases, id: \.self) { type in + Text(type.displayName).tag(type) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 4) + + Form { + Section { + TextField( + "Title", + text: $viewModel.title, + prompt: Text(String(localized: "Brief summary of the issue")) + ) + .focused($focusedField, equals: .title) + } + + Section { + TextEditor(text: $viewModel.description) + .font(.body) + .frame(minHeight: 72) + .scrollContentBackground(.hidden) + .focused($focusedField, equals: .description) + } header: { + Text("Description") + } + + if viewModel.feedbackType == .bugReport { + Section { + TextEditor(text: $viewModel.stepsToReproduce) + .font(.body) + .frame(minHeight: 48) + .scrollContentBackground(.hidden) + .focused($focusedField, equals: .steps) + } header: { + Text("Steps to Reproduce") + } + + Section { + TextEditor(text: $viewModel.expectedBehavior) + .font(.body) + .frame(minHeight: 48) + .scrollContentBackground(.hidden) + .focused($focusedField, equals: .expected) + } header: { + Text("Expected Behavior") + } + } + + Section("Attachments") { + attachmentsContent + } + + Section { + Toggle("Include diagnostics", isOn: $viewModel.includeDiagnostics) + + if viewModel.includeDiagnostics { + VStack(alignment: .leading, spacing: 2) { + Text(viewModel.diagnostics.formattedSummary) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + + DisclosureGroup(isExpanded: $showDiagnosticsDetail) { + Text(viewModel.diagnostics.installedPlugins.joined(separator: ", ")) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } label: { + Text(viewModel.diagnostics.pluginsSummary) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.tertiary) + } + } + } + } + } + .formStyle(.grouped) + + footerView + } + .onAppear { focusedField = .title } + .onDrop(of: [.image, .fileURL], isTargeted: $isDropTargeted) { providers in + handleDrop(providers: providers) + } + } + + // MARK: - Attachments + + private var attachmentsContent: some View { + VStack(alignment: .leading, spacing: 6) { + if !viewModel.attachments.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(viewModel.attachments) { attachment in + attachmentThumbnail(attachment) + } + } + } + .frame(height: 60) + } + + HStack(spacing: 6) { + Button { + viewModel.pasteFromClipboard() + } label: { + Label("Paste", systemImage: "doc.on.clipboard") + } + .controlSize(.small) + .disabled(!viewModel.canAddAttachment) + + Button { + viewModel.captureWindow() + } label: { + Label("Capture Window", systemImage: "camera.viewfinder") + } + .controlSize(.small) + .disabled(!viewModel.canAddAttachment) + + Button { + Task { await viewModel.browseFiles() } + } label: { + Label("Browse...", systemImage: "folder") + } + .controlSize(.small) + .disabled(!viewModel.canAddAttachment) + + Spacer() + + if !viewModel.attachments.isEmpty { + Text("\(viewModel.attachments.count)/5") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + } + + private func attachmentThumbnail(_ attachment: FeedbackAttachment) -> some View { + Image(nsImage: attachment.image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 72, height: 56) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .overlay(alignment: .topTrailing) { + Button { + viewModel.removeAttachment(attachment) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 14)) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .black.opacity(0.5)) + } + .buttonStyle(.plain) + .padding(2) + } + } + + private func handleDrop(providers: [NSItemProvider]) -> Bool { + var handled = false + for provider in providers { + guard viewModel.canAddAttachment else { break } + + if provider.canLoadObject(ofClass: NSImage.self) { + provider.loadObject(ofClass: NSImage.self) { image, _ in + Task { @MainActor in + if let nsImage = image as? NSImage { + viewModel.addImages([nsImage]) + } + } + } + handled = true + } else if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { data, _ in + guard let data = data as? Data, + let url = URL(dataRepresentation: data, relativeTo: nil), + let image = NSImage(contentsOf: url) else { + return + } + Task { @MainActor in + viewModel.addImages([image]) + } + } + handled = true + } + } + return handled + } + + // MARK: - Footer + + private var footerView: some View { + VStack(spacing: 6) { + if case .failure(let error) = viewModel.submissionResult { + Text(error.localizedDescription) + .font(.caption) + .foregroundStyle(Color(nsColor: .systemRed)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.top, 6) + } + + HStack { + Button("Cancel") { + NSApp.keyWindow?.close() + } + .keyboardShortcut(.cancelAction) + + Spacer() + + if viewModel.isSubmitting { + ProgressView() + .controlSize(.small) + } + + Button { + Task { await viewModel.submit() } + } label: { + Text(viewModel.isSubmitting ? String(localized: "Submitting...") : String(localized: "Submit")) + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(!viewModel.canSubmit) + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + } + } + + // MARK: - Success + + private func successView(issueUrl: URL, issueNumber: Int) -> some View { + VStack(spacing: 16) { + Spacer() + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 44)) + .foregroundStyle(Color(nsColor: .systemGreen)) + + Text("Feedback submitted!") + .font(.title3) + .fontWeight(.semibold) + + Text(String(format: String(localized: "Created as GitHub issue #%d"), issueNumber)) + .font(.subheadline) + .foregroundStyle(.secondary) + + Link(destination: issueUrl) { + Label("View on GitHub", systemImage: "arrow.up.right") + } + .buttonStyle(.borderedProminent) + + HStack(spacing: 16) { + Button("Submit Another") { + viewModel.resetForNewSubmission() + } + .font(.subheadline) + + Button("Close") { + NSApp.keyWindow?.close() + } + .font(.subheadline) + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + + Spacer() + } + .frame(minHeight: 300) + } +} diff --git a/TablePro/Views/Feedback/FeedbackWindowController.swift b/TablePro/Views/Feedback/FeedbackWindowController.swift new file mode 100644 index 00000000..ba41b478 --- /dev/null +++ b/TablePro/Views/Feedback/FeedbackWindowController.swift @@ -0,0 +1,65 @@ +// +// FeedbackWindowController.swift +// TablePro +// + +import AppKit +import SwiftUI + +@MainActor +final class FeedbackWindowController { + static let shared = FeedbackWindowController() + private var panel: NSPanel? + private var closeObserver: NSObjectProtocol? + private let viewModel = FeedbackViewModel() + + private init() {} + + func showFeedbackPanel() { + if let existingPanel = panel { + existingPanel.makeKeyAndOrderFront(nil) + return + } + + viewModel.captureTargetWindow = NSApp.keyWindow ?? NSApp.mainWindow + + let rootView = FeedbackView(viewModel: viewModel) + .fixedSize(horizontal: false, vertical: true) + + let hostingView = NSHostingView(rootView: rootView) + let size = hostingView.fittingSize + + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + panel.identifier = NSUserInterfaceItemIdentifier("feedback") + panel.title = String(localized: "Report an Issue") + panel.isReleasedWhenClosed = false + panel.collectionBehavior = [.fullScreenNone] + panel.standardWindowButton(.miniaturizeButton)?.isHidden = true + panel.standardWindowButton(.zoomButton)?.isHidden = true + panel.contentView = hostingView + panel.center() + panel.makeKeyAndOrderFront(nil) + self.panel = panel + + closeObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: panel, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.panel = nil + self?.viewModel.captureTargetWindow = nil + self?.viewModel.clearSubmissionResult() + if let observer = self?.closeObserver { + NotificationCenter.default.removeObserver(observer) + } + self?.closeObserver = nil + } + } + } +}