Compare commits

..

2 Commits

Author SHA1 Message Date
senstella
2a488ff6a8 fuzzy replace 2025-10-09 01:40:14 +09:00
senstella
61216c1044 sort of a gui 2025-10-09 00:41:58 +09:00
8 changed files with 418 additions and 532 deletions

156
Cargo.lock generated
View File

@@ -377,6 +377,15 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.5.3" version = "0.5.3"
@@ -890,6 +899,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7046468a81e6a002061c01e6a7c83139daf91b11c30e66795b13217c2d885c8b" checksum = "7046468a81e6a002061c01e6a7c83139daf91b11c30e66795b13217c2d885c8b"
[[package]]
name = "deranged"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [
"powerfmt",
]
[[package]] [[package]]
name = "detect-desktop-environment" name = "detect-desktop-environment"
version = "0.2.0" version = "0.2.0"
@@ -1676,6 +1694,7 @@ checksum = "88acfabc84ec077eaf9ede3457ffa3a104626d79022a9bf7f296093b1d60c73f"
dependencies = [ dependencies = [
"iced_core", "iced_core",
"iced_futures", "iced_futures",
"iced_highlighter",
"iced_renderer", "iced_renderer",
"iced_widget", "iced_widget",
"iced_winit", "iced_winit",
@@ -1775,6 +1794,17 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "iced_highlighter"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad88b25a1328cd4bb0b72d8e20f8207c0433649dc788f67e911423b9406f45c"
dependencies = [
"iced_core",
"once_cell",
"syntect",
]
[[package]] [[package]]
name = "iced_renderer" name = "iced_renderer"
version = "0.13.0" version = "0.13.0"
@@ -1844,6 +1874,7 @@ version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81429e1b950b0e4bca65be4c4278fea6678ea782030a411778f26fa9f8983e1d" checksum = "81429e1b950b0e4bca65be4c4278fea6678ea782030a411778f26fa9f8983e1d"
dependencies = [ dependencies = [
"iced_highlighter",
"iced_renderer", "iced_renderer",
"iced_runtime", "iced_runtime",
"num-traits", "num-traits",
@@ -2052,6 +2083,12 @@ dependencies = [
"redox_syscall 0.5.18", "redox_syscall 0.5.18",
] ]
[[package]]
name = "linked-hash-map"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.4.15" version = "0.4.15"
@@ -2333,6 +2370,12 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]] [[package]]
name = "num-format" name = "num-format"
version = "0.4.4" version = "0.4.4"
@@ -2622,6 +2665,28 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "onig"
version = "6.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0"
dependencies = [
"bitflags 2.9.4",
"libc",
"once_cell",
"onig_sys",
]
[[package]]
name = "onig_sys"
version = "69.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc"
dependencies = [
"cc",
"pkg-config",
]
[[package]] [[package]]
name = "orbclient" name = "orbclient"
version = "0.3.48" version = "0.3.48"
@@ -2841,6 +2906,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plist"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [
"base64",
"indexmap",
"quick-xml 0.38.3",
"serde",
"time",
]
[[package]] [[package]]
name = "png" name = "png"
version = "0.17.16" version = "0.17.16"
@@ -2868,6 +2946,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@@ -2916,6 +3000,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quick-xml"
version = "0.38.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.41" version = "1.0.41"
@@ -3668,6 +3761,27 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "syntect"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
dependencies = [
"bincode",
"flate2",
"fnv",
"once_cell",
"onig",
"plist",
"regex-syntax",
"serde",
"serde_derive",
"serde_json",
"thiserror 2.0.17",
"walkdir",
"yaml-rust",
]
[[package]] [[package]]
name = "sys-locale" name = "sys-locale"
version = "0.3.2" version = "0.3.2"
@@ -3748,6 +3862,37 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "time"
version = "0.3.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
[[package]]
name = "time-macros"
version = "0.2.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
dependencies = [
"num-conv",
"time-core",
]
[[package]] [[package]]
name = "tiny-skia" name = "tiny-skia"
version = "0.11.4" version = "0.11.4"
@@ -4307,7 +4452,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quick-xml", "quick-xml 0.37.5",
"quote", "quote",
] ]
@@ -4885,6 +5030,15 @@ version = "0.8.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7" checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7"
[[package]]
name = "yaml-rust"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
dependencies = [
"linked-hash-map",
]
[[package]] [[package]]
name = "yazi" name = "yazi"
version = "0.1.6" version = "0.1.6"

View File

@@ -5,7 +5,7 @@ edition = "2024"
[dependencies] [dependencies]
anyhow = "1.0.100" anyhow = "1.0.100"
iced = { version = "0.13.1", features = ["tokio"] } iced = { version = "0.13.1", features = ["tokio", "highlighter"] }
iced_aw = { version = "0.11.0", features = ["tab_bar"] } iced_aw = { version = "0.11.0", features = ["tab_bar"] }
imara-diff = "0.2.0" imara-diff = "0.2.0"
nucleo-matcher = "0.3.1" nucleo-matcher = "0.3.1"

View File

@@ -1,4 +1,4 @@
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use imara_diff::{Algorithm, BasicLineDiffPrinter, Diff, InternedInput, UnifiedDiffConfig}; use imara_diff::{Algorithm, BasicLineDiffPrinter, Diff, InternedInput, UnifiedDiffConfig};
@@ -65,7 +65,7 @@ impl Fossil {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct FossilManager { pub struct FossilManager {
pub fossils: Arc<Mutex<HashMap<String, Fossil>>>, pub fossils: Arc<Mutex<BTreeMap<String, Fossil>>>,
} }
impl FossilManager { impl FossilManager {

View File

@@ -1,115 +1,86 @@
use iced::highlighter;
use std::{collections::HashMap, path::Path, time::Duration};
use crate::fossil::FossilManager; use crate::fossil::FossilManager;
use iced::{ use iced::{
Element, Element, Subscription, time,
widget::{button, column, row, text, text_editor}, widget::{button, column, row, text, text_editor},
}; };
use iced_aw::{TabLabel, Tabs}; use iced_aw::{TabLabel, Tabs};
use std::collections::HashMap;
#[derive(Default)]
pub struct State { pub struct State {
fossil_manager: FossilManager, fossil_manager: FossilManager,
tab_editors: HashMap<String, text_editor::Content>,
active_tab: Option<String>, active_tab: Option<String>,
tab_order: Vec<String>, theme: highlighter::Theme,
editor_contents: HashMap<String, text_editor::Content>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Message { pub enum Message {
Edit(String, text_editor::Action),
TabSelected(String), TabSelected(String),
CreateFossil, CheckForChanges,
SyncFossil(String),
DeleteFossil(String),
RefreshFromManager,
} }
impl State { impl State {
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self {
fossil_manager: FossilManager::default(),
active_tab: None,
editor_contents: HashMap::new(),
theme: highlighter::Theme::SolarizedDark,
}
} }
pub fn with_shared_manager(fossil_manager: FossilManager) -> Self { pub fn with_shared_manager(fossil_manager: FossilManager) -> Self {
Self { let mut state = Self {
fossil_manager, fossil_manager,
tab_editors: HashMap::new(),
active_tab: None, active_tab: None,
tab_order: Vec::new(), editor_contents: HashMap::new(),
} theme: highlighter::Theme::SolarizedDark,
};
state.sync_editor_contents();
state
} }
pub fn add_fossil(&mut self, id: String, content: String) { fn sync_editor_contents(&mut self) {
// Add to fossil manager
{
let mut fossils = self.fossil_manager.fossils.lock().unwrap();
fossils.insert(id.clone(), crate::fossil::Fossil::new(content.clone()));
}
// Add editor tab
self.tab_editors
.insert(id.clone(), text_editor::Content::with_text(&content));
self.tab_order.push(id.clone());
self.active_tab = Some(id);
}
pub fn sync_fossil(&mut self, id: &str) {
if let Some(editor_content) = self.tab_editors.get(id) {
let content = editor_content.text();
let mut fossils = self.fossil_manager.fossils.lock().unwrap();
if let Some(fossil) = fossils.get_mut(id) {
fossil.commit(content);
}
}
}
pub fn load_fossil_to_editor(&mut self, id: &str) {
let fossils = self.fossil_manager.fossils.lock().unwrap(); let fossils = self.fossil_manager.fossils.lock().unwrap();
if let Some(fossil) = fossils.get(id) { self.editor_contents.clear();
if let Some(latest_content) = fossil.latest() {
self.tab_editors.insert( for (id, fossil) in &*fossils {
id.to_string(), self.editor_contents.insert(
text_editor::Content::with_text(latest_content), id.clone(),
text_editor::Content::with_text(&fossil.latest().unwrap()),
); );
} }
} }
}
pub fn refresh_from_manager(&mut self) { pub fn subscription(&self) -> Subscription<Message> {
let fossils = self.fossil_manager.fossils.lock().unwrap(); time::every(Duration::from_millis(100)).map(|_| Message::CheckForChanges)
let fossil_ids: Vec<String> = fossils.keys().cloned().collect(); }
drop(fossils); }
// Add new fossils that don't exist in GUI fn tab_content<'a>(state: &'a State, id: &str) -> Element<'a, Message> {
for id in &fossil_ids { state
if !self.tab_editors.contains_key(id) { .editor_contents
self.load_fossil_to_editor(id); .get(id)
self.tab_order.push(id.clone()); .map(|content| {
} text_editor(content)
} .highlight(
Path::new(id)
// Remove fossils that no longer exist in manager .extension()
let gui_fossil_ids: Vec<String> = self.tab_editors.keys().cloned().collect(); .and_then(|ext| ext.to_str())
for id in gui_fossil_ids { .unwrap_or("py"),
if !fossil_ids.contains(&id) { state.theme,
self.tab_editors.remove(&id); )
self.tab_order.retain(|x| x != &id); .into()
if self.active_tab.as_ref() == Some(&id) { })
self.active_tab = self.tab_order.first().cloned(); .unwrap_or_else(|| text("No content").into())
}
}
}
// Update active tab if none is selected but tabs exist
if self.active_tab.is_none() && !self.tab_order.is_empty() {
self.active_tab = Some(self.tab_order[0].clone());
}
}
} }
pub fn view(state: &State) -> Element<'_, Message> { pub fn view(state: &State) -> Element<'_, Message> {
let mut tabs = Tabs::new(Message::TabSelected); let mut tabs = Tabs::new(Message::TabSelected);
let fossils = state.fossil_manager.fossils.lock().unwrap();
for id in &state.tab_order { for (id, _) in &*fossils {
tabs = tabs.push( tabs = tabs.push(
id.clone(), id.clone(),
TabLabel::Text(id.clone()), TabLabel::Text(id.clone()),
@@ -118,82 +89,24 @@ pub fn view(state: &State) -> Element<'_, Message> {
} }
let tabs = if let Some(active_id) = &state.active_tab { let tabs = if let Some(active_id) = &state.active_tab {
if fossils.contains_key(active_id) {
tabs.set_active_tab(active_id) tabs.set_active_tab(active_id)
} else { } else {
tabs tabs
};
let controls = row![
button("New Fossil").on_press(Message::CreateFossil),
button("Sync Active").on_press_maybe(
state
.active_tab
.as_ref()
.map(|id| Message::SyncFossil(id.clone()))
),
button("Delete Active").on_press_maybe(
state
.active_tab
.as_ref()
.map(|id| Message::DeleteFossil(id.clone()))
),
button("Refresh").on_press(Message::RefreshFromManager),
]
.spacing(10)
.padding(10);
column![controls, tabs].spacing(10).padding(10).into()
}
fn tab_content<'a>(state: &'a State, fossil_id: &str) -> Element<'a, Message> {
if let Some(content) = state.tab_editors.get(fossil_id) {
text_editor(content)
.placeholder("Edit your fossil content here...")
.on_action({
let fossil_id = fossil_id.to_string();
move |action| Message::Edit(fossil_id.clone(), action)
})
.into()
} else {
text("No content available").into()
} }
} else {
tabs
};
column![tabs].spacing(10).padding(10).into()
} }
pub fn update(state: &mut State, message: Message) -> iced::Task<Message> { pub fn update(state: &mut State, message: Message) -> iced::Task<Message> {
match message { match message {
Message::Edit(fossil_id, action) => {
if let Some(content) = state.tab_editors.get_mut(&fossil_id) {
content.perform(action);
}
}
Message::TabSelected(tab_id) => { Message::TabSelected(tab_id) => {
state.active_tab = Some(tab_id); state.active_tab = Some(tab_id);
} }
Message::CreateFossil => { Message::CheckForChanges => {
let new_id = format!("fossil_{}", state.tab_order.len() + 1); state.sync_editor_contents();
state.add_fossil(new_id, String::new());
}
Message::SyncFossil(fossil_id) => {
state.sync_fossil(&fossil_id);
}
Message::DeleteFossil(fossil_id) => {
// Remove from fossil manager
{
let mut fossils = state.fossil_manager.fossils.lock().unwrap();
fossils.remove(&fossil_id);
}
// Remove from GUI state
state.tab_editors.remove(&fossil_id);
state.tab_order.retain(|id| id != &fossil_id);
// Update active tab
if state.active_tab.as_ref() == Some(&fossil_id) {
state.active_tab = state.tab_order.first().cloned();
}
}
Message::RefreshFromManager => {
state.refresh_from_manager();
} }
} }
iced::Task::none() iced::Task::none()

View File

@@ -1,7 +1,7 @@
use crate::{ use crate::{
gui::{update, view, State},
mcp::FossilEditor,
fossil::FossilManager, fossil::FossilManager,
gui::{State, update, view},
mcp::FossilMCP,
}; };
use iced::Task; use iced::Task;
use rmcp::{ServiceExt, transport::stdio}; use rmcp::{ServiceExt, transport::stdio};
@@ -15,7 +15,7 @@ mod mcp;
fn main() -> iced::Result { fn main() -> iced::Result {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::DEBUG.into())) .with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::WARN.into()))
.with_writer(std::io::stderr) .with_writer(std::io::stderr)
.with_ansi(false) .with_ansi(false)
.init(); .init();
@@ -23,11 +23,11 @@ fn main() -> iced::Result {
let rt = Runtime::new().unwrap(); let rt = Runtime::new().unwrap();
// Create shared fossil manager // Create shared fossil manager
let shared_fossil_manager = FossilManager::new(); let fossil_manager = FossilManager::new();
let editor = FossilEditor::with_shared_state(shared_fossil_manager.clone()); let mcp = FossilMCP::with_shared_state(fossil_manager.clone());
rt.spawn(async { rt.spawn(async {
let service = editor let service = mcp
.serve(stdio()) .serve(stdio())
.await .await
.inspect_err(|e| { .inspect_err(|e| {
@@ -39,11 +39,7 @@ fn main() -> iced::Result {
}); });
iced::application("Fossil Editor", update, view) iced::application("Fossil Editor", update, view)
.subscription(State::subscription)
.default_font(iced::Font::MONOSPACE) .default_font(iced::Font::MONOSPACE)
.run_with(move || { .run_with(move || (State::with_shared_manager(fossil_manager), Task::none()))
let mut state = State::with_shared_manager(shared_fossil_manager);
// Add a sample fossil to start with
state.add_fossil("sample.py".to_string(), "def hello():\n print('Hello, World!')".to_string());
(state, Task::none())
})
} }

View File

@@ -1,5 +1,6 @@
use seal::pair::{AlignmentSet, InMemoryAlignmentMatrix, SmithWaterman, Step}; use seal::pair::{AlignmentSet, MemoryMappedAlignmentMatrix, SmithWaterman, Step};
// (line pos, line pos)
pub fn match_lines(code: &str, start: &str, end: &str) -> Option<(usize, usize)> { pub fn match_lines(code: &str, start: &str, end: &str) -> Option<(usize, usize)> {
let code_chars: Vec<char> = code.chars().collect(); let code_chars: Vec<char> = code.chars().collect();
@@ -11,7 +12,7 @@ pub fn match_lines(code: &str, start: &str, end: &str) -> Option<(usize, usize)>
if start.ends_with(end) { if start.ends_with(end) {
let pattern_chars: Vec<char> = start.chars().collect(); let pattern_chars: Vec<char> = start.chars().collect();
let set: AlignmentSet<InMemoryAlignmentMatrix> = let set: AlignmentSet<MemoryMappedAlignmentMatrix> =
AlignmentSet::new(code_chars.len(), pattern_chars.len(), strategy, |x, y| { AlignmentSet::new(code_chars.len(), pattern_chars.len(), strategy, |x, y| {
code_chars[x] == pattern_chars[y] code_chars[x] == pattern_chars[y]
}) })
@@ -39,7 +40,7 @@ pub fn match_lines(code: &str, start: &str, end: &str) -> Option<(usize, usize)>
Some((start_line, end_line)) Some((start_line, end_line))
} else { } else {
let start_chars: Vec<char> = start.chars().collect(); let start_chars: Vec<char> = start.chars().collect();
let start_set: AlignmentSet<InMemoryAlignmentMatrix> = AlignmentSet::new( let start_set: AlignmentSet<MemoryMappedAlignmentMatrix> = AlignmentSet::new(
code_chars.len(), code_chars.len(),
start_chars.len(), start_chars.len(),
strategy.clone(), strategy.clone(),
@@ -61,14 +62,16 @@ pub fn match_lines(code: &str, start: &str, end: &str) -> Option<(usize, usize)>
let end_chars: Vec<char> = end.chars().collect(); let end_chars: Vec<char> = end.chars().collect();
let remaining_code: Vec<char> = code_chars[start_char_pos..].to_vec(); let remaining_code: Vec<char> = code_chars[start_char_pos..].to_vec();
let end_set: AlignmentSet<InMemoryAlignmentMatrix> = let end_set: AlignmentSet<MemoryMappedAlignmentMatrix> =
AlignmentSet::new(remaining_code.len(), end_chars.len(), strategy, |x, y| { AlignmentSet::new(remaining_code.len(), end_chars.len(), strategy, |x, y| {
remaining_code[x] == end_chars[y] remaining_code[x] == end_chars[y]
}) })
.ok()?; .ok()?;
let end_alignment = end_set.local_alignments().next().unwrap(); let end_char_pos_relative = end_set
let end_char_pos_relative = end_alignment .local_alignments()
.filter_map(|end_alignment| {
end_alignment
.steps() .steps()
.filter_map(|step| { .filter_map(|step| {
if let Step::Align { x, .. } = step { if let Step::Align { x, .. } = step {
@@ -77,13 +80,56 @@ pub fn match_lines(code: &str, start: &str, end: &str) -> Option<(usize, usize)>
None None
} }
}) })
.last()?; .last()
})
.min()?;
let end_char_pos = start_char_pos + end_char_pos_relative; let end_char_pos = start_char_pos + end_char_pos_relative;
let start_line = code[..start_char_pos].lines().count().saturating_sub(1); let start_line = code[..=start_char_pos].lines().count().saturating_sub(1);
let end_line = code[..=end_char_pos].lines().count().saturating_sub(1); let end_line = code[..=end_char_pos].lines().count().saturating_sub(1);
Some((start_line, end_line)) Some((start_line, end_line))
} }
} }
// (char pos, char pos)
pub fn match_content(code: &str, pattern: &str) -> Vec<(usize, usize)> {
let code_chars: Vec<char> = code.chars().collect();
if code_chars.is_empty() || pattern.is_empty() {
return vec![];
}
let strategy = SmithWaterman::new(3, -1, -1, -1);
let pattern_chars: Vec<char> = pattern.chars().collect();
let set: AlignmentSet<MemoryMappedAlignmentMatrix> =
match AlignmentSet::new(code_chars.len(), pattern_chars.len(), strategy, |x, y| {
code_chars[x] == pattern_chars[y]
}) {
Ok(set) => set,
Err(_) => return vec![],
};
set.local_alignments()
.filter_map(|alignment| {
let mut start_char_pos = None;
let mut end_char_pos = None;
for step in alignment.steps() {
if let Step::Align { x, .. } = step {
if start_char_pos.is_none() {
start_char_pos = Some(x);
}
end_char_pos = Some(x);
}
}
match (start_char_pos, end_char_pos) {
(Some(start), Some(end)) => Some((start, end)),
_ => None,
}
})
.collect()
}

View File

@@ -1,5 +1,5 @@
use crate::fossil::{Fossil, FossilManager}; use crate::fossil::{Fossil, FossilManager};
use crate::matcher::match_lines; use crate::matcher::{match_content, match_lines};
use rmcp::model::{ use rmcp::model::{
AnnotateAble, Implementation, ListResourceTemplatesResult, ListResourcesResult, AnnotateAble, Implementation, ListResourceTemplatesResult, ListResourcesResult,
PaginatedRequestParam, RawResource, ReadResourceRequestParam, ReadResourceResult, PaginatedRequestParam, RawResource, ReadResourceRequestParam, ReadResourceResult,
@@ -34,11 +34,11 @@ pub struct FuzzyEditRequest {
#[schemars(description = "The unique identifier for the fossil to edit.")] #[schemars(description = "The unique identifier for the fossil to edit.")]
pub id: String, pub id: String,
#[schemars( #[schemars(
description = "The start of the text to replace. Please try your best to provide the whole line. Do not attempt to mess with it since it will make it worse." description = "The start of the text to replace. Please try your best to provide the whole line."
)] )]
pub start_pattern: String, pub start_pattern: String,
#[schemars( #[schemars(
description = "The end of the text to replace. Please try your best to provide the whole line. Do not attempt to mess with it since it will make it worse." description = "The end of the text to replace. Please try your best to provide the whole line."
)] )]
pub end_pattern: String, pub end_pattern: String,
#[schemars( #[schemars(
@@ -47,6 +47,24 @@ pub struct FuzzyEditRequest {
pub replacement: String, pub replacement: String,
} }
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct FuzzyReplaceRequest {
#[schemars(description = "The unique identifier for the fossil to edit.")]
pub id: String,
#[schemars(
description = "The text to find and replace. Will use fuzzy matching to be forgiving of minor whitespace/formatting differences."
)]
pub old_str: String,
#[schemars(
description = "The new text that will replace the matched section. Empty string deletes the match."
)]
pub new_str: String,
#[schemars(
description = "Which occurrence to replace if multiple matches found. 0 = first, 1 = second, etc. Defaults to 0. Use -1 for last occurrence."
)]
pub occurrence: Option<i32>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct RevertRequest { pub struct RevertRequest {
#[schemars(description = "The unique identifier for the fossil to revert.")] #[schemars(description = "The unique identifier for the fossil to revert.")]
@@ -66,20 +84,13 @@ pub struct GetVersionRequest {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct FossilEditor { pub struct FossilMCP {
tool_router: ToolRouter<Self>, tool_router: ToolRouter<Self>,
state: FossilManager, state: FossilManager,
} }
#[tool_router] #[tool_router]
impl FossilEditor { impl FossilMCP {
pub fn new() -> Self {
Self {
tool_router: Self::tool_router(),
state: FossilManager::new(),
}
}
pub fn with_shared_state(state: FossilManager) -> Self { pub fn with_shared_state(state: FossilManager) -> Self {
Self { Self {
tool_router: Self::tool_router(), tool_router: Self::tool_router(),
@@ -163,6 +174,96 @@ impl FossilEditor {
Ok(history[idx as usize].clone()) Ok(history[idx as usize].clone())
} }
#[tool(
description = "Replaces a specific string in a fossil using fuzzy matching. Unlike fuzzy_edit which requires start and end patterns, this finds and replaces a single text section similar to old_str/new_str replacement but with tolerance for whitespace and formatting differences. If the pattern appears multiple times, use the occurrence parameter to specify which match to replace (0 for first, 1 for second, -1 for last). Empty new_str deletes the matched text. This is ideal for targeted edits like renaming variables, updating values, or fixing specific lines without needing to identify surrounding context."
)]
fn fuzzy_replace(
&self,
Parameters(req): Parameters<FuzzyReplaceRequest>,
) -> Result<String, ErrorData> {
let mut fossils = self.state.fossils.lock().unwrap();
let fossil = fossils.get_mut(&req.id).ok_or_else(|| {
ErrorData::resource_not_found(
"fossil_not_found",
Some(json!({
"id": req.id,
"message": format!("Fossil '{}' not found.", req.id)
})),
)
})?;
let current_code = fossil
.latest()
.ok_or_else(|| {
ErrorData::invalid_params(
"fossil_empty",
Some(json!({
"id": req.id,
"message": "Fossil is empty."
})),
)
})?
.clone();
let matches = match_content(&current_code, &req.old_str);
if matches.is_empty() {
return Err(ErrorData::invalid_params(
"no_matches",
Some(json!({
"id": req.id,
"message": "Pattern not found in fossil."
})),
));
}
let occurrence = req.occurrence.unwrap_or(0);
let idx = if occurrence < 0 {
let len = matches.len() as i32;
let adjusted = len + occurrence;
if adjusted < 0 {
return Err(ErrorData::invalid_params(
"invalid_occurrence",
Some(json!({
"id": req.id,
"matches": matches.len(),
"message": format!("Invalid occurrence {}. Only {} matches found.", occurrence, matches.len())
})),
));
}
adjusted as usize
} else {
occurrence as usize
};
if idx >= matches.len() {
return Err(ErrorData::invalid_params(
"invalid_occurrence",
Some(json!({
"id": req.id,
"matches": matches.len(),
"message": format!("Invalid occurrence {}. Only {} matches found.", occurrence, matches.len())
})),
));
}
let (start_char, end_char) = matches[idx];
let code_chars: Vec<char> = current_code.chars().collect();
let mut new_chars = Vec::new();
new_chars.extend_from_slice(&code_chars[..start_char]);
new_chars.extend(req.new_str.chars());
new_chars.extend_from_slice(&code_chars[end_char + 1..]);
let new_code: String = new_chars.into_iter().collect();
let diff = fossil.commit(new_code);
Ok(format!(
"Successfully replaced pattern in fossil '{}'\n```diff\n{}\n```",
req.id, diff
))
}
#[tool( #[tool(
description = "Replaces a section of code in an fossil using fuzzy matching. Providing an empty string for `replacement` deletes the section." description = "Replaces a section of code in an fossil using fuzzy matching. Providing an empty string for `replacement` deletes the section."
)] )]
@@ -207,7 +308,7 @@ impl FossilEditor {
let lines: Vec<&str> = current_code.lines().collect(); let lines: Vec<&str> = current_code.lines().collect();
let mut new_code_lines = Vec::new(); let mut new_code_lines = Vec::new();
new_code_lines.extend_from_slice(&lines[..start_line + 1]); new_code_lines.extend_from_slice(&lines[..start_line]);
new_code_lines.push(&req.replacement); new_code_lines.push(&req.replacement);
new_code_lines.extend_from_slice(&lines[end_line + 1..]); new_code_lines.extend_from_slice(&lines[end_line + 1..]);
@@ -273,7 +374,7 @@ impl FossilEditor {
} }
#[tool_handler] #[tool_handler]
impl ServerHandler for FossilEditor { impl ServerHandler for FossilMCP {
async fn list_resources( async fn list_resources(
&self, &self,
_request: Option<PaginatedRequestParam>, _request: Option<PaginatedRequestParam>,
@@ -349,349 +450,12 @@ impl ServerHandler for FossilEditor {
fn get_info(&self) -> ServerInfo { fn get_info(&self) -> ServerInfo {
ServerInfo { ServerInfo {
server_info: Implementation::from_build_env(), server_info: Implementation::from_build_env(),
instructions: Some( instructions: Some(include_str!("prompt.txt").into()),
r#"# Fossil MCP Server - Complete User Guide capabilities: ServerCapabilities::builder()
.enable_tools()
## Overview .enable_resources()
Fossil is an MCP server that provides git-like version control for code artifacts with fuzzy pattern matching. It enables precise, incremental code editing without rewriting entire files. .enable_tool_list_changed()
.build(),
## Core Concepts
### What is a Fossil?
A \"fossil\" is a versioned code artifact stored in memory. Each fossil has:
- A unique ID (e.g., `\"main.py\"`, `\"config-v2.json\"`)
- Full version history (like git commits)
- Support for fuzzy pattern matching during edits
### Why Fuzzy Matching?
Traditional string replacement requires exact character-by-character matches. Fuzzy matching allows you to:
- Ignore whitespace differences (spaces, tabs, blank lines)
- Match patterns even if formatting changed slightly
- Target code sections without knowing exact formatting
## Available Functions
### 1. `create_fossil`
Creates a new fossil with initial content.
**Parameters:**
- `id` (string, required): Unique identifier for the fossil
- Use descriptive names like `\"api-handler.py\"` or `\"utils-v1.js\"`
- This ID is used to reference the fossil in all other operations
- `content` (string, required): Initial code/text content
- Can be any text content (code, JSON, markdown, etc.)
**Example:**
```
create_fossil(
id: \"calculator.py\",
content: \"def add(a, b):\
return a + b\"
)
```
**Returns:** Success message with the fossil ID
---
### 2. `list_fossils`
Lists all currently managed fossils.
**Parameters:** None
**Returns:** Array of fossil IDs currently in memory
**Example output:**
```
Current fossils: main.py, config.json, utils.js
```
---
### 3. `fuzzy_edit`
Replaces a section of code using fuzzy pattern matching. This is the primary editing function.
**Parameters:**
- `id` (string, required): The fossil ID to edit
- `start_pattern` (string, required): Pattern marking the START of the section to replace
- Should be distinctive enough to find the right location
- Doesn't need exact whitespace - fuzzy matching handles variations
- **Best practice:** Use complete lines when possible
- `end_pattern` (string, required): Pattern marking the END of the section to replace
- The matched section includes both start and end patterns
- **Best practice:** Use complete lines when possible
- `replacement` (string, required): New code that replaces the matched section
- Empty string `\"\"` performs deletion (equivalent to `delete_fossil` functionality)
**How it works:**
1. Finds the first occurrence of `start_pattern` in the file
2. Finds the first occurrence of `end_pattern` after the start
3. Replaces everything from start to end (inclusive) with `replacement`
**Important Notes:**
- Patterns are fuzzy matched - whitespace differences are tolerated
- Both start and end patterns are INCLUDED in the replacement
- If you want to keep the start/end patterns, include them in the replacement
- Empty replacement = deletion
**Example 1 - Simple replacement:**
```
# Original code:
def greet(name):
return \"Hello\"
# Edit:
fuzzy_edit(
id: \"main.py\",
start_pattern: \"def greet(name):\",
end_pattern: \"return \\\"Hello\\\"\",
replacement: \"def greet(name):\
return f\\\"Hello, {name}!\\\"\"
)
# Result:
def greet(name):
return f\"Hello, {name}!\"
```
**Example 2 - Adding a parameter:**
```
# Original:
def calculate(x, y):
# Edit:
fuzzy_edit(
id: \"calc.py\",
start_pattern: \"def calculate(x, y):\",
end_pattern: \"def calculate(x, y):\",
replacement: \"def calculate(x, y, operation='add'):\"
)
# Result:
def calculate(x, y, operation='add'):
```
**Example 3 - Deletion (empty replacement):**
```
fuzzy_edit(
id: \"main.py\",
start_pattern: \"\# TODO: remove this\",
end_pattern: \"\# End of temporary code\",
replacement: \"\"
)
```
**Example 4 - Append to end of file:**
```
# Target the last line and rewrite from there
fuzzy_edit(
id: \"main.py\",
start_pattern: \"if __name__ == '__main__':\",
end_pattern: \" main()\",
replacement: \"if __name__ == '__main__':\
main()\
\
def new_function():\
pass\"
)
```
**Returns:**
- Success message with a clean diff showing what changed
- The diff uses `@@ line numbers @@` format
- `+` indicates added lines, `-` indicates removed lines
---
### 4. `get_version`
Retrieves a specific version of a fossil without modifying it. Use this to preview history.
**Parameters:**
- `id` (string, required): The fossil ID
- `version` (integer, optional): Version index to retrieve
- `0` = initial version (first creation)
- `1, 2, 3...` = subsequent versions (counting from start)
- `-1` = latest version (default)
- `-2` = previous version
- `-3` = two versions ago, etc.
**Example:**
```
get_version(id: \"main.py\", version: 0) # See original
get_version(id: \"main.py\", version: -1) # See latest
get_version(id: \"main.py\", version: -2) # See previous
```
**Returns:** The complete content of the fossil at that version
---
### 5. `revert`
Undoes recent edits by rolling back to a previous version. This actually modifies the fossil.
**Parameters:**
- `id` (string, required): The fossil ID to revert
- `steps` (integer, optional): Number of edits to undo (default: 1)
- `steps: 1` = undo last edit
- `steps: 2` = undo last 2 edits
- `steps: 5` = undo last 5 edits
**Example:**
```
revert(id: \"main.py\", steps: 1) # Undo last edit
revert(id: \"main.py\", steps: 3) # Undo last 3 edits
```
**Returns:**
- Success message with diff showing what was reverted
- The fossil is now at the earlier version
**Use cases:**
- \"Oops, that edit broke something\" → `revert(steps: 1)`
- \"Let's try a different approach\" → `revert()` then make new edit
- \"Go back to working version\" → `revert(steps: 5)`
---
### 6. `delete_fossil`
Permanently deletes a fossil and all its history.
**Parameters:**
- `id` (string, required): The fossil ID to delete
**Returns:** Success confirmation
**Warning:** This cannot be undone! The entire fossil and its version history are removed from memory.
---
## Workflow Patterns
### Pattern 1: Create and Iterate
```
1. create_fossil(id: \"app.py\", content: \"initial code\")
2. fuzzy_edit(id: \"app.py\", ...) # Add feature
3. fuzzy_edit(id: \"app.py\", ...) # Fix bug
4. fuzzy_edit(id: \"app.py\", ...) # Refactor
5. If something breaks: revert(id: \"app.py\", steps: 1)
```
### Pattern 2: Surgical Edits
```
# Only change what needs changing
fuzzy_edit(
start_pattern: \"def problematic_function\",
end_pattern: \"return result\",
replacement: \"def fixed_function...\
return new_result\"
)
```
### Pattern 3: Exploration with Safety Net
```
1. get_version(id: \"main.py\", version: -1) # Check current state
2. fuzzy_edit(...) # Try experimental change
3. Test it mentally/logically
4. If it works: continue
5. If it breaks: revert(steps: 1)
```
### Pattern 4: Append Pattern
```
# To add to end of file, target the last line(s)
fuzzy_edit(
start_pattern: \"last_line_or_function\",
end_pattern: \"last_line_or_function\",
replacement: \"last_line_or_function\
\
new_content_here\"
)
```
## Best Practices
### ✅ DO:
- Use distinctive patterns that uniquely identify the location
- Include complete lines in patterns when possible
- Use `get_version` to check current state before editing
- Use `revert` freely - it's there to help you experiment
- Keep fossil IDs descriptive and clear
- Remember that both start and end patterns are INCLUDED in the match
### ❌ DON'T:
- Use overly generic patterns that might match multiple locations
- Worry about exact whitespace in patterns - fuzzy matching handles it
- Rewrite entire files - use targeted edits instead
- Forget that the start/end patterns get replaced too
- Use patterns that are too short (might match wrong location)
## Advanced Tips
### Tip 1: Multi-line Patterns
Patterns can span multiple lines. Use `\
` for newlines:
```
start_pattern: \"class MyClass:\
def __init__\"
```
### Tip 2: Partial Line Matching
You don't need the entire line, just enough to be distinctive:
```
start_pattern: \"def calculate(\" # Matches any line starting with this
```
### Tip 3: Pattern Selection Strategy
- **Too specific:** Might not match due to whitespace differences
- **Too generic:** Might match wrong location
- **Just right:** Distinctive enough to be unique, fuzzy enough to be flexible
### Tip 4: When Edits Fail
If fuzzy matching can't find your pattern:
1. Use `get_version` to see the current actual content
2. Check if your pattern actually exists in the file
3. Try a more distinctive or simpler pattern
4. Remember: patterns are case-sensitive for content (but whitespace-flexible)
## Error Handling
If a fuzzy match fails, you'll get an error message. Common causes:
- Pattern doesn't exist in the file
- Pattern exists multiple times (uses first match)
- Pattern text doesn't match actual content
**Solution:** Use `get_version` to inspect the current content and adjust your pattern.
## Comparison to Traditional Artifacts
### Traditional Artifacts:
- ❌ Full file rewrites every edit
- ❌ Exact string matching (brittle)
- ❌ No version history
- ❌ No undo capability
- ❌ Token-heavy
### Fossil System:
- ✅ Surgical edits to specific sections
- ✅ Fuzzy pattern matching (robust)
- ✅ Full version history
- ✅ Git-like revert functionality
- ✅ Token-efficient
## Quick Reference
**Create:** `create_fossil(id, content)`
**Edit:** `fuzzy_edit(id, start_pattern, end_pattern, replacement)`
**Undo:** `revert(id, steps)`
**View:** `get_version(id, version)`
**List:** `list_fossils()`
**Delete:** `delete_fossil(id)`
---
Remember: Fossil enables **iterative, precise code editing** with full version control. Use fuzzy patterns to target sections, make incremental changes, and revert freely when needed. It's designed to make LLM-assisted coding efficient and safe.`,"#.into(),
),
capabilities: ServerCapabilities::builder().enable_tools().enable_resources().enable_tool_list_changed().build(),
..Default::default() ..Default::default()
} }
} }

13
src/prompt.txt Normal file
View File

@@ -0,0 +1,13 @@
Fossil is version-controlled code artifact management. Each fossil has an ID (treat it like a filename) and maintains complete edit history. Every change generates a diff and can be reverted.
The key architectural choice is between fuzzy_edit and fuzzy_replace. fuzzy_edit takes start_pattern and end_pattern and replaces everything between them (inclusive of the boundary lines). This is designed for structural changes where you're replacing entire functions, refactoring classes, or swapping out major blocks of logic. You're essentially saying "from this line to that line, replace it all with this new content." The patterns define boundaries and everything in between gets replaced.
fuzzy_replace works differently - it searches for old_str and swaps it with new_str, much like find-and-replace in a text editor. This is meant for surgical edits: renaming a variable throughout a function, changing a configuration value, fixing a specific line, or updating an import statement. When the pattern appears multiple times, use occurrence to specify which one (0 for first, -1 for last, or positive integers for nth occurrence). If you don't specify occurrence and multiple matches exist, the tool errors and tells you how many it found with their line numbers.
Both tools use Smith-Waterman fuzzy matching, which means they tolerate whitespace variations, minor formatting differences, and don't require character-perfect patterns. However "fuzzy" doesn't mean "vague" - patterns still need to be distinctive enough to match uniquely. If you search for a bare variable name like "count", it might match dozens of places. Include enough context to make it unambiguous: the full statement, the function signature, or surrounding code that makes the location clear. The matcher will handle if indentation changed or there's an extra space somewhere, but it won't guess which of five identical variable names you meant.
When edits fail, they fail atomically - nothing changes. Error messages include specifics: "pattern not found" means the fuzzy matcher couldn't align it with anything in the code (check if the code changed since you last saw it, or if your pattern has typos), "multiple matches" means your pattern is too generic (response includes line numbers of all matches so you can see what got caught), "invalid occurrence" means you asked for the 5th match but only 3 exist. Read the error details, they're designed to be actionable.
Version control works with integer indices. Version 0 is initial creation, positive indices count forward from start, negative indices count backward from current (so -1 is latest, -2 is previous, etc). Use get_version to review past states without changing anything - useful when you want to see what code looked like three edits ago without actually reverting. Use revert when you want to undo changes. You can revert multiple steps at once by specifying the steps parameter. Both return diffs showing what changed.
The system infers rendering from content and ID. A .tsx file with React imports gets rendered as a React component, .html gets rendered as HTML, .py shows as Python with syntax highlighting. The GUI provides copy/paste and lets users interact with rendered output. Files persist for the conversation duration with full history. Use fossils for code users will iterate on or actually use, not for throwaway examples or snippets under 20 lines that are just illustrative.