diff --git a/README.md b/README.md index efc918d0..aa50569f 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,27 @@

- Version - Platform - Tauri - License - CI + + Version + + + Downloads + + + Platform + + + Tauri + + + License + + + CI +

+ ## Screenshots Origin IDE - Welcome screen with AI panel diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c89055d4..90e557ba 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2972,6 +2972,7 @@ dependencies = [ name = "origin" version = "0.1.4" dependencies = [ + "dpi", "keyring", "portable-pty", "reqwest 0.12.28", @@ -2987,6 +2988,7 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tokio", + "url", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5091ebf1..48e36598 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,7 +18,9 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["unstable"] } +url = "2" +dpi = "0.1" tauri-plugin-opener = "2" tauri-plugin-store = "2" tauri-plugin-dialog = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 1d1e14dc..34a0ebf0 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -20,6 +20,10 @@ "sql:default", "sql:allow-execute", "updater:default", - "process:allow-restart" + "process:allow-restart", + "core:webview:allow-create-webview", + "core:webview:allow-set-webview-position", + "core:webview:allow-set-webview-size", + "core:webview:allow-webview-close" ] } diff --git a/src-tauri/src/fs.rs b/src-tauri/src/fs.rs index 1af509c1..ddba93cf 100644 --- a/src-tauri/src/fs.rs +++ b/src-tauri/src/fs.rs @@ -90,6 +90,68 @@ pub fn create_dir_cmd(path: String) -> Result<(), String> { std::fs::create_dir_all(&path).map_err(|e| e.to_string()) } +/// Returns the config base directory for a known editor on the current OS. +/// e.g. editor_config_dir("Code") → C:\Users\\AppData\Roaming\Code +fn editor_config_dir(folder_name: &str) -> Result { + #[cfg(windows)] + { + let appdata = std::env::var("APPDATA").map_err(|_| "APPDATA not set".to_string())?; + Ok(std::path::PathBuf::from(appdata).join(folder_name)) + } + #[cfg(target_os = "macos")] + { + let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?; + Ok(std::path::PathBuf::from(home) + .join("Library") + .join("Application Support") + .join(folder_name)) + } + #[cfg(target_os = "linux")] + { + let config = std::env::var("XDG_CONFIG_HOME").unwrap_or_else(|_| { + let home = std::env::var("HOME").unwrap_or_default(); + format!("{home}/.config") + }); + Ok(std::path::PathBuf::from(config).join(folder_name)) + } +} + +fn editor_folder(id: &str) -> Option<&'static str> { + match id { + "vscode" => Some("Code"), + "cursor" => Some("Cursor"), + "windsurf" => Some("Windsurf"), + _ => None, + } +} + +/// Read the keybindings.json for a given editor (vscode | cursor | windsurf). +#[tauri::command] +pub fn read_editor_keybindings(editor: String) -> Result { + let folder = editor_folder(&editor) + .ok_or_else(|| format!("Unknown editor: {editor}"))?; + let path = editor_config_dir(folder)? + .join("User") + .join("keybindings.json"); + std::fs::read_to_string(&path) + .map_err(|e| format!("Could not read {}: {e}", path.display())) +} + +/// Returns which of vscode / cursor / windsurf have a keybindings.json on disk. +#[tauri::command] +pub fn detect_installed_editors() -> Vec { + ["vscode", "cursor", "windsurf"] + .iter() + .filter(|&&id| { + editor_folder(id) + .and_then(|f| editor_config_dir(f).ok()) + .map(|p| p.join("User").join("keybindings.json").exists()) + .unwrap_or(false) + }) + .map(|s| s.to_string()) + .collect() +} + #[tauri::command] pub fn reveal_in_explorer(path: String) -> Result<(), String> { #[cfg(windows)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d7867986..317b822f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,88 @@ mod system; mod terminal; mod tree; +use tauri::{AppHandle, Manager, WebviewBuilder, WebviewUrl}; +use dpi::{LogicalPosition, LogicalSize}; + +// IMPORTANT: these commands MUST be `async fn`. +// +// `Window::add_child` (Tauri 2.11.2) dispatches the webview build onto the main +// thread via `run_on_main_thread` and then *blocks* on `rx.recv()` waiting for +// the result. A synchronous `#[tauri::command]` runs on the main thread itself, +// so the command would dispatch work to the main thread and then block that same +// thread waiting for it — a self-deadlock. The spinner would spin forever. +// +// Declaring the command `async` makes Tauri run it on its async runtime thread +// pool instead of the main thread, so the dispatch-and-wait completes normally. +#[tauri::command] +async fn embed_ide_panel( + app: AppHandle, + panel_id: String, + url: String, + x: f64, + y: f64, + width: f64, + height: f64, +) -> Result<(), String> { + // Destroy any existing embedded webview with this label first + if let Some(existing) = app.get_webview(&panel_id) { + let _ = existing.close(); + } + let host = app + .get_webview_window("main") + .ok_or_else(|| "Host window not found".to_string())?; + let window = host.as_ref().window(); + let parsed_url = url::Url::parse(&url).map_err(|e| e.to_string())?; + window + .add_child( + WebviewBuilder::new(&panel_id, WebviewUrl::External(parsed_url)), + LogicalPosition::new(x, y), + LogicalSize::new(width, height), + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +async fn resize_ide_panel( + app: AppHandle, + panel_id: String, + x: f64, + y: f64, + width: f64, + height: f64, +) -> Result<(), String> { + if let Some(webview) = app.get_webview(&panel_id) { + webview + .set_position(LogicalPosition::new(x, y)) + .map_err(|e| e.to_string())?; + webview + .set_size(LogicalSize::new(width, height)) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +#[tauri::command] +async fn destroy_ide_panel( + app: AppHandle, + panel_id: String, +) -> Result<(), String> { + if let Some(webview) = app.get_webview(&panel_id) { + webview.close().map_err(|e| e.to_string())?; + } + Ok(()) +} + +#[tauri::command] +async fn get_ide_panel_url(app: AppHandle, panel_id: String) -> Result { + if let Some(webview) = app.get_webview(&panel_id) { + webview.url().map(|u| u.to_string()).map_err(|e| e.to_string()) + } else { + Err("panel not found".to_string()) + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -32,6 +114,8 @@ pub fn run() { fs::delete_path, fs::create_dir_cmd, fs::reveal_in_explorer, + fs::read_editor_keybindings, + fs::detect_installed_editors, git::git_branch, git::git_changes, git::git_status_files, @@ -58,6 +142,10 @@ pub fn run() { dap::dap_start, dap::dap_request, dap::dap_stop, + embed_ide_panel, + resize_ide_panel, + destroy_ide_panel, + get_ide_panel_url, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.tsx b/src/App.tsx index b3a2f9e9..1b99da88 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -212,10 +212,22 @@ function App() { }, [activeTab, debugCtx.session.breakpoints, debugCtx.session.stackFrames]); const { isFullscreen, toggleFullscreen } = useGlobalKeybindings({ - saveActive: () => { if (activeTab) handleSave(activeTab); }, - toggleTerminal: () => setTerminalOpen(v => !v), - togglePalette: () => setPaletteOpen(v => !v), - toggleSettings: () => setSettingsOpen(v => !v), + saveActive: () => { if (activeTab) handleSave(activeTab); }, + toggleTerminal: () => setTerminalOpen(v => !v), + togglePalette: () => setPaletteOpen(v => !v), + toggleSettings: () => setSettingsOpen(v => !v), + newFile: () => handleNewFile(), + openFile: () => handleOpenFile(), + closeTab: () => { if (activeTab) closeTab(activeTab); }, + toggleSidebar: () => setSidebarOpen(v => !v), + zoomIn: () => handleZoomIn(), + zoomOut: () => handleZoomOut(), + zoomReset: () => handleZoomReset(), + startDebug: () => { setSidebarOpen(true); debugCtx.startSession?.(); }, + stopDebug: () => debugCtx.stopSession(), + stepOver: () => debugCtx.stepOver?.(), + stepInto: () => debugCtx.stepIn?.(), + stepOut: () => debugCtx.stepOut?.(), }); async function completeOnboarding() { diff --git a/src/components/editor/WebPreviewPane.tsx b/src/components/editor/WebPreviewPane.tsx index ce81377d..8a3d0529 100644 --- a/src/components/editor/WebPreviewPane.tsx +++ b/src/components/editor/WebPreviewPane.tsx @@ -1,9 +1,13 @@ -import { useState } from 'react'; -import { RefreshCw, ExternalLink, ArrowLeft, ArrowRight, Globe, PictureInPicture2, X, Smartphone, Tablet, Monitor } from 'lucide-react'; +import { useState, useEffect, useRef, useCallback } from 'react'; +import { RefreshCw, ExternalLink, ArrowLeft, ArrowRight, Globe, PictureInPicture2, X, Smartphone, Tablet, Monitor, Loader2 } from 'lucide-react'; import { openUrl } from '@tauri-apps/plugin-opener'; import { WebviewWindow } from '@tauri-apps/api/webviewWindow'; +import { invoke } from '@tauri-apps/api/core'; import { Tooltip } from '../ui/Tooltip'; +// Stable label for the single embedded preview webview. +const PREVIEW_PANEL_ID = 'origin-web-preview'; + const COMMON_PORTS = [ { port: 5173, label: 'Vite' }, { port: 3000, label: 'Next / CRA' }, @@ -18,6 +22,19 @@ function normalize(raw: string): string { return `http://${trimmed}`; } +function sameUrl(a: string, b: string): boolean { + if (a === b) return true; + try { + const ua = new URL(a); + const ub = new URL(b); + const pa = ua.pathname.replace(/\/$/, '') || '/'; + const pb = ub.pathname.replace(/\/$/, '') || '/'; + return ua.origin === ub.origin && pa === pb && ua.search === ub.search && ua.hash === ub.hash; + } catch { + return false; + } +} + // ── Empty state shown before user picks a URL ────────────────────────────── function UrlPicker({ onNavigate }: { onNavigate: (url: string) => void }) { @@ -262,15 +279,31 @@ export default function WebPreviewPane() { const [reloadKey, setReloadKey] = useState(0); const [viewW, setViewW] = useState(null); const [viewH, setViewH] = useState(null); + const [iframeLoading, setIframeLoading] = useState(false); + + // Container that reserves layout space for the native embedded webview. + const containerRef = useRef(null); + // Outer scrollable wrapper — used to clip the native webview so it never + // overflows the pane into other UI (title bar, status bar, etc.) in + // constrained (tablet / mobile) viewport modes. + const outerWrapperRef = useRef(null); + + // Tracks whether the URL input is focused so the poller doesn't overwrite + // text the user is currently editing. + const urlBarFocusedRef = useRef(false); + // Set to the URL we're about to load programmatically so the poller skips + // the first "new URL" it sees after our own navigation (avoids false-positive + // flicker between the old URL and the new one during page load). + const expectedNavUrlRef = useRef(null); const activePreset = detectPreset(viewW, viewH); - const url = index >= 0 ? history[index] : null; const canGoBack = index > 0; const canGoForward = index < history.length - 1; // Push a new entry, truncating any forward history (standard browser behaviour). function pushEntry(to: string) { + expectedNavUrlRef.current = to; setHistory(prev => [...prev.slice(0, index + 1), to]); setIndex(index + 1); setInputUrl(to); @@ -279,6 +312,8 @@ export default function WebPreviewPane() { // Open a fresh URL from the empty-state picker — resets the stack. function navigateFresh(to: string) { + expectedNavUrlRef.current = to; + setIframeLoading(true); setHistory([to]); setIndex(0); setInputUrl(to); @@ -286,8 +321,10 @@ export default function WebPreviewPane() { } function moveTo(nextIndex: number) { + const to = history[nextIndex] ?? ''; + expectedNavUrlRef.current = to; + setIframeLoading(true); setIndex(nextIndex); - const to = history[nextIndex]; setInputUrl(to); localStorage.setItem(LS_PREVIEW_URL, to); } @@ -295,9 +332,9 @@ export default function WebPreviewPane() { function handleUrlBarNavigate(raw: string) { const next = normalize(raw); if (!next) return; - // Same URL as current → treat Enter as a reload rather than pushing - // a duplicate history entry. + setIframeLoading(true); if (next === url) { + expectedNavUrlRef.current = next; setReloadKey(k => k + 1); setInputUrl(next); return; @@ -331,6 +368,118 @@ export default function WebPreviewPane() { }); } + // Push the current container geometry to the native webview, clipped to the + // visible bounds of the outer wrapper so the panel never overflows into the + // title bar / status bar in constrained (tablet / mobile) viewport modes. + const syncBounds = useCallback(() => { + const el = containerRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); + const outer = outerWrapperRef.current; + let x = r.left, y = r.top, w = r.width, h = r.height; + if (outer) { + const o = outer.getBoundingClientRect(); + x = Math.max(r.left, o.left); + y = Math.max(r.top, o.top); + w = Math.max(0, Math.min(r.right, o.right) - x); + h = Math.max(0, Math.min(r.bottom, o.bottom) - y); + } + void invoke('resize_ide_panel', { + panelId: PREVIEW_PANEL_ID, x, y, width: w, height: h, + }).catch(() => {}); + }, []); + + // Embed / re-embed the native webview whenever the URL or reload key changes. + useEffect(() => { + const el = containerRef.current; + if (!url || !el) return; + + let cancelled = false; + setIframeLoading(true); + + const r = el.getBoundingClientRect(); + const outer = outerWrapperRef.current; + let x = r.left, y = r.top, w = r.width, h = r.height; + if (outer) { + const o = outer.getBoundingClientRect(); + x = Math.max(r.left, o.left); + y = Math.max(r.top, o.top); + w = Math.max(0, Math.min(r.right, o.right) - x); + h = Math.max(0, Math.min(r.bottom, o.bottom) - y); + } + void invoke('embed_ide_panel', { + panelId: PREVIEW_PANEL_ID, url, x, y, width: w, height: h, + }) + .catch(() => {}) + .finally(() => { + if (!cancelled) setIframeLoading(false); + }); + + return () => { + cancelled = true; + void invoke('destroy_ide_panel', { panelId: PREVIEW_PANEL_ID }).catch(() => {}); + }; + }, [url, reloadKey]); + + // Keep the native webview aligned with the placeholder as it resizes. + useEffect(() => { + const el = containerRef.current; + if (!url || !el) return; + + const ro = new ResizeObserver(() => syncBounds()); + ro.observe(el); + window.addEventListener('resize', syncBounds); + window.addEventListener('scroll', syncBounds, true); + + return () => { + ro.disconnect(); + window.removeEventListener('resize', syncBounds); + window.removeEventListener('scroll', syncBounds, true); + }; + }, [url, viewW, viewH, syncBounds]); + + // Re-sync after preset/dimension changes so the webview tracks the new box. + useEffect(() => { + if (!url) return; + syncBounds(); + }, [viewW, viewH, url, syncBounds]); + + // Poll the native webview's current URL every 500ms to keep the URL bar in + // sync when the user clicks links inside the preview (including SPA pushState + // navigations that do not trigger Tauri's on_navigation callback). + // + // We only update inputUrl here, NOT history/index. In-webview navigation + // (app routing, link clicks) is handled by the webview's own history; our + // Back/Forward buttons only track explicit navigations triggered from this + // toolbar. Updating history here would change `url`, which would re-trigger + // the embed effect and reload the page unnecessarily. + useEffect(() => { + if (!url) return; + + const poll = async () => { + try { + const current: string = await invoke('get_ide_panel_url', { panelId: PREVIEW_PANEL_ID }); + if (!current || current === 'about:blank') return; + + // Wait for our own programmatic navigation to land before tracking changes. + if (expectedNavUrlRef.current !== null) { + if (sameUrl(current, expectedNavUrlRef.current)) { + expectedNavUrlRef.current = null; + } + return; + } + + // Update the URL bar display; don't touch history so embed isn't re-triggered. + if (!urlBarFocusedRef.current) setInputUrl(current); + } catch { + // Panel not yet created or already destroyed — silently ignore. + } + }; + + const id = setInterval(poll, 500); + return () => clearInterval(id); + }, [url]); // only restart when an explicit navigation changes the base URL + // No URL yet — show picker if (url === null) { return ( @@ -358,7 +507,7 @@ export default function WebPreviewPane() {
- setReloadKey(k => k + 1)} title="Refresh"> + { setIframeLoading(true); setReloadKey(k => k + 1); }} title="Refresh"> @@ -382,8 +531,8 @@ export default function WebPreviewPane() { (e.target as HTMLInputElement).blur(); } }} - onFocus={e => e.currentTarget.select()} - onBlur={() => setInputUrl(url)} + onFocus={e => { urlBarFocusedRef.current = true; e.currentTarget.select(); }} + onBlur={() => { urlBarFocusedRef.current = false; setInputUrl(url); }} spellCheck={false} style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', @@ -451,28 +600,40 @@ export default function WebPreviewPane() {
- {/* Iframe wrapper */} -
-