From 3fca47f73114e82fe1e1f2a346024881cd77caf9 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 14:56:37 -0600 Subject: [PATCH] feat: add ppg feedback command and Mac app feedback button Add a `ppg feedback` CLI command that creates GitHub issues via `gh issue create` when available, falling back to opening a pre-filled issue URL in the browser. Includes --title, --body, --label, and --json flags. Add a feedback button (exclamationmark.bubble icon) to the Mac app sidebar footer that opens a native sheet with category picker, title/body fields, and submits via the CLI command. --- .../DashboardSplitViewController.swift | 9 + PPG CLI/PPG CLI/FeedbackViewController.swift | 201 ++++++++++++++++++ PPG CLI/PPG CLI/SidebarViewController.swift | 19 ++ src/cli.ts | 12 ++ src/commands/feedback.ts | 61 ++++++ 5 files changed, 302 insertions(+) create mode 100644 PPG CLI/PPG CLI/FeedbackViewController.swift create mode 100644 src/commands/feedback.ts diff --git a/PPG CLI/PPG CLI/DashboardSplitViewController.swift b/PPG CLI/PPG CLI/DashboardSplitViewController.swift index a1360ef..8a22e3f 100644 --- a/PPG CLI/PPG CLI/DashboardSplitViewController.swift +++ b/PPG CLI/PPG CLI/DashboardSplitViewController.swift @@ -92,6 +92,10 @@ class DashboardSplitViewController: NSSplitViewController { self?.showSettings() } + sidebar.onFeedbackClicked = { [weak self] in + self?.showFeedback() + } + sidebar.onAddProject = { guard let delegate = NSApp.delegate as? AppDelegate else { return } delegate.openProject() @@ -199,6 +203,11 @@ class DashboardSplitViewController: NSSplitViewController { presentAsSheet(settingsVC) } + private func showFeedback() { + let feedbackVC = FeedbackViewController() + presentAsSheet(feedbackVC) + } + // MARK: - Single Entry Conversion /// Convert a sidebar item to a single TabEntry, or nil for container items. diff --git a/PPG CLI/PPG CLI/FeedbackViewController.swift b/PPG CLI/PPG CLI/FeedbackViewController.swift new file mode 100644 index 0000000..9927192 --- /dev/null +++ b/PPG CLI/PPG CLI/FeedbackViewController.swift @@ -0,0 +1,201 @@ +import AppKit + +class FeedbackViewController: NSViewController { + + private let categoryControl = NSSegmentedControl() + private let titleField = NSTextField() + private let bodyScrollView = NSScrollView() + private let bodyTextView = NSTextView() + private let submitButton = NSButton() + private let cancelButton = NSButton() + private let spinner = NSProgressIndicator() + + private let categories = ["bug", "feature", "feedback"] + + override func loadView() { + let container = ThemeAwareView(frame: NSRect(x: 0, y: 0, width: 480, height: 380)) + container.onAppearanceChanged = { [weak self] in + guard let self = self else { return } + self.applyTheme() + } + view = container + } + + override func viewDidLoad() { + super.viewDidLoad() + title = "Submit Feedback" + + applyTheme() + + // Category picker + categoryControl.segmentCount = 3 + categoryControl.setLabel("Bug", forSegment: 0) + categoryControl.setLabel("Feature Request", forSegment: 1) + categoryControl.setLabel("General Feedback", forSegment: 2) + categoryControl.segmentStyle = .texturedRounded + categoryControl.selectedSegment = 2 + categoryControl.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(categoryControl) + + // Title label + let titleLabel = NSTextField(labelWithString: "Title") + titleLabel.font = .systemFont(ofSize: 12, weight: .medium) + titleLabel.textColor = Theme.primaryText + titleLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(titleLabel) + + // Title field + titleField.placeholderString = "Brief summary of your feedback" + titleField.font = .systemFont(ofSize: 13) + titleField.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(titleField) + + // Body label + let bodyLabel = NSTextField(labelWithString: "Details") + bodyLabel.font = .systemFont(ofSize: 12, weight: .medium) + bodyLabel.textColor = Theme.primaryText + bodyLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(bodyLabel) + + // Body text view + bodyScrollView.hasVerticalScroller = true + bodyScrollView.borderType = .bezelBorder + bodyScrollView.translatesAutoresizingMaskIntoConstraints = false + + bodyTextView.isRichText = false + bodyTextView.font = .systemFont(ofSize: 13) + bodyTextView.isVerticallyResizable = true + bodyTextView.isHorizontallyResizable = false + bodyTextView.textContainer?.widthTracksTextView = true + bodyTextView.minSize = NSSize(width: 0, height: 0) + bodyTextView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + bodyScrollView.documentView = bodyTextView + view.addSubview(bodyScrollView) + + // Spinner + spinner.style = .spinning + spinner.isDisplayedWhenStopped = false + spinner.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(spinner) + + // Cancel button + cancelButton.title = "Cancel" + cancelButton.bezelStyle = .rounded + cancelButton.target = self + cancelButton.action = #selector(cancelClicked) + cancelButton.keyEquivalent = "\u{1b}" // Escape + cancelButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(cancelButton) + + // Submit button + submitButton.title = "Submit" + submitButton.bezelStyle = .rounded + submitButton.keyEquivalent = "\r" + submitButton.target = self + submitButton.action = #selector(submitClicked) + submitButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(submitButton) + + NSLayoutConstraint.activate([ + categoryControl.topAnchor.constraint(equalTo: view.topAnchor, constant: 20), + categoryControl.centerXAnchor.constraint(equalTo: view.centerXAnchor), + + titleLabel.topAnchor.constraint(equalTo: categoryControl.bottomAnchor, constant: 16), + titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + + titleField.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4), + titleField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + titleField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + + bodyLabel.topAnchor.constraint(equalTo: titleField.bottomAnchor, constant: 12), + bodyLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + + bodyScrollView.topAnchor.constraint(equalTo: bodyLabel.bottomAnchor, constant: 4), + bodyScrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + bodyScrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + bodyScrollView.heightAnchor.constraint(equalToConstant: 150), + + submitButton.topAnchor.constraint(equalTo: bodyScrollView.bottomAnchor, constant: 16), + submitButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + submitButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -20), + + cancelButton.centerYAnchor.constraint(equalTo: submitButton.centerYAnchor), + cancelButton.trailingAnchor.constraint(equalTo: submitButton.leadingAnchor, constant: -8), + + spinner.centerYAnchor.constraint(equalTo: submitButton.centerYAnchor), + spinner.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor, constant: -8), + ]) + } + + private func applyTheme() { + view.wantsLayer = true + view.layer?.backgroundColor = Theme.contentBackground.resolvedCGColor(for: view.effectiveAppearance) + } + + @objc private func cancelClicked() { + dismiss(nil) + } + + @objc private func submitClicked() { + let titleText = titleField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + let bodyText = bodyTextView.string.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !titleText.isEmpty else { + shake(titleField) + return + } + guard !bodyText.isEmpty else { + shake(bodyScrollView) + return + } + + let label = categories[categoryControl.selectedSegment] + + submitButton.isEnabled = false + cancelButton.isEnabled = false + spinner.startAnimation(nil) + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + // Use the first open project root, or fall back to home directory + let projectRoot = OpenProjects.shared.projects.first?.projectRoot ?? NSHomeDirectory() + let args = "feedback --title \(shellEscape(titleText)) --body \(shellEscape(bodyText)) --label \(shellEscape(label)) --json" + let result = PPGService.shared.runPPGCommand(args, projectRoot: projectRoot) + + DispatchQueue.main.async { + guard let self = self else { return } + self.spinner.stopAnimation(nil) + self.submitButton.isEnabled = true + self.cancelButton.isEnabled = true + + if result.exitCode == 0 { + self.showSuccessAndDismiss() + } else { + let msg = result.stderr.isEmpty ? result.stdout : result.stderr + let alert = NSAlert() + alert.messageText = "Failed to submit feedback" + alert.informativeText = msg.trimmingCharacters(in: .whitespacesAndNewlines) + alert.alertStyle = .warning + alert.runModal() + } + } + } + } + + private func showSuccessAndDismiss() { + let alert = NSAlert() + alert.messageText = "Feedback submitted" + alert.informativeText = "Thank you for your feedback!" + alert.alertStyle = .informational + alert.beginSheetModal(for: view.window!) { [weak self] _ in + self?.dismiss(nil) + } + } + + private func shake(_ view: NSView) { + let animation = CAKeyframeAnimation(keyPath: "position.x") + animation.values = [0, -6, 6, -4, 4, -2, 2, 0].map { view.frame.midX + $0 } + animation.duration = 0.4 + animation.calculationMode = .linear + view.layer?.add(animation, forKey: "shake") + } +} diff --git a/PPG CLI/PPG CLI/SidebarViewController.swift b/PPG CLI/PPG CLI/SidebarViewController.swift index 893906d..6bcc5b8 100644 --- a/PPG CLI/PPG CLI/SidebarViewController.swift +++ b/PPG CLI/PPG CLI/SidebarViewController.swift @@ -70,6 +70,7 @@ class SidebarViewController: NSViewController, NSOutlineViewDataSource, NSOutlin let scrollView = NSScrollView() let outlineView = NSOutlineView() private let gearButton = NSButton() + private let feedbackButton = NSButton() private let addProjectButton = NSButton() var projectWorktrees: [String: [WorktreeModel]] = [:] @@ -85,6 +86,7 @@ class SidebarViewController: NSViewController, NSOutlineViewDataSource, NSOutlin var onDeleteWorktree: ((ProjectContext, String) -> Void)? // (project, worktreeId) var onDataRefreshed: ((SidebarItem?) -> Void)? var onSettingsClicked: (() -> Void)? + var onFeedbackClicked: (() -> Void)? var onAddProject: (() -> Void)? var onProjectAddClicked: ((ProjectContext) -> Void)? var onDashboardClicked: (() -> Void)? @@ -219,6 +221,15 @@ class SidebarViewController: NSViewController, NSOutlineViewDataSource, NSOutlin gearButton.translatesAutoresizingMaskIntoConstraints = false footerBar.addSubview(gearButton) + feedbackButton.bezelStyle = .accessoryBarAction + feedbackButton.image = NSImage(systemSymbolName: "exclamationmark.bubble", accessibilityDescription: "Feedback") + feedbackButton.isBordered = false + feedbackButton.contentTintColor = Theme.primaryText + feedbackButton.target = self + feedbackButton.action = #selector(feedbackButtonClicked) + feedbackButton.translatesAutoresizingMaskIntoConstraints = false + footerBar.addSubview(feedbackButton) + addProjectButton.bezelStyle = .accessoryBarAction addProjectButton.image = NSImage(systemSymbolName: "folder.badge.plus", accessibilityDescription: "Add Project") addProjectButton.title = "Add Project" @@ -257,6 +268,9 @@ class SidebarViewController: NSViewController, NSOutlineViewDataSource, NSOutlin gearButton.leadingAnchor.constraint(equalTo: footerBar.leadingAnchor, constant: 8), gearButton.centerYAnchor.constraint(equalTo: footerBar.centerYAnchor), + feedbackButton.leadingAnchor.constraint(equalTo: gearButton.trailingAnchor, constant: 2), + feedbackButton.centerYAnchor.constraint(equalTo: footerBar.centerYAnchor), + addProjectButton.trailingAnchor.constraint(equalTo: shortcutLabel.leadingAnchor, constant: -4), addProjectButton.centerYAnchor.constraint(equalTo: footerBar.centerYAnchor), @@ -271,6 +285,7 @@ class SidebarViewController: NSViewController, NSOutlineViewDataSource, NSOutlin view.wantsLayer = true view.layer?.backgroundColor = Theme.contentBackground.resolvedCGColor(for: view.effectiveAppearance) gearButton.contentTintColor = Theme.primaryText.resolvedColor(for: view.effectiveAppearance) + feedbackButton.contentTintColor = Theme.primaryText.resolvedColor(for: view.effectiveAppearance) addProjectButton.contentTintColor = Theme.primaryText.resolvedColor(for: view.effectiveAppearance) // Rebuild row views so inline + buttons don't keep stale cached control chrome. @@ -331,6 +346,10 @@ class SidebarViewController: NSViewController, NSOutlineViewDataSource, NSOutlin onSettingsClicked?() } + @objc private func feedbackButtonClicked() { + onFeedbackClicked?() + } + @objc private func addProjectButtonClicked() { onAddProject?() } diff --git a/src/cli.ts b/src/cli.ts index bfb207a..2256558 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -282,6 +282,18 @@ program await installDashboardCommand(options); }); +program + .command('feedback') + .description('Submit feedback or report an issue') + .requiredOption('--title ', 'Issue title') + .requiredOption('--body ', 'Issue body') + .option('--label