Files
oak-gpui/crates/journal/src/journal.rs
T
03f9cf4414 Represent relative paths using a dedicated, separator-agnostic type (#38744)
Closes https://github.com/zed-industries/zed/issues/38690
Closes #37353

### Background

On Windows, paths are normally separated by `\`, unlike mac and linux
where they are separated by `/`. When editing code in a project that
uses a different path style than your local system (e.g. remoting from
Windows to Linux, using WSL, and collaboration between windows and unix
users), the correct separator for a path may differ from the "native"
separator.

Previously, to work around this, Zed converted paths' separators in
numerous places. This was applied to both absolute and relative paths,
leading to incorrect conversions in some cases.

### Solution

Many code paths in Zed use paths that are *relative* to either a
worktree root or a git repository. This PR introduces a dedicated type
for these paths called `RelPath`, which stores the path in the same way
regardless of host platform, and offers `Path`-like manipulation APIs.
RelPath supports *displaying* the path using either separator, so that
we can display paths in a style that is determined at runtime based on
the current project.

The representation of absolute paths is left untouched, for now.
Absolute paths are different from relative paths because (except in
contexts where we know that the path refers to the local filesystem)
they should generally be treated as opaque strings. Currently we use a
mix of types for these paths (std::path::Path, String, SanitizedPath).

Release Notes:

- N/A

---------

Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
Co-authored-by: Peter Tripp <petertripp@gmail.com>
Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
Co-authored-by: Lukas Wirth <me@lukaswirth.dev>
2025-09-24 18:57:33 -04:00

230 lines
7.5 KiB
Rust

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::Settings;
use std::{
fs::OpenOptions,
path::{Path, PathBuf},
sync::Arc,
};
use workspace::{AppState, OpenVisible, Workspace};
actions!(
journal,
[
/// Creates a new journal entry for today.
NewJournalEntry
]
);
/// Settings specific to journaling
#[derive(Clone, Debug)]
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, _cx: &mut App) -> 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) {
JournalSettings::register(cx);
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 (new_workspace, _) = cx
.update(|_window, cx| {
workspace::open_paths(
&[journal_dir],
app_state,
workspace::OpenOptions::default(),
cx,
)
})?
.await?;
new_workspace
.update(cx, |workspace, window, 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 {
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> {
shellexpand::full(path) //TODO handle this better
.ok()
.map(|dir| Path::new(&dir.to_string()).to_path_buf().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);
}
}
}