From 1a03c223168f33a451bff6174ecf99e9e97e7d31 Mon Sep 17 00:00:00 2001
From: gsvprharsha
Date: Mon, 22 Jun 2026 16:58:06 +0530
Subject: [PATCH 1/2] feat: replace iframe with native Tauri embedded webview
---
src-tauri/Cargo.lock | 2 +
src-tauri/Cargo.toml | 4 +-
src-tauri/capabilities/default.json | 6 +-
src-tauri/src/lib.rs | 76 ++++++++++++++
src/components/editor/WebPreviewPane.tsx | 124 +++++++++++++++++++----
5 files changed, 190 insertions(+), 22 deletions(-)
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/lib.rs b/src-tauri/src/lib.rs
index d7867986..0e2a2956 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -9,6 +9,79 @@ 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(())
+}
+
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -58,6 +131,9 @@ pub fn run() {
dap::dap_start,
dap::dap_request,
dap::dap_stop,
+ embed_ide_panel,
+ resize_ide_panel,
+ destroy_ide_panel,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
diff --git a/src/components/editor/WebPreviewPane.tsx b/src/components/editor/WebPreviewPane.tsx
index ce81377d..a5007e10 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' },
@@ -262,6 +266,10 @@ 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);
const activePreset = detectPreset(viewW, viewH);
@@ -279,6 +287,7 @@ export default function WebPreviewPane() {
// Open a fresh URL from the empty-state picker — resets the stack.
function navigateFresh(to: string) {
+ setIframeLoading(true);
setHistory([to]);
setIndex(0);
setInputUrl(to);
@@ -286,6 +295,7 @@ export default function WebPreviewPane() {
}
function moveTo(nextIndex: number) {
+ setIframeLoading(true);
setIndex(nextIndex);
const to = history[nextIndex];
setInputUrl(to);
@@ -295,8 +305,7 @@ 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) {
setReloadKey(k => k + 1);
setInputUrl(next);
@@ -331,6 +340,71 @@ export default function WebPreviewPane() {
});
}
+ // Push the current container geometry to the native webview.
+ const syncBounds = useCallback(() => {
+ const el = containerRef.current;
+ if (!el) return;
+ const rect = el.getBoundingClientRect();
+ void invoke('resize_ide_panel', {
+ panelId: PREVIEW_PANEL_ID,
+ x: rect.left,
+ y: rect.top,
+ width: rect.width,
+ height: rect.height,
+ }).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 rect = el.getBoundingClientRect();
+ void invoke('embed_ide_panel', {
+ panelId: PREVIEW_PANEL_ID,
+ url,
+ x: rect.left,
+ y: rect.top,
+ width: rect.width,
+ height: rect.height,
+ })
+ .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]);
+
// No URL yet — show picker
if (url === null) {
return (
@@ -358,7 +432,7 @@ export default function WebPreviewPane() {
- setReloadKey(k => k + 1)} title="Refresh">
+ { setIframeLoading(true); setReloadKey(k => k + 1); }} title="Refresh">
@@ -451,7 +525,9 @@ export default function WebPreviewPane() {
- {/* Iframe wrapper */}
+ {/* Native embedded webview wrapper. The container below is a transparent
+ placeholder that reserves layout space; the real content is a native
+ Tauri webview positioned over it at viewport-relative coordinates. */}
-
+
+ {/* Placeholder the native webview is anchored to. */}
+
+
+ {iframeLoading && (
+
+
+
+ )}
+
);
From 29a6b00d1089f6668648679dbba10fdf050e01eb Mon Sep 17 00:00:00 2001
From: gsvprharsha
Date: Tue, 23 Jun 2026 14:38:04 +0530
Subject: [PATCH 2/2] fix: Improved web preview from and keybinding
improvements
---
README.md | 24 +-
src-tauri/src/fs.rs | 62 ++++
src-tauri/src/lib.rs | 12 +
src/App.tsx | 20 +-
src/components/editor/WebPreviewPane.tsx | 121 ++++--
src/components/onboarding/PersonalizePage.tsx | 61 ++-
.../settings/KeybindingsSection.tsx | 347 ++++++++++++++++++
src/components/settings/SettingsPanel.tsx | 16 +-
src/hooks/useGlobalKeybindings.ts | 78 +++-
src/lib/keybindings.ts | 235 ++++++++++++
10 files changed, 914 insertions(+), 62 deletions(-)
create mode 100644 src/components/settings/KeybindingsSection.tsx
create mode 100644 src/lib/keybindings.ts
diff --git a/README.md b/README.md
index efc918d0..aa50569f 100644
--- a/README.md
+++ b/README.md
@@ -7,13 +7,27 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
## Screenshots
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 0e2a2956..317b822f 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -82,6 +82,15 @@ async fn destroy_ide_panel(
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()
@@ -105,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,
@@ -134,6 +145,7 @@ pub fn run() {
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 a5007e10..8a3d0529 100644
--- a/src/components/editor/WebPreviewPane.tsx
+++ b/src/components/editor/WebPreviewPane.tsx
@@ -22,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 }) {
@@ -269,16 +282,28 @@ export default function WebPreviewPane() {
const [iframeLoading, setIframeLoading] = useState(false);
// Container that reserves layout space for the native embedded webview.
- const containerRef = useRef(null);
+ 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);
@@ -287,6 +312,7 @@ 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);
@@ -295,9 +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);
}
@@ -307,6 +334,7 @@ export default function WebPreviewPane() {
if (!next) return;
setIframeLoading(true);
if (next === url) {
+ expectedNavUrlRef.current = next;
setReloadKey(k => k + 1);
setInputUrl(next);
return;
@@ -340,17 +368,24 @@ export default function WebPreviewPane() {
});
}
- // Push the current container geometry to the native webview.
+ // 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 rect = el.getBoundingClientRect();
+ 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: rect.left,
- y: rect.top,
- width: rect.width,
- height: rect.height,
+ panelId: PREVIEW_PANEL_ID, x, y, width: w, height: h,
}).catch(() => {});
}, []);
@@ -362,14 +397,18 @@ export default function WebPreviewPane() {
let cancelled = false;
setIframeLoading(true);
- const rect = el.getBoundingClientRect();
+ 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: rect.left,
- y: rect.top,
- width: rect.width,
- height: rect.height,
+ panelId: PREVIEW_PANEL_ID, url, x, y, width: w, height: h,
})
.catch(() => {})
.finally(() => {
@@ -405,6 +444,42 @@ export default function WebPreviewPane() {
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 (
@@ -456,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',
@@ -527,9 +602,11 @@ export default function WebPreviewPane() {
{/* Native embedded webview wrapper. The container below is a transparent
placeholder that reserves layout space; the real content is a native
- Tauri webview positioned over it at viewport-relative coordinates. */}
- void; onComplete: () => void; onSkip: () => void; }
@@ -87,6 +88,7 @@ export default function PersonalizePage({ onBack, onComplete, onSkip }: Props) {
const [selectedTheme, setSelectedTheme] = useState
('dark');
const [selectedKeymap, setSelectedKeymap] = useState('vscode');
const [selectedImport, setSelectedImport] = useState(null);
+ const [importing, setImporting] = useState(false);
function handleThemeSelect(id: 'dark' | 'light') {
setSelectedTheme(id);
@@ -95,6 +97,28 @@ export default function PersonalizePage({ onBack, onComplete, onSkip }: Props) {
if (t) setTheme(t);
}
+ // Persist the keymap preference so Settings can read it later
+ function persistKeymap(id: string) {
+ localStorage.setItem('origin-keymap', id);
+ }
+
+ async function handleEnterOrigin() {
+ persistKeymap(selectedKeymap);
+
+ if (selectedImport) {
+ setImporting(true);
+ try {
+ await applyKeybindingsFromEditor(selectedImport);
+ } catch {
+ // Import failure is non-fatal — proceed to IDE anyway
+ } finally {
+ setImporting(false);
+ }
+ }
+
+ onComplete();
+ }
+
return (
@@ -117,16 +141,29 @@ export default function PersonalizePage({ onBack, onComplete, onSkip }: Props) {
Keymap
- {KEYMAPS.map(k => setSelectedKeymap(k.id)}> )}
+ {KEYMAPS.map(k => (
+ setSelectedKeymap(k.id)}>
+
+
+ ))}
{/* Import */}
-
Import settings from
+
Import keybindings from
- {IMPORTS.map(imp => setSelectedImport(selectedImport === imp.id ? null : imp.id)}> )}
+ {IMPORTS.map(imp => (
+ setSelectedImport(selectedImport === imp.id ? null : imp.id)}>
+
+
+ ))}
+ {selectedImport && (
+
+ Your keybindings.json from {IMPORTS.find(i => i.id === selectedImport)?.label} will be imported. You can always adjust them later in Settings → Keyboard Shortcuts.
+
+ )}
{/* Footer */}
@@ -142,6 +179,7 @@ export default function PersonalizePage({ onBack, onComplete, onSkip }: Props) {
{ (e.currentTarget as HTMLElement).style.color = 'var(--origin-fg-default)'; (e.currentTarget as HTMLElement).style.borderColor = 'var(--origin-fg-muted)'; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.color = 'var(--origin-fg-muted)'; (e.currentTarget as HTMLElement).style.borderColor = 'var(--origin-border-default)'; }}
@@ -149,12 +187,17 @@ export default function PersonalizePage({ onBack, onComplete, onSkip }: Props) {
Back
{ (e.currentTarget as HTMLElement).style.opacity = '0.88'; }}
- onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = '1'; }}
+ onClick={handleEnterOrigin}
+ disabled={importing}
+ style={{ ...navBtn(), opacity: importing ? 0.7 : 1 }}
+ onMouseEnter={e => { if (!importing) (e.currentTarget as HTMLElement).style.opacity = '0.88'; }}
+ onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = importing ? '0.7' : '1'; }}
>
- Enter Origin
+ {importing ? (
+ <> Importing…>
+ ) : (
+ <>Enter Origin >
+ )}
diff --git a/src/components/settings/KeybindingsSection.tsx b/src/components/settings/KeybindingsSection.tsx
new file mode 100644
index 00000000..65e695af
--- /dev/null
+++ b/src/components/settings/KeybindingsSection.tsx
@@ -0,0 +1,347 @@
+import { useState, useEffect, useRef } from "react";
+import { RotateCcw, Search, Download, Check, AlertCircle } from "lucide-react";
+import {
+ COMMANDS,
+ loadKeybindings,
+ setKeybinding,
+ resetKeybindings,
+ getEffectiveKey,
+ detectInstalledEditors,
+ applyKeybindingsFromEditor,
+} from "../../lib/keybindings";
+import { useToast } from "../ui/Toast";
+
+// ── Key recorder ─────────────────────────────────────────────────────────────
+
+function formatEvent(e: KeyboardEvent): string {
+ const parts: string[] = [];
+ if (e.ctrlKey || e.metaKey) parts.push("ctrl");
+ if (e.shiftKey) parts.push("shift");
+ if (e.altKey) parts.push("alt");
+ const k = e.key === " " ? "space" : e.key.toLowerCase();
+ // Ignore bare modifiers
+ if (["control", "shift", "alt", "meta", "os"].includes(k)) return "";
+ parts.push(k);
+ return parts.join("+");
+}
+
+// ── Import row ────────────────────────────────────────────────────────────────
+
+interface ImportEditorRowProps {
+ detected: string[];
+ onImported: () => void;
+}
+
+const EDITOR_LABELS: Record
= {
+ vscode: "VS Code",
+ cursor: "Cursor",
+ windsurf: "Windsurf",
+};
+
+function ImportEditorRow({ detected, onImported }: ImportEditorRowProps) {
+ const { showToast } = useToast();
+ const [importing, setImporting] = useState(null);
+ const [done, setDone] = useState(null);
+
+ async function doImport(id: string) {
+ setImporting(id);
+ try {
+ const count = await applyKeybindingsFromEditor(id);
+ setDone(id);
+ setTimeout(() => setDone(null), 2000);
+ showToast(`Imported ${count} keybinding${count !== 1 ? "s" : ""} from ${EDITOR_LABELS[id]}`, "success");
+ onImported();
+ } catch (err) {
+ showToast(`Could not import from ${EDITOR_LABELS[id]}: ${err}`, "error");
+ } finally {
+ setImporting(null);
+ }
+ }
+
+ const candidates = Object.keys(EDITOR_LABELS);
+
+ return (
+
+ {candidates.map(id => {
+ const available = detected.includes(id);
+ const isImporting = importing === id;
+ const isDone = done === id;
+ return (
+ available && doImport(id)}
+ disabled={!available || isImporting !== false}
+ title={available ? `Import keybindings from ${EDITOR_LABELS[id]}` : `${EDITOR_LABELS[id]} not detected`}
+ style={{
+ display: "flex", alignItems: "center", gap: "6px",
+ padding: "5px 12px",
+ borderRadius: "6px",
+ border: "1px solid var(--origin-border-default)",
+ background: isDone
+ ? "color-mix(in srgb, var(--origin-semantic-success) 12%, transparent)"
+ : "transparent",
+ color: isDone
+ ? "var(--origin-semantic-success)"
+ : available
+ ? "var(--origin-fg-default)"
+ : "var(--origin-fg-subtle)",
+ fontSize: "12px",
+ cursor: available ? "pointer" : "not-allowed",
+ opacity: available ? 1 : 0.45,
+ transition: "all 0.15s",
+ fontFamily: "inherit",
+ }}
+ >
+ {isDone ? : isImporting ? : }
+ {isImporting ? "Importing…" : EDITOR_LABELS[id]}
+
+ );
+ })}
+
+ );
+}
+
+// ── Conflict badge ────────────────────────────────────────────────────────────
+
+function conflictFor(commandId: string, key: string): string | null {
+ const user = loadKeybindings();
+ for (const cmd of COMMANDS) {
+ if (cmd.id === commandId) continue;
+ const effective = user.find(b => b.command === cmd.id)?.key ?? cmd.defaultKey;
+ if (effective.toLowerCase() === key.toLowerCase()) return cmd.label;
+ }
+ return null;
+}
+
+// ── Keybinding row ────────────────────────────────────────────────────────────
+
+interface RowProps {
+ commandId: string;
+ label: string;
+ category: string;
+ defaultKey: string;
+ onChange: () => void;
+}
+
+function KeybindingRow({ commandId, label, defaultKey, onChange }: RowProps) {
+ const effectiveKey = getEffectiveKey(commandId) ?? defaultKey;
+ const isCustom = loadKeybindings().some(b => b.command === commandId);
+ const [recording, setRecording] = useState(false);
+ const [conflict, setConflict] = useState(null);
+ const btnRef = useRef(null);
+
+ useEffect(() => {
+ if (!recording) return;
+ function onKey(e: KeyboardEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.key === "Escape") { setRecording(false); setConflict(null); return; }
+ const combo = formatEvent(e);
+ if (!combo) return;
+ const c = conflictFor(commandId, combo);
+ setConflict(c);
+ setKeybinding(commandId, combo);
+ setRecording(false);
+ onChange();
+ }
+ window.addEventListener("keydown", onKey, { capture: true });
+ return () => window.removeEventListener("keydown", onKey, { capture: true });
+ }, [recording, commandId, onChange]);
+
+ function resetThis() {
+ setKeybinding(commandId, null);
+ setConflict(null);
+ onChange();
+ }
+
+ return (
+
+
{label}
+
+ {conflict && (
+
+
+
+ )}
+
setRecording(r => !r)}
+ title={recording ? "Press a key combination (Escape to cancel)" : "Click to rebind"}
+ style={{
+ padding: "3px 10px",
+ borderRadius: "5px",
+ border: `1px solid ${recording ? "var(--origin-accent-blue)" : "var(--origin-border-default)"}`,
+ background: recording
+ ? "color-mix(in srgb, var(--origin-accent-blue) 10%, transparent)"
+ : "var(--origin-bg-base)",
+ color: recording ? "var(--origin-accent-blue)" : "var(--origin-fg-default)",
+ fontSize: "11px",
+ fontFamily: "var(--font-mono)",
+ cursor: "pointer",
+ minWidth: "80px",
+ textAlign: "center",
+ transition: "all 0.12s",
+ whiteSpace: "nowrap",
+ }}
+ >
+ {recording ? "Press key…" : effectiveKey}
+
+ {isCustom && (
+
{ (e.currentTarget as HTMLElement).style.color = "var(--origin-fg-muted)"; }}
+ onMouseLeave={e => { (e.currentTarget as HTMLElement).style.color = "var(--origin-fg-subtle)"; }}
+ >
+
+
+ )}
+
+
+ );
+}
+
+// ── Main section ─────────────────────────────────────────────────────────────
+
+export default function KeybindingsSection() {
+ const { showToast } = useToast();
+ const [query, setQuery] = useState("");
+ const [detected, setDetected] = useState([]);
+ const [tick, setTick] = useState(0); // bump to re-render after changes
+
+ useEffect(() => {
+ detectInstalledEditors().then(setDetected).catch(() => setDetected([]));
+ }, []);
+
+ function refresh() { setTick(t => t + 1); }
+
+ const userCount = loadKeybindings().length;
+
+ const filtered = COMMANDS.filter(cmd => {
+ if (!query) return true;
+ const q = query.toLowerCase();
+ return (
+ cmd.label.toLowerCase().includes(q) ||
+ cmd.category.toLowerCase().includes(q) ||
+ cmd.id.toLowerCase().includes(q) ||
+ (getEffectiveKey(cmd.id) ?? "").includes(q)
+ );
+ });
+
+ // Group by category
+ const categories = [...new Set(filtered.map(c => c.category))];
+
+ function handleResetAll() {
+ resetKeybindings();
+ refresh();
+ showToast("All keybindings reset to defaults", "info");
+ }
+
+ return (
+
+
+ {/* Import row */}
+
+
+ Import from
+
+ {detected.length === 0 ? (
+
+ No VS Code, Cursor, or Windsurf installation detected on this machine.
+
+ ) : (
+
+ )}
+
+
+ {/* Divider */}
+
+
+ {/* Search + reset */}
+
+
+
+ setQuery(e.target.value)}
+ placeholder="Search commands or keys…"
+ style={{
+ width: "100%", boxSizing: "border-box",
+ padding: "6px 10px 6px 28px",
+ borderRadius: "6px",
+ border: "1px solid var(--origin-border-default)",
+ background: "var(--origin-bg-base)",
+ color: "var(--origin-fg-default)",
+ fontSize: "12px",
+ fontFamily: "inherit",
+ outline: "none",
+ }}
+ />
+
+ {userCount > 0 && (
+
+ Reset all
+
+ )}
+
+
+ {/* Column headers */}
+
+ Command
+ Keybinding
+
+
+ {/* Command list grouped by category */}
+ {categories.length === 0 ? (
+
No commands match "{query}".
+ ) : (
+ categories.map(cat => (
+
+
+ {cat}
+
+ {filtered.filter(c => c.category === cat).map(cmd => (
+
+ ))}
+
+ ))
+ )}
+
+
+ {userCount > 0 ? `${userCount} custom binding${userCount !== 1 ? "s" : ""}` : "Using all defaults"}
+ {" · "}Click a keybinding to rebind · Esc to cancel
+
+
+ );
+}
diff --git a/src/components/settings/SettingsPanel.tsx b/src/components/settings/SettingsPanel.tsx
index 1b3261cd..57d25d6e 100644
--- a/src/components/settings/SettingsPanel.tsx
+++ b/src/components/settings/SettingsPanel.tsx
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
-import { X, Eye, EyeOff, Bot, Palette, Check, MessageSquareDot, SlidersHorizontal, Terminal, Copy } from "lucide-react";
+import { X, Eye, EyeOff, Bot, Palette, Check, MessageSquareDot, SlidersHorizontal, Terminal, Copy, Keyboard } from "lucide-react";
+import KeybindingsSection from "./KeybindingsSection";
import { PROVIDERS } from "../ai/providers";
import { loadApiKey, saveApiKey, deleteApiKey } from "../../lib/secrets";
import { useTheme } from "../../themes/ThemeContext";
@@ -11,7 +12,7 @@ import { DEFAULT_SYSTEM_PROMPT, DEFAULT_ASK_PROMPT, DEFAULT_PLAN_PROMPT } from "
const LOCAL_IDS = new Set(["ollama", "lmstudio", "vllm"]);
const API_PROVIDERS = PROVIDERS.filter(p => !LOCAL_IDS.has(p.id));
-type Section = "general" | "ai" | "prompts" | "appearance" | "terminal";
+type Section = "general" | "ai" | "prompts" | "appearance" | "terminal" | "keybindings";
// ── Nav ──────────────────────────────────────────────────────────────────────
@@ -879,6 +880,12 @@ export default function SettingsPanel({ onClose }: SettingsPanelProps) {
label="Terminal"
onClick={() => setSection("terminal")}
/>
+ }
+ label="Keyboard Shortcuts"
+ onClick={() => setSection("keybindings")}
+ />
{/* Content */}
@@ -888,13 +895,14 @@ export default function SettingsPanel({ onClose }: SettingsPanelProps) {
color: "var(--origin-fg-default)",
marginBottom: "14px",
}}>
- {section === "general" ? "General" : section === "ai" ? "AI Providers" : section === "prompts" ? "System Prompts" : section === "appearance" ? "Appearance" : "Terminal"}
+ {section === "general" ? "General" : section === "ai" ? "AI Providers" : section === "prompts" ? "System Prompts" : section === "appearance" ? "Appearance" : section === "keybindings" ? "Keyboard Shortcuts" : "Terminal"}
{section === "general" && }
{section === "ai" && }
{section === "prompts" && }
{section === "appearance" && }
- {section === "terminal" && }
+ {section === "terminal" && }
+ {section === "keybindings" && }
diff --git a/src/hooks/useGlobalKeybindings.ts b/src/hooks/useGlobalKeybindings.ts
index 3809e3b9..e11a705e 100644
--- a/src/hooks/useGlobalKeybindings.ts
+++ b/src/hooks/useGlobalKeybindings.ts
@@ -1,29 +1,40 @@
import { useState, useEffect, useRef } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
+import { getCommandKeyMap, matchesEvent } from "../lib/keybindings";
-interface Handlers {
+export interface GlobalHandlers {
+ // Always provided
saveActive: () => void;
toggleTerminal: () => void;
togglePalette: () => void;
toggleSettings: () => void;
+ // Optional — wired when available in App.tsx
+ newFile?: () => void;
+ openFile?: () => void;
+ closeTab?: () => void;
+ toggleSidebar?: () => void;
+ zoomIn?: () => void;
+ zoomOut?: () => void;
+ zoomReset?: () => void;
+ startDebug?: () => void;
+ stopDebug?: () => void;
+ stepOver?: () => void;
+ stepInto?: () => void;
+ stepOut?: () => void;
+ toggleBreakpoint?: () => void;
}
-export function useGlobalKeybindings(handlers: Handlers) {
+export function useGlobalKeybindings(handlers: GlobalHandlers) {
const [isFullscreen, setIsFullscreen] = useState(false);
- // Keep handlers ref current so the single listener always sees the latest callbacks
const h = useRef(handlers);
- // eslint-disable-next-line react-hooks/refs -- stable ref pattern: keep latest handlers visible to the single listener
h.current = handlers;
- // Refs so the single useEffect closure always reads current values
const isFullscreenRef = useRef(false);
const wasMaximizedRef = useRef(false);
async function toggleFullscreen() {
const win = getCurrentWindow();
if (!isFullscreenRef.current) {
- // Unmaximize before entering fullscreen — frameless + WebView2 bug: going
- // fullscreen from a maximized state leaves a black bar (tauri-apps/tauri#11788)
const maximized = await win.isMaximized();
wasMaximizedRef.current = maximized;
if (maximized) {
@@ -46,29 +57,60 @@ export function useGlobalKeybindings(handlers: Handlers) {
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
- const ctrl = e.ctrlKey || e.metaKey;
- if (ctrl && e.key === 's') { e.preventDefault(); h.current.saveActive(); return; }
- if (e.key === 'F11') { e.preventDefault(); toggleFullscreen(); return; }
- if (ctrl && e.key === '`') { e.preventDefault(); h.current.toggleTerminal(); return; }
- if (ctrl && !e.shiftKey && e.key === 'p'){ e.preventDefault(); h.current.togglePalette(); return; }
- if (ctrl && e.key === ',') { e.preventDefault(); h.current.toggleSettings(); return; }
+ // Read the live keymap on every event so custom bindings apply immediately
+ // without a page reload (localStorage is synchronous).
+ const km = getCommandKeyMap();
+
+ // Build the handler dispatch table — maps command ID → action fn
+ const dispatch: Record void) | undefined> = {
+ "origin.save": () => h.current.saveActive(),
+ "origin.toggleTerminal": () => h.current.toggleTerminal(),
+ "origin.togglePalette": () => h.current.togglePalette(),
+ "origin.toggleSettings": () => h.current.toggleSettings(),
+ "origin.toggleFullscreen": () => toggleFullscreen(),
+ "origin.newFile": () => h.current.newFile?.(),
+ "origin.openFile": () => h.current.openFile?.(),
+ "origin.closeTab": () => h.current.closeTab?.(),
+ "origin.toggleSidebar": () => h.current.toggleSidebar?.(),
+ "origin.zoomIn": () => h.current.zoomIn?.(),
+ "origin.zoomOut": () => h.current.zoomOut?.(),
+ "origin.zoomReset": () => h.current.zoomReset?.(),
+ "origin.startDebug": () => h.current.startDebug?.(),
+ "origin.stopDebug": () => h.current.stopDebug?.(),
+ "origin.stepOver": () => h.current.stepOver?.(),
+ "origin.stepInto": () => h.current.stepInto?.(),
+ "origin.stepOut": () => h.current.stepOut?.(),
+ "origin.toggleBreakpoint": () => h.current.toggleBreakpoint?.(),
+ };
+
+ for (const [cmdId, keyStr] of Object.entries(km)) {
+ if (!keyStr) continue;
+ const handler = dispatch[cmdId];
+ if (!handler) continue;
+ if (matchesEvent(e, keyStr)) {
+ e.preventDefault();
+ handler();
+ return;
+ }
+ }
}
- window.addEventListener('keydown', onKeyDown);
+
+ window.addEventListener("keydown", onKeyDown);
let unlistenResized: (() => void) | undefined;
let unlistenScale: (() => void) | undefined;
getCurrentWindow().onResized(() => {
- window.dispatchEvent(new Event('resize'));
+ window.dispatchEvent(new Event("resize"));
}).then(fn => { unlistenResized = fn; });
getCurrentWindow().onScaleChanged(({ payload: scaleFactor }) => {
- document.documentElement.style.setProperty('--scale-factor', String(scaleFactor));
- window.dispatchEvent(new Event('resize'));
+ document.documentElement.style.setProperty("--scale-factor", String(scaleFactor));
+ window.dispatchEvent(new Event("resize"));
}).then(fn => { unlistenScale = fn; });
return () => {
- window.removeEventListener('keydown', onKeyDown);
+ window.removeEventListener("keydown", onKeyDown);
unlistenResized?.();
unlistenScale?.();
};
diff --git a/src/lib/keybindings.ts b/src/lib/keybindings.ts
new file mode 100644
index 00000000..721970c5
--- /dev/null
+++ b/src/lib/keybindings.ts
@@ -0,0 +1,235 @@
+import { invoke } from "@tauri-apps/api/core";
+
+// ── Types ────────────────────────────────────────────────────────────────────
+
+export interface UserKeybinding {
+ key: string;
+ command: string;
+ when?: string;
+}
+
+export interface CommandDef {
+ id: string;
+ label: string;
+ category: string;
+ defaultKey: string;
+ defaultKeyMac?: string;
+}
+
+// ── Default command registry ─────────────────────────────────────────────────
+
+export const COMMANDS: CommandDef[] = [
+ // File
+ { id: "origin.newFile", label: "New File", category: "File", defaultKey: "ctrl+n" },
+ { id: "origin.openFile", label: "Open File", category: "File", defaultKey: "ctrl+o" },
+ { id: "origin.save", label: "Save", category: "File", defaultKey: "ctrl+s" },
+ { id: "origin.saveAs", label: "Save As", category: "File", defaultKey: "ctrl+shift+s" },
+ { id: "origin.closeTab", label: "Close Editor", category: "File", defaultKey: "ctrl+w" },
+ // Edit
+ { id: "origin.editorUndo", label: "Undo", category: "Edit", defaultKey: "ctrl+z" },
+ { id: "origin.editorRedo", label: "Redo", category: "Edit", defaultKey: "ctrl+y" },
+ { id: "origin.editorFind", label: "Find", category: "Edit", defaultKey: "ctrl+f" },
+ { id: "origin.editorReplace", label: "Replace", category: "Edit", defaultKey: "ctrl+h" },
+ { id: "origin.editorCut", label: "Cut", category: "Edit", defaultKey: "ctrl+x" },
+ { id: "origin.editorCopy", label: "Copy", category: "Edit", defaultKey: "ctrl+c" },
+ { id: "origin.editorPaste", label: "Paste", category: "Edit", defaultKey: "ctrl+v" },
+ { id: "origin.editorSelectAll",label: "Select All", category: "Edit", defaultKey: "ctrl+a" },
+ // View
+ { id: "origin.togglePalette", label: "Command Palette", category: "View", defaultKey: "ctrl+p" },
+ { id: "origin.toggleSidebar", label: "Toggle Sidebar", category: "View", defaultKey: "ctrl+b" },
+ { id: "origin.toggleTerminal", label: "Toggle Terminal", category: "View", defaultKey: "ctrl+`" },
+ { id: "origin.toggleSettings", label: "Open Settings", category: "View", defaultKey: "ctrl+," },
+ { id: "origin.toggleFullscreen",label: "Toggle Full Screen", category: "View", defaultKey: "f11" },
+ { id: "origin.zoomIn", label: "Zoom In", category: "View", defaultKey: "ctrl+=" },
+ { id: "origin.zoomOut", label: "Zoom Out", category: "View", defaultKey: "ctrl+-" },
+ { id: "origin.zoomReset", label: "Reset Zoom", category: "View", defaultKey: "ctrl+0" },
+ // Debug
+ { id: "origin.startDebug", label: "Start Debugging", category: "Debug", defaultKey: "f5" },
+ { id: "origin.stopDebug", label: "Stop Debugging", category: "Debug", defaultKey: "shift+f5" },
+ { id: "origin.stepOver", label: "Step Over", category: "Debug", defaultKey: "f10" },
+ { id: "origin.stepInto", label: "Step Into", category: "Debug", defaultKey: "f11" },
+ { id: "origin.stepOut", label: "Step Out", category: "Debug", defaultKey: "shift+f11" },
+ { id: "origin.toggleBreakpoint",label: "Toggle Breakpoint", category: "Debug", defaultKey: "f9" },
+];
+
+// ── Storage ──────────────────────────────────────────────────────────────────
+
+const STORAGE_KEY = "origin-keybindings";
+
+export function loadKeybindings(): UserKeybinding[] {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ return raw ? (JSON.parse(raw) as UserKeybinding[]) : [];
+ } catch {
+ return [];
+ }
+}
+
+export function saveKeybindings(bindings: UserKeybinding[]): void {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(bindings));
+}
+
+export function resetKeybindings(): void {
+ localStorage.removeItem(STORAGE_KEY);
+}
+
+/** Returns effective key for a command: user override → default. */
+export function getEffectiveKey(commandId: string): string | null {
+ const user = loadKeybindings();
+ const override = user.find(b => b.command === commandId);
+ if (override) return override.key;
+ return COMMANDS.find(c => c.id === commandId)?.defaultKey ?? null;
+}
+
+/** Full map of commandId → effective key (used by the global listener). */
+export function getCommandKeyMap(): Record {
+ const user = loadKeybindings();
+ const map: Record = {};
+ for (const cmd of COMMANDS) {
+ map[cmd.id] = cmd.defaultKey;
+ }
+ for (const b of user) {
+ if (b.command) map[b.command] = b.key;
+ }
+ return map;
+}
+
+/** Rebind a single command. Pass null key to remove the override. */
+export function setKeybinding(commandId: string, key: string | null): void {
+ const bindings = loadKeybindings().filter(b => b.command !== commandId);
+ if (key !== null) bindings.push({ command: commandId, key });
+ saveKeybindings(bindings);
+}
+
+// ── Key matching ─────────────────────────────────────────────────────────────
+
+interface ParsedKey {
+ ctrl: boolean;
+ shift: boolean;
+ alt: boolean;
+ key: string;
+}
+
+function parseKey(keyStr: string): ParsedKey {
+ const parts = keyStr.toLowerCase().split("+");
+ const key = parts[parts.length - 1];
+ return {
+ ctrl: parts.includes("ctrl") || parts.includes("cmd"),
+ shift: parts.includes("shift"),
+ alt: parts.includes("alt"),
+ key,
+ };
+}
+
+export function matchesEvent(e: KeyboardEvent, keyStr: string): boolean {
+ const { ctrl, shift, alt, key } = parseKey(keyStr);
+ // Normalise e.key → lowercase single token
+ const eKey = e.key === "`" ? "`"
+ : e.key === "=" ? "="
+ : e.key === "-" ? "-"
+ : e.key === "0" ? "0"
+ : e.key === "," ? ","
+ : e.key.toLowerCase();
+
+ return (
+ (e.ctrlKey || e.metaKey) === ctrl &&
+ e.shiftKey === shift &&
+ e.altKey === alt &&
+ eKey === key
+ );
+}
+
+// ── VS Code / Cursor / Windsurf import ───────────────────────────────────────
+
+// Maps VS Code command IDs → Origin command IDs
+const VSCODE_CMD_MAP: Record = {
+ "workbench.action.files.newUntitledFile": "origin.newFile",
+ "workbench.action.files.openFile": "origin.openFile",
+ "workbench.action.files.save": "origin.save",
+ "workbench.action.files.saveAs": "origin.saveAs",
+ "workbench.action.closeActiveEditor": "origin.closeTab",
+ "undo": "origin.editorUndo",
+ "redo": "origin.editorRedo",
+ "actions.find": "origin.editorFind",
+ "editor.action.startFindReplaceAction": "origin.editorReplace",
+ "editor.action.clipboardCutAction": "origin.editorCut",
+ "editor.action.clipboardCopyAction": "origin.editorCopy",
+ "editor.action.clipboardPasteAction": "origin.editorPaste",
+ "editor.action.selectAll": "origin.editorSelectAll",
+ "workbench.action.quickOpen": "origin.togglePalette",
+ "workbench.action.showCommands": "origin.togglePalette",
+ "workbench.action.toggleSidebarVisibility": "origin.toggleSidebar",
+ "workbench.action.terminal.toggleTerminal": "origin.toggleTerminal",
+ "workbench.action.openSettings": "origin.toggleSettings",
+ "workbench.action.toggleFullScreen": "origin.toggleFullscreen",
+ "workbench.action.zoomIn": "origin.zoomIn",
+ "workbench.action.zoomOut": "origin.zoomOut",
+ "workbench.action.zoomReset": "origin.zoomReset",
+ "workbench.action.debug.start": "origin.startDebug",
+ "workbench.action.debug.run": "origin.startDebug",
+ "workbench.action.debug.stop": "origin.stopDebug",
+ "workbench.action.debug.stepOver": "origin.stepOver",
+ "workbench.action.debug.stepInto": "origin.stepInto",
+ "workbench.action.debug.stepOut": "origin.stepOut",
+ "editor.debug.action.toggleBreakpoint": "origin.toggleBreakpoint",
+};
+
+interface ImportResult {
+ imported: number;
+ bindings: UserKeybinding[];
+}
+
+function stripJsonComments(raw: string): string {
+ // Very simple single-line comment stripper for keybindings.json
+ return raw.replace(/\/\/[^\n]*/g, "");
+}
+
+export function parseEditorKeybindings(json: string): ImportResult {
+ try {
+ const items = JSON.parse(stripJsonComments(json));
+ if (!Array.isArray(items)) return { imported: 0, bindings: [] };
+ const bindings: UserKeybinding[] = [];
+ for (const item of items) {
+ if (typeof item.key !== "string" || typeof item.command !== "string") continue;
+ // Skip negation entries (command starting with -)
+ if (item.command.startsWith("-")) continue;
+ const originCmd = VSCODE_CMD_MAP[item.command];
+ if (originCmd) {
+ bindings.push({
+ key: item.key.toLowerCase(),
+ command: originCmd,
+ ...(item.when ? { when: item.when } : {}),
+ });
+ }
+ }
+ return { imported: bindings.length, bindings };
+ } catch {
+ return { imported: 0, bindings: [] };
+ }
+}
+
+// ── Tauri wrappers ───────────────────────────────────────────────────────────
+
+export async function detectInstalledEditors(): Promise {
+ return invoke("detect_installed_editors");
+}
+
+export async function importKeybindingsFromEditor(editorId: string): Promise {
+ const json = await invoke("read_editor_keybindings", { editor: editorId });
+ return parseEditorKeybindings(json);
+}
+
+/**
+ * Import keybindings from an editor, merge with existing user overrides,
+ * and persist. Returns the number of commands that were mapped.
+ */
+export async function applyKeybindingsFromEditor(editorId: string): Promise {
+ const { imported, bindings } = await importKeybindingsFromEditor(editorId);
+ if (imported === 0) return 0;
+ // Merge: editor import wins over current user overrides for matching commands
+ const existing = loadKeybindings().filter(
+ b => !bindings.some(nb => nb.command === b.command)
+ );
+ saveKeybindings([...existing, ...bindings]);
+ return imported;
+}