300 lines · 10.1 KB · Rust
RawDownload
use chrono::{Datelike, Local, NaiveTime, Timelike};use editor::scroll::Autoscroll;use editor::{Editor, SelectionEffects};use gpui::{App, AppContext as _, Context, Window, actions};pub use settings::HourFormat;use settings::{RegisterSetting, Settings};use std::{    fs::OpenOptions,    path::{Path, PathBuf},    sync::Arc,};use workspace::{AppState, OpenResult, OpenVisible, Workspace}; actions!(    journal,    [        /// Creates a new journal entry for today.        NewJournalEntry    ]); /// Settings specific to journaling#[derive(Clone, Debug, RegisterSetting)]pub struct JournalSettings {    /// The path of the directory where journal entries are stored.    ///    /// Default: `~`    pub path: String,    /// What format to display the hours in.    ///    /// Default: hour12    pub hour_format: HourFormat,} impl settings::Settings for JournalSettings {    fn from_settings(content: &settings::SettingsContent) -> Self {        let journal = content.journal.clone().unwrap();         Self {            path: journal.path.unwrap(),            hour_format: journal.hour_format.unwrap(),        }    }} pub fn init(_: Arc<AppState>, cx: &mut App) {    cx.observe_new(        |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {            workspace.register_action(|workspace, _: &NewJournalEntry, window, cx| {                new_journal_entry(workspace, window, cx);            });        },    )    .detach();} pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut App) {    let settings = JournalSettings::get_global(cx);    let journal_dir = match journal_dir(&settings.path) {        Some(journal_dir) => journal_dir,        None => {            log::error!("Can't determine journal directory");            return;        }    };    let journal_dir_clone = journal_dir.clone();     let now = Local::now();    let month_dir = journal_dir        .join(format!("{:02}", now.year()))        .join(format!("{:02}", now.month()));    let entry_path = month_dir.join(format!("{:02}.md", now.day()));    let now = now.time();    let entry_heading = heading_entry(now, &settings.hour_format);     let create_entry = cx.background_spawn(async move {        std::fs::create_dir_all(month_dir)?;        OpenOptions::new()            .create(true)            .truncate(false)            .write(true)            .open(&entry_path)?;        Ok::<_, std::io::Error>((journal_dir, entry_path))    });     let worktrees = workspace.visible_worktrees(cx).collect::<Vec<_>>();    let mut open_new_workspace = true;    'outer: for worktree in worktrees.iter() {        let worktree_root = worktree.read(cx).abs_path();        if *worktree_root == journal_dir_clone {            open_new_workspace = false;            break;        }        for directory in worktree.read(cx).directories(true, 1) {            let full_directory_path = worktree_root.join(directory.path.as_std_path());            if full_directory_path.ends_with(&journal_dir_clone) {                open_new_workspace = false;                break 'outer;            }        }    }     let app_state = workspace.app_state().clone();    let view_snapshot = workspace.weak_handle();     window        .spawn(cx, async move |cx| {            let (journal_dir, entry_path) = create_entry.await?;            let opened = if open_new_workspace {                let OpenResult {                    window: new_workspace,                    ..                } = cx                    .update(|_window, cx| {                        workspace::open_paths(                            &[journal_dir],                            app_state,                            workspace::OpenOptions::default(),                            cx,                        )                    })?                    .await?;                new_workspace                    .update(cx, |multi_workspace, window, cx| {                        let workspace = multi_workspace.workspace().clone();                        workspace.update(cx, |workspace, cx| {                            workspace.open_paths(                                vec![entry_path],                                workspace::OpenOptions {                                    visible: Some(OpenVisible::All),                                    ..Default::default()                                },                                None,                                window,                                cx,                            )                        })                    })?                    .await            } else {                view_snapshot                    .update_in(cx, |workspace, window, cx| {                        workspace.open_paths(                            vec![entry_path],                            workspace::OpenOptions {                                visible: Some(OpenVisible::All),                                ..Default::default()                            },                            None,                            window,                            cx,                        )                    })?                    .await            };             if let Some(Some(Ok(item))) = opened.first()                && let Some(editor) = item.downcast::<Editor>().map(|editor| editor.downgrade())            {                editor.update_in(cx, |editor, window, cx| {                    let len = editor.buffer().read(cx).len(cx);                    editor.change_selections(                        SelectionEffects::scroll(Autoscroll::center()),                        window,                        cx,                        |s| s.select_ranges([len..len]),                    );                    if len.0 > 0 {                        editor.insert("\n\n", window, cx);                    }                    editor.insert(&entry_heading, window, cx);                    editor.insert("\n\n", window, cx);                })?;            }             anyhow::Ok(())        })        .detach_and_log_err(cx);} fn journal_dir(path: &str) -> Option<PathBuf> {    let expanded = shellexpand::full(path).ok()?;    let base_path = Path::new(expanded.as_ref());    let absolute_path = if base_path.is_absolute() {        base_path.to_path_buf()    } else {        log::warn!("Invalid journal path {path:?} (not absolute), falling back to home directory",);        std::env::home_dir()?    };    Some(absolute_path.join("journal"))} fn heading_entry(now: NaiveTime, hour_format: &HourFormat) -> String {    match hour_format {        HourFormat::Hour24 => {            let hour = now.hour();            format!("# {}:{:02}", hour, now.minute())        }        HourFormat::Hour12 => {            let (pm, hour) = now.hour12();            let am_or_pm = if pm { "PM" } else { "AM" };            format!("# {}:{:02} {}", hour, now.minute(), am_or_pm)        }    }} #[cfg(test)]mod tests {    mod heading_entry_tests {        use super::super::*;         #[test]        fn test_heading_entry_defaults_to_hour_12() {            let naive_time = NaiveTime::from_hms_milli_opt(15, 0, 0, 0).unwrap();            let actual_heading_entry = heading_entry(naive_time, &HourFormat::Hour12);            let expected_heading_entry = "# 3:00 PM";             assert_eq!(actual_heading_entry, expected_heading_entry);        }         #[test]        fn test_heading_entry_is_hour_12() {            let naive_time = NaiveTime::from_hms_milli_opt(15, 0, 0, 0).unwrap();            let actual_heading_entry = heading_entry(naive_time, &HourFormat::Hour12);            let expected_heading_entry = "# 3:00 PM";             assert_eq!(actual_heading_entry, expected_heading_entry);        }         #[test]        fn test_heading_entry_is_hour_24() {            let naive_time = NaiveTime::from_hms_milli_opt(15, 0, 0, 0).unwrap();            let actual_heading_entry = heading_entry(naive_time, &HourFormat::Hour24);            let expected_heading_entry = "# 15:00";             assert_eq!(actual_heading_entry, expected_heading_entry);        }    }     mod journal_dir_tests {        use super::super::*;         #[test]        #[cfg(target_family = "unix")]        fn test_absolute_unix_path() {            let result = journal_dir("/home/user");            assert!(result.is_some());            let path = result.unwrap();            assert!(path.is_absolute());            assert_eq!(path, PathBuf::from("/home/user/journal"));        }         #[test]        fn test_tilde_expansion() {            let result = journal_dir("~/documents");            assert!(result.is_some());            let path = result.unwrap();             assert!(path.is_absolute(), "Tilde should expand to absolute path");             if let Some(home) = std::env::home_dir() {                assert_eq!(path, home.join("documents").join("journal"));            }        }         #[test]        fn test_relative_path_falls_back_to_home() {            for relative_path in ["relative/path", "NONEXT/some/path", "../some/path"] {                let result = journal_dir(relative_path);                assert!(result.is_some(), "Failed for path: {}", relative_path);                let path = result.unwrap();                 assert!(                    path.is_absolute(),                    "Path should be absolute for input '{}', got: {:?}",                    relative_path,                    path                );                 if let Some(home) = std::env::home_dir() {                    assert_eq!(                        path,                        home.join("journal"),                        "Should fall back to home directory for input '{}'",                        relative_path                    );                }            }        }         #[test]        #[cfg(target_os = "windows")]        fn test_absolute_path_windows_style() {            let result = journal_dir("C:\\Users\\user\\Documents");            assert!(result.is_some());            let path = result.unwrap();            assert_eq!(path, PathBuf::from("C:\\Users\\user\\Documents\\journal"));        }    }}