Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ PromptLight removes that friction with a keystroke-summoned launcher that finds

## Features

- **Global Hotkey** - Summon with `Cmd+Shift+Space` from anywhere
- **Global Hotkey** - Summon with `Cmd+/` from anywhere
- **Instant Search** - Type to filter prompts in real-time
- **Keyboard Navigation** - Arrow keys to navigate, Enter to paste
- **Folders** - Organize prompts by project or category
Expand Down
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export default defineConfig({
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run dev:vite',
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ async-trait = "0.1"
once_cell = "1.19"
# OAuth and HTTP
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["sync", "net", "io-util", "macros"] }
tokio = { version = "1", features = ["sync", "net", "io-util", "macros", "rt"] }
url = "2"
base64 = "0.22"
sha2 = "0.10"
Expand Down
11 changes: 8 additions & 3 deletions src-tauri/src/auth/google.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,16 @@ pub async fn start_google_sign_in() -> Result<GoogleTokens, String> {
// Build the authorization URL
let auth_url = build_auth_url(&redirect_uri, &code_challenge)?;

// Open the browser
// Open the browser - do this BEFORE spawning the blocking task
// so the browser has time to start while we wait
opener::open(&auth_url).map_err(|e| format!("Failed to open browser: {}", e))?;

// Wait for the OAuth callback
let code = wait_for_callback(listener)?;
// Wait for the OAuth callback in a blocking task to not block the async runtime
// This is critical: TcpListener::accept() is blocking and would freeze the app
let code = tokio::task::spawn_blocking(move || wait_for_callback(listener))
.await
.map_err(|e| format!("Task join error: {}", e))?
.map_err(|e| format!("OAuth callback error: {}", e))?;

// Exchange the authorization code for tokens
let tokens = exchange_code_for_tokens(&code, &code_verifier, &redirect_uri).await?;
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/src/data/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ pub struct GeneralSettings {
pub welcome_screen_dismissed: bool,
}

/// Default hotkey: Cmd/Ctrl+Shift+Space
/// Default hotkey: Cmd/Ctrl+/
fn default_hotkey() -> Option<String> {
Some("CommandOrControl+Shift+Space".to_string())
Some("CommandOrControl+/".to_string())
}

/// Default editor always on top: true
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ pub fn run() {
}
}

// Register global shortcut from settings (defaults to Cmd+Shift+Space)
// Register global shortcut from settings (defaults to Cmd+/)
let app_handle = app.handle();
if let Err(e) = init_hotkey_from_settings(app_handle) {
eprintln!("Failed to register global hotkey from settings: {}", e);
Expand Down
22 changes: 16 additions & 6 deletions src-tauri/src/os/platform/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ use cocoa::foundation::NSString;
use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation, CGKeyCode};
use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
use objc::{class, msg_send, sel, sel_impl};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;

/// Track if we've already prompted for accessibility permissions this session.
/// Prevents repeatedly showing the prompt on every paste attempt.
static PERMISSION_PROMPTED: AtomicBool = AtomicBool::new(false);

/// Check if the app has accessibility permissions (required for CGEvent paste simulation).
/// Uses AXIsProcessTrusted() from the Accessibility framework.
pub fn check_accessibility_permission() -> bool {
Expand Down Expand Up @@ -180,13 +185,18 @@ impl InputSimulator for MacOSInputSimulator {
println!("[platform:macos] Paste simulation may fail. Grant permission in:");
println!("[platform:macos] System Settings > Privacy & Security > Accessibility");

// Request permission (shows system dialog)
let prompted = request_accessibility_permission();
println!("[platform:macos] Permission request dialog shown: {}", prompted);

if !prompted {
return Err("Accessibility permission required for paste. Please grant in System Settings > Privacy & Security > Accessibility".to_string());
// Only prompt once per session to avoid spamming the user
if !PERMISSION_PROMPTED.swap(true, Ordering::SeqCst) {
// First time this session - show the system dialog
let prompted = request_accessibility_permission();
println!("[platform:macos] Permission request dialog shown: {}", prompted);
} else {
println!("[platform:macos] Already prompted this session, skipping dialog");
}

// Return error - paste will fail without permission
// The content is already in the clipboard, so user can manually paste
return Err("Accessibility permission required for paste. Content copied to clipboard - use Cmd+V to paste manually.".to_string());
} else {
println!("[platform:macos] Accessibility permission: GRANTED");
}
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/e2e/pages/EditorPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ export class EditorPage {
/** Trigger editor store to reload prompts from backend */
async reloadPrompts() {
await this.page.evaluate(() => {
// Access the editor store via zustand's getState
const store = (window as unknown as { __ZUSTAND_DEVTOOLS_GLOBAL__?: { stores: Map<string, { getState: () => { loadPrompts: () => Promise<void> } }> } }).__ZUSTAND_DEVTOOLS_GLOBAL__?.stores?.get('editor');
// Access the editor store via the exposed __editorStore
const store = (window as unknown as { __editorStore?: { getState: () => { loadPrompts: () => Promise<void> } } }).__editorStore;
if (store) {
store.getState().loadPrompts();
}
Expand Down
235 changes: 235 additions & 0 deletions src/__tests__/e2e/select-all.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
/**
* Select All (Cmd+A) E2E Tests
*
* Tests that Cmd+A properly selects ALL content in the markdown editor,
* including content that is virtualized/scrolled out of view.
*
* This is a regression test for the CodeMirror virtualization issue where
* Cmd+A would only select visible content instead of the full document.
*
* IMPORTANT: These tests load EXISTING prompts (not create new ones) because
* the virtualization issue only occurs when content is loaded from storage,
* not when it's typed in (typing renders all content).
*/

import { test, expect } from '@playwright/test';
import { EditorPage } from './pages/EditorPage';
import { createTestPrompt } from './fixtures/test-data';
import os from 'os';

const modifier = os.platform() === 'darwin' ? 'Meta' : 'Control';

// Generate a very large body that requires multiple scrolls
function generateLargeContent(lines: number): string {
const content: string[] = [];
for (let i = 1; i <= lines; i++) {
content.push(`Line ${i}: This is paragraph ${i} of the test content. It contains enough text to make each line meaningful and help verify that all content is properly selected when using Cmd+A.`);
content.push(''); // Empty line between paragraphs
}
return content.join('\n');
}

test.describe('Select All in Editor', () => {
let editor: EditorPage;

test.beforeEach(async ({ page }) => {
editor = new EditorPage(page);
});

test('should select ALL content including virtualized/scrolled content when pressing Cmd+A', async () => {
// This test verifies the fix for CodeMirror virtualization issue
// where Cmd+A only selected visible content instead of the full document
//
// CRITICAL: This test LOADS an existing prompt (not creates a new one)
// because the virtualization issue only occurs with loaded content.
// When content is typed, it's all rendered. When loaded, only visible
// lines are rendered due to CodeMirror virtualization.

// Generate very large content (100 lines = definitely multiple scrolls worth)
const largeContent = generateLargeContent(100);
expect(largeContent.length).toBeGreaterThan(10000); // Sanity check

// Create a prompt with large content that will be loaded from "storage"
const largePrompt = createTestPrompt({
id: 'large-content-prompt',
name: 'Large Content Test',
description: 'A prompt with lots of content for select-all testing',
content: largeContent,
});

// Navigate and seed the data
await editor.goto();
await editor.page.waitForTimeout(500);

// Seed the prompt into mock backend
await editor.seedPrompts([largePrompt]);
await editor.page.waitForTimeout(300);

// Reload prompts so the store picks up the seeded data
await editor.reloadPrompts();
await editor.page.waitForTimeout(500);

// Click on the prompt in the sidebar to LOAD it (not create new)
await editor.clickSidebarPromptByName('Large Content Test');
await editor.page.waitForTimeout(500);

// Click into the content editor
const cmContent = editor.contentEditor.locator('.cm-content');
await cmContent.click();
await editor.page.waitForTimeout(500);

// Leave the field by clicking on the title
await editor.titleInput.click();
await editor.page.waitForTimeout(500);

// Go back to the content field
await cmContent.click();
await editor.page.waitForTimeout(500);

// Press Cmd+A to select all
await editor.page.keyboard.press(`${modifier}+a`);
await editor.page.waitForTimeout(500);

// Type "hello" to replace ALL selected content
await editor.page.keyboard.type('hello');
await editor.page.waitForTimeout(500);

// Get the new content
const contentAfter = await cmContent.textContent();

// The content should be EXACTLY "hello" (no trailing text from previous content)
// If select-all only selected visible content, there would be leftover text
expect(contentAfter?.trim()).toBe('hello');
});

test('should select all with shorter content (sanity check)', async () => {
// Sanity check with short content to ensure test mechanics work
await editor.goto();
await editor.page.waitForTimeout(500);

await editor.clickNewPrompt();
await editor.page.waitForTimeout(300);

const shortContent = 'This is a short test message.';

const cmContent = editor.contentEditor.locator('.cm-content');
await cmContent.click();
await editor.page.keyboard.type(shortContent);
await editor.page.waitForTimeout(200);

// Leave and return
await editor.titleInput.click();
await editor.page.waitForTimeout(100);
await cmContent.click();
await editor.page.waitForTimeout(100);

// Select all and replace
await editor.page.keyboard.press(`${modifier}+a`);
await editor.page.keyboard.type('replaced');
await editor.page.waitForTimeout(100);

const contentAfter = await cmContent.textContent();
expect(contentAfter?.trim()).toBe('replaced');
});

test('should select all content when scrolled to bottom', async () => {
// Test that select-all works even when scrolled away from the start
// Uses LOADED prompt (not created) to test virtualization issue

const largeContent = generateLargeContent(50);
const bottomPrompt = createTestPrompt({
id: 'bottom-scroll-prompt',
name: 'Bottom Scroll Test',
description: 'Testing select-all when scrolled to bottom',
content: largeContent,
});

await editor.goto();
await editor.page.waitForTimeout(500);

await editor.seedPrompts([bottomPrompt]);
await editor.page.waitForTimeout(300);
await editor.reloadPrompts();
await editor.page.waitForTimeout(500);

// Load the prompt by clicking it in sidebar
await editor.clickSidebarPromptByName('Bottom Scroll Test');
await editor.page.waitForTimeout(500);

const cmContent = editor.contentEditor.locator('.cm-content');
await cmContent.click();
await editor.page.waitForTimeout(500);

// Scroll to the bottom by pressing Cmd+End or just End multiple times
await editor.page.keyboard.press(`${modifier}+End`);
await editor.page.waitForTimeout(500);

// Leave and re-enter the field
await editor.titleInput.click();
await editor.page.waitForTimeout(500);
await cmContent.click();
await editor.page.waitForTimeout(500);

// Now select all from the bottom position
await editor.page.keyboard.press(`${modifier}+a`);
await editor.page.waitForTimeout(500);

// Replace with "bottom-test"
await editor.page.keyboard.type('bottom-test');
await editor.page.waitForTimeout(500);

const contentAfter = await cmContent.textContent();
expect(contentAfter?.trim()).toBe('bottom-test');
});

test('should select all content when scrolled to middle', async () => {
// Test that select-all works when scrolled to the middle of content
// Uses LOADED prompt (not created) to test virtualization issue

const largeContent = generateLargeContent(80);
const middlePrompt = createTestPrompt({
id: 'middle-scroll-prompt',
name: 'Middle Scroll Test',
description: 'Testing select-all when scrolled to middle',
content: largeContent,
});

await editor.goto();
await editor.page.waitForTimeout(500);

await editor.seedPrompts([middlePrompt]);
await editor.page.waitForTimeout(300);
await editor.reloadPrompts();
await editor.page.waitForTimeout(500);

// Load the prompt by clicking it in sidebar
await editor.clickSidebarPromptByName('Middle Scroll Test');
await editor.page.waitForTimeout(500);

const cmContent = editor.contentEditor.locator('.cm-content');
await cmContent.click();
await editor.page.waitForTimeout(500);

// Go to the middle using Cmd+G or just scroll via Page Down
await editor.page.keyboard.press('PageDown');
await editor.page.keyboard.press('PageDown');
await editor.page.waitForTimeout(500);

// Leave and re-enter the field
await editor.titleInput.click();
await editor.page.waitForTimeout(500);
await cmContent.click();
await editor.page.waitForTimeout(500);

// Select all from middle position
await editor.page.keyboard.press(`${modifier}+a`);
await editor.page.waitForTimeout(500);

// Replace
await editor.page.keyboard.type('middle-test');
await editor.page.waitForTimeout(500);

const contentAfter = await cmContent.textContent();
expect(contentAfter?.trim()).toBe('middle-test');
});
});
Loading
Loading