Based on our discussion and your repository's goals, I have designed a comprehensive refactoring plan.
This plan focuses on Compile-Time Modularity. This means using Rust's [features] flags so that if a user (or you) compiles the app without a specific feature, the dependencies are never downloaded, and the code is never compiled into the binary.
Here is the strategy document.
The goal is to transform Ferrite from a monolithic editor into a "Core + Plugins" architecture (where plugins are compile-time features).
- Core: Filesystem I/O, Basic Text Buffer, Window Management, Theme System.
- Modules: Markdown Preview, JSON Tree, Syntax Highlighting, Git Integration.
The Golden Rule: If a feature flag is disabled, the application treats those specific files as plain text. The binary size shrinks, and dependencies (like syntect or serde) are dropped.
We need to define the boundaries in your manifest file. This separates the dependencies.
Current (Conceptual):
[dependencies]
eframe = "..."
serde_json = "..."
comrak = "..."
syntect = "..."Refactored Cargo.toml:
[package]
name = "ferrite"
# ...
[features]
default = ["syntax_highlighting", "markdown", "json", "languages"]
# Core Features
syntax_highlighting = ["dep:syntect"]
# Language/Format Features
markdown = ["dep:comrak"]
json = ["dep:serde_json"]
yaml = ["dep:serde_yaml"]
# "languages" aggregates all format support
languages = ["markdown", "json", "yaml"]
[dependencies]
# Core dependencies (Always required)
eframe = "0.29"
anyhow = "1.0"
# Optional Dependencies (Gated)
serde_json = { version = "1.0", optional = true }
comrak = { version = "0.18", optional = true }
syntect = { version = "5.0", optional = true }
serde_yaml = { version = "0.9", optional = true }Move code away from the root src/ into a features folder. This keeps the main.rs clean and makes it easy to delete/disable modules.
src/
├── main.rs
├── app.rs <-- Main loop, generic UI
├── editor.rs <-- The Plain Text Editor (The Core)
└── features/
├── mod.rs <-- The "Switchboard"
├── markdown.rs <-- #[cfg(feature = "markdown")]
├── json.rs <-- #[cfg(feature = "json")]
└── syntax.rs <-- #[cfg(feature = "syntax_highlighting")]
Instead of having string_content, json_tree, and markdown_preview living side-by-side in your struct, we make them mutually exclusive using an Enum.
src/app.rs
use crate::editor::TextEditor;
// This enum holds the specific state for the current tab
pub enum DocumentView {
// Plain text is the fallback and always exists
Plain(TextEditor),
// These variants only exist if the feature is enabled
#[cfg(feature = "markdown")]
Markdown(crate::features::markdown::MarkdownViewer),
#[cfg(feature = "json")]
Json(crate::features::json::JsonEditor),
}
pub struct OpenedFile {
pub path: std::path::PathBuf,
pub view: DocumentView, // The modular part
pub is_dirty: bool,
}We need a central function that decides how to open a file. This is where the logic "If JSON is disabled, open as Text" lives.
src/features/mod.rs
use std::path::Path;
use crate::app::DocumentView;
use crate::editor::TextEditor;
pub fn determine_view_type(path: &Path, content: &str) -> DocumentView {
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
// 1. Try JSON
#[cfg(feature = "json")]
if extension == "json" {
// Attempt to parse. If valid, return Json view.
// If invalid, fall through to Plain text.
if let Ok(json_state) = crate::features::json::JsonEditor::new(content) {
return DocumentView::Json(json_state);
}
}
// 2. Try Markdown
#[cfg(feature = "markdown")]
if extension == "md" {
return DocumentView::Markdown(
crate::features::markdown::MarkdownViewer::new(content)
);
}
// 3. Fallback to Plain Text (Always compiles)
DocumentView::Plain(TextEditor::new(content))
}In your main update function, you simply match on the enum.
src/app.rs
impl eframe::App for FerriteApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// ... window setup ...
match &mut self.active_document.view {
DocumentView::Plain(editor) => {
editor.ui(ui);
}
#[cfg(feature = "markdown")]
DocumentView::Markdown(md_view) => {
md_view.ui(ui);
}
#[cfg(feature = "json")]
DocumentView::Json(json_view) => {
json_view.ui(ui);
}
}
}
}You mentioned you sync the views constantly.
Since we are using an Enum (DocumentView), you usually can't be in Plain mode and Json mode at the exact same time in the same variable.
Strategy:
- Shared Buffer: The
DocumentViewvariants should probably own the specialized state (like the Tree expansion state), but they should perhaps share or clone the raw text. - Toggle Mode: Add a button in the UI to switch views.
- Action: User clicks "View Source" while in JSON mode.
- Code: Serialize the JSON tree back to string, drop the
DocumentView::Jsonvariant, and create aDocumentView::Plainvariant with that string.
- Backup: Create a
refactor-modularitybranch. - Manifest: Edit
Cargo.tomlto add the features and set dependencies tooptional = true. - Breakage: The code will immediately break because usages of
serde_jsonorcomrakare now undefined if the feature isn't explicitly enabled. - Isolation: Move the JSON logic to
src/features/json.rs. Wrap the whole file (or module) in#![cfg(feature = "json")]. - Integration: Implement the
DocumentViewenum inapp.rs. - Fix UI: Update the main
updateloop to use thematchstatement with#[cfg]guards. - Test:
- Run
cargo run(All features default). - Run
cargo run --no-default-features(Should be a lightweight notepad). - Run
cargo run --no-default-features --features json(Only JSON tools enabled).
- Run
- Git: Add a
gitfeature usinggit2(optional dependency). - Wasm: Since generic dependencies (like
std::fs) don't work in WebAssembly, this modular approach allows you to disable file-system heavy features easily when compiling fortarget_arch = "wasm32".