Skip to content

Commit cb67b1c

Browse files
authored
merge pull request #52 from mariinkys/file_watcher
feat: detect external file modifications
2 parents b2b341a + f6d82ec commit cb67b1c

8 files changed

Lines changed: 205 additions & 25 deletions

File tree

Cargo.lock

Lines changed: 10 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ icu_collator.workspace = true
3434
icu_locale.workspace = true
3535
font-kit.workspace = true
3636
grep.workspace = true
37+
notify.workspace = true
3738

3839
# PDF Generation Generation
3940
gotenberg_pdf.workspace = true
@@ -79,6 +80,7 @@ gotenberg_pdf = { git = "https://github.com/mariinkys/gotenberg_pdf" }
7980
font-kit = "0.14.3"
8081
image = { version= "0.25.10", default-features=false }
8182
grep = "0.4.1"
83+
notify = "8.2.0"
8284

8385
[workspace.dependencies.libcosmic]
8486
git = "https://github.com/pop-os/libcosmic.git"

i18n/en/cedilla.ftl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ delete-confirmation = Are you sure you want to delete this file/folder and it's
2424
save-changes-closing = Save changes before closing?
2525
save-warning = You have unsaved changes. If you continue without saving, these changes will be lost.
2626
discard-changes = Discard Changes
27+
file-changed-externally = The file has been changed from another application
28+
reload = Reload File
29+
keep-my-version = Keep Current Version
2730

2831
<#-- Appearance -->
2932
appearance = Appearance

src/app.rs

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ use slotmap::Key as SlotMapKey;
3030
use std::any::TypeId;
3131
use std::collections::{HashMap, VecDeque};
3232
use std::path::{Path, PathBuf};
33-
use std::sync::Arc;
33+
use std::sync::{Arc, Mutex};
3434
use widgets::{TextEditor, text_editor};
3535

3636
pub mod app_menu;
@@ -162,9 +162,11 @@ pub enum Message {
162162
/// Callback after opening a new file
163163
OpenFile(Result<(PathBuf, Arc<String>), anywho::Error>),
164164
/// Callback after saving the current file
165-
FileSaved(Result<PathBuf, anywho::Error>),
165+
FileSaved(Option<Result<PathBuf, anywho::Error>>),
166166
/// Callback after asking to close a file discarding changes
167167
DiscardChanges(DiscardChangesAction),
168+
/// Fired when the watcher detects an external change to the open file
169+
ExternalFileChanged(PathBuf),
168170

169171
/// Deletes the given node entity of the navbar folder or file
170172
DeleteNode(cosmic::widget::segmented_button::Entity),
@@ -649,6 +651,11 @@ impl cosmic::Application for AppModel {
649651
struct ConfigSubscription;
650652
struct ThemeSubscription;
651653

654+
let watched_path = match &self.state {
655+
State::Ready { editor, .. } => editor.path.clone(),
656+
_ => None,
657+
};
658+
652659
// Add subscriptions which are always active.
653660
let subscriptions = vec![
654661
// Watch for key_bind inputs
@@ -695,6 +702,8 @@ impl cosmic::Application for AppModel {
695702
}
696703
Message::ConfigInput(ConfigInput::SystemThemeModeChange)
697704
}),
705+
// Watch for external file changes
706+
file_watch_subscription(watched_path),
698707
];
699708

700709
Subscription::batch(subscriptions)
@@ -736,6 +745,7 @@ impl cosmic::Application for AppModel {
736745
Message::OpenFile(result) => self.handle_open_file(result),
737746
Message::FileSaved(result) => self.handle_file_saved(result),
738747
Message::DiscardChanges(action) => self.handle_discard_changes(action),
748+
Message::ExternalFileChanged(path) => self.handle_external_file_changed(path),
739749

740750
// Vault / Node
741751
Message::DeleteNode(entity) => self.handle_delete_node(entity),
@@ -1320,6 +1330,74 @@ fn cedilla_main_view<'a>(
13201330
}
13211331
}
13221332

1333+
/// Watches for external changes on the currently open file
1334+
fn file_watch_subscription(path: Option<PathBuf>) -> Subscription<Message> {
1335+
use cosmic::iced::futures::SinkExt;
1336+
use cosmic::iced_futures::futures::channel::mpsc;
1337+
use notify::{EventKind, RecursiveMode, Watcher, recommended_watcher};
1338+
1339+
let Some(path) = path else {
1340+
return Subscription::none();
1341+
};
1342+
1343+
Subscription::run_with(path, |path| {
1344+
let path_owned = path.clone();
1345+
1346+
cosmic::iced_futures::stream::channel(
1347+
16,
1348+
move |mut output: mpsc::Sender<Message>| async move {
1349+
let (tx, rx) = std::sync::mpsc::channel::<PathBuf>();
1350+
let rx = Arc::new(Mutex::new(rx));
1351+
1352+
let mut watcher =
1353+
match recommended_watcher(move |res: notify::Result<notify::Event>| {
1354+
if let Ok(event) = res {
1355+
let is_relevant =
1356+
matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_));
1357+
if is_relevant {
1358+
for p in event.paths {
1359+
let _ = tx.send(p);
1360+
}
1361+
}
1362+
}
1363+
}) {
1364+
Ok(w) => w,
1365+
Err(e) => {
1366+
eprintln!("file_watcher: failed to create watcher: {e}");
1367+
return;
1368+
}
1369+
};
1370+
1371+
if let Err(e) = watcher.watch(&path_owned, RecursiveMode::NonRecursive) {
1372+
eprintln!("file_watcher: failed to watch {path_owned:?}: {e}");
1373+
return;
1374+
}
1375+
1376+
let _watcher = watcher;
1377+
1378+
loop {
1379+
let rx2 = Arc::clone(&rx);
1380+
let changed_path =
1381+
match tokio::task::spawn_blocking(move || rx2.lock().unwrap().recv()).await
1382+
{
1383+
Ok(Ok(p)) => p,
1384+
_ => return,
1385+
};
1386+
1387+
if changed_path == path_owned {
1388+
let _ = output
1389+
.send(Message::ExternalFileChanged(changed_path))
1390+
.await;
1391+
1392+
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1393+
while rx.lock().unwrap().try_recv().is_ok() {}
1394+
}
1395+
}
1396+
},
1397+
)
1398+
})
1399+
}
1400+
13231401
/// Returns the text editor scrollable Id
13241402
fn editor_scrollable_id() -> widget::Id {
13251403
widget::Id::new("editor_scroll")

src/app/core/editor.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ pub struct EditorState {
2020
pub scroll: EditorScrollState,
2121
/// Holds the state of the search faetures of the editor
2222
pub search: EditorSearchState,
23+
/// Helper field to not open the external changes dialog when we save a file, move, delete...
24+
pub ignore_next_external_change: bool,
2325
}
2426

2527
/// Allows us to correctly follow the cursor with the scrollbar

src/app/dialogs.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ pub enum DialogPage {
2424
MoveNode(cosmic::widget::segmented_button::Entity, Option<PathBuf>),
2525
/// Dialog for when closing a file with pending changes
2626
ConfirmCloseFile(DiscardChangesAction),
27+
/// Open file was modified externally
28+
ExternalFileModified(PathBuf),
2729
}
2830

2931
impl DialogPage {
@@ -194,6 +196,32 @@ impl DialogPage {
194196
])
195197
.spacing(spacing.space_xxs),
196198
),
199+
DialogPage::ExternalFileModified(path) => {
200+
let file_name = path
201+
.file_name()
202+
.and_then(|n| n.to_str())
203+
.unwrap_or("this file");
204+
205+
widget::dialog()
206+
.title(fl!("file-changed-externally"))
207+
.primary_action(
208+
widget::button::suggested(fl!("reload"))
209+
.on_press(Message::DialogAction(DialogAction::DialogComplete)),
210+
)
211+
.secondary_action(
212+
widget::button::standard(fl!("keep-my-version"))
213+
.on_press(Message::DialogAction(DialogAction::KeepMyFile)),
214+
)
215+
.control(
216+
widget::column::with_children(vec![
217+
widget::text::body(format!(
218+
"\"{file_name}\" was modified by another program."
219+
))
220+
.into(),
221+
])
222+
.spacing(spacing.space_xxs),
223+
)
224+
}
197225
};
198226

199227
Some(dialog.into())
@@ -203,6 +231,8 @@ impl DialogPage {
203231
/// Represents an Action related to a Dialog
204232
#[derive(Clone, Debug, Eq, PartialEq)]
205233
pub enum DialogAction {
234+
/// File has been modified externally and user want's to keep it's version of the file
235+
KeepMyFile,
206236
/// Asks to open the [`DialogPage`] for creating a new vault file
207237
OpenNewVaultFileDialog,
208238
/// Asks to open the [`DialogPage`] for creating a new vault folder
@@ -233,6 +263,10 @@ impl DialogAction {
233263
dialog_state: &DialogState,
234264
) -> Task<cosmic::Action<Message>> {
235265
match self {
266+
DialogAction::KeepMyFile => {
267+
dialog_pages.pop_front();
268+
Task::done(cosmic::action::app(Message::SaveFile))
269+
}
236270
DialogAction::OpenNewVaultFileDialog => {
237271
dialog_pages.push_back(DialogPage::NewVaultFile(String::new()));
238272
widget::text_input::focus(dialog_state.dialog_text_input.clone())
@@ -283,6 +317,12 @@ impl DialogAction {
283317
return Task::done(cosmic::action::app(Message::SaveFile));
284318
}
285319
},
320+
321+
DialogPage::ExternalFileModified(path) => {
322+
return Task::done(cosmic::action::app(Message::DiscardChanges(
323+
DiscardChangesAction::OpenFile(path.clone()),
324+
)));
325+
}
286326
}
287327
}
288328
Task::none()

0 commit comments

Comments
 (0)