Skip to content

Commit 444d480

Browse files
committed
feat(preview): add web preview with screenshot capability
- Implement WebviewPreview component with browser-like navigation - Add headless Chrome integration for capturing screenshots - Create split-pane component for side-by-side layout - Add dialog for URL detection prompts Allows users to preview web applications and capture screenshots directly into Claude prompts for better context sharing.
1 parent 0c6c089 commit 444d480

4 files changed

Lines changed: 1030 additions & 0 deletions

File tree

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
use headless_chrome::{Browser, LaunchOptions};
2+
use headless_chrome::protocol::cdp::Page;
3+
use std::fs;
4+
use std::time::Duration;
5+
use tauri::AppHandle;
6+
7+
/// Captures a screenshot of a URL using headless Chrome
8+
///
9+
/// This function launches a headless Chrome browser, navigates to the specified URL,
10+
/// and captures a screenshot of either the entire page or a specific element.
11+
///
12+
/// # Arguments
13+
/// * `app` - The Tauri application handle
14+
/// * `url` - The URL to capture
15+
/// * `selector` - Optional CSS selector for a specific element to capture
16+
/// * `full_page` - Whether to capture the entire page or just the viewport
17+
///
18+
/// # Returns
19+
/// * `Result<String, String>` - The path to the saved screenshot file, or an error message
20+
#[tauri::command]
21+
pub async fn capture_url_screenshot(
22+
_app: AppHandle,
23+
url: String,
24+
selector: Option<String>,
25+
full_page: bool,
26+
) -> Result<String, String> {
27+
log::info!(
28+
"Capturing screenshot of URL: {}, selector: {:?}, full_page: {}",
29+
url,
30+
selector,
31+
full_page
32+
);
33+
34+
// Run the browser operations in a blocking task since headless_chrome is not async
35+
let result = tokio::task::spawn_blocking(move || {
36+
capture_screenshot_sync(url, selector, full_page)
37+
})
38+
.await
39+
.map_err(|e| format!("Failed to spawn blocking task: {}", e))?;
40+
41+
// Log the result of the headless Chrome capture before returning
42+
match &result {
43+
Ok(path) => log::info!("capture_url_screenshot returning path: {}", path),
44+
Err(err) => log::error!("capture_url_screenshot encountered error: {}", err),
45+
}
46+
47+
result
48+
}
49+
50+
/// Synchronous helper function to capture screenshots using headless Chrome
51+
fn capture_screenshot_sync(
52+
url: String,
53+
selector: Option<String>,
54+
full_page: bool,
55+
) -> Result<String, String> {
56+
// Configure browser launch options
57+
let launch_options = LaunchOptions {
58+
headless: true,
59+
window_size: Some((1920, 1080)),
60+
..Default::default()
61+
};
62+
63+
// Launch the browser
64+
let browser = Browser::new(launch_options)
65+
.map_err(|e| format!("Failed to launch browser: {}", e))?;
66+
67+
// Create a new tab
68+
let tab = browser
69+
.new_tab()
70+
.map_err(|e| format!("Failed to create new tab: {}", e))?;
71+
72+
// Set a reasonable timeout for page navigation
73+
tab.set_default_timeout(Duration::from_secs(30));
74+
75+
// Navigate to the URL
76+
tab.navigate_to(&url)
77+
.map_err(|e| format!("Failed to navigate to URL: {}", e))?;
78+
79+
// Wait for the page to load
80+
// Try to wait for network idle, but don't fail if it times out
81+
let _ = tab.wait_until_navigated();
82+
83+
// Additional wait to ensure dynamic content loads
84+
std::thread::sleep(Duration::from_millis(500));
85+
86+
// Wait explicitly for the <body> element to exist – this often prevents
87+
// "Unable to capture screenshot" CDP errors on some pages
88+
if let Err(e) = tab.wait_for_element("body") {
89+
log::warn!("Timed out waiting for <body> element: {} – continuing anyway", e);
90+
}
91+
92+
// Capture the screenshot
93+
let screenshot_data = if let Some(selector) = selector {
94+
// Wait for the element and capture it
95+
log::info!("Waiting for element with selector: {}", selector);
96+
97+
let element = tab
98+
.wait_for_element(&selector)
99+
.map_err(|e| format!("Failed to find element '{}': {}", selector, e))?;
100+
101+
element
102+
.capture_screenshot(Page::CaptureScreenshotFormatOption::Png)
103+
.map_err(|e| format!("Failed to capture element screenshot: {}", e))?
104+
} else {
105+
// Capture the entire page or viewport
106+
log::info!("Capturing {} screenshot", if full_page { "full page" } else { "viewport" });
107+
108+
// Get the page dimensions for full page screenshot
109+
let clip = if full_page {
110+
// Execute JavaScript to get the full page dimensions
111+
let dimensions = tab
112+
.evaluate(
113+
r#"
114+
({
115+
width: Math.max(
116+
document.body.scrollWidth,
117+
document.documentElement.scrollWidth,
118+
document.body.offsetWidth,
119+
document.documentElement.offsetWidth,
120+
document.documentElement.clientWidth
121+
),
122+
height: Math.max(
123+
document.body.scrollHeight,
124+
document.documentElement.scrollHeight,
125+
document.body.offsetHeight,
126+
document.documentElement.offsetHeight,
127+
document.documentElement.clientHeight
128+
)
129+
})
130+
"#,
131+
false,
132+
)
133+
.map_err(|e| format!("Failed to get page dimensions: {}", e))?;
134+
135+
// Extract dimensions from the result
136+
let width = dimensions
137+
.value
138+
.as_ref()
139+
.and_then(|v| v.as_object())
140+
.and_then(|obj| obj.get("width"))
141+
.and_then(|v| v.as_f64())
142+
.unwrap_or(1920.0);
143+
144+
let height = dimensions
145+
.value
146+
.as_ref()
147+
.and_then(|v| v.as_object())
148+
.and_then(|obj| obj.get("height"))
149+
.and_then(|v| v.as_f64())
150+
.unwrap_or(1080.0);
151+
152+
Some(Page::Viewport {
153+
x: 0.0,
154+
y: 0.0,
155+
width,
156+
height,
157+
scale: 1.0,
158+
})
159+
} else {
160+
None
161+
};
162+
163+
let capture_result = tab.capture_screenshot(
164+
Page::CaptureScreenshotFormatOption::Png,
165+
None,
166+
clip.clone(),
167+
full_page, // capture_beyond_viewport only makes sense for full page
168+
);
169+
170+
match capture_result {
171+
Ok(data) => data,
172+
Err(err) => {
173+
// Retry once with capture_beyond_viewport=true which works around some Chromium bugs
174+
log::warn!(
175+
"Initial screenshot attempt failed: {}. Retrying with capture_beyond_viewport=true",
176+
err
177+
);
178+
179+
tab.capture_screenshot(
180+
Page::CaptureScreenshotFormatOption::Png,
181+
None,
182+
clip,
183+
true,
184+
)
185+
.map_err(|e| format!("Failed to capture screenshot after retry: {}", e))?
186+
}
187+
}
188+
};
189+
190+
// Save to temporary file
191+
let temp_dir = std::env::temp_dir();
192+
let timestamp = chrono::Utc::now().timestamp_millis();
193+
let filename = format!("claudia_screenshot_{}.png", timestamp);
194+
let file_path = temp_dir.join(filename);
195+
196+
fs::write(&file_path, screenshot_data)
197+
.map_err(|e| format!("Failed to save screenshot: {}", e))?;
198+
199+
// Log the screenshot path prominently
200+
println!("═══════════════════════════════════════════════════════════════");
201+
println!("📸 SCREENSHOT SAVED SUCCESSFULLY!");
202+
println!("📁 Location: {}", file_path.display());
203+
println!("═══════════════════════════════════════════════════════════════");
204+
205+
log::info!("Screenshot saved to: {:?}", file_path);
206+
207+
Ok(file_path.to_string_lossy().to_string())
208+
}
209+
210+
/// Cleans up old screenshot files from the temporary directory
211+
///
212+
/// This function removes screenshot files older than the specified number of minutes
213+
/// to prevent accumulation of temporary files.
214+
///
215+
/// # Arguments
216+
/// * `older_than_minutes` - Remove files older than this many minutes (default: 60)
217+
///
218+
/// # Returns
219+
/// * `Result<usize, String>` - The number of files deleted, or an error message
220+
#[tauri::command]
221+
pub async fn cleanup_screenshot_temp_files(
222+
older_than_minutes: Option<u64>,
223+
) -> Result<usize, String> {
224+
let minutes = older_than_minutes.unwrap_or(60);
225+
log::info!("Cleaning up screenshot files older than {} minutes", minutes);
226+
227+
let temp_dir = std::env::temp_dir();
228+
let cutoff_time = chrono::Utc::now() - chrono::Duration::minutes(minutes as i64);
229+
let mut deleted_count = 0;
230+
231+
// Read directory entries
232+
let entries = fs::read_dir(&temp_dir)
233+
.map_err(|e| format!("Failed to read temp directory: {}", e))?;
234+
235+
for entry in entries {
236+
if let Ok(entry) = entry {
237+
let path = entry.path();
238+
239+
// Check if it's a claudia screenshot file
240+
if let Some(filename) = path.file_name() {
241+
if let Some(filename_str) = filename.to_str() {
242+
if filename_str.starts_with("claudia_screenshot_") && filename_str.ends_with(".png") {
243+
// Check file age
244+
if let Ok(metadata) = fs::metadata(&path) {
245+
if let Ok(modified) = metadata.modified() {
246+
let modified_time = chrono::DateTime::<chrono::Utc>::from(modified);
247+
if modified_time < cutoff_time {
248+
// Delete the file
249+
if fs::remove_file(&path).is_ok() {
250+
deleted_count += 1;
251+
log::debug!("Deleted old screenshot: {:?}", path);
252+
}
253+
}
254+
}
255+
}
256+
}
257+
}
258+
}
259+
}
260+
}
261+
262+
log::info!("Cleaned up {} old screenshot files", deleted_count);
263+
Ok(deleted_count)
264+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import React from "react";
2+
import { motion } from "framer-motion";
3+
import { Globe, ExternalLink } from "lucide-react";
4+
import { Button } from "@/components/ui/button";
5+
import {
6+
Dialog,
7+
DialogContent,
8+
DialogDescription,
9+
DialogFooter,
10+
DialogHeader,
11+
DialogTitle,
12+
} from "@/components/ui/dialog";
13+
14+
interface PreviewPromptDialogProps {
15+
/**
16+
* Whether the dialog is open
17+
*/
18+
isOpen: boolean;
19+
/**
20+
* The detected URL to preview
21+
*/
22+
url: string;
23+
/**
24+
* Callback when user confirms opening preview
25+
*/
26+
onConfirm: () => void;
27+
/**
28+
* Callback when user cancels
29+
*/
30+
onCancel: () => void;
31+
}
32+
33+
/**
34+
* Dialog component that prompts the user to open a detected URL in the preview pane
35+
*
36+
* @example
37+
* <PreviewPromptDialog
38+
* isOpen={showPrompt}
39+
* url="http://localhost:3000"
40+
* onConfirm={() => openPreview(url)}
41+
* onCancel={() => setShowPrompt(false)}
42+
* />
43+
*/
44+
export const PreviewPromptDialog: React.FC<PreviewPromptDialogProps> = ({
45+
isOpen,
46+
url,
47+
onConfirm,
48+
onCancel,
49+
}) => {
50+
// Extract domain for display
51+
const getDomain = (urlString: string) => {
52+
try {
53+
const urlObj = new URL(urlString);
54+
return urlObj.hostname;
55+
} catch {
56+
return urlString;
57+
}
58+
};
59+
60+
const domain = getDomain(url);
61+
const isLocalhost = domain.includes('localhost') || domain.includes('127.0.0.1');
62+
63+
return (
64+
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
65+
<DialogContent className="sm:max-w-[425px]">
66+
<DialogHeader>
67+
<DialogTitle className="flex items-center gap-2">
68+
<Globe className="h-5 w-5 text-primary" />
69+
Open Preview?
70+
</DialogTitle>
71+
<DialogDescription>
72+
A URL was detected in the terminal output. Would you like to open it in the preview pane?
73+
</DialogDescription>
74+
</DialogHeader>
75+
76+
<div className="py-4">
77+
<div className="rounded-lg border bg-muted/50 p-4">
78+
<div className="flex items-start gap-3">
79+
<ExternalLink className={`h-4 w-4 mt-0.5 ${isLocalhost ? 'text-green-500' : 'text-blue-500'}`} />
80+
<div className="flex-1 min-w-0">
81+
<p className="text-sm font-medium">
82+
{isLocalhost ? 'Local Development Server' : 'External URL'}
83+
</p>
84+
<p className="text-xs text-muted-foreground mt-1 break-all">
85+
{url}
86+
</p>
87+
</div>
88+
</div>
89+
</div>
90+
91+
<motion.div
92+
initial={{ opacity: 0, y: 10 }}
93+
animate={{ opacity: 1, y: 0 }}
94+
transition={{ delay: 0.1 }}
95+
className="mt-3 text-xs text-muted-foreground"
96+
>
97+
The preview will open in a split view on the right side of the screen.
98+
</motion.div>
99+
</div>
100+
101+
<DialogFooter>
102+
<Button variant="outline" onClick={onCancel}>
103+
Cancel
104+
</Button>
105+
<Button onClick={onConfirm} className="gap-2">
106+
<ExternalLink className="h-4 w-4" />
107+
Open Preview
108+
</Button>
109+
</DialogFooter>
110+
</DialogContent>
111+
</Dialog>
112+
);
113+
};

0 commit comments

Comments
 (0)