diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ea14f328..6a26cd5fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iOS: SQL Server (MSSQL) connections via FreeTDS over TDS 7.4. Uses the shared `SSLConfiguration` model from connection settings. Supports connect, query, streaming results, schema browsing (tables, columns, indexes, foreign keys), database and schema switching, and explicit transactions. - iOS: data browser, search, filter, and pagination now render correct SQL Server syntax (bracket-quoted identifiers, `OFFSET ... ROWS FETCH NEXT ... ROWS ONLY` pagination, `SELECT TOP 1` for cell value fetch). +### Removed + +- Help > Report an Issue panel. The menu item now opens GitHub Issues in a browser. + ## [0.41.0] - 2026-05-13 ### Added diff --git a/TablePro/Core/Services/AppServices.swift b/TablePro/Core/Services/AppServices.swift index 3d38a5ca2..f2cfcfc06 100644 --- a/TablePro/Core/Services/AppServices.swift +++ b/TablePro/Core/Services/AppServices.swift @@ -32,7 +32,6 @@ struct AppServices { let mcpServerManager: MCPServerManager let syncTracker: SyncChangeTracker let themeEngine: ThemeEngine - let feedbackAPIClient: FeedbackAPIClient static let live = AppServices( appEvents: .shared, @@ -59,8 +58,7 @@ struct AppServices { copilotService: .shared, mcpServerManager: .shared, syncTracker: .shared, - themeEngine: .shared, - feedbackAPIClient: .shared + themeEngine: .shared ) } diff --git a/TablePro/Core/Services/Infrastructure/FeedbackAPIClient.swift b/TablePro/Core/Services/Infrastructure/FeedbackAPIClient.swift deleted file mode 100644 index a9ffa7946..000000000 --- a/TablePro/Core/Services/Infrastructure/FeedbackAPIClient.swift +++ /dev/null @@ -1,157 +0,0 @@ -// -// 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 deleted file mode 100644 index 599c95757..000000000 --- a/TablePro/Core/Services/Infrastructure/FeedbackDiagnosticsCollector.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// 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 deleted file mode 100644 index e9188bbff..000000000 --- a/TablePro/Models/UI/FeedbackDraft.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// 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 060cf0995..821e7bd16 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -2358,6 +2358,7 @@ } }, "%lld/5" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -6833,6 +6834,7 @@ }, "Attachments" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -7783,6 +7785,7 @@ } }, "Brief summary of the issue" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -7892,28 +7895,6 @@ } } }, - "Bug Report" : { - "localizations" : { - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hata Raporu" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Báo lỗi" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bug 报告" - } - } - } - }, "Built-in" : { "localizations" : { "tr" : { @@ -8540,6 +8521,7 @@ } }, "Capture Window" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -13366,6 +13348,7 @@ } }, "Created as GitHub issue #%d" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -18809,6 +18792,7 @@ } }, "Expected Behavior" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -21156,28 +21140,6 @@ } } }, - "Feature Request" : { - "localizations" : { - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Özellik İsteği" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Yêu cầu tính năng" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "功能请求" - } - } - } - }, "Feature Routing" : { "extractionState" : "stale", "localizations" : { @@ -21202,6 +21164,7 @@ } }, "Feedback submitted!" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -22500,28 +22463,6 @@ } } }, - "General Feedback" : { - "localizations" : { - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Genel Geri Bildirim" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Phản hồi chung" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "一般反馈" - } - } - } - }, "Generate" : { "localizations" : { "tr" : { @@ -24343,6 +24284,7 @@ } }, "Include diagnostics" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -29375,28 +29317,6 @@ } } }, - "Network error. Check your connection and try again." : { - "localizations" : { - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ağ hatası. Bağlantınızı kontrol edin ve tekrar deneyin." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lỗi mạng. Hãy kiểm tra kết nối và thử lại." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "网络错误。请检查网络连接后重试。" - } - } - } - }, "Network is unavailable. Changes will sync when connectivity is restored." : { "localizations" : { "tr" : { @@ -38365,28 +38285,6 @@ } } }, - "Report an Issue..." : { - "localizations" : { - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sorun Bildir..." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Báo cáo sự cố..." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "报告问题…" - } - } - } - }, "Require authentication" : { "localizations" : { "tr" : { @@ -41276,6 +41174,7 @@ } }, "Select images to attach" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -44880,6 +44779,7 @@ } }, "Steps to Reproduce" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -45100,29 +45000,8 @@ } } }, - "Submission too large. Try removing the screenshot." : { - "localizations" : { - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gönderim çok büyük. Ekran görüntüsünü kaldırmayı deneyin." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nội dung gửi quá lớn. Hãy thử bỏ ảnh chụp màn hình." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "提交内容过大。请尝试移除截图。" - } - } - } - }, "Submit" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -45145,6 +45024,7 @@ } }, "Submit Another" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -45167,6 +45047,7 @@ } }, "Submitting..." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -48242,6 +48123,7 @@ } }, "Title" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -48943,28 +48825,6 @@ } } }, - "Too many submissions. Please try again later." : { - "localizations" : { - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Çok fazla gönderim. Lütfen daha sonra tekrar deneyin." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gửi quá nhiều lần. Hãy thử lại sau." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "提交次数过多。请稍后重试。" - } - } - } - }, "Tool" : { "localizations" : { "tr" : { @@ -49666,28 +49526,6 @@ } } }, - "Unexpected server response." : { - "localizations" : { - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Beklenmeyen sunucu yanıtı." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Phản hồi server không mong đợi." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "服务器响应异常。" - } - } - } - }, "Uninstall" : { "localizations" : { "tr" : { @@ -51377,6 +51215,7 @@ } }, "View on GitHub" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index c6e3c3e29..b4bd87b5e 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -624,8 +624,10 @@ struct AppMenuCommands: Commands { Divider() - Button(String(localized: "Report an Issue...")) { - FeedbackWindowController.shared.showFeedbackPanel() + Button(String(localized: "Report an Issue")) { + if let url = URL(string: "https://github.com/TableProApp/TablePro/issues") { + NSWorkspace.shared.open(url) + } } } } diff --git a/TablePro/ViewModels/FeedbackViewModel.swift b/TablePro/ViewModels/FeedbackViewModel.swift deleted file mode 100644 index f246dfd8f..000000000 --- a/TablePro/ViewModels/FeedbackViewModel.swift +++ /dev/null @@ -1,304 +0,0 @@ -// -// 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? - @ObservationIgnored private let services: AppServices - - // MARK: - Init - - init(services: AppServices = .live) { - self.services = services - 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 services.feedbackAPIClient.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 deleted file mode 100644 index 34cf9de15..000000000 --- a/TablePro/Views/Feedback/FeedbackView.swift +++ /dev/null @@ -1,315 +0,0 @@ -// -// 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(.body) - .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(.largeTitle) - .imageScale(.large) - .symbolRenderingMode(.hierarchical) - .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(.borderless) - } - - Spacer() - } - .frame(minHeight: 300) - } -} diff --git a/TablePro/Views/Feedback/FeedbackWindowController.swift b/TablePro/Views/Feedback/FeedbackWindowController.swift deleted file mode 100644 index 0b1e569e9..000000000 --- a/TablePro/Views/Feedback/FeedbackWindowController.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// FeedbackWindowController.swift -// TablePro -// - -import AppKit -import SwiftUI - -@MainActor -final class FeedbackWindowController { - static let shared = FeedbackWindowController() - private static let autosaveName: NSWindow.FrameAutosaveName = "FeedbackWindow" - 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.applyAutosaveName(Self.autosaveName) - 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 - } - } - } -} diff --git a/docs/docs.json b/docs/docs.json index 6182e585d..870ef26e1 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -143,8 +143,7 @@ { "group": "Extensibility", "pages": ["features/plugins"] - }, - "features/feedback" + } ] }, { diff --git a/docs/features/feedback.mdx b/docs/features/feedback.mdx deleted file mode 100644 index 589224a78..000000000 --- a/docs/features/feedback.mdx +++ /dev/null @@ -1,70 +0,0 @@ ---- -title: Feedback Form -description: Report bugs, request features, and send feedback directly from TablePro ---- - -# Feedback Form - -Report bugs, request features, or send general feedback without leaving the app. Submissions go straight to GitHub Issues. - - - TablePro feedback form with bug report fields and screenshot attachments - TablePro feedback form with bug report fields and screenshot attachments - - -Open it from **Help > Report an Issue** in the menu bar. No active database connection required. - -## Feedback Types - -The form has three modes, selectable via a segmented control at the top: - -- **Bug Report** - includes extra fields for steps to reproduce and expected behavior -- **Feature Request** - title and description only -- **General Feedback** - title and description only - -## Screenshots - -Attach up to 5 images per submission. Four ways to add them: - -| Method | How | -|--------|-----| -| Paste | Click **Paste** to grab an image from the clipboard | -| Drag and drop | Drop image files anywhere on the form | -| Capture Window | Click **Capture Window** to screenshot the current TablePro window | -| Browse | Click **Browse** to pick files from Finder (PNG, JPEG, TIFF, BMP, GIF, HEIC) | - -Images over 2 MB are automatically scaled down before upload. - -## Diagnostics - -The form auto-collects system info and attaches it to the submission: - -- App version and build number -- macOS version -- Architecture (Apple Silicon / Intel) -- Active database type (if connected) -- Installed plugins - -Toggle **Include diagnostics** off to exclude this data. Expand the disclosure to review what gets sent. - -## Draft Persistence - -Closing the feedback panel saves your draft automatically. Reopening restores the title, description, feedback type, and all text fields. Drafts are cleared after a successful submission. - -## After Submission - -A success screen shows the created GitHub issue number with a link to view it. You can submit another report or close the panel. - -## Other Ways to Report - -You can also file bugs or request features directly on [GitHub Issues](https://github.com/TableProApp/TablePro/issues). Use the bug report or feature request template. - -For questions, ideas, and general discussion, visit [GitHub Discussions](https://github.com/TableProApp/TablePro/discussions). diff --git a/docs/images/feedback-dark.png b/docs/images/feedback-dark.png deleted file mode 100644 index 62792ab17..000000000 Binary files a/docs/images/feedback-dark.png and /dev/null differ diff --git a/docs/images/feedback.png b/docs/images/feedback.png deleted file mode 100644 index 0402d9a8e..000000000 Binary files a/docs/images/feedback.png and /dev/null differ