吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 278|回复: 2
上一主题 下一主题
收起左侧

[其他转载] 速创文档、文件夹的 小工具 (tauri)

[复制链接]
跳转到指定楼层
楼主
pyjiujiu 发表于 2026-8-12 10:43 回帖奖励


Windows 下,快速 创建文件/文件夹 小工具

现状:
要么手动 一个一个(虽然灵活 效率低),要么 python脚本化(效率是高,却无法兼顾自定义,灵活性),
本软件 在两者之间,适合一次性新建很多 文本文档+空文件夹,这样的场景。同时兼顾 键盘连续操作 的工作流。

个人来说,主要是 日常研究大模型,发现一直在频繁 新建和修改文档,遂想研究下 方便的窍门

目前是探索性的 实验性的,没完全想明白 这个项目的最终形态,可能仅适合少数群体(有什么看法意见,欢迎反馈)

---
AI 背景:
- deepseek-flash-0731,利用 opencode agent 模式完成 (不细说了,总之 大模型造这个项目很轻松(特别是前端部分),卡点也有 主要是 windows 的 字符解码问题(powershell5.1),优化性能需要时间(按键延迟))
(下面的 介绍图,由 qwen-image-3.0 绘制,(描述夸大部分,不必在意))
---

软件截图:




---

项目 维护在 github:        github.com/fun-tailor/tool_win_FileForge
打包的 exe(只有 3.2M),github.com/fun-tailor/tool_win_FileForge/releases/download/latest/fileforge_260811.exe

---

源代码
(摘录后端  rust 部分 ‘src-tauri\src\lib.rs’)

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();
    }
}




发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
ocean51 发表于 2026-8-12 11:58
很实用,感谢分享
3#
hainanyu 发表于 2026-8-12 15:19
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-8-12 15:45

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表