mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-11 18:32:12 +00:00
Refactor file manager path opening logic for Linux systems
This commit is contained in:
parent
7d671e407d
commit
5ad6f9aaa7
@ -226,7 +226,7 @@ public partial class AssistantLogViewer : MSGComponentBase
|
||||
OpenPathResponse response;
|
||||
try
|
||||
{
|
||||
response = await this.RustService.OpenPathInFileManager(path);
|
||||
response = await this.RustService.TryOpenPathInRuntimeFileManager(path);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
||||
@ -87,28 +87,26 @@ public sealed partial class RustService
|
||||
return await result.Content.ReadFromJsonAsync<FileSaveResponse>(this.jsonRustSerializerOptions);
|
||||
}
|
||||
|
||||
public async Task<OpenPathResponse> OpenPathInFileManager(string path)
|
||||
{
|
||||
var runtimeResponse = await this.TryOpenPathInRuntimeFileManager(path);
|
||||
if (runtimeResponse.Success)
|
||||
return runtimeResponse;
|
||||
|
||||
var localResponse = this.TryOpenPathInLocalFileManager(path);
|
||||
if (localResponse.Success)
|
||||
return localResponse;
|
||||
|
||||
var issue = string.IsNullOrWhiteSpace(runtimeResponse.Issue)
|
||||
? localResponse.Issue
|
||||
: $"{runtimeResponse.Issue} {localResponse.Issue}";
|
||||
|
||||
return new OpenPathResponse(false, issue.Trim());
|
||||
}
|
||||
|
||||
private async Task<OpenPathResponse> TryOpenPathInRuntimeFileManager(string path)
|
||||
public async Task<OpenPathResponse> TryOpenPathInRuntimeFileManager(string path)
|
||||
{
|
||||
HttpResponseMessage result;
|
||||
try
|
||||
{
|
||||
result = await this.http.PostAsJsonAsync("/open/path", new OpenPathRequest(path), this.jsonRustSerializerOptions);
|
||||
}
|
||||
catch (HttpRequestException e)
|
||||
{
|
||||
this.logger!.LogWarning(e, "Failed to reach the Rust runtime file manager endpoint.");
|
||||
return new OpenPathResponse(false, TB("The runtime file manager endpoint is not available."));
|
||||
}
|
||||
catch (TaskCanceledException e)
|
||||
{
|
||||
this.logger!.LogWarning(e, "Timed out while reaching the Rust runtime file manager endpoint.");
|
||||
return new OpenPathResponse(false, TB("The runtime file manager endpoint is not available."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await this.http.PostAsJsonAsync("/open/path", new OpenPathRequest(path), this.jsonRustSerializerOptions);
|
||||
if (!result.IsSuccessStatusCode)
|
||||
{
|
||||
this.logger!.LogWarning("Failed to open a path in the file manager through the Rust runtime: '{StatusCode}'", result.StatusCode);
|
||||
@ -116,79 +114,20 @@ public sealed partial class RustService
|
||||
}
|
||||
|
||||
var response = await result.Content.ReadFromJsonAsync<OpenPathResponse>(this.jsonRustSerializerOptions);
|
||||
return response.Success
|
||||
var normalizedResponse = response.Success
|
||||
? response
|
||||
: new OpenPathResponse(false, string.IsNullOrWhiteSpace(response.Issue) ? TB("The runtime file manager endpoint failed without details.") : response.Issue);
|
||||
|
||||
return normalizedResponse;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.logger!.LogWarning(e, "Failed to open a path in the file manager through the Rust runtime.");
|
||||
return new OpenPathResponse(false, TB("The runtime file manager endpoint is not available."));
|
||||
this.logger!.LogWarning(e, "Failed to process the Rust runtime file manager endpoint response.");
|
||||
return new OpenPathResponse(false, TB("The runtime file manager endpoint failed without details."));
|
||||
}
|
||||
}
|
||||
|
||||
private OpenPathResponse TryOpenPathInLocalFileManager(string path)
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
var target = ResolveFileManagerTarget(path);
|
||||
if (target is null)
|
||||
return new OpenPathResponse(false, TB("The path does not exist and its parent folder could not be found."));
|
||||
|
||||
using var process = Process.Start(CreateFileManagerStartInfo(target.Value));
|
||||
return process is null
|
||||
? new OpenPathResponse(false, TB("The local file manager command did not start."))
|
||||
: new OpenPathResponse(true, string.Empty);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.logger!.LogWarning(e, "Failed to open a path in the local file manager.");
|
||||
return new OpenPathResponse(false, string.Format(TB("The local file manager command failed: {0}"), e.Message));
|
||||
result.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static FileManagerTarget? ResolveFileManagerTarget(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return null;
|
||||
|
||||
var requestedPath = path.Trim();
|
||||
if (File.Exists(requestedPath))
|
||||
return new FileManagerTarget(requestedPath, true);
|
||||
|
||||
if (Directory.Exists(requestedPath))
|
||||
return new FileManagerTarget(requestedPath, false);
|
||||
|
||||
var parent = Directory.GetParent(requestedPath)?.FullName;
|
||||
return !string.IsNullOrWhiteSpace(parent) && Directory.Exists(parent)
|
||||
? new FileManagerTarget(parent, false)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo CreateFileManagerStartInfo(FileManagerTarget target)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
var windowsInfo = new ProcessStartInfo("explorer.exe") { UseShellExecute = false };
|
||||
windowsInfo.ArgumentList.Add(target.RevealFile ? $"/select,{target.Path}" : target.Path);
|
||||
return windowsInfo;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
var macOsInfo = new ProcessStartInfo("open") { UseShellExecute = false };
|
||||
if (target.RevealFile)
|
||||
macOsInfo.ArgumentList.Add("-R");
|
||||
|
||||
macOsInfo.ArgumentList.Add(target.Path);
|
||||
return macOsInfo;
|
||||
}
|
||||
|
||||
var linuxInfo = new ProcessStartInfo("xdg-open") { UseShellExecute = false };
|
||||
var directory = target.RevealFile ? Path.GetDirectoryName(target.Path) ?? target.Path : target.Path;
|
||||
linuxInfo.ArgumentList.Add(directory);
|
||||
return linuxInfo;
|
||||
}
|
||||
|
||||
private readonly record struct FileManagerTarget(string Path, bool RevealFile);
|
||||
}
|
||||
17
runtime/Cargo.lock
generated
17
runtime/Cargo.lock
generated
@ -242,6 +242,20 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.13.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "281e6645758940dee594495e28807a7672ce40f11ebf4df6c22c4fcd59e2689f"
|
||||
dependencies = [
|
||||
"enumflags2",
|
||||
"futures-util",
|
||||
"getrandom 0.4.2",
|
||||
"serde",
|
||||
"tokio",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.1"
|
||||
@ -4072,6 +4086,7 @@ dependencies = [
|
||||
"aes 0.9.1",
|
||||
"apple-native-keyring-store",
|
||||
"arboard",
|
||||
"ashpd",
|
||||
"async-stream",
|
||||
"axum",
|
||||
"axum-server",
|
||||
@ -7664,6 +7679,7 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@ -9566,6 +9582,7 @@ dependencies = [
|
||||
"rustix 1.1.4",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
|
||||
@ -72,6 +72,7 @@ windows-native-keyring-store = "1.1.0"
|
||||
apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
ashpd = { version = "0.13.12", default-features = false, features = ["tokio", "open_uri"] }
|
||||
dbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }
|
||||
webkit2gtk = { version = "2.0.2", features = ["v2_8"] }
|
||||
|
||||
|
||||
@ -3,13 +3,19 @@ use axum::extract::Query;
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
use tauri_plugin_dialog::{DialogExt, FileDialogBuilder};
|
||||
use crate::api_token::APIToken;
|
||||
use crate::app_window::MAIN_WINDOW;
|
||||
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use ashpd::desktop::open_uri::{OpenDirectoryRequest, OpenFileRequest};
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
/// Microsoft documents CREATE_NO_WINDOW as a process creation flag with value 0x08000000.
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
@ -329,6 +335,29 @@ pub async fn open_path_in_file_manager(
|
||||
});
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
return match open_path_in_linux_file_manager(&target).await {
|
||||
Ok(()) => {
|
||||
info!("Opened file manager for path: {:?}", target.path);
|
||||
Json(OpenPathResponse {
|
||||
success: true,
|
||||
issue: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
Err(issue) => {
|
||||
error!(Source = "Tauri"; "{issue}");
|
||||
Json(OpenPathResponse {
|
||||
success: false,
|
||||
issue,
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
{
|
||||
let mut command = create_file_manager_command(&target);
|
||||
|
||||
#[cfg(windows)]
|
||||
@ -353,6 +382,7 @@ pub async fn open_path_in_file_manager(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies an optional file type filter to a FileDialogBuilder.
|
||||
fn apply_filter<R: tauri::Runtime>(file_dialog: FileDialogBuilder<R>, filter: &Option<FileTypeFilter>) -> FileDialogBuilder<R> {
|
||||
@ -366,11 +396,19 @@ fn apply_filter<R: tauri::Runtime>(file_dialog: FileDialogBuilder<R>, filter: &O
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct FileManagerTarget {
|
||||
path: PathBuf,
|
||||
reveal_file: bool,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum LinuxPortalOperation {
|
||||
RevealFile,
|
||||
OpenDirectory,
|
||||
}
|
||||
|
||||
fn resolve_file_manager_target(requested_path: &Path) -> Option<FileManagerTarget> {
|
||||
if requested_path.is_file() {
|
||||
return Some(FileManagerTarget {
|
||||
@ -394,6 +432,75 @@ fn resolve_file_manager_target(requested_path: &Path) -> Option<FileManagerTarge
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn linux_portal_operation(target: &FileManagerTarget) -> LinuxPortalOperation {
|
||||
if target.reveal_file {
|
||||
LinuxPortalOperation::RevealFile
|
||||
} else {
|
||||
LinuxPortalOperation::OpenDirectory
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn xdg_open_fallback_path(target: &FileManagerTarget) -> &Path {
|
||||
if target.reveal_file {
|
||||
target.path.parent().unwrap_or(&target.path)
|
||||
} else {
|
||||
&target.path
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
enum LinuxPortalError {
|
||||
Unavailable(String),
|
||||
RequestFailed(String),
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn open_path_with_linux_portal(target: &FileManagerTarget) -> Result<(), LinuxPortalError> {
|
||||
let file = std::fs::File::open(&target.path)
|
||||
.map_err(|error| LinuxPortalError::Unavailable(format!("Failed to open the path for the desktop portal: {error}")))?;
|
||||
|
||||
let request = match linux_portal_operation(target) {
|
||||
LinuxPortalOperation::RevealFile => OpenDirectoryRequest::default().send(&file).await,
|
||||
LinuxPortalOperation::OpenDirectory => OpenFileRequest::default().send_file(&file).await,
|
||||
}
|
||||
.map_err(|error| LinuxPortalError::Unavailable(format!("Desktop portal invocation failed: {error}")))?;
|
||||
|
||||
request.response()
|
||||
.map_err(|error| LinuxPortalError::RequestFailed(format!("Desktop portal request failed: {error}")))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn open_path_with_xdg_open(target: &FileManagerTarget) -> Result<(), String> {
|
||||
let fallback_path = xdg_open_fallback_path(target);
|
||||
let status = tokio::process::Command::new("xdg-open")
|
||||
.arg(fallback_path)
|
||||
.status()
|
||||
.await
|
||||
.map_err(|error| format!("xdg-open failed to start for '{}': {error}", fallback_path.to_string_lossy()))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("xdg-open failed for '{}' with exit status {status}", fallback_path.to_string_lossy()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn open_path_in_linux_file_manager(target: &FileManagerTarget) -> Result<(), String> {
|
||||
match open_path_with_linux_portal(target).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(LinuxPortalError::RequestFailed(error)) => Err(error),
|
||||
Err(LinuxPortalError::Unavailable(portal_error)) => {
|
||||
match open_path_with_xdg_open(target).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(fallback_error) => Err(format!("{portal_error} Fallback failed: {fallback_error}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn create_file_manager_command(target: &FileManagerTarget) -> Command {
|
||||
let mut command = Command::new("explorer.exe");
|
||||
@ -417,15 +524,55 @@ fn create_file_manager_command(target: &FileManagerTarget) -> Command {
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn create_file_manager_command(target: &FileManagerTarget) -> Command {
|
||||
let mut command = Command::new("xdg-open");
|
||||
let directory = if target.reveal_file {
|
||||
target.path.parent().unwrap_or(&target.path)
|
||||
} else {
|
||||
&target.path
|
||||
};
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
command.arg(directory);
|
||||
command
|
||||
#[test]
|
||||
fn existing_file_is_revealed_and_falls_back_to_its_parent() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let file_path = temp_dir.path().join("application.log");
|
||||
fs::write(&file_path, "log").unwrap();
|
||||
|
||||
let target = resolve_file_manager_target(&file_path).unwrap();
|
||||
|
||||
assert_eq!(target.path, file_path);
|
||||
assert!(target.reveal_file);
|
||||
assert_eq!(linux_portal_operation(&target), LinuxPortalOperation::RevealFile);
|
||||
assert_eq!(xdg_open_fallback_path(&target), temp_dir.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_directory_is_opened_directly() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let target = resolve_file_manager_target(temp_dir.path()).unwrap();
|
||||
|
||||
assert_eq!(target.path, temp_dir.path());
|
||||
assert!(!target.reveal_file);
|
||||
assert_eq!(linux_portal_operation(&target), LinuxPortalOperation::OpenDirectory);
|
||||
assert_eq!(xdg_open_fallback_path(&target), temp_dir.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_uses_its_existing_parent_directory() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let missing_file = temp_dir.path().join("missing.log");
|
||||
|
||||
let target = resolve_file_manager_target(&missing_file).unwrap();
|
||||
|
||||
assert_eq!(target.path, temp_dir.path());
|
||||
assert!(!target.reveal_file);
|
||||
assert_eq!(linux_portal_operation(&target), LinuxPortalOperation::OpenDirectory);
|
||||
assert_eq!(xdg_open_fallback_path(&target), temp_dir.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_path_without_existing_parent_is_rejected() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let invalid_path = temp_dir.path().join("missing-directory").join("missing.log");
|
||||
|
||||
assert!(resolve_file_manager_target(&invalid_path).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user