diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0a52eaf..aae913b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -139,6 +139,11 @@ jobs:
CFBundleIconFileicon
NSHighResolutionCapable
LSMinimumSystemVersion11.0
+ CFBundleURLTypes
+
+ CFBundleURLNameVoid Deep Link
+ CFBundleURLSchemesvoid
+
PLIST
mkdir -p dmg-stage
@@ -193,6 +198,11 @@ jobs:
CFBundleIconFileicon
NSHighResolutionCapable
LSMinimumSystemVersion11.0
+ CFBundleURLTypes
+
+ CFBundleURLNameVoid Deep Link
+ CFBundleURLSchemesvoid
+
PLIST
mkdir -p dmg-stage
diff --git a/assets/void.desktop b/assets/void.desktop
new file mode 100644
index 0000000..f245ccd
--- /dev/null
+++ b/assets/void.desktop
@@ -0,0 +1,9 @@
+[Desktop Entry]
+Name=Void Terminal
+Comment=Infinite canvas terminal emulator
+Exec=void %u
+Icon=void
+Terminal=false
+Type=Application
+Categories=System;TerminalEmulator;
+MimeType=x-scheme-handler/void;
diff --git a/installer/void.nsi b/installer/void.nsi
index 3291fa7..497dfa4 100644
--- a/installer/void.nsi
+++ b/installer/void.nsi
@@ -61,6 +61,12 @@ Section "Install"
CreateShortcut "$SMPROGRAMS\Void\Void.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\void.ico" 0
CreateShortcut "$SMPROGRAMS\Void\Uninstall.lnk" "$INSTDIR\uninstall.exe"
+ ; Register void:// URL protocol handler for deep-link navigation
+ WriteRegStr HKCU "Software\Classes\void" "" "URL:Void Protocol"
+ WriteRegStr HKCU "Software\Classes\void" "URL Protocol" ""
+ WriteRegStr HKCU "Software\Classes\void\DefaultIcon" "" '"$INSTDIR\void.ico"'
+ WriteRegStr HKCU "Software\Classes\void\shell\open\command" "" '"$INSTDIR\${APP_EXE}" "%1"'
+
; Registry — install path + Add/Remove Programs
WriteRegStr HKCU "Software\Void" "InstallDir" "$INSTDIR"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Void" \
@@ -98,6 +104,7 @@ Section "Uninstall"
Delete "$SMPROGRAMS\Void\Uninstall.lnk"
RMDir "$SMPROGRAMS\Void"
+ DeleteRegKey HKCU "Software\Classes\void"
DeleteRegKey HKCU "Software\Void"
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Void"
SectionEnd
diff --git a/src/app.rs b/src/app.rs
index 69653ba..cfd4d1a 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -4,6 +4,9 @@ use egui::{Color32, Pos2, Vec2};
use crate::canvas::viewport::Viewport;
use crate::command_palette::commands::Command;
use crate::command_palette::CommandPalette;
+use crate::deeplink::ipc::IpcServer;
+use crate::deeplink::toast::Toast;
+use crate::deeplink::DeepLink;
use crate::sidebar::{Sidebar, SidebarResponse, SIDEBAR_BG, SIDEBAR_BORDER, SIDEBAR_PADDING_H};
use crate::state::workspace::Workspace;
use crate::terminal::panel::PanelAction;
@@ -35,13 +38,22 @@ pub struct VoidApp {
brand_texture: egui::TextureHandle,
sidebar: Sidebar,
update_checker: UpdateChecker,
+ // Deep-link navigation
+ pending_deeplink: Option,
+ ipc_server: Option,
+ toast: Option,
+ navigate_dialog_open: bool,
+ navigate_buf: String,
}
impl VoidApp {
- pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
+ pub fn new(cc: &eframe::CreationContext<'_>, url_arg: Option) -> Self {
let ctx = cc.egui_ctx.clone();
Self::setup_fonts(&ctx);
+ // Start IPC server for receiving deep-links from other instances
+ let ipc_server = IpcServer::start(ctx.clone());
+
let brand_texture = {
let png = include_bytes!("../assets/brand.png");
let img = image::load_from_memory(png)
@@ -106,6 +118,11 @@ impl VoidApp {
brand_texture,
sidebar: Sidebar::default(),
update_checker: UpdateChecker::new(cc.egui_ctx.clone()),
+ pending_deeplink: url_arg,
+ ipc_server,
+ toast: None,
+ navigate_dialog_open: false,
+ navigate_buf: String::new(),
}
}
@@ -237,6 +254,29 @@ impl VoidApp {
let is_fullscreen = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!is_fullscreen));
}
+ Command::NavigateToLink => {
+ self.navigate_dialog_open = true;
+ self.navigate_buf.clear();
+ }
+ Command::CopyLink => {
+ // Copy link to focused panel, or viewport position if none focused
+ let ws_id = self.ws().id;
+ let url = if let Some(p) = self.ws().panels.iter().find(|p| p.focused()) {
+ format!("void://open/{}/{}", ws_id, p.id())
+ } else {
+ let center = self.viewport.visible_canvas_rect(screen_rect).center();
+ let z = self.viewport.zoom;
+ format!(
+ "void://open/{ws_id}/@{:.0},{:.0},{:.2}",
+ center.x, center.y, z
+ )
+ };
+ if let Ok(mut clipboard) = arboard::Clipboard::new() {
+ let _ = clipboard.set_text(&url);
+ }
+ let time = ctx.input(|i| i.time);
+ self.toast = Some(Toast::new("Link copied to clipboard", 2.0, time));
+ }
}
}
@@ -269,6 +309,77 @@ impl VoidApp {
);
}
+ /// Navigate to a deep-link target: workspace, panel, or canvas position.
+ fn navigate_to_deeplink(&mut self, link: DeepLink, canvas_rect: egui::Rect, time: f64) {
+ // Find workspace by UUID
+ let ws_id = match &link {
+ DeepLink::Workspace { workspace_id }
+ | DeepLink::Panel { workspace_id, .. }
+ | DeepLink::Position { workspace_id, .. } => workspace_id.clone(),
+ };
+
+ let ws_idx = self
+ .workspaces
+ .iter()
+ .position(|ws| ws.id.to_string() == ws_id);
+
+ let Some(ws_idx) = ws_idx else {
+ self.toast = Some(Toast::new("Workspace not found", 3.0, time));
+ return;
+ };
+
+ self.switch_workspace(ws_idx);
+
+ match link {
+ DeepLink::Workspace { .. } => {}
+ DeepLink::Panel { panel_id, .. } => {
+ let panel_pos = self
+ .ws()
+ .panels
+ .iter()
+ .position(|p| p.id().to_string() == panel_id);
+
+ if let Some(idx) = panel_pos {
+ let center = self.ws().panels[idx].rect().center();
+ self.viewport.pan_to_center(center, canvas_rect);
+ self.ws_mut().bring_to_front(idx);
+ } else {
+ self.toast = Some(Toast::new("Panel not found", 3.0, time));
+ }
+ }
+ DeepLink::Position { x, y, zoom, .. } => {
+ self.viewport.pan_to_center(Pos2::new(x, y), canvas_rect);
+ if let Some(z) = zoom {
+ self.viewport.zoom = z.clamp(
+ crate::canvas::config::ZOOM_MIN,
+ crate::canvas::config::ZOOM_MAX,
+ );
+ }
+ }
+ }
+ }
+
+ /// Process any pending deep-link URL (from CLI arg or IPC).
+ fn process_pending_deeplinks(&mut self, canvas_rect: egui::Rect, time: f64) {
+ // Check IPC server for incoming URL from another instance
+ if let Some(ref server) = self.ipc_server {
+ if let Some(url) = server.take_pending() {
+ self.pending_deeplink = Some(url);
+ }
+ }
+
+ // Process pending deep-link
+ if let Some(url) = self.pending_deeplink.take() {
+ match crate::deeplink::parse(&url) {
+ Ok(link) => self.navigate_to_deeplink(link, canvas_rect, time),
+ Err(e) => {
+ log::warn!("Invalid deep-link URL: {e}");
+ self.toast = Some(Toast::new(format!("Invalid link: {e}"), 3.0, time));
+ }
+ }
+ }
+ }
+
fn handle_shortcuts(&mut self, ctx: &egui::Context) -> Option {
if self.command_palette.open {
return None;
@@ -298,6 +409,10 @@ impl VoidApp {
cmd = Some(Command::ZoomToFit);
} else if i.key_pressed(egui::Key::F2) && !i.modifiers.ctrl {
cmd = Some(Command::RenameTerminal);
+ } else if i.modifiers.ctrl && !i.modifiers.shift && i.key_pressed(egui::Key::L) {
+ cmd = Some(Command::NavigateToLink);
+ } else if i.modifiers.ctrl && i.modifiers.shift && i.key_pressed(egui::Key::L) {
+ cmd = Some(Command::CopyLink);
}
});
cmd
@@ -351,13 +466,18 @@ impl eframe::App for VoidApp {
self.execute_command(cmd, ctx, canvas_rect_for_commands);
}
+ // Process pending deep-link navigation (from CLI arg or IPC)
+ let time = ctx.input(|i| i.time);
+ self.process_pending_deeplinks(canvas_rect_for_commands, time);
+
// Sync titles
for p in &mut self.ws_mut().panels {
p.sync_title();
}
// Keyboard input to focused terminal
- if !self.command_palette.open && self.renaming_panel.is_none() {
+ if !self.command_palette.open && self.renaming_panel.is_none() && !self.navigate_dialog_open
+ {
for p in &mut self.ws_mut().panels {
if p.focused() {
p.handle_input(ctx);
@@ -429,6 +549,61 @@ impl eframe::App for VoidApp {
}
}
+ // Navigate to Link dialog
+ if self.navigate_dialog_open {
+ let mut close = false;
+ let mut navigate_url: Option = None;
+ egui::Area::new(egui::Id::new("navigate_dialog"))
+ .order(egui::Order::Debug)
+ .fixed_pos(Pos2::new(
+ screen_rect.center().x - 200.0,
+ screen_rect.min.y + 120.0,
+ ))
+ .show(ctx, |ui| {
+ egui::Frame::default()
+ .fill(Color32::from_rgb(20, 20, 20))
+ .stroke(egui::Stroke::new(0.5, Color32::from_rgb(40, 40, 40)))
+ .rounding(8.0)
+ .inner_margin(14.0)
+ .show(ui, |ui| {
+ ui.label(
+ egui::RichText::new("Navigate to Link")
+ .color(Color32::from_rgb(160, 160, 160))
+ .size(12.0),
+ );
+ ui.add_space(6.0);
+ let r = ui.add(
+ egui::TextEdit::singleline(&mut self.navigate_buf)
+ .desired_width(380.0)
+ .font(egui::FontId::monospace(12.0))
+ .hint_text("void://open/..."),
+ );
+ r.request_focus();
+ ui.add_space(6.0);
+ ui.horizontal(|ui| {
+ if ui.button("Go").clicked()
+ || ui.input(|i| i.key_pressed(egui::Key::Enter))
+ {
+ navigate_url = Some(self.navigate_buf.clone());
+ close = true;
+ }
+ if ui.button("Cancel").clicked()
+ || ui.input(|i| i.key_pressed(egui::Key::Escape))
+ {
+ close = true;
+ }
+ });
+ });
+ });
+ if close {
+ self.navigate_dialog_open = false;
+ self.navigate_buf.clear();
+ }
+ if let Some(url) = navigate_url {
+ self.pending_deeplink = Some(url);
+ }
+ }
+
// --- Sidebar ---
if self.sidebar_visible {
egui::SidePanel::left("sidebar")
@@ -615,7 +790,7 @@ impl eframe::App for VoidApp {
.show(ctx, |ui| {
ctx.set_transform_layer(ui.layer_id(), transform);
ui.set_clip_rect(clip);
- ui.allocate_rect(clip, egui::Sense::hover());
+ let canvas_bg_resp = ui.allocate_rect(clip, egui::Sense::click());
let mut order: Vec = (0..self.ws().panels.len()).collect();
order.sort_by_key(|&i| self.ws().panels[i].z_index());
@@ -737,6 +912,17 @@ impl eframe::App for VoidApp {
self.renaming_panel = Some(self.ws().panels[*idx].id());
self.rename_buf = self.ws().panels[*idx].title().to_string();
}
+ PanelAction::CopyLink => {
+ let ws_id = self.ws().id;
+ let panel_id = self.ws().panels[*idx].id();
+ let url = format!("void://open/{ws_id}/{panel_id}");
+ if let Ok(mut clipboard) = arboard::Clipboard::new() {
+ let _ = clipboard.set_text(&url);
+ }
+ let time = ctx.input(|i| i.time);
+ self.toast =
+ Some(Toast::new("Link copied to clipboard", 2.0, time));
+ }
}
}
}
@@ -772,6 +958,33 @@ impl eframe::App for VoidApp {
}
}
+ // Canvas context menu — Copy Link with coordinates + zoom
+ // Placed in the content layer (Order::Middle) so it receives right-clicks
+ // that the background layer (Order::Background) would miss.
+ canvas_bg_resp.context_menu(|ui| {
+ if ui.button("Copy Link to Position").clicked() {
+ let canvas_pos = ui
+ .input(|i| i.pointer.hover_pos())
+ .map(|pos| self.viewport.screen_to_canvas(pos, canvas_rect))
+ .unwrap_or_else(|| {
+ self.viewport.visible_canvas_rect(canvas_rect).center()
+ });
+ let ws_id = self.ws().id;
+ let z = self.viewport.zoom;
+ let url = format!(
+ "void://open/{ws_id}/@{:.0},{:.0},{:.2}",
+ canvas_pos.x, canvas_pos.y, z
+ );
+ if let Ok(mut clipboard) = arboard::Clipboard::new() {
+ let _ = clipboard.set_text(&url);
+ }
+ let time = ui.input(|i| i.time);
+ self.toast =
+ Some(Toast::new("Position link copied to clipboard", 2.0, time));
+ ui.close_menu();
+ }
+ });
+
// Draw snap guides
let painter = ui.painter();
let guide_stroke =
@@ -825,5 +1038,24 @@ impl eframe::App for VoidApp {
}
});
}
+
+ // --- Toast notification overlay ---
+ if let Some(ref toast) = self.toast {
+ let time = ctx.input(|i| i.time);
+ egui::Area::new(egui::Id::new("toast_overlay"))
+ .order(egui::Order::Debug)
+ .fixed_pos(canvas_rect.center_bottom() - Vec2::new(0.0, 60.0))
+ .interactable(false)
+ .show(ctx, |ui| {
+ if !toast.show(ui, canvas_rect, time) {
+ // will be cleaned up below
+ }
+ });
+ if toast.is_expired(time) {
+ self.toast = None;
+ } else {
+ ctx.request_repaint();
+ }
+ }
}
}
diff --git a/src/command_palette/commands.rs b/src/command_palette/commands.rs
index fc5bc12..53fcd74 100644
--- a/src/command_palette/commands.rs
+++ b/src/command_palette/commands.rs
@@ -16,6 +16,8 @@ pub enum Command {
FocusNext,
FocusPrev,
ToggleFullscreen,
+ NavigateToLink,
+ CopyLink,
}
/// A registered command with display info.
@@ -92,4 +94,14 @@ pub const COMMANDS: &[CommandEntry] = &[
label: "Toggle Fullscreen",
shortcut: "F11",
},
+ CommandEntry {
+ command: Command::NavigateToLink,
+ label: "Navigate to Link",
+ shortcut: "Ctrl+L",
+ },
+ CommandEntry {
+ command: Command::CopyLink,
+ label: "Copy Link to Focused Panel",
+ shortcut: "Ctrl+Shift+L",
+ },
];
diff --git a/src/deeplink/ipc.rs b/src/deeplink/ipc.rs
new file mode 100644
index 0000000..3ddc050
--- /dev/null
+++ b/src/deeplink/ipc.rs
@@ -0,0 +1,122 @@
+// Single-instance IPC via TCP loopback + lockfile discovery.
+//
+// The first Void instance binds a TCP listener on 127.0.0.1 (OS-assigned port)
+// and writes the port to a lockfile. Subsequent launches read the lockfile,
+// send the void:// URL to the running instance, and exit.
+
+use std::io::{BufRead, BufReader, Write};
+use std::net::{TcpListener, TcpStream};
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex};
+use std::thread;
+
+/// Pending deep-link URL received from another instance.
+pub type PendingUrl = Arc>>;
+
+/// IPC server that listens for incoming void:// URLs from secondary instances.
+pub struct IpcServer {
+ pending: PendingUrl,
+ lock_path: PathBuf,
+ // Keep the listener alive so the port stays bound.
+ _listener: TcpListener,
+}
+
+impl IpcServer {
+ /// Start the IPC server. Binds a random port and writes it to the lockfile.
+ /// Returns `None` if the data directory cannot be determined.
+ pub fn start(ctx: egui::Context) -> Option {
+ let lock_path = lock_file_path()?;
+
+ let listener = TcpListener::bind("127.0.0.1:0").ok()?;
+ let port = listener.local_addr().ok()?.port();
+
+ // Write port to lockfile
+ if let Some(parent) = lock_path.parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+ let _ = std::fs::write(&lock_path, port.to_string());
+
+ let pending: PendingUrl = Arc::new(Mutex::new(None));
+ let pending_clone = Arc::clone(&pending);
+
+ let accept_listener = listener.try_clone().expect("failed to clone TcpListener");
+ thread::spawn(move || {
+ for stream in accept_listener.incoming().flatten() {
+ if let Some(url) = read_url(stream) {
+ if let Ok(mut guard) = pending_clone.lock() {
+ *guard = Some(url);
+ }
+ ctx.request_repaint();
+ }
+ }
+ });
+
+ Some(Self {
+ pending,
+ lock_path,
+ _listener: listener,
+ })
+ }
+
+ /// Take the pending URL if one has arrived.
+ pub fn take_pending(&self) -> Option {
+ self.pending.lock().ok()?.take()
+ }
+}
+
+impl Drop for IpcServer {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_file(&self.lock_path);
+ }
+}
+
+/// Try to send a URL to a running Void instance. Returns `true` if successful.
+pub fn try_send_to_running(url: &str) -> bool {
+ let Some(lock_path) = lock_file_path() else {
+ return false;
+ };
+ let Ok(port_str) = std::fs::read_to_string(&lock_path) else {
+ return false;
+ };
+ let Ok(port) = port_str.trim().parse::() else {
+ // Corrupt lockfile — remove it
+ let _ = std::fs::remove_file(&lock_path);
+ return false;
+ };
+
+ // Try to connect with a short timeout
+ let addr = format!("127.0.0.1:{port}");
+ let Ok(mut stream) =
+ TcpStream::connect_timeout(&addr.parse().unwrap(), std::time::Duration::from_secs(2))
+ else {
+ // Stale lockfile — server not running
+ let _ = std::fs::remove_file(&lock_path);
+ return false;
+ };
+
+ // Send the URL followed by a newline
+ let _ = stream.set_write_timeout(Some(std::time::Duration::from_secs(2)));
+ if writeln!(stream, "{url}").is_err() {
+ return false;
+ }
+ let _ = stream.flush();
+ true
+}
+
+fn read_url(stream: TcpStream) -> Option {
+ let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5)));
+ let mut reader = BufReader::new(stream);
+ let mut line = String::new();
+ reader.read_line(&mut line).ok()?;
+ let trimmed = line.trim().to_string();
+ if trimmed.starts_with("void://") {
+ Some(trimmed)
+ } else {
+ None
+ }
+}
+
+fn lock_file_path() -> Option {
+ let dirs = directories::ProjectDirs::from("", "", "void")?;
+ Some(dirs.data_dir().join("void.lock"))
+}
diff --git a/src/deeplink/mod.rs b/src/deeplink/mod.rs
new file mode 100644
index 0000000..f35be80
--- /dev/null
+++ b/src/deeplink/mod.rs
@@ -0,0 +1,248 @@
+// Deep-link URL parsing and navigation for void:// protocol
+//
+// URL format:
+// void://open/ → switch to workspace
+// void://open// → focus panel + center viewport
+// void://open//@,[,] → navigate to canvas coordinates
+
+pub mod ipc;
+pub mod register;
+pub mod toast;
+
+use std::fmt;
+
+/// Parsed deep-link target.
+#[derive(Debug, Clone, PartialEq)]
+pub enum DeepLink {
+ Workspace {
+ workspace_id: String,
+ },
+ Panel {
+ workspace_id: String,
+ panel_id: String,
+ },
+ Position {
+ workspace_id: String,
+ x: f32,
+ y: f32,
+ zoom: Option,
+ },
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum DeepLinkError {
+ InvalidScheme,
+ MissingAction,
+ MissingWorkspaceId,
+ InvalidWorkspaceId,
+ InvalidPanelId,
+ InvalidCoordinates,
+}
+
+impl fmt::Display for DeepLinkError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::InvalidScheme => write!(f, "URL must start with void://"),
+ Self::MissingAction => write!(f, "missing action (expected void://open/...)"),
+ Self::MissingWorkspaceId => write!(f, "missing workspace ID"),
+ Self::InvalidWorkspaceId => write!(f, "invalid workspace UUID"),
+ Self::InvalidPanelId => write!(f, "invalid panel UUID"),
+ Self::InvalidCoordinates => {
+ write!(f, "invalid coordinates (expected @x,y or @x,y,zoom)")
+ }
+ }
+ }
+}
+
+/// Parse a `void://` deep-link URL into a navigation target.
+pub fn parse(url: &str) -> Result {
+ // Strip scheme
+ let rest = url
+ .strip_prefix("void://")
+ .ok_or(DeepLinkError::InvalidScheme)?;
+
+ // Strip action
+ let rest = rest
+ .strip_prefix("open/")
+ .ok_or(DeepLinkError::MissingAction)?;
+
+ // Split into segments (filter empty for trailing slashes)
+ let segments: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
+
+ if segments.is_empty() {
+ return Err(DeepLinkError::MissingWorkspaceId);
+ }
+
+ let workspace_id = segments[0];
+ // Validate workspace UUID
+ uuid::Uuid::parse_str(workspace_id).map_err(|_| DeepLinkError::InvalidWorkspaceId)?;
+ let workspace_id = workspace_id.to_string();
+
+ if segments.len() == 1 {
+ return Ok(DeepLink::Workspace { workspace_id });
+ }
+
+ let second = segments[1];
+
+ // Check if it's a coordinate segment (@x,y or @x,y,zoom)
+ if let Some(coords) = second.strip_prefix('@') {
+ let parts: Vec<&str> = coords.split(',').collect();
+ if parts.len() < 2 || parts.len() > 3 {
+ return Err(DeepLinkError::InvalidCoordinates);
+ }
+ let x: f32 = parts[0]
+ .parse()
+ .map_err(|_| DeepLinkError::InvalidCoordinates)?;
+ let y: f32 = parts[1]
+ .parse()
+ .map_err(|_| DeepLinkError::InvalidCoordinates)?;
+ let zoom: Option = if parts.len() == 3 {
+ Some(
+ parts[2]
+ .parse()
+ .map_err(|_| DeepLinkError::InvalidCoordinates)?,
+ )
+ } else {
+ None
+ };
+ return Ok(DeepLink::Position {
+ workspace_id,
+ x,
+ y,
+ zoom,
+ });
+ }
+
+ // Otherwise it's a panel ID
+ uuid::Uuid::parse_str(second).map_err(|_| DeepLinkError::InvalidPanelId)?;
+ Ok(DeepLink::Panel {
+ workspace_id,
+ panel_id: second.to_string(),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const WS_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
+ const PANEL_ID: &str = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
+
+ #[test]
+ fn parse_workspace_url() {
+ let url = format!("void://open/{WS_ID}");
+ assert_eq!(
+ parse(&url).unwrap(),
+ DeepLink::Workspace {
+ workspace_id: WS_ID.to_string()
+ }
+ );
+ }
+
+ #[test]
+ fn parse_workspace_with_trailing_slash() {
+ let url = format!("void://open/{WS_ID}/");
+ assert_eq!(
+ parse(&url).unwrap(),
+ DeepLink::Workspace {
+ workspace_id: WS_ID.to_string()
+ }
+ );
+ }
+
+ #[test]
+ fn parse_panel_url() {
+ let url = format!("void://open/{WS_ID}/{PANEL_ID}");
+ assert_eq!(
+ parse(&url).unwrap(),
+ DeepLink::Panel {
+ workspace_id: WS_ID.to_string(),
+ panel_id: PANEL_ID.to_string(),
+ }
+ );
+ }
+
+ #[test]
+ fn parse_position_without_zoom() {
+ let url = format!("void://open/{WS_ID}/@500.5,300");
+ assert_eq!(
+ parse(&url).unwrap(),
+ DeepLink::Position {
+ workspace_id: WS_ID.to_string(),
+ x: 500.5,
+ y: 300.0,
+ zoom: None,
+ }
+ );
+ }
+
+ #[test]
+ fn parse_position_with_zoom() {
+ let url = format!("void://open/{WS_ID}/@100,200,1.5");
+ assert_eq!(
+ parse(&url).unwrap(),
+ DeepLink::Position {
+ workspace_id: WS_ID.to_string(),
+ x: 100.0,
+ y: 200.0,
+ zoom: Some(1.5),
+ }
+ );
+ }
+
+ #[test]
+ fn reject_invalid_scheme() {
+ assert_eq!(
+ parse("http://open/abc").unwrap_err(),
+ DeepLinkError::InvalidScheme
+ );
+ }
+
+ #[test]
+ fn reject_missing_action() {
+ assert_eq!(
+ parse("void://foo/bar").unwrap_err(),
+ DeepLinkError::MissingAction
+ );
+ }
+
+ #[test]
+ fn reject_missing_workspace() {
+ assert_eq!(
+ parse("void://open/").unwrap_err(),
+ DeepLinkError::MissingWorkspaceId
+ );
+ }
+
+ #[test]
+ fn reject_invalid_workspace_uuid() {
+ assert_eq!(
+ parse("void://open/not-a-uuid").unwrap_err(),
+ DeepLinkError::InvalidWorkspaceId
+ );
+ }
+
+ #[test]
+ fn reject_invalid_panel_uuid() {
+ let url = format!("void://open/{WS_ID}/not-a-uuid");
+ assert_eq!(parse(&url).unwrap_err(), DeepLinkError::InvalidPanelId);
+ }
+
+ #[test]
+ fn reject_invalid_coordinates() {
+ let url = format!("void://open/{WS_ID}/@abc,def");
+ assert_eq!(parse(&url).unwrap_err(), DeepLinkError::InvalidCoordinates);
+ }
+
+ #[test]
+ fn reject_coordinates_too_few_parts() {
+ let url = format!("void://open/{WS_ID}/@100");
+ assert_eq!(parse(&url).unwrap_err(), DeepLinkError::InvalidCoordinates);
+ }
+
+ #[test]
+ fn reject_coordinates_too_many_parts() {
+ let url = format!("void://open/{WS_ID}/@1,2,3,4");
+ assert_eq!(parse(&url).unwrap_err(), DeepLinkError::InvalidCoordinates);
+ }
+}
diff --git a/src/deeplink/register.rs b/src/deeplink/register.rs
new file mode 100644
index 0000000..d961dc8
--- /dev/null
+++ b/src/deeplink/register.rs
@@ -0,0 +1,167 @@
+// Auto-register the void:// protocol handler on first launch.
+// Checks if already registered before doing anything.
+
+/// Register the void:// URL scheme handler if not already present.
+pub fn ensure_registered() {
+ #[cfg(target_os = "windows")]
+ register_windows();
+
+ #[cfg(target_os = "linux")]
+ register_linux();
+
+ #[cfg(target_os = "macos")]
+ register_macos();
+}
+
+#[cfg(target_os = "windows")]
+fn register_windows() {
+ #[cfg(target_os = "windows")]
+ use std::os::windows::process::CommandExt;
+ use std::process::Command;
+
+ // Check if already registered
+ let check = Command::new("reg")
+ .args([
+ "query",
+ "HKCU\\Software\\Classes\\void\\shell\\open\\command",
+ ])
+ .creation_flags(0x08000000) // CREATE_NO_WINDOW
+ .output();
+ if check.is_ok_and(|o| o.status.success()) {
+ return;
+ }
+
+ let exe = match std::env::current_exe() {
+ Ok(p) => p.to_string_lossy().to_string(),
+ Err(_) => return,
+ };
+
+ let command_value = format!("\"{}\" \"%1\"", exe);
+
+ let entries: &[(&str, &str, &str)] = &[
+ ("HKCU\\Software\\Classes\\void", "", "URL:Void Protocol"),
+ ("HKCU\\Software\\Classes\\void", "URL Protocol", ""),
+ (
+ "HKCU\\Software\\Classes\\void\\shell\\open\\command",
+ "",
+ &command_value,
+ ),
+ ];
+
+ for (key, name, value) in entries {
+ let mut args = vec!["add", key];
+ if name.is_empty() {
+ args.extend(["/ve", "/d", value, "/f"]);
+ } else {
+ args.extend(["/v", name, "/d", value, "/f"]);
+ }
+ let _ = Command::new("reg")
+ .args(&args)
+ .creation_flags(0x08000000)
+ .output();
+ }
+
+ log::info!("Registered void:// protocol handler (Windows)");
+}
+
+#[cfg(target_os = "linux")]
+fn register_linux() {
+ use std::process::Command;
+
+ let Some(app_dir) = dirs_path("applications") else {
+ return;
+ };
+ let desktop_path = app_dir.join("void-terminal.desktop");
+
+ // Already registered
+ if desktop_path.exists() {
+ return;
+ }
+
+ let exe = match std::env::current_exe() {
+ Ok(p) => p.to_string_lossy().to_string(),
+ Err(_) => return,
+ };
+
+ let desktop_content = format!(
+ "[Desktop Entry]\n\
+ Name=Void Terminal\n\
+ Comment=Infinite canvas terminal emulator\n\
+ Exec={} %u\n\
+ Icon=void\n\
+ Terminal=false\n\
+ Type=Application\n\
+ Categories=System;TerminalEmulator;\n\
+ MimeType=x-scheme-handler/void;\n",
+ exe
+ );
+
+ let _ = std::fs::create_dir_all(&app_dir);
+ if std::fs::write(&desktop_path, desktop_content).is_err() {
+ return;
+ }
+
+ let _ = Command::new("xdg-mime")
+ .args(["default", "void-terminal.desktop", "x-scheme-handler/void"])
+ .output();
+
+ let _ = Command::new("update-desktop-database")
+ .arg(&app_dir)
+ .output();
+
+ log::info!("Registered void:// protocol handler (Linux)");
+}
+
+#[cfg(target_os = "linux")]
+fn dirs_path(subdir: &str) -> Option {
+ let home = std::env::var("HOME").ok()?;
+ let xdg = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| format!("{}/.local/share", home));
+ Some(std::path::PathBuf::from(xdg).join(subdir))
+}
+
+#[cfg(target_os = "macos")]
+fn register_macos() {
+ use std::process::Command;
+
+ let exe = match std::env::current_exe() {
+ Ok(p) => p,
+ Err(_) => return,
+ };
+
+ // Walk up from the binary to find the .app bundle
+ let mut app_bundle = None;
+ let mut path = exe.as_path();
+ for _ in 0..4 {
+ if let Some(parent) = path.parent() {
+ if parent.extension().is_some_and(|ext| ext == "app") {
+ app_bundle = Some(parent.to_path_buf());
+ break;
+ }
+ path = parent;
+ }
+ }
+
+ let Some(bundle) = app_bundle else {
+ return; // Not running from .app bundle (dev build)
+ };
+
+ // Check if already registered by querying LaunchServices
+ let check = Command::new("defaults")
+ .args([
+ "read",
+ "com.apple.LaunchServices/com.apple.launchservices.secure",
+ ])
+ .output();
+ if check.is_ok_and(|o| String::from_utf8_lossy(&o.stdout).contains("x-scheme-handler/void")) {
+ return;
+ }
+
+ let lsregister = "/System/Library/Frameworks/CoreServices.framework\
+ /Frameworks/LaunchServices.framework/Support/lsregister";
+ let _ = Command::new(lsregister)
+ .args(["-R", "-f"])
+ .arg(&bundle)
+ .output();
+
+ log::info!("Registered void:// protocol handler (macOS)");
+}
diff --git a/src/deeplink/toast.rs b/src/deeplink/toast.rs
new file mode 100644
index 0000000..1baa33c
--- /dev/null
+++ b/src/deeplink/toast.rs
@@ -0,0 +1,63 @@
+// Lightweight toast notifications for deep-link navigation feedback.
+
+use egui::{Align2, Color32, FontId, Rect, Ui};
+
+pub struct Toast {
+ pub message: String,
+ pub expires_at: f64,
+}
+
+impl Toast {
+ pub fn new(message: impl Into, duration_secs: f64, current_time: f64) -> Self {
+ Self {
+ message: message.into(),
+ expires_at: current_time + duration_secs,
+ }
+ }
+
+ pub fn is_expired(&self, current_time: f64) -> bool {
+ current_time >= self.expires_at
+ }
+
+ /// Render the toast as a bottom-center overlay. Returns `true` if still visible.
+ pub fn show(&self, ui: &mut Ui, canvas_rect: Rect, current_time: f64) -> bool {
+ if self.is_expired(current_time) {
+ return false;
+ }
+
+ let remaining = self.expires_at - current_time;
+ let alpha = if remaining < 0.5 {
+ (remaining / 0.5) as f32
+ } else {
+ 1.0
+ };
+
+ let painter = ui.painter();
+ let font = FontId::proportional(14.0);
+ let text_color = Color32::from_white_alpha((230.0 * alpha) as u8);
+ let bg_color = Color32::from_rgba_unmultiplied(30, 30, 30, (200.0 * alpha) as u8);
+
+ let galley = painter.layout_no_wrap(self.message.clone(), font, Color32::WHITE);
+ let text_size = galley.size();
+ let padding = egui::vec2(16.0, 10.0);
+ let toast_size = text_size + padding * 2.0;
+
+ let center_x = canvas_rect.center().x;
+ let bottom_y = canvas_rect.max.y - 40.0;
+ let toast_rect = Rect::from_center_size(
+ egui::pos2(center_x, bottom_y - toast_size.y / 2.0),
+ toast_size,
+ );
+
+ painter.rect_filled(toast_rect, 8.0, bg_color);
+ painter.text(
+ toast_rect.center(),
+ Align2::CENTER_CENTER,
+ &self.message,
+ FontId::proportional(14.0),
+ text_color,
+ );
+
+ true
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index bb28d24..620e859 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3,6 +3,7 @@
mod app;
mod canvas;
mod command_palette;
+mod deeplink;
mod panel;
mod shortcuts;
mod sidebar;
@@ -19,6 +20,20 @@ fn main() -> Result<()> {
env_logger::init();
log::info!("Starting Void terminal...");
+ // Register void:// protocol handler on this system (idempotent, silent)
+ deeplink::register::ensure_registered();
+
+ // Check for void:// deep-link URL passed as CLI argument
+ let url_arg = std::env::args().nth(1).filter(|a| a.starts_with("void://"));
+
+ // If another instance is already running, send the URL to it and exit
+ if let Some(ref url) = url_arg {
+ if deeplink::ipc::try_send_to_running(url) {
+ log::info!("Sent deep-link to running instance: {url}");
+ return Ok(());
+ }
+ }
+
let icon = {
let png = include_bytes!("../assets/icon.png");
let img = image::load_from_memory(png)
@@ -45,7 +60,7 @@ fn main() -> Result<()> {
eframe::run_native(
"Void",
options,
- Box::new(|cc| Ok(Box::new(app::VoidApp::new(cc)))),
+ Box::new(move |cc| Ok(Box::new(app::VoidApp::new(cc, url_arg)))),
)
.map_err(|e| anyhow::anyhow!("eframe error: {}", e))?;
diff --git a/src/state/persistence.rs b/src/state/persistence.rs
index 2cd9819..c1cb8c8 100644
--- a/src/state/persistence.rs
+++ b/src/state/persistence.rs
@@ -29,6 +29,8 @@ pub struct WorkspaceState {
/// Serializable snapshot of a single terminal panel (layout only, no PTY).
#[derive(Serialize, Deserialize)]
pub struct PanelState {
+ #[serde(default)]
+ pub id: Option,
pub title: String,
pub position: [f32; 2],
pub size: [f32; 2],
diff --git a/src/terminal/panel.rs b/src/terminal/panel.rs
index 898061e..903579a 100644
--- a/src/terminal/panel.rs
+++ b/src/terminal/panel.rs
@@ -67,6 +67,27 @@ pub const VOID_SHORTCUTS: &[(Modifiers, Key)] = &[
},
Key::T,
),
+ // Deep-link navigation shortcuts
+ (
+ Modifiers {
+ alt: false,
+ ctrl: true,
+ shift: false,
+ mac_cmd: false,
+ command: false,
+ },
+ Key::L,
+ ),
+ (
+ Modifiers {
+ alt: false,
+ ctrl: true,
+ shift: true,
+ mac_cmd: false,
+ command: false,
+ },
+ Key::L,
+ ),
];
pub struct TerminalPanel {
@@ -107,6 +128,7 @@ pub struct TerminalPanel {
pub enum PanelAction {
Close,
Rename,
+ CopyLink,
}
#[derive(Default)]
@@ -264,6 +286,12 @@ impl TerminalPanel {
let color = Color32::from_rgb(state.color[0], state.color[1], state.color[2]);
let mut panel = Self::new_with_terminal(ctx, position, size, color, cwd);
+ // Restore persisted panel ID if available (stable deep-link targets)
+ if let Some(ref id_str) = state.id {
+ if let Ok(id) = uuid::Uuid::parse_str(id_str) {
+ panel.id = id;
+ }
+ }
panel.z_index = state.z_index;
panel.focused = state.focused;
panel
@@ -272,6 +300,7 @@ impl TerminalPanel {
/// Snapshot the panel layout for persistence (no PTY state).
pub fn to_saved(&self) -> crate::state::persistence::PanelState {
crate::state::persistence::PanelState {
+ id: Some(self.id.to_string()),
title: self.title.clone(),
position: [self.position.x, self.position.y],
size: [self.size.x, self.size.y],
@@ -1286,6 +1315,11 @@ impl TerminalPanel {
ix.action = Some(PanelAction::Close);
ui.close_menu();
}
+ ui.separator();
+ if ui.button("Copy Link").clicked() {
+ ix.action = Some(PanelAction::CopyLink);
+ ui.close_menu();
+ }
});
ix