kern-code 0.1.0

Kern โ€” a calm, complete desktop code editor (Tauri 2 + React + Monaco)
// src-tauri/src/fs.rs โ€” filesystem command surface (ยง8).
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
use tauri::AppHandle;
use tauri_plugin_dialog::DialogExt;

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenedFile {
    pub path: String,
    pub content: String,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileEntry {
    pub path: String,
    pub name: String,
    pub is_dir: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub children: Option<Vec<FileEntry>>,
}

/// Directories we never recurse into (keeps the tree + go-to-file sane).
const IGNORE_DIRS: &[&str] = &[
    ".git",
    "node_modules",
    "target",
    "dist",
    "build",
    ".next",
    ".turbo",
    ".cache",
    ".svelte-kit",
    "out",
    "coverage",
];

#[tauri::command]
pub fn open_file(path: String) -> Result<OpenedFile, String> {
    let content = fs::read_to_string(&path).map_err(|e| e.to_string())?;
    Ok(OpenedFile { path, content })
}

#[tauri::command]
pub fn save_file(path: String, content: String) -> Result<(), String> {
    fs::write(&path, content).map_err(|e| e.to_string())
}

#[tauri::command]
pub fn create_file(path: String) -> Result<(), String> {
    if let Some(parent) = Path::new(&path).parent() {
        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
    }
    fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(&path)
        .map_err(|e| e.to_string())?;
    Ok(())
}

#[tauri::command]
pub fn create_dir(path: String) -> Result<(), String> {
    fs::create_dir_all(&path).map_err(|e| e.to_string())
}

#[tauri::command]
pub fn delete_path(path: String) -> Result<(), String> {
    let p = Path::new(&path);
    if p.is_dir() {
        fs::remove_dir_all(p).map_err(|e| e.to_string())
    } else {
        fs::remove_file(p).map_err(|e| e.to_string())
    }
}

#[tauri::command]
pub fn rename_path(from: String, to: String) -> Result<(), String> {
    fs::rename(&from, &to).map_err(|e| e.to_string())
}

#[tauri::command]
pub fn list_dir(path: String, depth: u32) -> Result<FileEntry, String> {
    let p = PathBuf::from(&path);
    build_entry(&p, depth).ok_or_else(|| format!("cannot read {path}"))
}

#[tauri::command]
pub async fn pick_folder(app: AppHandle) -> Result<Option<String>, String> {
    let picked = app.dialog().file().blocking_pick_folder();
    Ok(picked
        .and_then(|p| p.into_path().ok())
        .map(|pb| pb.to_string_lossy().to_string()))
}

fn build_entry(path: &Path, depth: u32) -> Option<FileEntry> {
    let name = path
        .file_name()
        .map(|n| n.to_string_lossy().to_string())
        .unwrap_or_else(|| path.to_string_lossy().to_string());
    let is_dir = path.is_dir();

    let children = if is_dir && depth > 0 && !is_ignored(&name) {
        Some(read_children(path, depth - 1))
    } else {
        None
    };

    Some(FileEntry {
        path: path.to_string_lossy().to_string(),
        name,
        is_dir,
        children,
    })
}

fn read_children(dir: &Path, depth: u32) -> Vec<FileEntry> {
    let mut entries: Vec<FileEntry> = match fs::read_dir(dir) {
        Ok(rd) => rd
            .filter_map(|e| e.ok())
            .filter_map(|e| build_entry(&e.path(), depth))
            .collect(),
        Err(_) => Vec::new(),
    };
    // Directories first, then files; each alphabetical, case-insensitive.
    entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
        (true, false) => std::cmp::Ordering::Less,
        (false, true) => std::cmp::Ordering::Greater,
        _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
    });
    entries
}

fn is_ignored(name: &str) -> bool {
    IGNORE_DIRS.contains(&name)
}