journal.rs

  1use chrono::{Datelike, Local, NaiveTime, Timelike};
  2use editor::scroll::Autoscroll;
  3use editor::{Editor, SelectionEffects};
  4use gpui::{App, AppContext as _, Context, Window, actions};
  5pub use settings::HourFormat;
  6use settings::Settings;
  7use std::{
  8    fs::OpenOptions,
  9    path::{Path, PathBuf},
 10    sync::Arc,
 11};
 12use util::MergeFrom;
 13use workspace::{AppState, OpenVisible, Workspace};
 14
 15actions!(
 16    journal,
 17    [
 18        /// Creates a new journal entry for today.
 19        NewJournalEntry
 20    ]
 21);
 22
 23/// Settings specific to journaling
 24#[derive(Clone, Debug)]
 25pub struct JournalSettings {
 26    /// The path of the directory where journal entries are stored.
 27    ///
 28    /// Default: `~`
 29    pub path: String,
 30    /// What format to display the hours in.
 31    ///
 32    /// Default: hour12
 33    pub hour_format: HourFormat,
 34}
 35
 36impl settings::Settings for JournalSettings {
 37    fn from_defaults(content: &settings::SettingsContent, _cx: &mut App) -> Self {
 38        let journal = content.journal.clone().unwrap();
 39
 40        Self {
 41            path: journal.path.unwrap(),
 42            hour_format: journal.hour_format.unwrap(),
 43        }
 44    }
 45
 46    fn refine(&mut self, content: &settings::SettingsContent, _cx: &mut App) {
 47        let Some(journal) = content.journal.as_ref() else {
 48            return;
 49        };
 50        self.path.merge_from(&journal.path);
 51        self.hour_format.merge_from(&journal.hour_format);
 52    }
 53}
 54
 55pub fn init(_: Arc<AppState>, cx: &mut App) {
 56    JournalSettings::register(cx);
 57
 58    cx.observe_new(
 59        |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
 60            workspace.register_action(|workspace, _: &NewJournalEntry, window, cx| {
 61                new_journal_entry(workspace, window, cx);
 62            });
 63        },
 64    )
 65    .detach();
 66}
 67
 68pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut App) {
 69    let settings = JournalSettings::get_global(cx);
 70    let journal_dir = match journal_dir(&settings.path) {
 71        Some(journal_dir) => journal_dir,
 72        None => {
 73            log::error!("Can't determine journal directory");
 74            return;
 75        }
 76    };
 77    let journal_dir_clone = journal_dir.clone();
 78
 79    let now = Local::now();
 80    let month_dir = journal_dir
 81        .join(format!("{:02}", now.year()))
 82        .join(format!("{:02}", now.month()));
 83    let entry_path = month_dir.join(format!("{:02}.md", now.day()));
 84    let now = now.time();
 85    let entry_heading = heading_entry(now, &settings.hour_format);
 86
 87    let create_entry = cx.background_spawn(async move {
 88        std::fs::create_dir_all(month_dir)?;
 89        OpenOptions::new()
 90            .create(true)
 91            .truncate(false)
 92            .write(true)
 93            .open(&entry_path)?;
 94        Ok::<_, std::io::Error>((journal_dir, entry_path))
 95    });
 96
 97    let worktrees = workspace.visible_worktrees(cx).collect::<Vec<_>>();
 98    let mut open_new_workspace = true;
 99    'outer: for worktree in worktrees.iter() {
100        let worktree_root = worktree.read(cx).abs_path();
101        if *worktree_root == journal_dir_clone {
102            open_new_workspace = false;
103            break;
104        }
105        for directory in worktree.read(cx).directories(true, 1) {
106            let full_directory_path = worktree_root.join(&directory.path);
107            if full_directory_path.ends_with(&journal_dir_clone) {
108                open_new_workspace = false;
109                break 'outer;
110            }
111        }
112    }
113
114    let app_state = workspace.app_state().clone();
115    let view_snapshot = workspace.weak_handle();
116
117    window
118        .spawn(cx, async move |cx| {
119            let (journal_dir, entry_path) = create_entry.await?;
120            let opened = if open_new_workspace {
121                let (new_workspace, _) = cx
122                    .update(|_window, cx| {
123                        workspace::open_paths(
124                            &[journal_dir],
125                            app_state,
126                            workspace::OpenOptions::default(),
127                            cx,
128                        )
129                    })?
130                    .await?;
131                new_workspace
132                    .update(cx, |workspace, window, cx| {
133                        workspace.open_paths(
134                            vec![entry_path],
135                            workspace::OpenOptions {
136                                visible: Some(OpenVisible::All),
137                                ..Default::default()
138                            },
139                            None,
140                            window,
141                            cx,
142                        )
143                    })?
144                    .await
145            } else {
146                view_snapshot
147                    .update_in(cx, |workspace, window, cx| {
148                        workspace.open_paths(
149                            vec![entry_path],
150                            workspace::OpenOptions {
151                                visible: Some(OpenVisible::All),
152                                ..Default::default()
153                            },
154                            None,
155                            window,
156                            cx,
157                        )
158                    })?
159                    .await
160            };
161
162            if let Some(Some(Ok(item))) = opened.first()
163                && let Some(editor) = item.downcast::<Editor>().map(|editor| editor.downgrade())
164            {
165                editor.update_in(cx, |editor, window, cx| {
166                    let len = editor.buffer().read(cx).len(cx);
167                    editor.change_selections(
168                        SelectionEffects::scroll(Autoscroll::center()),
169                        window,
170                        cx,
171                        |s| s.select_ranges([len..len]),
172                    );
173                    if len > 0 {
174                        editor.insert("\n\n", window, cx);
175                    }
176                    editor.insert(&entry_heading, window, cx);
177                    editor.insert("\n\n", window, cx);
178                })?;
179            }
180
181            anyhow::Ok(())
182        })
183        .detach_and_log_err(cx);
184}
185
186fn journal_dir(path: &str) -> Option<PathBuf> {
187    shellexpand::full(path) //TODO handle this better
188        .ok()
189        .map(|dir| Path::new(&dir.to_string()).to_path_buf().join("journal"))
190}
191
192fn heading_entry(now: NaiveTime, hour_format: &HourFormat) -> String {
193    match hour_format {
194        HourFormat::Hour24 => {
195            let hour = now.hour();
196            format!("# {}:{:02}", hour, now.minute())
197        }
198        HourFormat::Hour12 => {
199            let (pm, hour) = now.hour12();
200            let am_or_pm = if pm { "PM" } else { "AM" };
201            format!("# {}:{:02} {}", hour, now.minute(), am_or_pm)
202        }
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    mod heading_entry_tests {
209        use super::super::*;
210
211        #[test]
212        fn test_heading_entry_defaults_to_hour_12() {
213            let naive_time = NaiveTime::from_hms_milli_opt(15, 0, 0, 0).unwrap();
214            let actual_heading_entry = heading_entry(naive_time, &HourFormat::Hour12);
215            let expected_heading_entry = "# 3:00 PM";
216
217            assert_eq!(actual_heading_entry, expected_heading_entry);
218        }
219
220        #[test]
221        fn test_heading_entry_is_hour_12() {
222            let naive_time = NaiveTime::from_hms_milli_opt(15, 0, 0, 0).unwrap();
223            let actual_heading_entry = heading_entry(naive_time, &HourFormat::Hour12);
224            let expected_heading_entry = "# 3:00 PM";
225
226            assert_eq!(actual_heading_entry, expected_heading_entry);
227        }
228
229        #[test]
230        fn test_heading_entry_is_hour_24() {
231            let naive_time = NaiveTime::from_hms_milli_opt(15, 0, 0, 0).unwrap();
232            let actual_heading_entry = heading_entry(naive_time, &HourFormat::Hour24);
233            let expected_heading_entry = "# 15:00";
234
235            assert_eq!(actual_heading_entry, expected_heading_entry);
236        }
237    }
238}