Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions PPG CLI/PPG CLI/DashboardSplitViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down
201 changes: 201 additions & 0 deletions PPG CLI/PPG CLI/FeedbackViewController.swift
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)
}
Comment on lines +189 to +191
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid force unwrap on view.window!.

If view.window is nil (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
Verify each finding against the current code and only fix it if needed.

In `@PPG` CLI/PPG CLI/FeedbackViewController.swift around lines 189 - 191, Replace
the force-unwrap of view.window in the FeedbackViewController where
alert.beginSheetModal(for: view.window!) is called: use optional binding (e.g.,
guard/if let) to safely unwrap view.window, bail out or handle the nil case
gracefully if the window is nil, and call alert.beginSheetModal(for: window)
only when the unwrapped window is available; update any early-return behavior so
dismissal (self?.dismiss) only runs when appropriate.

}

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")
}
}
19 changes: 19 additions & 0 deletions PPG CLI/PPG CLI/SidebarViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = [:]
Expand All @@ -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)?
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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),

Expand All @@ -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.
Expand Down Expand Up @@ -331,6 +346,10 @@ class SidebarViewController: NSViewController, NSOutlineViewDataSource, NSOutlin
onSettingsClicked?()
}

@objc private func feedbackButtonClicked() {
onFeedbackClicked?()
}

@objc private func addProjectButtonClicked() {
onAddProject?()
}
Expand Down
12 changes: 12 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,18 @@ program
await installDashboardCommand(options);
});

program
.command('feedback')
.description('Submit feedback or report an issue')
.requiredOption('--title <text>', 'Issue title')
.requiredOption('--body <text>', 'Issue body')
.option('--label <label>', 'GitHub label (bug, feature, feedback)', 'feedback')
.option('--json', 'Output as JSON')
.action(async (options) => {
const { feedbackCommand } = await import('./commands/feedback.js');
await feedbackCommand(options);
});

const cronCmd = program.command('cron').description('Manage scheduled runs');

cronCmd
Expand Down
61 changes: 61 additions & 0 deletions src/commands/feedback.ts
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add error handling for gh issue create failure.

If gh issue create fails (e.g., authentication issues, network errors, invalid label), the promise will reject with an unhandled error. Consider catching and providing a user-friendly error message.

🛡️ 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
Verify each finding against the current code and only fix it if needed.

In `@src/commands/feedback.ts` around lines 42 - 49, Wrap the execa call that runs
the GitHub CLI ("const result = await execa('gh', ghArgs, execaEnv);") in a
try/catch and handle rejections from the "gh issue create" invocation: on error,
parse the thrown Error (or execa error) and emit a user-friendly message (use
output({ success: false, error: err.message }, json) when json is requested or
call error(`Failed to create issue: ${err.message}`) for human output), and
ensure the function exits/returns after handling the error so downstream code
(the success() branch) does not run.

} else {
const issueUrl = buildIssueUrl(title, body, label);
info('Opening GitHub issue in browser (gh CLI not found)');
await execa('open', [issueUrl]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Browser fallback uses macOS-specific open command.

The open command is macOS-specific. On Linux, xdg-open is used, and on Windows, start is used. Consider using a cross-platform approach or documenting this limitation.

♻️ 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 open npm package which handles cross-platform URL opening.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/feedback.ts` at line 53, The call to execa('open', [issueUrl])
is macOS-specific; update the feedback command to open URLs cross-platform by
either switching to the well-tested "open" npm package or by selecting the
OS-specific opener before calling execa: detect process.platform and use 'open'
on darwin, 'xdg-open' on linux, and the Windows equivalent ('start' via shell)
for win32; modify the code around the execa invocation in
src/commands/feedback.ts (the location with the execa('open', [issueUrl]) call)
to perform this platform check or replace it with the "open" package so opening
the issue URL works on Linux and Windows as well.


if (json) {
output({ success: true, issueUrl, method: 'browser' }, true);
} else {
success(`Opened browser: ${issueUrl}`);
}
}
}
Loading