-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add ppg feedback command and Mac app feedback button #124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import { execa } from 'execa'; | ||
| import { output, success, info } from '../lib/output.js'; | ||
| import { execaEnv } from '../lib/env.js'; | ||
|
|
||
| const REPO = '2witstudios/ppg-cli'; | ||
|
|
||
| export interface FeedbackOptions { | ||
| title: string; | ||
| body: string; | ||
| label?: string; | ||
| json?: boolean; | ||
| } | ||
|
|
||
| async function isGhAvailable(): Promise<boolean> { | ||
| try { | ||
| await execa('gh', ['--version'], execaEnv); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| function buildIssueUrl(title: string, body: string, label: string): string { | ||
| const params = new URLSearchParams({ title, body, labels: label }); | ||
| return `https://github.com/${REPO}/issues/new?${params.toString()}`; | ||
| } | ||
|
|
||
| export async function feedbackCommand(options: FeedbackOptions): Promise<void> { | ||
| const { title, body, json = false } = options; | ||
| const label = options.label ?? 'feedback'; | ||
|
|
||
| if (await isGhAvailable()) { | ||
| info('Creating GitHub issue via gh CLI'); | ||
| const ghArgs = [ | ||
| 'issue', 'create', | ||
| '--repo', REPO, | ||
| '--title', title, | ||
| '--body', body, | ||
| '--label', label, | ||
| ]; | ||
|
|
||
| const result = await execa('gh', ghArgs, execaEnv); | ||
| const issueUrl = result.stdout.trim(); | ||
|
|
||
| if (json) { | ||
| output({ success: true, issueUrl, method: 'gh' }, true); | ||
| } else { | ||
| success(`Issue created: ${issueUrl}`); | ||
| } | ||
|
Comment on lines
+42
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add error handling for If 🛡️ Proposed fix- const result = await execa('gh', ghArgs, execaEnv);
- const issueUrl = result.stdout.trim();
-
- if (json) {
- output({ success: true, issueUrl, method: 'gh' }, true);
- } else {
- success(`Issue created: ${issueUrl}`);
- }
+ try {
+ const result = await execa('gh', ghArgs, execaEnv);
+ const issueUrl = result.stdout.trim();
+
+ if (json) {
+ output({ success: true, issueUrl, method: 'gh' }, true);
+ } else {
+ success(`Issue created: ${issueUrl}`);
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ if (json) {
+ output({ success: false, error: message, method: 'gh' }, true);
+ } else {
+ throw new Error(`Failed to create issue: ${message}`);
+ }
+ }🤖 Prompt for AI Agents |
||
| } else { | ||
| const issueUrl = buildIssueUrl(title, body, label); | ||
| info('Opening GitHub issue in browser (gh CLI not found)'); | ||
| await execa('open', [issueUrl]); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Browser fallback uses macOS-specific The ♻️ Cross-platform alternative+import { platform } from 'node:os';
+
+function getOpenCommand(): string {
+ switch (platform()) {
+ case 'darwin': return 'open';
+ case 'win32': return 'start';
+ default: return 'xdg-open';
+ }
+}
+
// In the else branch:
- await execa('open', [issueUrl]);
+ const openCmd = getOpenCommand();
+ await execa(openCmd, [issueUrl], { shell: platform() === 'win32' });Alternatively, consider using the 🤖 Prompt for AI Agents |
||
|
|
||
| if (json) { | ||
| output({ success: true, issueUrl, method: 'browser' }, true); | ||
| } else { | ||
| success(`Opened browser: ${issueUrl}`); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid force unwrap on
view.window!.If
view.windowisnil(e.g., the view was dismissed before this code runs), this will crash. Use optional binding or a guard statement.🛡️ Proposed fix
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) - } + guard let window = view.window else { + dismiss(nil) + return + } + alert.beginSheetModal(for: window) { [weak self] _ in + self?.dismiss(nil) + } }🤖 Prompt for AI Agents