From 208cfffd1a188a22ae60a285e3bf7e551c8b4e99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 15:21:12 +0700 Subject: [PATCH 01/10] feat: add in-app feedback form for bug reports and feature requests --- CHANGELOG.md | 1 + .../Infrastructure/AppNotifications.swift | 4 + .../Infrastructure/FeedbackAPIClient.swift | 158 +++++++++ .../FeedbackDiagnosticsCollector.swift | 60 ++++ TablePro/Models/UI/FeedbackDraft.swift | 15 + TablePro/Resources/Localizable.xcstrings | 85 +++++ TablePro/TableProApp.swift | 6 + TablePro/ViewModels/FeedbackViewModel.swift | 301 +++++++++++++++++ TablePro/Views/Feedback/FeedbackView.swift | 316 ++++++++++++++++++ .../Feedback/FeedbackWindowController.swift | 58 ++++ 10 files changed, 1004 insertions(+) create mode 100644 TablePro/Core/Services/Infrastructure/FeedbackAPIClient.swift create mode 100644 TablePro/Core/Services/Infrastructure/FeedbackDiagnosticsCollector.swift create mode 100644 TablePro/Models/UI/FeedbackDraft.swift create mode 100644 TablePro/ViewModels/FeedbackViewModel.swift create mode 100644 TablePro/Views/Feedback/FeedbackView.swift create mode 100644 TablePro/Views/Feedback/FeedbackWindowController.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index df8a35aab..b4e4cb9b0 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 ea57cf2c1..2f6154d54 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 000000000..3df7a8318 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/FeedbackAPIClient.swift @@ -0,0 +1,158 @@ +// +// 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 + config.waitsForConnectivity = true + 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 000000000..0c654465c --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/FeedbackDiagnosticsCollector.swift @@ -0,0 +1,60 @@ +// +// 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 lines = [ + "TablePro \(appVersion)", + "\(osVersion) - \(architecture)" + ] + if let db = activeDatabaseType { + lines.append("Database: \(db)") + } + if !installedPlugins.isEmpty { + lines.append("Plugins: \(installedPlugins.joined(separator: ", "))") + } + return lines.joined(separator: "\n") + } +} + +@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 000000000..e9188bbff --- /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 187d10fce..531c30475 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 a86c66009..7b0494b37 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 000000000..f79354486 --- /dev/null +++ b/TablePro/ViewModels/FeedbackViewModel.swift @@ -0,0 +1,301 @@ +// +// 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 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: [:]) + } + + guard let finalData = pngData, finalData.count <= Self.maxScreenshotBytes else { + return nil + } + return finalData.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 000000000..6a95f15ac --- /dev/null +++ b/TablePro/Views/Feedback/FeedbackView.swift @@ -0,0 +1,316 @@ +// +// FeedbackView.swift +// TablePro +// + +import SwiftUI +import UniformTypeIdentifiers + +struct FeedbackView: View { + @Bindable var viewModel: FeedbackViewModel + + enum FocusField { + case title, description, steps, expected + } + + @FocusState private var focusedField: FocusField? + @State private var isDropTargeted = false + + var body: some View { + Group { + if case .success(let url, let number) = viewModel.submissionResult { + successView(issueUrl: url, issueNumber: number) + } else { + formView + } + } + .frame(width: 520, height: 580) + } + + // MARK: - Form + + private var formView: some View { + VStack(spacing: 0) { + Form { + Section { + Picker("Type", selection: $viewModel.feedbackType) { + ForEach(FeedbackType.allCases, id: \.self) { type in + Label(type.displayName, systemImage: type.iconName) + .tag(type) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + + Section { + TextField("Title", text: $viewModel.title, prompt: Text(String(localized: "Brief summary of the issue"))) + .focused($focusedField, equals: .title) + + LabeledContent("Description") { + TextEditor(text: $viewModel.description) + .font(.system(.body)) + .frame(minHeight: 60) + .scrollContentBackground(.hidden) + .padding(4) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .focused($focusedField, equals: .description) + } + } + + if viewModel.feedbackType == .bugReport { + Section { + LabeledContent("Steps to Reproduce") { + TextEditor(text: $viewModel.stepsToReproduce) + .font(.system(.body)) + .frame(minHeight: 44) + .scrollContentBackground(.hidden) + .padding(4) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .focused($focusedField, equals: .steps) + } + + LabeledContent("Expected Behavior") { + TextEditor(text: $viewModel.expectedBehavior) + .font(.system(.body)) + .frame(minHeight: 44) + .scrollContentBackground(.hidden) + .padding(4) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .focused($focusedField, equals: .expected) + } + } + } + + Section("Attachments") { + attachmentsContent + } + + Section { + Toggle("Include diagnostics", isOn: $viewModel.includeDiagnostics) + + if viewModel.includeDiagnostics { + Text(viewModel.diagnostics.formattedSummary) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } footer: { + Text("Includes app version, macOS version, and installed plugins only.") + } + } + .formStyle(.grouped) + + Divider() + + footerView + } + .animation(.default, value: viewModel.feedbackType) + .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: 8) { + if !viewModel.attachments.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(viewModel.attachments) { attachment in + attachmentThumbnail(attachment) + } + } + } + .frame(height: 72) + } + + HStack(spacing: 8) { + Button { + viewModel.pasteFromClipboard() + } label: { + Label("Paste", systemImage: "doc.on.clipboard") + } + .disabled(!viewModel.canAddAttachment) + + Button { + viewModel.captureWindow() + } label: { + Label("Capture Window", systemImage: "camera.viewfinder") + } + .disabled(!viewModel.canAddAttachment) + + Button { + Task { await viewModel.browseFiles() } + } label: { + Label("Browse...", systemImage: "folder") + } + .disabled(!viewModel.canAddAttachment) + + Spacer() + + if !viewModel.attachments.isEmpty { + Text("\(viewModel.attachments.count)/\(5)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + private func attachmentThumbnail(_ attachment: FeedbackAttachment) -> some View { + ZStack(alignment: .topTrailing) { + Image(nsImage: attachment.image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 80, height: 64) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + + Button { + viewModel.removeAttachment(attachment) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.caption) + .foregroundStyle(.white, Color(nsColor: .systemGray)) + } + .buttonStyle(.plain) + .offset(x: 4, y: -4) + } + } + + 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: 8) { + if case .failure(let error) = viewModel.submissionResult { + Text(error.localizedDescription) + .font(.caption) + .foregroundStyle(Color(nsColor: .systemRed)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 8) + } + + HStack { + Button("Cancel") { + NSApp.windows.first { $0.identifier?.rawValue == "feedback" }?.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, 16) + .padding(.vertical, 12) + } + .background(Color(nsColor: .windowBackgroundColor)) + } + + // MARK: - Success + + private func successView(issueUrl: URL, issueNumber: Int) -> some View { + VStack(spacing: 16) { + Spacer() + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 48)) + .foregroundStyle(Color(nsColor: .systemGreen)) + + Text("Feedback submitted!") + .font(.title2) + .fontWeight(.semibold) + + Text(String(format: String(localized: "Created as GitHub issue #%d"), issueNumber)) + .font(.subheadline) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + 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.windows.first { $0.identifier?.rawValue == "feedback" }?.close() + } + .font(.subheadline) + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + + Spacer() + } + } +} diff --git a/TablePro/Views/Feedback/FeedbackWindowController.swift b/TablePro/Views/Feedback/FeedbackWindowController.swift new file mode 100644 index 000000000..0b03738cf --- /dev/null +++ b/TablePro/Views/Feedback/FeedbackWindowController.swift @@ -0,0 +1,58 @@ +// +// 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 panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 580), + 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 = NSHostingView(rootView: FeedbackView(viewModel: viewModel)) + 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 + if let observer = self?.closeObserver { + NotificationCenter.default.removeObserver(observer) + } + self?.closeObserver = nil + } + } + } +} From ff099d47c163bc715335925a1d96771fe055129b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 15:29:22 +0700 Subject: [PATCH 02/10] fix: native macOS layout, multi-screenshot array API, capture window --- TablePro/Views/Feedback/FeedbackView.swift | 207 +++++++++--------- .../Feedback/FeedbackWindowController.swift | 2 +- 2 files changed, 109 insertions(+), 100 deletions(-) diff --git a/TablePro/Views/Feedback/FeedbackView.swift b/TablePro/Views/Feedback/FeedbackView.swift index 6a95f15ac..b412805aa 100644 --- a/TablePro/Views/Feedback/FeedbackView.swift +++ b/TablePro/Views/Feedback/FeedbackView.swift @@ -9,13 +9,13 @@ import UniformTypeIdentifiers struct FeedbackView: View { @Bindable var viewModel: FeedbackViewModel + @FocusState private var focusedField: FocusField? + @State private var isDropTargeted = false + enum FocusField { case title, description, steps, expected } - @FocusState private var focusedField: FocusField? - @State private var isDropTargeted = false - var body: some View { Group { if case .success(let url, let number) = viewModel.submissionResult { @@ -24,97 +24,47 @@ struct FeedbackView: View { formView } } - .frame(width: 520, height: 580) + .frame(width: 480) } // MARK: - Form private var formView: some View { VStack(spacing: 0) { - Form { - Section { - Picker("Type", selection: $viewModel.feedbackType) { - ForEach(FeedbackType.allCases, id: \.self) { type in - Label(type.displayName, systemImage: type.iconName) - .tag(type) - } - } - .pickerStyle(.segmented) - .labelsHidden() + Picker("", selection: $viewModel.feedbackType) { + ForEach(FeedbackType.allCases, id: \.self) { type in + Text(type.displayName).tag(type) } - - Section { - TextField("Title", text: $viewModel.title, prompt: Text(String(localized: "Brief summary of the issue"))) + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 4) + + ScrollView { + VStack(alignment: .leading, spacing: 12) { + textField("Title", text: $viewModel.title, prompt: "Brief summary of the issue") .focused($focusedField, equals: .title) - LabeledContent("Description") { - TextEditor(text: $viewModel.description) - .font(.system(.body)) - .frame(minHeight: 60) - .scrollContentBackground(.hidden) - .padding(4) - .background(Color(nsColor: .textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - .focused($focusedField, equals: .description) - } - } + textArea("Description", text: $viewModel.description, minHeight: 72) + .focused($focusedField, equals: .description) - if viewModel.feedbackType == .bugReport { - Section { - LabeledContent("Steps to Reproduce") { - TextEditor(text: $viewModel.stepsToReproduce) - .font(.system(.body)) - .frame(minHeight: 44) - .scrollContentBackground(.hidden) - .padding(4) - .background(Color(nsColor: .textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - .focused($focusedField, equals: .steps) - } + if viewModel.feedbackType == .bugReport { + textArea("Steps to Reproduce", text: $viewModel.stepsToReproduce, minHeight: 48) + .focused($focusedField, equals: .steps) - LabeledContent("Expected Behavior") { - TextEditor(text: $viewModel.expectedBehavior) - .font(.system(.body)) - .frame(minHeight: 44) - .scrollContentBackground(.hidden) - .padding(4) - .background(Color(nsColor: .textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - .focused($focusedField, equals: .expected) - } + textArea("Expected Behavior", text: $viewModel.expectedBehavior, minHeight: 48) + .focused($focusedField, equals: .expected) } - } - - Section("Attachments") { - attachmentsContent - } - Section { - Toggle("Include diagnostics", isOn: $viewModel.includeDiagnostics) + attachmentsSection - if viewModel.includeDiagnostics { - Text(viewModel.diagnostics.formattedSummary) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - } footer: { - Text("Includes app version, macOS version, and installed plugins only.") + diagnosticsSection } + .padding(.horizontal, 20) + .padding(.vertical, 12) } - .formStyle(.grouped) Divider() @@ -127,49 +77,91 @@ struct FeedbackView: View { } } + // MARK: - Reusable Fields + + private func textField(_ label: String, text: Binding, prompt: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(label) + .font(.callout) + .foregroundStyle(.secondary) + + TextField("", text: text, prompt: Text(prompt)) + .textFieldStyle(.roundedBorder) + } + } + + private func textArea(_ label: String, text: Binding, minHeight: CGFloat) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(label) + .font(.callout) + .foregroundStyle(.secondary) + + TextEditor(text: text) + .font(.body) + .frame(minHeight: minHeight) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } + } + // MARK: - Attachments - private var attachmentsContent: some View { - VStack(alignment: .leading, spacing: 8) { + private var attachmentsSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Attachments") + .font(.callout) + .foregroundStyle(.secondary) + if !viewModel.attachments.isEmpty { ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { + HStack(spacing: 6) { ForEach(viewModel.attachments) { attachment in attachmentThumbnail(attachment) } } } - .frame(height: 72) } - HStack(spacing: 8) { + HStack(spacing: 6) { Button { viewModel.pasteFromClipboard() } label: { Label("Paste", systemImage: "doc.on.clipboard") + .font(.callout) } + .controlSize(.small) .disabled(!viewModel.canAddAttachment) Button { viewModel.captureWindow() } label: { Label("Capture Window", systemImage: "camera.viewfinder") + .font(.callout) } + .controlSize(.small) .disabled(!viewModel.canAddAttachment) Button { Task { await viewModel.browseFiles() } } label: { Label("Browse...", systemImage: "folder") + .font(.callout) } + .controlSize(.small) .disabled(!viewModel.canAddAttachment) Spacer() if !viewModel.attachments.isEmpty { - Text("\(viewModel.attachments.count)/\(5)") + Text("\(viewModel.attachments.count)/5") .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(.tertiary) } } } @@ -180,7 +172,7 @@ struct FeedbackView: View { Image(nsImage: attachment.image) .resizable() .aspectRatio(contentMode: .fill) - .frame(width: 80, height: 64) + .frame(width: 72, height: 56) .clipShape(RoundedRectangle(cornerRadius: 4)) .overlay( RoundedRectangle(cornerRadius: 4) @@ -192,6 +184,7 @@ struct FeedbackView: View { } label: { Image(systemName: "xmark.circle.fill") .font(.caption) + .symbolRenderingMode(.palette) .foregroundStyle(.white, Color(nsColor: .systemGray)) } .buttonStyle(.plain) @@ -230,17 +223,35 @@ struct FeedbackView: View { return handled } + // MARK: - Diagnostics + + private var diagnosticsSection: some View { + VStack(alignment: .leading, spacing: 4) { + Toggle(isOn: $viewModel.includeDiagnostics) { + Text("Include diagnostics") + .font(.callout) + } + + if viewModel.includeDiagnostics { + Text(viewModel.diagnostics.formattedSummary) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + } + } + } + // MARK: - Footer private var footerView: some View { - VStack(spacing: 8) { + 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, 16) - .padding(.top, 8) + .padding(.horizontal, 20) + .padding(.top, 6) } HStack { @@ -265,10 +276,9 @@ struct FeedbackView: View { .buttonStyle(.borderedProminent) .disabled(!viewModel.canSubmit) } - .padding(.horizontal, 16) - .padding(.vertical, 12) + .padding(.horizontal, 20) + .padding(.vertical, 10) } - .background(Color(nsColor: .windowBackgroundColor)) } // MARK: - Success @@ -278,23 +288,21 @@ struct FeedbackView: View { Spacer() Image(systemName: "checkmark.circle.fill") - .font(.system(size: 48)) + .font(.system(size: 44)) .foregroundStyle(Color(nsColor: .systemGreen)) Text("Feedback submitted!") - .font(.title2) + .font(.title3) .fontWeight(.semibold) Text(String(format: String(localized: "Created as GitHub issue #%d"), issueNumber)) .font(.subheadline) .foregroundStyle(.secondary) - HStack(spacing: 12) { - Link(destination: issueUrl) { - Label("View on GitHub", systemImage: "arrow.up.right") - } - .buttonStyle(.borderedProminent) + Link(destination: issueUrl) { + Label("View on GitHub", systemImage: "arrow.up.right") } + .buttonStyle(.borderedProminent) HStack(spacing: 16) { Button("Submit Another") { @@ -312,5 +320,6 @@ struct FeedbackView: View { Spacer() } + .frame(minHeight: 300) } } diff --git a/TablePro/Views/Feedback/FeedbackWindowController.swift b/TablePro/Views/Feedback/FeedbackWindowController.swift index 0b03738cf..19bdba0eb 100644 --- a/TablePro/Views/Feedback/FeedbackWindowController.swift +++ b/TablePro/Views/Feedback/FeedbackWindowController.swift @@ -24,7 +24,7 @@ final class FeedbackWindowController { viewModel.captureTargetWindow = NSApp.keyWindow ?? NSApp.mainWindow let panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 580), + contentRect: NSRect(x: 0, y: 0, width: 480, height: 540), styleMask: [.titled, .closable], backing: .buffered, defer: false From 80bdc184f6fa1decb73e8da937956f7c4c34c482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 15:41:09 +0700 Subject: [PATCH 03/10] fix: use native Form grouped style, collapse diagnostics, auto-size window --- .../FeedbackDiagnosticsCollector.swift | 5 +- TablePro/Views/Feedback/FeedbackView.swift | 128 ++++++++---------- .../Feedback/FeedbackWindowController.swift | 8 +- 3 files changed, 64 insertions(+), 77 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/FeedbackDiagnosticsCollector.swift b/TablePro/Core/Services/Infrastructure/FeedbackDiagnosticsCollector.swift index 0c654465c..edc64ecc3 100644 --- a/TablePro/Core/Services/Infrastructure/FeedbackDiagnosticsCollector.swift +++ b/TablePro/Core/Services/Infrastructure/FeedbackDiagnosticsCollector.swift @@ -16,13 +16,14 @@ struct FeedbackDiagnostics { var formattedSummary: String { var lines = [ "TablePro \(appVersion)", - "\(osVersion) - \(architecture)" + "\(osVersion) · \(architecture)" ] if let db = activeDatabaseType { lines.append("Database: \(db)") } if !installedPlugins.isEmpty { - lines.append("Plugins: \(installedPlugins.joined(separator: ", "))") + let count = installedPlugins.count + lines.append("\(count) plugin\(count == 1 ? "" : "s") installed") } return lines.joined(separator: "\n") } diff --git a/TablePro/Views/Feedback/FeedbackView.swift b/TablePro/Views/Feedback/FeedbackView.swift index b412805aa..90ad65584 100644 --- a/TablePro/Views/Feedback/FeedbackView.swift +++ b/TablePro/Views/Feedback/FeedbackView.swift @@ -11,6 +11,7 @@ struct FeedbackView: View { @FocusState private var focusedField: FocusField? @State private var isDropTargeted = false + @State private var showDiagnosticsDetail = false enum FocusField { case title, description, steps, expected @@ -42,31 +43,69 @@ struct FeedbackView: View { .padding(.top, 12) .padding(.bottom, 4) - ScrollView { - VStack(alignment: .leading, spacing: 12) { - textField("Title", text: $viewModel.title, prompt: "Brief summary of the issue") - .focused($focusedField, equals: .title) + Form { + Section { + TextField( + "Title", + text: $viewModel.title, + prompt: Text(String(localized: "Brief summary of the issue")) + ) + .focused($focusedField, equals: .title) + } - textArea("Description", text: $viewModel.description, minHeight: 72) + Section { + TextEditor(text: $viewModel.description) + .font(.body) + .frame(minHeight: 72) .focused($focusedField, equals: .description) + } header: { + Text("Description") + } - if viewModel.feedbackType == .bugReport { - textArea("Steps to Reproduce", text: $viewModel.stepsToReproduce, minHeight: 48) + if viewModel.feedbackType == .bugReport { + Section { + TextEditor(text: $viewModel.stepsToReproduce) + .font(.body) + .frame(minHeight: 48) .focused($focusedField, equals: .steps) + } header: { + Text("Steps to Reproduce") + } - textArea("Expected Behavior", text: $viewModel.expectedBehavior, minHeight: 48) + Section { + TextEditor(text: $viewModel.expectedBehavior) + .font(.body) + .frame(minHeight: 48) .focused($focusedField, equals: .expected) + } header: { + Text("Expected Behavior") } + } - attachmentsSection + Section("Attachments") { + attachmentsContent + } - diagnosticsSection + Section { + Toggle("Include diagnostics", isOn: $viewModel.includeDiagnostics) + + if viewModel.includeDiagnostics { + DisclosureGroup( + viewModel.diagnostics.formattedSummary, + isExpanded: $showDiagnosticsDetail + ) { + Text(viewModel.diagnostics.installedPlugins.joined(separator: "\n")) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.tertiary) + } } - .padding(.horizontal, 20) - .padding(.vertical, 12) } - - Divider() + .formStyle(.grouped) footerView } @@ -77,47 +116,10 @@ struct FeedbackView: View { } } - // MARK: - Reusable Fields - - private func textField(_ label: String, text: Binding, prompt: String) -> some View { - VStack(alignment: .leading, spacing: 3) { - Text(label) - .font(.callout) - .foregroundStyle(.secondary) - - TextField("", text: text, prompt: Text(prompt)) - .textFieldStyle(.roundedBorder) - } - } - - private func textArea(_ label: String, text: Binding, minHeight: CGFloat) -> some View { - VStack(alignment: .leading, spacing: 3) { - Text(label) - .font(.callout) - .foregroundStyle(.secondary) - - TextEditor(text: text) - .font(.body) - .frame(minHeight: minHeight) - .scrollContentBackground(.hidden) - .padding(6) - .background(Color(nsColor: .controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay( - RoundedRectangle(cornerRadius: 5) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) - } - } - // MARK: - Attachments - private var attachmentsSection: some View { + private var attachmentsContent: some View { VStack(alignment: .leading, spacing: 6) { - Text("Attachments") - .font(.callout) - .foregroundStyle(.secondary) - if !viewModel.attachments.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { @@ -126,6 +128,7 @@ struct FeedbackView: View { } } } + .frame(height: 60) } HStack(spacing: 6) { @@ -133,7 +136,6 @@ struct FeedbackView: View { viewModel.pasteFromClipboard() } label: { Label("Paste", systemImage: "doc.on.clipboard") - .font(.callout) } .controlSize(.small) .disabled(!viewModel.canAddAttachment) @@ -142,7 +144,6 @@ struct FeedbackView: View { viewModel.captureWindow() } label: { Label("Capture Window", systemImage: "camera.viewfinder") - .font(.callout) } .controlSize(.small) .disabled(!viewModel.canAddAttachment) @@ -151,7 +152,6 @@ struct FeedbackView: View { Task { await viewModel.browseFiles() } } label: { Label("Browse...", systemImage: "folder") - .font(.callout) } .controlSize(.small) .disabled(!viewModel.canAddAttachment) @@ -223,24 +223,6 @@ struct FeedbackView: View { return handled } - // MARK: - Diagnostics - - private var diagnosticsSection: some View { - VStack(alignment: .leading, spacing: 4) { - Toggle(isOn: $viewModel.includeDiagnostics) { - Text("Include diagnostics") - .font(.callout) - } - - if viewModel.includeDiagnostics { - Text(viewModel.diagnostics.formattedSummary) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.tertiary) - .textSelection(.enabled) - } - } - } - // MARK: - Footer private var footerView: some View { diff --git a/TablePro/Views/Feedback/FeedbackWindowController.swift b/TablePro/Views/Feedback/FeedbackWindowController.swift index 19bdba0eb..b8e843790 100644 --- a/TablePro/Views/Feedback/FeedbackWindowController.swift +++ b/TablePro/Views/Feedback/FeedbackWindowController.swift @@ -24,7 +24,7 @@ final class FeedbackWindowController { viewModel.captureTargetWindow = NSApp.keyWindow ?? NSApp.mainWindow let panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 540), + contentRect: NSRect(x: 0, y: 0, width: 480, height: 100), styleMask: [.titled, .closable], backing: .buffered, defer: false @@ -35,7 +35,11 @@ final class FeedbackWindowController { panel.collectionBehavior = [.fullScreenNone] panel.standardWindowButton(.miniaturizeButton)?.isHidden = true panel.standardWindowButton(.zoomButton)?.isHidden = true - panel.contentView = NSHostingView(rootView: FeedbackView(viewModel: viewModel)) + + let hostingView = NSHostingView(rootView: FeedbackView(viewModel: viewModel)) + panel.contentView = hostingView + hostingView.setFrameSize(hostingView.fittingSize) + panel.setContentSize(hostingView.fittingSize) panel.center() panel.makeKeyAndOrderFront(nil) self.panel = panel From 6b9bf47ad6e63c403a4d0bbf0b70140183188eed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 15:46:43 +0700 Subject: [PATCH 04/10] fix: separate diagnostics summary from plugin list disclosure --- .../FeedbackDiagnosticsCollector.swift | 18 +++++++------- TablePro/Views/Feedback/FeedbackView.swift | 24 ++++++++++++------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/FeedbackDiagnosticsCollector.swift b/TablePro/Core/Services/Infrastructure/FeedbackDiagnosticsCollector.swift index edc64ecc3..599c95757 100644 --- a/TablePro/Core/Services/Infrastructure/FeedbackDiagnosticsCollector.swift +++ b/TablePro/Core/Services/Infrastructure/FeedbackDiagnosticsCollector.swift @@ -14,18 +14,16 @@ struct FeedbackDiagnostics { let machineId: String var formattedSummary: String { - var lines = [ - "TablePro \(appVersion)", - "\(osVersion) · \(architecture)" - ] + var parts = ["TablePro \(appVersion)", "\(osVersion) · \(architecture)"] if let db = activeDatabaseType { - lines.append("Database: \(db)") + parts.append("Database: \(db)") } - if !installedPlugins.isEmpty { - let count = installedPlugins.count - lines.append("\(count) plugin\(count == 1 ? "" : "s") installed") - } - return lines.joined(separator: "\n") + return parts.joined(separator: "\n") + } + + var pluginsSummary: String { + let count = installedPlugins.count + return "\(count) plugin\(count == 1 ? "" : "s") installed" } } diff --git a/TablePro/Views/Feedback/FeedbackView.swift b/TablePro/Views/Feedback/FeedbackView.swift index 90ad65584..0450885a8 100644 --- a/TablePro/Views/Feedback/FeedbackView.swift +++ b/TablePro/Views/Feedback/FeedbackView.swift @@ -90,18 +90,24 @@ struct FeedbackView: View { Toggle("Include diagnostics", isOn: $viewModel.includeDiagnostics) if viewModel.includeDiagnostics { - DisclosureGroup( - viewModel.diagnostics.formattedSummary, - isExpanded: $showDiagnosticsDetail - ) { - Text(viewModel.diagnostics.installedPlugins.joined(separator: "\n")) - .font(.system(.caption2, design: .monospaced)) + VStack(alignment: .leading, spacing: 2) { + Text(viewModel.diagnostics.formattedSummary) + .font(.system(.caption, design: .monospaced)) .foregroundStyle(.tertiary) .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) + + DisclosureGroup(isExpanded: $showDiagnosticsDetail) { + Text(viewModel.diagnostics.installedPlugins.joined(separator: ", ")) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.quaternary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } label: { + Text(viewModel.diagnostics.pluginsSummary) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.tertiary) + } } - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.tertiary) } } } From 4e230b1e272858d0f9291490b05d061e8244714b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 15:54:32 +0700 Subject: [PATCH 05/10] fix: improve plugin list text contrast --- TablePro/Views/Feedback/FeedbackView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/Feedback/FeedbackView.swift b/TablePro/Views/Feedback/FeedbackView.swift index 0450885a8..c1989c5e5 100644 --- a/TablePro/Views/Feedback/FeedbackView.swift +++ b/TablePro/Views/Feedback/FeedbackView.swift @@ -99,7 +99,7 @@ struct FeedbackView: View { DisclosureGroup(isExpanded: $showDiagnosticsDetail) { Text(viewModel.diagnostics.installedPlugins.joined(separator: ", ")) .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.quaternary) + .foregroundStyle(.tertiary) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } label: { From 50703121a0c8f85cfc2e49fdc19194a9dcfc2397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 15:58:18 +0700 Subject: [PATCH 06/10] fix: remove TextEditor double background in grouped form sections --- TablePro/Views/Feedback/FeedbackView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/TablePro/Views/Feedback/FeedbackView.swift b/TablePro/Views/Feedback/FeedbackView.swift index c1989c5e5..cfc410044 100644 --- a/TablePro/Views/Feedback/FeedbackView.swift +++ b/TablePro/Views/Feedback/FeedbackView.swift @@ -57,6 +57,7 @@ struct FeedbackView: View { TextEditor(text: $viewModel.description) .font(.body) .frame(minHeight: 72) + .scrollContentBackground(.hidden) .focused($focusedField, equals: .description) } header: { Text("Description") @@ -67,6 +68,7 @@ struct FeedbackView: View { TextEditor(text: $viewModel.stepsToReproduce) .font(.body) .frame(minHeight: 48) + .scrollContentBackground(.hidden) .focused($focusedField, equals: .steps) } header: { Text("Steps to Reproduce") @@ -76,6 +78,7 @@ struct FeedbackView: View { TextEditor(text: $viewModel.expectedBehavior) .font(.body) .frame(minHeight: 48) + .scrollContentBackground(.hidden) .focused($focusedField, equals: .expected) } header: { Text("Expected Behavior") From 35005955aeed40f5fd5089bff079dd90e0f2e8db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 16:01:26 +0700 Subject: [PATCH 07/10] fix: auto-size window height to fit content using fixedSize --- .../Views/Feedback/FeedbackWindowController.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/TablePro/Views/Feedback/FeedbackWindowController.swift b/TablePro/Views/Feedback/FeedbackWindowController.swift index b8e843790..99eb43573 100644 --- a/TablePro/Views/Feedback/FeedbackWindowController.swift +++ b/TablePro/Views/Feedback/FeedbackWindowController.swift @@ -23,8 +23,14 @@ final class FeedbackWindowController { 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: 480, height: 100), + contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height), styleMask: [.titled, .closable], backing: .buffered, defer: false @@ -35,11 +41,7 @@ final class FeedbackWindowController { panel.collectionBehavior = [.fullScreenNone] panel.standardWindowButton(.miniaturizeButton)?.isHidden = true panel.standardWindowButton(.zoomButton)?.isHidden = true - - let hostingView = NSHostingView(rootView: FeedbackView(viewModel: viewModel)) panel.contentView = hostingView - hostingView.setFrameSize(hostingView.fittingSize) - panel.setContentSize(hostingView.fittingSize) panel.center() panel.makeKeyAndOrderFront(nil) self.panel = panel From 0b67ee812a0c1d8d35c75d344181d7b2cb12bd8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 16:07:34 +0700 Subject: [PATCH 08/10] fix: remove non-native animation on feedback type switch --- TablePro/Views/Feedback/FeedbackView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/TablePro/Views/Feedback/FeedbackView.swift b/TablePro/Views/Feedback/FeedbackView.swift index cfc410044..8a702555d 100644 --- a/TablePro/Views/Feedback/FeedbackView.swift +++ b/TablePro/Views/Feedback/FeedbackView.swift @@ -118,7 +118,6 @@ struct FeedbackView: View { footerView } - .animation(.default, value: viewModel.feedbackType) .onAppear { focusedField = .title } .onDrop(of: [.image, .fileURL], isTargeted: $isDropTargeted) { providers in handleDrop(providers: providers) From 53dc86939e142f05e911da8204f88b424e9f6732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 16:13:09 +0700 Subject: [PATCH 09/10] fix: review fixes - stale state, offline hang, silent drop, close button --- .../Core/Services/Infrastructure/FeedbackAPIClient.swift | 1 - TablePro/ViewModels/FeedbackViewModel.swift | 9 +++++---- TablePro/Views/Feedback/FeedbackView.swift | 4 ++-- TablePro/Views/Feedback/FeedbackWindowController.swift | 1 + 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/FeedbackAPIClient.swift b/TablePro/Core/Services/Infrastructure/FeedbackAPIClient.swift index 3df7a8318..a9ffa7946 100644 --- a/TablePro/Core/Services/Infrastructure/FeedbackAPIClient.swift +++ b/TablePro/Core/Services/Infrastructure/FeedbackAPIClient.swift @@ -97,7 +97,6 @@ final class FeedbackAPIClient { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 15 config.timeoutIntervalForResource = 30 - config.waitsForConnectivity = true self.session = URLSession(configuration: config) } diff --git a/TablePro/ViewModels/FeedbackViewModel.swift b/TablePro/ViewModels/FeedbackViewModel.swift index f79354486..d7b46b9d5 100644 --- a/TablePro/ViewModels/FeedbackViewModel.swift +++ b/TablePro/ViewModels/FeedbackViewModel.swift @@ -189,6 +189,10 @@ final class FeedbackViewModel { } } + func clearSubmissionResult() { + submissionResult = nil + } + func resetForNewSubmission() { feedbackType = .bugReport title = "" @@ -250,10 +254,7 @@ final class FeedbackViewModel { pngData = currentImage.representation(using: .png, properties: [:]) } - guard let finalData = pngData, finalData.count <= Self.maxScreenshotBytes else { - return nil - } - return finalData.base64EncodedString() + return pngData?.base64EncodedString() } private func scheduleDraftSave() { diff --git a/TablePro/Views/Feedback/FeedbackView.swift b/TablePro/Views/Feedback/FeedbackView.swift index 8a702555d..e54195174 100644 --- a/TablePro/Views/Feedback/FeedbackView.swift +++ b/TablePro/Views/Feedback/FeedbackView.swift @@ -246,7 +246,7 @@ struct FeedbackView: View { HStack { Button("Cancel") { - NSApp.windows.first { $0.identifier?.rawValue == "feedback" }?.close() + NSApp.keyWindow?.close() } .keyboardShortcut(.cancelAction) @@ -301,7 +301,7 @@ struct FeedbackView: View { .font(.subheadline) Button("Close") { - NSApp.windows.first { $0.identifier?.rawValue == "feedback" }?.close() + NSApp.keyWindow?.close() } .font(.subheadline) .buttonStyle(.plain) diff --git a/TablePro/Views/Feedback/FeedbackWindowController.swift b/TablePro/Views/Feedback/FeedbackWindowController.swift index 99eb43573..ba41b4789 100644 --- a/TablePro/Views/Feedback/FeedbackWindowController.swift +++ b/TablePro/Views/Feedback/FeedbackWindowController.swift @@ -54,6 +54,7 @@ final class FeedbackWindowController { Task { @MainActor in self?.panel = nil self?.viewModel.captureTargetWindow = nil + self?.viewModel.clearSubmissionResult() if let observer = self?.closeObserver { NotificationCenter.default.removeObserver(observer) } From f8d6b68a5fa2a641567c893c29092c1d183fd8f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 16:16:00 +0700 Subject: [PATCH 10/10] fix: position screenshot remove button inside thumbnail bounds --- TablePro/Views/Feedback/FeedbackView.swift | 41 +++++++++++----------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/TablePro/Views/Feedback/FeedbackView.swift b/TablePro/Views/Feedback/FeedbackView.swift index e54195174..b781ed88f 100644 --- a/TablePro/Views/Feedback/FeedbackView.swift +++ b/TablePro/Views/Feedback/FeedbackView.swift @@ -176,28 +176,27 @@ struct FeedbackView: View { } private func attachmentThumbnail(_ attachment: FeedbackAttachment) -> some View { - ZStack(alignment: .topTrailing) { - 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) - ) - - Button { - viewModel.removeAttachment(attachment) - } label: { - Image(systemName: "xmark.circle.fill") - .font(.caption) - .symbolRenderingMode(.palette) - .foregroundStyle(.white, Color(nsColor: .systemGray)) + 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) } - .buttonStyle(.plain) - .offset(x: 4, y: -4) - } } private func handleDrop(providers: [NSItemProvider]) -> Bool {