journal.rs

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