feat: implement getSelectedFinderItems functionality

Added schemas and message handling for the new get-selected-finder-items command. Implemented the logic to retrieve selected items from the Finder on macOS, Windows, and Linux, enhancing the application's ability to interact with the file system.
This commit is contained in:
ByteAtATime 2025-06-17 14:28:51 -07:00
parent a3c2929e1c
commit 0c94e973a0
No known key found for this signature in database
8 changed files with 357 additions and 9 deletions

View file

@ -140,6 +140,14 @@ const GetSelectedTextMessageSchema = z.object({
payload: GetSelectedTextPayloadSchema
});
const GetSelectedFinderItemsPayloadSchema = z.object({
requestId: z.string()
});
const GetSelectedFinderItemsMessageSchema = z.object({
type: z.literal('get-selected-finder-items'),
payload: GetSelectedFinderItemsPayloadSchema
});
export const SidecarMessageWithPluginsSchema = z.union([
BatchUpdateSchema,
CommandSchema,
@ -148,6 +156,7 @@ export const SidecarMessageWithPluginsSchema = z.union([
PreferenceValuesSchema,
GoBackToPluginListSchema,
OpenMessageSchema,
GetSelectedTextMessageSchema
GetSelectedTextMessageSchema,
GetSelectedFinderItemsMessageSchema
]);
export type SidecarMessageWithPlugins = z.infer<typeof SidecarMessageWithPluginsSchema>;

View file

@ -33,8 +33,45 @@ export const environment = {
}
};
const pendingFinderItemsRequests = new Map<
string,
{ resolve: (items: FileSystemItem[]) => void; reject: (error: Error) => void }
>();
export async function getSelectedFinderItems(): Promise<FileSystemItem[]> {
return Promise.reject(new Error('Finder is not the frontmost application.'));
return new Promise((resolve, reject) => {
const requestId = Math.random().toString(36).substring(7);
pendingFinderItemsRequests.set(requestId, { resolve, reject });
writeOutput({
type: 'get-selected-finder-items',
payload: { requestId }
});
setTimeout(() => {
if (pendingFinderItemsRequests.has(requestId)) {
pendingFinderItemsRequests.delete(requestId);
reject(new Error('Timeout: Could not get selected finder items'));
}
}, 1000);
});
}
export function handleGetSelectedFinderItemsResponse(
requestId: string,
items: FileSystemItem[] | null,
error?: string
) {
const pending = pendingFinderItemsRequests.get(requestId);
if (pending) {
pendingFinderItemsRequests.delete(requestId);
if (error) {
pending.reject(new Error(error));
} else {
pending.resolve(items || []);
}
}
}
const pendingTextRequests = new Map<

View file

@ -5,7 +5,11 @@ import { instances, navigationStack } from './state';
import { batchedUpdates, updateContainer } from './reconciler';
import { preferencesStore } from './preferences';
import type { RaycastInstance } from './types';
import { handleSelectedTextResponse } from './api/environment';
import {
handleGetSelectedFinderItemsResponse,
handleSelectedTextResponse,
type FileSystemItem
} from './api/environment';
process.on('unhandledRejection', (reason: unknown) => {
writeLog(`--- UNHANDLED PROMISE REJECTION ---`);
@ -102,6 +106,15 @@ rl.on('line', (line) => {
handleSelectedTextResponse(requestId, text ?? null, error);
break;
}
case 'selected-finder-items-response': {
const { requestId, items, error } = command.payload as {
requestId: string;
items?: FileSystemItem[] | null;
error?: string;
};
handleGetSelectedFinderItemsResponse(requestId, items ?? null, error);
break;
}
default:
writeLog(`Unknown command action: ${command.action}`);
}

3
src-tauri/Cargo.lock generated
View file

@ -3323,6 +3323,7 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
name = "raycast-linux"
version = "0.1.0"
dependencies = [
"arboard",
"bincode",
"freedesktop-file-parser",
"rayon",
@ -3334,6 +3335,8 @@ dependencies = [
"tauri-plugin-clipboard-manager",
"tauri-plugin-opener",
"tauri-plugin-shell",
"url",
"zbus",
]
[[package]]

View file

@ -28,4 +28,7 @@ freedesktop-file-parser = "0.2.0"
bincode = { version = "2.0.1", features = ["serde"] }
rayon = "1.10.0"
selection = "1.2.0"
url = "2.5.4"
arboard = "3.5.0"
zbus = "5.7.1"

View file

@ -4,7 +4,8 @@ use std::io;
pub enum AppError {
Io(io::Error),
Serialization(String),
DirectoryNotFound
DirectoryNotFound,
CacheError(String),
}
impl From<io::Error> for AppError {

View file

@ -3,12 +3,274 @@ mod cache;
mod desktop;
mod error;
use crate::{
app::App,
cache::AppCache,
};
#[cfg(target_os = "linux")]
use arboard;
use crate::{app::App, cache::AppCache};
use selection::get_text;
use std::{process::Command, thread, time::Duration};
#[cfg(target_os = "linux")]
use std::path::Path;
#[cfg(target_os = "linux")]
use url::Url;
#[cfg(target_os = "linux")]
use zbus;
#[derive(serde::Serialize, Clone, Debug)]
pub struct FileSystemItem {
path: String,
}
#[tauri::command]
async fn get_selected_finder_items() -> Result<Vec<FileSystemItem>, String> {
#[cfg(target_os = "macos")]
{
get_selected_finder_items_macos()
}
#[cfg(target_os = "windows")]
{
get_selected_finder_items_windows()
}
#[cfg(target_os = "linux")]
{
get_selected_finder_items_linux().await
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
Err("Unsupported operating system".to_string())
}
}
#[cfg(target_os = "macos")]
fn get_selected_finder_items_macos() -> Result<Vec<FileSystemItem>, String> {
let script = r#"
tell application "Finder"
if not running then
return ""
end if
try
set theSelection to selection
if theSelection is {} then
return ""
end if
set thePaths to {}
repeat with i from 1 to count of theSelection
set end of thePaths to (POSIX path of (item i of theSelection as alias))
end repeat
return thePaths
on error
return ""
end try
end tell
"#;
let output = std::process::Command::new("osascript")
.arg("-l")
.arg("AppleScript")
.arg("-e")
.arg(script)
.output()
.map_err(|e| format!("Failed to execute osascript: {}", e))?;
if !output.status.success() {
let error_message = String::from_utf8_lossy(&output.stderr);
if error_message.contains("Finder is not running") {
return Ok(vec![]);
}
return Err(format!("osascript failed with error: {}", error_message));
}
let result_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
if result_str.is_empty() {
return Ok(vec![]);
}
let paths: Vec<FileSystemItem> = result_str
.split(", ")
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(|p| FileSystemItem {
path: p.to_string(),
})
.collect();
Ok(paths)
}
#[cfg(target_os = "windows")]
fn get_selected_finder_items_windows() -> Result<Vec<FileSystemItem>, String> {
let script = r#"
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class Win32 {
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
}
"@
$foreground_hwnd = [Win32]::GetForegroundWindow()
$shell = New-Object -ComObject Shell.Application
$window = $shell.Windows() | Where-Object { $_.HWND -eq $foreground_hwnd } | Select-Object -First 1
if ($window) {
if ($window.FullName -like "*\explorer.exe") {
$selection = $window.Document.SelectedItems()
if ($selection) {
$paths = $selection | ForEach-Object { $_.Path }
if ($paths) {
return $paths -join [System.Environment]::NewLine
}
}
}
}
return ""
"#;
let output = std::process::Command::new("powershell")
.arg("-NoProfile")
.arg("-ExecutionPolicy")
.arg("Bypass")
.arg("-Command")
.arg(script)
.output()
.map_err(|e| format!("Failed to execute powershell: {}", e))?;
if !output.status.success() {
return Err(format!(
"powershell failed with error: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let result_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
if result_str.is_empty() {
return Ok(vec![]);
}
let paths: Vec<FileSystemItem> = result_str
.lines()
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(|p| FileSystemItem {
path: p.to_string(),
})
.collect();
Ok(paths)
}
#[cfg(target_os = "linux")]
async fn get_selected_finder_items_linux() -> Result<Vec<FileSystemItem>, String> {
if let Ok(paths) = get_from_file_manager().await {
if !paths.is_empty() {
return Ok(paths);
}
}
if let Ok(paths) = get_from_clipboard() {
if !paths.is_empty() {
return Ok(paths);
}
}
Err("Could not determine selected files. Please copy them to your clipboard.".to_string())
}
#[cfg(target_os = "linux")]
async fn get_from_file_manager() -> Result<Vec<FileSystemItem>, String> {
let connection = match zbus::Connection::session().await {
Ok(c) => c,
Err(_) => return Ok(vec![]),
};
let proxy = match zbus::Proxy::new(
&connection,
"org.freedesktop.FileManager1",
"/org/freedesktop/FileManager1",
"org.freedesktop.FileManager1",
)
.await
{
Ok(p) => p,
Err(_) => return Ok(vec![]),
};
let fm_service: String = proxy.destination().to_string();
if fm_service.is_empty() {
return Ok(vec![]);
}
let fm_name = fm_service.split('.').last().unwrap_or_default();
let window_interface = format!("org.{}.Window", fm_name);
if fm_name != "nautilus" && fm_name != "nemo" {
return Ok(vec![]);
}
let response = match proxy.call_method("GetWindows", &()).await {
Ok(r) => r,
Err(_) => return Ok(vec![]),
};
let body = response.body();
let windows: Vec<zbus::zvariant::ObjectPath> = body.deserialize().unwrap_or_default();
for window_path in windows.iter().rev() {
let fm_service_ref = fm_service.as_str();
let window_interface_ref = window_interface.as_str();
let window_proxy = match zbus::Proxy::new(
&connection,
fm_service_ref,
window_path,
window_interface_ref,
)
.await
{
Ok(p) => p,
Err(_) => continue,
};
if let Ok(is_active) = window_proxy.get_property::<bool>("Active").await {
if is_active {
if let Ok(uris) = window_proxy.get_property::<Vec<String>>("SelectedUris").await {
let paths = uris
.iter()
.filter_map(|uri_str| Url::parse(uri_str).ok())
.filter_map(|url| url.to_file_path().ok())
.map(|path_buf| FileSystemItem {
path: path_buf.to_string_lossy().into_owned(),
})
.collect();
return Ok(paths);
}
}
}
}
Ok(vec![])
}
#[cfg(target_os = "linux")]
fn get_from_clipboard() -> Result<Vec<FileSystemItem>, String> {
let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
if let Ok(text) = clipboard.get_text() {
let paths: Vec<FileSystemItem> = text
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("file://") {
Url::parse(trimmed).ok().and_then(|u| u.to_file_path().ok())
} else {
Some(Path::new(trimmed).to_path_buf())
}
})
.filter(|p| p.exists())
.map(|p| FileSystemItem {
path: p.to_string_lossy().to_string(),
})
.collect();
return Ok(paths);
}
Ok(vec![])
}
#[tauri::command]
fn get_installed_apps() -> Vec<App> {
@ -56,7 +318,8 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
get_installed_apps,
launch_app,
get_selected_text
get_selected_text,
get_selected_finder_items
])
.setup(|_app| {
thread::spawn(|| {

View file

@ -169,6 +169,25 @@ class SidecarService {
return;
}
if (typedMessage.type === 'get-selected-finder-items') {
const { requestId } = typedMessage.payload;
invoke('get_selected_finder_items')
.then((items) => {
this.dispatchEvent('selected-finder-items-response', {
requestId,
items
});
})
.catch((error) => {
this.#log(`ERROR getting selected finder items: ${error}`);
this.dispatchEvent('selected-finder-items-response', {
requestId,
error: String(error)
});
});
return;
}
const commands = typedMessage.type === 'BATCH_UPDATE' ? typedMessage.payload : [typedMessage];
if (commands.length > 0) {
uiStore.applyCommands(commands);