use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tauri::{AppHandle, Manager};
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[derive(Serialize)]
pub struct EntryInfo {
pub name: String,
pub path: String,
pub parent: String,
pub kind: String,
pub size: Option<u64>,
}
#[derive(Serialize, Deserialize)]
pub struct AppState {
pub pins: Vec<String>,
pub last_path: Option<String>,
pub recent: Vec<String>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Shortcuts {
pub toggle_kind: String,
pub advance: String,
pub create: String,
}
impl Default for Shortcuts {
fn default() -> Self {
Shortcuts {
toggle_kind: "Tab".into(),
advance: "Enter".into(),
create: "Ctrl+Enter".into(),
}
}
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(default)]
pub struct Settings {
pub shortcuts: Shortcuts,
pub lang: String,
}
impl Default for Settings {
fn default() -> Self {
Settings {
shortcuts: Shortcuts::default(),
lang: "en".into(),
}
}
}
const ILLEGAL_CHARS: &str = r#"\/:*?"<>|"#;
const RESERVED: &[&str] = &[
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
fn validate_name(raw: &str) -> Result<String, String> {
if raw.ends_with('.') || raw.ends_with(' ') {
return Err("Name cannot end with a dot or a space".into());
}
let name = raw.trim();
if name.is_empty() {
return Err("Name is empty".into());
}
if name.len() > 240 {
return Err("Name is too long (max 240 chars)".into());
}
if name.chars().any(|c| c.is_control() || ILLEGAL_CHARS.contains(c)) {
return Err(format!(
"Name contains characters not allowed on Windows: \\ / : * ? \" < > |"
));
}
let stem = name.split('.').next().unwrap_or(name).to_ascii_uppercase();
if RESERVED.contains(&stem.as_str()) {
return Err(format!("{stem} is a reserved name on Windows"));
}
Ok(name.to_string())
}
fn friendly_io(e: &std::io::Error) -> String {
match e.kind() {
std::io::ErrorKind::NotFound => "Target folder no longer exists".to_string(),
std::io::ErrorKind::PermissionDenied => {
"Access denied — the file may be open or locked by another app".to_string()
}
std::io::ErrorKind::AlreadyExists => "An entry with this name already exists".to_string(),
_ => e.to_string(),
}
}
fn is_dir(p: &Path) -> bool {
fs::metadata(p).map(|m| m.is_dir()).unwrap_or(false)
}
#[tauri::command]
fn create_entry(
parent: String,
name: String,
kind: String,
content: Option<String>,
) -> Result<EntryInfo, String> {
let name = validate_name(&name)?;
let parent_path = PathBuf::from(&parent);
if !parent_path.is_dir() {
return Err(format!(
"Target folder does not exist: {}",
parent_path.display()
));
}
let path = parent_path.join(&name);
if path.exists() {
return Err(format!("“{name}” already exists in {}", parent_path.display()));
}
match kind.as_str() {
"folder" => fs::create_dir(&path).map_err(|e| friendly_io(&e))?,
_ => fs::write(&path, content.unwrap_or_default()).map_err(|e| friendly_io(&e))?,
}
let size = fs::metadata(&path)
.ok()
.filter(|m| m.is_file())
.map(|m| m.len());
Ok(EntryInfo {
name,
path: path.to_string_lossy().to_string(),
parent: parent,
kind: kind,
size,
})
}
#[tauri::command]
fn rename_entry(path: String, new_name: String) -> Result<EntryInfo, String> {
let new_name = validate_name(&new_name)?;
let old = PathBuf::from(&path);
if !old.exists() {
return Err("Entry no longer exists".to_string());
}
let parent = old
.parent()
.ok_or_else(|| "Invalid path".to_string())?
.to_path_buf();
let new_path = parent.join(&new_name);
if new_path.exists() && new_path != old {
let same_case_only =
new_path.to_string_lossy().to_lowercase() == old.to_string_lossy().to_lowercase();
if !same_case_only {
return Err(format!("“{new_name}” already exists"));
}
}
if new_path.to_string_lossy() != old.to_string_lossy() {
// case-only rename needs a temp hop on Windows
if new_path.exists() {
let tmp = parent.join(format!(".ff-rename-{}", std::process::id()));
fs::rename(&old, &tmp).map_err(|e| friendly_io(&e))?;
fs::rename(&tmp, &new_path).map_err(|e| friendly_io(&e))?;
} else {
fs::rename(&old, &new_path).map_err(|e| friendly_io(&e))?;
}
}
let kind = if is_dir(&new_path) {
"folder".to_string()
} else {
"file".to_string()
};
let size = if is_dir(&new_path) {
None
} else {
fs::metadata(&new_path).ok().map(|m| m.len())
};
Ok(EntryInfo {
name: new_name,
path: new_path.to_string_lossy().to_string(),
parent: parent.to_string_lossy().to_string(),
kind,
size,
})
}
#[tauri::command]
fn delete_entry(path: String) -> Result<(), String> {
let p = PathBuf::from(&path);
if !p.exists() {
return Err("Entry no longer exists".to_string());
}
if is_dir(&p) {
fs::remove_dir(&p).map_err(|e| match e.kind() {
std::io::ErrorKind::DirectoryNotEmpty => {
"Folder is not empty".to_string()
}
_ => friendly_io(&e),
})?;
} else {
fs::remove_file(&p).map_err(|e| friendly_io(&e))?;
}
Ok(())
}
#[tauri::command]
fn read_file(path: String) -> Result<String, String> {
let p = PathBuf::from(&path);
if !p.is_file() {
return Err("Entry is not a file".to_string());
}
fs::read_to_string(&p).map_err(|e| friendly_io(&e))
}
#[tauri::command]
fn save_file(path: String, content: String) -> Result<u64, String> {
let p = PathBuf::from(&path);
if !p.is_file() {
return Err("Entry is not a file".to_string());
}
fs::write(&p, content).map_err(|e| friendly_io(&e))?;
fs::metadata(&p).map(|m| m.len()).map_err(|e| friendly_io(&e))
}
#[tauri::command]
fn reveal(path: String) -> Result<(), String> {
let p = PathBuf::from(&path);
if !p.exists() {
return Err("Entry no longer exists".to_string());
}
// If it's a folder, open it directly. For a file, use /select,<path>.
// Never embed quotes ourselves: Rust quotes args safely (spaces etc.),
// and Explorer falls back to "This PC" on any parse problem.
let mut target = p.to_string_lossy().to_string();
while target.ends_with('\\') || target.ends_with('/') {
target.pop();
}
let arg = if is_dir(&p) {
target
} else {
format!("/select,{target}")
};
Command::new("explorer.exe")
.arg(arg)
.spawn()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
fn normalize_path(path: String) -> Result<String, String> {
let trimmed = path.trim().trim_matches('"').trim();
if trimmed.is_empty() {
return Err("Path is empty".to_string());
}
let p = PathBuf::from(trimmed);
if p.is_dir() {
Ok(p.to_string_lossy().to_string())
} else if p.is_file() {
p.parent()
.map(|d| d.to_string_lossy().to_string())
.ok_or_else(|| "Invalid path".to_string())
} else {
Err(format!("Path not found: {trimmed}"))
}
}
// Returns the path of the last-activated Explorer window
// (the topmost visible Explorer window in Z-order).
#[cfg(windows)]
#[link(name = "user32")]
extern "system" {
fn EnumWindows(
lpEnumFunc: Option<unsafe extern "system" fn(isize, isize) -> i32>,
lParam: isize,
) -> i32;
fn IsWindowVisible(hWnd: isize) -> i32;
fn IsIconic(hWnd: isize) -> i32;
fn GetClassNameW(hWnd: isize, lpClassName: *mut u16, nMaxCount: i32) -> i32;
}
thread_local! {
static TOP_HWNDS: std::cell::RefCell<Vec<isize>> = std::cell::RefCell::new(Vec::new());
}
unsafe extern "system" fn collect_top_windows(h: isize, _l: isize) -> i32 {
TOP_HWNDS.with(|c| c.borrow_mut().push(h));
1
}
/// Topmost visible, non-minimized Explorer (CabinetWClass) window by Z-order.
fn topmost_explorer_hwnd() -> Option<isize> {
TOP_HWNDS.with(|c| c.borrow_mut().clear());
unsafe { EnumWindows(Some(collect_top_windows), 0) };
let mut buf: [u16; 64] = [0; 64];
TOP_HWNDS.with(|c| {
let tops = c.borrow();
for &h in tops.iter() {
unsafe {
if IsWindowVisible(h) == 0 || IsIconic(h) != 0 {
continue;
}
let n = GetClassNameW(h, buf.as_mut_ptr(), buf.len() as i32);
if n <= 0 {
continue;
}
let cls = String::from_utf16_lossy(&buf[..n as usize]);
if cls == "CabinetWClass" {
return Some(h);
}
}
}
None
})
}
/// Pick the `hwnd|url` report line whose window handle matches.
fn pick_path_from_report(report: &str, target_hwnd: u64) -> Option<String> {
for line in report.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let (hw, url) = line.split_once('|')?;
let h = hw.trim().parse::<u64>().ok()?;
if h == target_hwnd {
let p = url_to_path(url.trim())?;
if !p.is_empty() {
return Some(p);
}
}
}
None
}
fn explorer_path_impl() -> Result<String, String> {
let target = topmost_explorer_hwnd()
.ok_or_else(|| "No Explorer window found — open a folder first".to_string())?;
let script = r#"
$ErrorActionPreference = 'Stop'
try {
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
$shell = New-Object -ComObject Shell.Application
foreach ($w in $shell.Windows()) {
if (-not $w) { continue }
$u = [string]$w.LocationURL
if ($u -and $u.StartsWith('file:')) {
Write-Output ("{0}|{1}" -f $w.HWND, $u)
}
}
} catch {
Write-Error $_.Exception.Message
exit 1
}
"#;
let out = Command::new("powershell.exe")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
])
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err("No Explorer window found — open a folder first".to_string());
}
let stdout = String::from_utf8(out.stdout)
.map_err(|_| "explorer_path: bad utf8 output".to_string())?;
pick_path_from_report(&stdout, target as u64)
.ok_or_else(|| "No Explorer window found — open a folder first".to_string())
}
/// Convert a `file:` URL (as reported by Explorer's LocationURL) to a Windows path.
/// Handles percent-encoding, raw UTF-8 passthrough, drive letters and UNC hosts.
fn url_to_path(url: &str) -> Option<String> {
let body = url.strip_prefix("file://")?;
let (host, rel) = match body.split_once('/') {
Some((h, p)) => (h, p),
None => return None,
};
let bytes = percent_decode(rel.as_bytes());
let s = String::from_utf8(bytes).ok()?;
let slashed = s.replace('/', "\\");
let mut path = if host.is_empty() {
slashed
} else {
format!("\\\\{host}\\{slashed}")
};
while path.ends_with('\\') {
path.pop();
}
if path.is_empty() {
None
} else {
Some(path)
}
}
fn percent_decode(input: &[u8]) -> Vec<u8> {
fn hex(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
let mut out = Vec::with_capacity(input.len());
let mut i = 0;
while i < input.len() {
if input[i] == b'%' && i + 2 < input.len() {
if let (Some(hi), Some(lo)) = (hex(input[i + 1]), hex(input[i + 2])) {
out.push(hi * 16 + lo);
i += 3;
continue;
}
}
out.push(input[i]);
i += 1;
}
out
}
#[tauri::command]
fn explorer_path() -> Result<String, String> {
explorer_path_impl()
}
fn config_file(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_config_dir()
.map_err(|e| e.to_string())?;
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir.join("state.json"))
}
fn load_state(app: &AppHandle) -> Result<AppState, String> {
let f = config_file(app)?;
if !f.exists() {
return Ok(AppState {
pins: Vec::new(),
last_path: None,
recent: Vec::new(),
});
}
let s = fs::read_to_string(&f).map_err(|e| e.to_string())?;
Ok(serde_json::from_str::<AppState>(&s).unwrap_or(AppState {
pins: Vec::new(),
last_path: None,
recent: Vec::new(),
}))
}
#[tauri::command]
fn get_state(app: AppHandle) -> Result<AppState, String> {
load_state(&app)
}
#[tauri::command]
fn save_state(
app: AppHandle,
pins: Vec<String>,
last_path: Option<String>,
recent: Vec<String>,
) -> Result<(), String> {
let state = AppState {
pins,
last_path,
recent,
};
let s = serde_json::to_string_pretty(&state).map_err(|e| e.to_string())?;
fs::write(config_file(&app)?, s).map_err(|e| e.to_string())
}
fn settings_file(app: &AppHandle) -> Result<PathBuf, String> {
if let Ok(dir) = app.path().executable_dir() {
if dir.is_dir() {
return Ok(dir.join("settings.json"));
}
}
let dir = app.path().app_config_dir().map_err(|e| e.to_string())?;
Ok(dir.join("settings.json"))
}
#[tauri::command]
fn get_settings(app: AppHandle) -> Result<Settings, String> {
let f = settings_file(&app)?;
if !f.exists() {
return Ok(Settings::default());
}
let s = fs::read_to_string(&f).map_err(|e| e.to_string())?;
Ok(serde_json::from_str::<Settings>(&s).unwrap_or_default())
}
#[tauri::command]
fn save_settings(app: AppHandle, settings: Settings) -> Result<(), String> {
let s = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
fs::write(settings_file(&app)?, s).map_err(|e| e.to_string())
}
#[tauri::command]
fn settings_path(app: AppHandle) -> Result<String, String> {
settings_file(&app).map(|p| p.to_string_lossy().to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
create_entry,
rename_entry,
delete_entry,
read_file,
save_file,
reveal,
normalize_path,
explorer_path,
get_state,
save_state,
get_settings,
save_settings,
settings_path,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[cfg(test)]
mod tests {
use super::*;
fn tmpdir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("ff-test-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn validate_names() {
assert!(validate_name("").is_err());
assert!(validate_name(" ").is_err());
assert!(validate_name("a/b").is_err());
assert!(validate_name("a:b").is_err());
assert!(validate_name("a<b").is_err());
assert!(validate_name("a|b").is_err());
assert!(validate_name("trailing.").is_err());
assert!(validate_name("trailing ").is_err());
assert!(validate_name("CON").is_err());
assert!(validate_name("com3.txt").is_err());
assert!(validate_name("lpt1.old").is_err());
assert!(validate_name("laptop1.files").is_ok());
assert!(validate_name("notes [draft] 「x」 (y)").is_ok());
assert!(validate_name("中文 名字 🎉 v2").is_ok());
}
#[test]
fn url_to_path_handles_percent_and_cjk() {
assert_eq!(
url_to_path("file:///C:/Users/Admin/%E6%B5%8B%E8%AF%95").unwrap(),
"C:\\Users\\Admin\\测试"
);
assert_eq!(url_to_path("file:///I:/").unwrap(), "I:");
assert_eq!(
url_to_path("file:///E:/x/软件/%20a%20").unwrap(),
"E:\\x\\软件\\ a "
);
assert_eq!(url_to_path("file://server/share/小文件").unwrap(), "\\\\server\\share\\小文件");
assert_eq!(url_to_path("https://example.com/x").is_none(), true);
assert_eq!(url_to_path("file:///").is_none(), true);
}
#[test]
fn percent_decode_roundtrip() {
assert_eq!(percent_decode(b"a%20b%2Fc"), b"a b/c");
assert_eq!(percent_decode(b"%E6%B5%8B"), "测".as_bytes());
assert_eq!(percent_decode(b"100%"), b"100%");
assert_eq!(percent_decode(b"ok"), b"ok");
}
#[test]
fn pick_path_matches_report_by_hwnd() {
let report = "3278496|file:///C:/a/%E6%B5%8B%E8%AF%95\n589928|file:///I:/x\n";
assert_eq!(
pick_path_from_report(report, 589928).unwrap(),
"I:\\x"
);
assert_eq!(
pick_path_from_report(report, 3278496).unwrap(),
"C:\\a\\测试"
);
assert_eq!(pick_path_from_report(report, 999), None);
assert_eq!(pick_path_from_report("", 589928), None);
assert_eq!(pick_path_from_report("abc\n", 589928), None);
}
#[test]
fn create_and_rename() {
let dir = tmpdir("cr");
let parent = dir.to_string_lossy().to_string();
let e = create_entry(parent.clone(), "notes.txt".into(), "file".into(), Some("hi".into()))
.unwrap();
assert!(dir.join("notes.txt").is_file());
assert_eq!(fs::read_to_string(&e.path).unwrap(), "hi");
assert!(create_entry(parent.clone(), "notes.txt".into(), "file".into(), None).is_err());
let d = create_entry(parent.clone(), "sub".into(), "folder".into(), None).unwrap();
assert!(dir.join("sub").is_dir());
let r = rename_entry(d.path.clone(), "renamed".into()).unwrap();
assert_eq!(r.name, "renamed");
assert!(dir.join("renamed").is_dir());
assert!(!dir.join("sub").exists());
// case-only rename
rename_entry(r.path.clone(), "RENAMED".into()).unwrap();
assert!(dir.join("RENAMED").is_dir());
// conflict
create_entry(parent.clone(), "other".into(), "folder".into(), None).unwrap();
assert!(rename_entry(r.path, "other".into()).is_err());
// delete
fs::create_dir(dir.join("RENAMED").join("inner")).unwrap();
assert!(delete_entry(dir.join("RENAMED").to_string_lossy().to_string()).is_err());
fs::remove_dir(dir.join("RENAMED").join("inner")).unwrap();
delete_entry(dir.join("RENAMED").to_string_lossy().to_string()).unwrap();
assert!(!dir.join("RENAMED").exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn normalize_and_reveal_errors() {
let dir = tmpdir("norm");
let f = dir.join("x.txt");
fs::write(&f, "").unwrap();
assert_eq!(
normalize_path(f.to_string_lossy().to_string()).unwrap(),
dir.to_string_lossy().to_string()
);
assert_eq!(
normalize_path(format!("\"{}\"", dir.display())).unwrap(),
dir.to_string_lossy().to_string()
);
assert!(normalize_path("Z:\\definitely\\not\\here\\x".into()).is_err());
fs::remove_dir_all(&dir).ok();
}
}