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