diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f0d7ab..aa10f0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,7 @@ name: CI on: pull_request: - push: - branches: [master] + workflow_call: env: CARGO_TERM_COLOR: always diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c08839..fe3ec74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,8 @@ permissions: contents: write jobs: + test: + uses: ./.github/workflows/ci.yml build: name: Build (${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/Cargo.lock b/Cargo.lock index d8ed812..1892228 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1368,6 +1368,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.8" @@ -1677,6 +1686,7 @@ dependencies = [ "serde_json", "tempfile", "tracing", + "tracing-appender", "tracing-subscriber", "ureq", "walkdir", @@ -6930,6 +6940,12 @@ dependencies = [ "zeno", ] +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "2.0.119" @@ -7436,6 +7452,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.20", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" diff --git a/Cargo.toml b/Cargo.toml index 2080499..f5992a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ tempfile = "3.27.0" imagesize = "0.15.0" base64 = "0.23.1" image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] } - +tracing-appender = "0.2.5" ureq = { version = "3.4.2", features = ["json"] } # Fast iteration: skip most optimization, keep debug info light, diff --git a/locales/en.yaml b/locales/en.yaml index d5bd991..5495959 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -97,6 +97,8 @@ label: search_images: Search Images file_type: File Type ok: OK + export_logs: Export Logs + download: Download description: use_attach: Attach to running browser instead of starting a new browser, you might want to use this mode if you want specific browser settings to be applied. min_height: Height must be greater than %{min}. @@ -145,3 +147,4 @@ error: failed_to_select_save_to: Failed to select save to folder selected_folder_not_in_project: Selected folder is not in the project cannot_save_to_existing_file: Cannot save to existing file + failed_to_export_logs: Failed to export logs diff --git a/src/config/app_config.rs b/src/config/app_config.rs index c0afb78..3168037 100644 --- a/src/config/app_config.rs +++ b/src/config/app_config.rs @@ -39,6 +39,9 @@ impl AppConfig { pub fn load() -> Self { Self::load_from(&Self::config_path()) } + pub fn log_dir() -> PathBuf { + Self::config_dir().join("logs") + } pub fn save(&self) { self.save_to(&Self::config_path()); @@ -46,7 +49,7 @@ impl AppConfig { fn load_from(config_path: &Path) -> Self { tracing::info!("Loading app config"); - match fs::read_to_string(&config_path) { + match fs::read_to_string(config_path) { Ok(contents) => match serde_json::from_str(&contents) { Ok(config) => config, Err(error) => { @@ -56,32 +59,34 @@ impl AppConfig { "Could not parse app config; using defaults" ); let config = Self::default(); - config.save(); + config.save_to(config_path); config } }, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - let config = Self::default(); - config.save(); - config - } + Err(error) => { tracing::warn!( ?error, path = %config_path.display(), "Could not read app config; using defaults" ); - Self::default() + let config = Self::default(); + config.save_to(config_path); + config } } } fn save_to(&self, config_path: &Path) { - let app_dir = Self::config_dir(); - if let Err(error) = fs::create_dir_all(app_dir) - .and_then(|_| serde_json::to_string_pretty(self).map_err(std::io::Error::other)) - .and_then(|contents| fs::write(config_path, contents)) - { + let result = (|| -> std::io::Result<()> { + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent)?; + } + let contents = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?; + fs::write(config_path, contents) + })(); + + if let Err(error) = result { tracing::warn!( ?error, path = %config_path.display(), @@ -94,7 +99,7 @@ impl AppConfig { #[cfg(test)] mod tests { use super::*; - use serde_json::{json, Value}; + use serde_json::{Value, json}; use tempfile::tempdir; // Compare via JSON so the tests don't need PartialEq on the nested types. @@ -213,7 +218,10 @@ mod tests { let config = AppConfig::load_from(&path); assert_eq!(as_json(&config), as_json(&AppConfig::default())); - assert!(path.is_dir(), "load must not clobber the path on read errors"); + assert!( + path.is_dir(), + "load must not clobber the path on read errors" + ); } #[test] @@ -240,4 +248,4 @@ mod tests { let contents = fs::read_to_string(&path).unwrap(); assert!(serde_json::from_str::(&contents).is_ok()); } -} \ No newline at end of file +} diff --git a/src/config/image_format.rs b/src/config/image_format.rs index 8a2ed67..e4ac869 100644 --- a/src/config/image_format.rs +++ b/src/config/image_format.rs @@ -1,3 +1,5 @@ +use std::fmt; + use headless_chrome::protocol::cdp::Page::CaptureScreenshotFormatOption; use serde::{Deserialize, Serialize}; @@ -17,7 +19,7 @@ impl ImageFormat { ImageFormat::Webp => CaptureScreenshotFormatOption::Webp, } } - + pub fn all_str() -> &'static [&'static str] { &["png", "jpeg", "webp"] } @@ -25,7 +27,7 @@ impl ImageFormat { pub fn all() -> &'static [ImageFormat] { &[ImageFormat::Png, ImageFormat::Jpeg, ImageFormat::Webp] } - + pub fn to_value(&self) -> &str { match self { ImageFormat::Png => "png", @@ -41,7 +43,13 @@ impl ImageFormat { _ => ImageFormat::Png, } } - pub fn to_string(&self) -> String { - self.to_value().to_uppercase() + // pub fn to_string(&self) -> String { + // self.to_value().to_uppercase().clone() + // } +} + +impl fmt::Display for ImageFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.to_value().to_uppercase()) } } diff --git a/src/config/project_settings_data.rs b/src/config/project_settings_data.rs index 02c7598..d22e53e 100644 --- a/src/config/project_settings_data.rs +++ b/src/config/project_settings_data.rs @@ -1,4 +1,7 @@ -use std::{fs, path::PathBuf}; +use std::{ + fs, + path::{Path, PathBuf}, +}; use serde::{Deserialize, Serialize}; @@ -22,12 +25,12 @@ impl Default for ProjectSettingsData { } } impl ProjectSettingsData { - pub fn save(&self, project_path: &PathBuf) { + pub fn save(&self, project_path: &Path) { let config_path = project_path.join(".dockeep"); let contents = serde_json::to_string(self).unwrap(); fs::write(&config_path, contents).unwrap(); } - pub fn load(project_path: &PathBuf) -> Self { + pub fn load(project_path: &Path) -> Self { let config_path = project_path.join(".dockeep"); if config_path.exists() { match fs::read_to_string(&config_path) { @@ -55,6 +58,6 @@ impl ProjectSettingsData { } tracing::info!("Loaded default project settings ",); // any error, or config not found, return default - return Self::default(); + Self::default() } } diff --git a/src/files/files_test.rs b/src/files/files_test.rs index c2068fc..da74c71 100644 --- a/src/files/files_test.rs +++ b/src/files/files_test.rs @@ -9,9 +9,8 @@ mod tests { use crate::config::ImageFormat; - use crate::files::files::*; + use crate::files::prelude::*; - use super::*; use image::{DynamicImage, ImageFormat as ImgFmt}; use std::collections::BTreeSet; use std::fs; @@ -30,7 +29,10 @@ mod tests { } fn names(paths: Vec) -> BTreeSet { - paths.iter().map(file_name_with_extension).collect() + paths + .iter() + .map(|ee| file_name_with_extension(ee)) + .collect() } const URL: &str = "https://example.com/page?a=1&b=2"; @@ -329,7 +331,7 @@ mod tests { fs::write(dir.path().join("sub/b.txt"), b"").unwrap(); fs::write(dir.path().join("sub/deeper/c.txt"), b"").unwrap(); - let got = names(read_dir(&dir.path().to_path_buf())); + let got = names(read_dir(dir.path())); assert_eq!( got, ["a.txt", "b.txt", "c.txt"] @@ -347,7 +349,7 @@ mod tests { fs::write(dir.path().join("keep.png"), b"").unwrap(); fs::write(dir.path().join(".dockeepignore"), "skip\n").unwrap(); - let got = names(read_dir(&dir.path().to_path_buf())); + let got = names(read_dir(dir.path())); assert!(got.contains("keep.png")); assert!(!got.contains("hidden.png")); } @@ -359,7 +361,7 @@ mod tests { fs::write(dir.path().join("b.tmp"), b"").unwrap(); fs::write(dir.path().join(".gitignore"), "*.tmp\n").unwrap(); - let got = names(read_dir(&dir.path().to_path_buf())); + let got = names(read_dir(dir.path())); assert!(got.contains("a.png")); assert!(!got.contains("b.tmp")); } @@ -386,7 +388,7 @@ mod tests { fs::write(p.join("anim.gif"), b"GIF89a....").unwrap(); // header ok, ext not allowed fs::write(p.join("noext"), encoded(ImgFmt::Png)).unwrap(); // no ext - let got = names(read_images(&p.to_path_buf())); + let got = names(read_images(p)); let want: BTreeSet = [ "real.png", "real.jpg", @@ -408,7 +410,7 @@ mod tests { fs::write(dir.path().join("y.png"), encoded(ImgFmt::Png)).unwrap(); fs::write(dir.path().join(".dockeepignore"), "cache\n").unwrap(); - let got = names(read_images(&dir.path().to_path_buf())); + let got = names(read_images(dir.path())); assert_eq!(got, ["y.png".to_string()].into_iter().collect()); } } diff --git a/src/files/mod.rs b/src/files/mod.rs index 87ccb97..fd32b17 100644 --- a/src/files/mod.rs +++ b/src/files/mod.rs @@ -1,2 +1,2 @@ mod files_test; -pub mod files; \ No newline at end of file +pub mod prelude; diff --git a/src/files/files.rs b/src/files/prelude.rs similarity index 91% rename from src/files/files.rs rename to src/files/prelude.rs index af3383c..824bc22 100644 --- a/src/files/files.rs +++ b/src/files/prelude.rs @@ -22,11 +22,11 @@ pub struct SaveScreenshotResult { pub path_id: PathBuf, pub error: Option, } -pub fn last_modified(path: &PathBuf) -> std::io::Result { +pub fn last_modified(path: &Path) -> std::io::Result { fs::metadata(path)?.modified() } const KEY: &str = "dockeep_capture_url"; -pub fn rename_file(path: &PathBuf, new_name: &str) -> Result { +pub fn rename_file(path: &Path, new_name: &str) -> Result { let parent = path.parent().unwrap_or(Path::new("")); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); let new_path = parent.join(format!("{new_name}.{ext}")); @@ -36,13 +36,13 @@ pub fn rename_file(path: &PathBuf, new_name: &str) -> Result { fs::rename(path, &new_path)?; Ok(new_path) } -pub fn file_name_with_extension(path: &PathBuf) -> String { +pub fn file_name_with_extension(path: &Path) -> String { path.file_name() .and_then(|n| n.to_str()) .unwrap_or("") .to_string() } -pub fn file_name_without_extension(path: &PathBuf) -> String { +pub fn file_name_without_extension(path: &Path) -> String { path.file_stem() .and_then(|n| n.to_str()) .unwrap_or("") @@ -169,7 +169,7 @@ pub fn save_screenshot( None => { let working_dir = working_dir.clone(); let filename = if project_settings.save_with_tab_title && !tab_title.is_empty() { - format!("{}", tab_title.replace(" ", "-")) + tab_title.replace(" ", "-").to_string() } else { let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); format!("Screenshot_{}", timestamp) @@ -249,7 +249,7 @@ pub fn save_screenshot( } } } -pub fn read_images(path: &PathBuf) -> Vec { +pub fn read_images(path: &Path) -> Vec { read_dir(path) .into_iter() .filter(|p| { @@ -308,18 +308,18 @@ pub(super) fn has_image_header(path: &Path) -> bool { false } -pub fn read_dir(path: &PathBuf) -> Vec { +pub fn read_dir(path: &Path) -> Vec { let root = path; - let ignore_patterns = load_ignore_patterns(&root); + let ignore_patterns = load_ignore_patterns(root); - WalkDir::new(&root) + WalkDir::new(root) .into_iter() .filter_entry(|entry| { // Always descend into the root itself if entry.depth() == 0 { return true; } - let relative = entry.path().strip_prefix(&root).unwrap_or(entry.path()); + let relative = entry.path().strip_prefix(root).unwrap_or(entry.path()); !is_ignored(relative, &ignore_patterns) }) .filter_map(|entry| { @@ -427,3 +427,35 @@ pub(super) fn glob_to_regex(pattern: &str) -> String { regex.push('$'); regex } + +pub fn open_in_file_explorer(path: &Path) -> std::io::Result<()> { + #[cfg(target_os = "windows")] + { + // /select, highlights the file itself instead of just opening the folder + Command::new("explorer") + .args(["/select,", &path.to_string_lossy()]) + .spawn()?; + } + + #[cfg(target_os = "macos")] + { + Command::new("open") + .args(["-R", &path.to_string_lossy()]) + .spawn()?; + } + + #[cfg(target_os = "linux")] + { + // Linux has no universal "reveal and select" — fall back to opening the containing folder + + use std::process::Command; + let dir = if path.is_dir() { + path + } else { + path.parent().unwrap_or(path) + }; + Command::new("xdg-open").arg(dir).spawn()?; + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index c7e5d00..be187fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,16 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] pub mod components; pub mod config; +pub mod files; pub mod scenes; pub mod utils; -pub mod files; use gpui_kit::component::*; use gpui_kit::*; -use tracing_subscriber::EnvFilter; use crate::{ config::AppConfig, scenes::{app::MyApp, home::view::HomePage}, - utils::app_theme::AppTheme, + utils::{app_theme::AppTheme, logging::init_logging}, }; #[macro_use] extern crate rust_i18n; @@ -25,12 +25,9 @@ fn setup(cx: &mut App) { } fn main() { - tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), - ) - .init(); - + let log_dir = AppConfig::log_dir(); + std::fs::create_dir_all(&log_dir).ok(); + let _guard = init_logging(&log_dir); // bind it as _guard it lives until main returns gpui_kit::application() .with_assets(utils::asset_source::Assets) .run(move |cx| { @@ -42,12 +39,11 @@ fn main() { window_decorations: Some(WindowDecorations::Client), // no WM frame titlebar: Some(TitlebarOptions { title: Some(SharedString::new("DocKeep")), - + appears_transparent: true, // hides the native bar on Windows/macOS ..Default::default() }), is_resizable: true, app_id: Some("dockeep".into()), - ..Default::default() }, |window, cx| { diff --git a/src/scenes/edit/browser_actions.rs b/src/scenes/edit/browser_actions.rs index cae2701..64f0057 100644 --- a/src/scenes/edit/browser_actions.rs +++ b/src/scenes/edit/browser_actions.rs @@ -16,7 +16,7 @@ use headless_chrome::{Browser, LaunchOptionsBuilder, Tab}; use crate::config::AppConfig; use crate::config::project_settings_data::ProjectSettingsData; -use crate::files::files::save_screenshot; +use crate::files::prelude::save_screenshot; use crate::utils::app_icons::AppIcons; use crate::utils::random::random_string; use base64::{Engine, engine::general_purpose::STANDARD}; @@ -69,7 +69,7 @@ impl BrowserActions { Self { state: BrowserActionsState::Stopped, browser: None, - working_dir: working_dir, + working_dir, replace_path: None, taking_cropped: false, } @@ -124,7 +124,7 @@ impl BrowserActions { .path(Some(chrome_path)) .build() .map_err(anyhow::Error::from)?; - Ok(Browser::new(opts)?) + Browser::new(opts) } fn attach_browser(port: u16) -> anyhow::Result { @@ -140,10 +140,7 @@ impl BrowserActions { .ok_or_else(|| anyhow::anyhow!("webSocketDebuggerUrl missing in /json/version"))? .to_string(); - Ok(Browser::connect_with_timeout( - ws_url, - Duration::from_secs(60 * 60 * 24), - )?) + Browser::connect_with_timeout(ws_url, Duration::from_secs(60 * 60 * 24)) } fn start(&mut self, window: &mut Window, cx: &mut Context) { match self.state { @@ -260,7 +257,7 @@ impl BrowserActions { self.state = BrowserActionsState::Stopped; cx.notify(); window.push_notification(t!("error.browser_is_not_running"), cx); - return None; + None } } } @@ -327,12 +324,12 @@ impl BrowserActions { } } fn focus(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(tab) = self.get_first_tab(window, cx) { - if let Err(e) = tab.bring_to_front() { - tracing::error!("Failed to bring tab to front: {:?}", e); - window.push_notification(t!("error.failed_to_bring_to_front"), cx); - }; - } + if let Some(tab) = self.get_first_tab(window, cx) + && let Err(e) = tab.bring_to_front() + { + tracing::error!("Failed to bring tab to front: {:?}", e); + window.push_notification(t!("error.failed_to_bring_to_front"), cx); + }; } fn capture_screenshot( tab: &Tab, @@ -348,7 +345,7 @@ impl BrowserActions { format: Some(project_settings.save_format.to_cdp_format()), quality: Some(app_config.capture_setting.quality as u32), // only clip for cropped captures; None = current viewport - clip: clip, + clip, from_surface: Some(app_config.capture_setting.capture_from_surface), // true only for cropped, since the clip uses document coordinates // and may sit outside the visible area @@ -406,7 +403,7 @@ impl BrowserActions { let crop_timeout = AppConfig::load().capture_setting.crop_timeout; tracing::info!("waiting for selector {}", m_full_evaluation.clone()); - let rr = loop { + loop { if Instant::now() - started_at > Duration::from_secs(crop_timeout) { tracing::error!("Timed out waiting for selector"); break Err(anyhow::anyhow!("Timed out waiting for selector")); @@ -454,8 +451,7 @@ impl BrowserActions { )); } } - }; - rr + } }) .await; let Ok(selection) = selection else { @@ -571,8 +567,8 @@ impl BrowserActions { &capture_url, &tab_title, ); - if saved.saved { - if let Err(e) = entity.update(cx, |_, cx| { + if saved.saved + && let Err(e) = entity.update(cx, |_, cx| { if saved.is_replaced { cx.emit(BrowserActionsEvents::Replace( saved.save_path.clone(), @@ -582,9 +578,9 @@ impl BrowserActions { cx.emit(BrowserActionsEvents::Add(saved.save_path.clone())); } cx.notify(); - }) { - tracing::error!("failed to emit event: {:?}", e); - } + }) + { + tracing::error!("failed to emit event: {:?}", e); } }) .detach(); diff --git a/src/scenes/edit/image_view.rs b/src/scenes/edit/image_view.rs index 57cd4fc..d6e7fb8 100644 --- a/src/scenes/edit/image_view.rs +++ b/src/scenes/edit/image_view.rs @@ -8,10 +8,10 @@ use gpui_kit::component::*; use gpui_kit::prelude::FluentBuilder; use gpui_kit::*; -use crate::files::files; +use crate::files::prelude as files; +use crate::files::prelude::open_in_file_explorer; use crate::scenes::edit::image_view_info_popup::{ImageViewInfoPopup, ImageViewInfoPopupEvents}; use crate::utils::app_icons::AppIcons; -use crate::utils::prelude::open_in_file_explorer; pub enum ImageViewEvents { Replace(PathBuf), OpenInFullscreen(PathBuf), diff --git a/src/scenes/edit/image_view_info_popup.rs b/src/scenes/edit/image_view_info_popup.rs index 432b610..eabac6b 100644 --- a/src/scenes/edit/image_view_info_popup.rs +++ b/src/scenes/edit/image_view_info_popup.rs @@ -11,9 +11,11 @@ use gpui_kit::component::label::Label; use gpui_kit::component::popover::Popover; use gpui_kit::prelude::FluentBuilder; use gpui_kit::*; +use std::path::Path; use crate::config::AppConfig; -use crate::files::files::{self, read_capture_url}; +use crate::files::prelude as files; +use crate::files::prelude::read_capture_url; use crate::utils::app_icons::AppIcons; use crate::utils::{date_format::DateFormat, time_format::TimeFormat}; pub enum ImageViewInfoPopupEvents { @@ -52,14 +54,14 @@ impl ImageViewInfoPopup { file_type, } } - fn get_file_type(path: &PathBuf) -> String { + fn get_file_type(path: &Path) -> String { path.extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_string() } - fn get_capture_url(path: &PathBuf) -> Option { - match std::fs::read(&path) { + fn get_capture_url(path: &Path) -> Option { + match std::fs::read(path) { Ok(data) => { if let Some(url) = read_capture_url(&data) { println!("captured from {url}"); diff --git a/src/scenes/edit/project_settings.rs b/src/scenes/edit/project_settings.rs index 5672d53..8a01054 100644 --- a/src/scenes/edit/project_settings.rs +++ b/src/scenes/edit/project_settings.rs @@ -14,6 +14,7 @@ use gpui_kit::{ prelude::FluentBuilder, *, }; +use std::path::Path; use std::sync::Arc; use rfd::FileDialog; @@ -29,7 +30,7 @@ pub struct ProjectSettings { } impl ProjectSettings { - pub fn new(project_path: &PathBuf, window: &mut Window, cx: &mut Context) -> Self { + pub fn new(project_path: &Path, window: &mut Window, cx: &mut Context) -> Self { let project_settings = ProjectSettingsData::load(project_path); let save_value = crate::config::ImageFormat::all() .iter() @@ -47,15 +48,13 @@ impl ProjectSettings { cx, ) }); - let make_save_to = project_path - .clone() - .join(project_settings.save_to_dir.clone()); + let make_save_to = project_path.join(project_settings.save_to_dir.clone()); let save_to = cx.new(|cx| { InputState::new(window, cx).default_value(make_save_to.to_string_lossy().to_string()) }); Self { - project_path: project_path.clone(), + project_path: project_path.to_path_buf(), data: project_settings, save_format_state: state, save_to, diff --git a/src/scenes/edit/view.rs b/src/scenes/edit/view.rs index e93551c..cdc1b32 100644 --- a/src/scenes/edit/view.rs +++ b/src/scenes/edit/view.rs @@ -15,12 +15,14 @@ use gpui_kit::prelude::FluentBuilder; use gpui_kit::*; use gpui_kit::{px, size}; use rust_i18n::t; +use std::path::Path; +use std::sync::Arc; use crate::components::sort_button::{SortButton, SortButtonEvent}; use crate::components::window_decor::WindowDecor; use crate::config::AppConfig; use crate::config::project_settings_data::ProjectSettingsData; -use crate::files::files; +use crate::files::prelude as files; use crate::scenes::app::MyApp; use crate::scenes::edit::browser_actions::{BrowserActions, BrowserActionsEvents}; use crate::scenes::edit::image_view::{ImageView, ImageViewEvents}; @@ -95,7 +97,7 @@ impl EditPage { window, cx, ), - browser_action: browser_action, + browser_action, fullscreen_image_cache: RetainAllImageCache::new(cx), sort_button, _sort_button_subscription, @@ -129,7 +131,6 @@ impl EditPage { } }); } - return; } } fn close_full_view(&mut self, cx: &mut Context) { @@ -234,8 +235,10 @@ impl EditPage { tracing::error!("Failed to commit image: {:?}", e); } }; - if let Some(entity) = - this.items.iter().find(|item| item.read(cx).path == *path_id) + if let Some(entity) = this + .items + .iter() + .find(|item| item.read(cx).path == *path_id) { entity.update(cx, |image_view, cx| { image_view.reload(p.clone(), cx); @@ -293,21 +296,22 @@ impl EditPage { ImageViewEvents::Delete(path) => { tracing::debug!("Deleting Image: {:?}", path); this.items.retain(|item| item.read(cx).path != *path); - if app_config.git_setting.auto_commit && is_git_repo { - if let Err(e) = remove_path_and_commit( + if app_config.git_setting.auto_commit + && is_git_repo + && let Err(e) = remove_path_and_commit( &repo_path, path.clone().as_path(), "description.delete_screenshot", - ) { - tracing::error!("Failed to commit image: {:?}", e); - } + ) + { + tracing::error!("Failed to commit image: {:?}", e); }; this.browser_action.update(cx, |action, cx| { // cancel replace if the path matches - if let Some(replace_path) = &action.replace_path { - if replace_path.as_os_str() == path.as_os_str() { - action.cancel_replace(); - } + if let Some(replace_path) = &action.replace_path + && replace_path.as_os_str() == path.as_os_str() + { + action.cancel_replace(); } cx.notify(); }); @@ -325,7 +329,7 @@ impl EditPage { } ImageViewEvents::OpenUrlInBrowser(url) => { this.browser_action.update(cx, |action, cx| { - action.open_url(&url, window, cx); + action.open_url(url, window, cx); cx.notify(); }); } @@ -336,26 +340,26 @@ impl EditPage { /// Builds an image entity for the given `path` and `index`. fn build_image_entity( &mut self, - path: &PathBuf, + path: &Path, index: usize, cx: &mut Context, ) -> Entity { - let path = path.clone(); - let last_modified_timestamp = files::last_modified(&path).unwrap_or(SystemTime::UNIX_EPOCH); + let last_modified_timestamp = files::last_modified(path).unwrap_or(SystemTime::UNIX_EPOCH); let last_modified_timestamp = last_modified_timestamp .duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - cx.new(move |cx| ImageView::new(path, last_modified_timestamp, index, cx)) + cx.new(move |cx| ImageView::new(path.to_path_buf(), last_modified_timestamp, index, cx)) } fn refresh_project(&mut self, cx: &mut Context) { Self::load_project(cx, &self.path); } /// Loads the project at `path` and populates `self.items` with the images. - fn load_project(cx: &mut Context, path: &PathBuf) { - let path = path.clone(); + fn load_project(cx: &mut Context, path: &Path) { + let path = Arc::new(path.to_path_buf()); cx.spawn(async move |entity, cx| { + let path = path.clone(); // Runs on a background thread, why? UI stays responsive. entity .update(cx, |entity, cx| { @@ -423,7 +427,7 @@ impl Render for EditPage { .collect(); let error = self.error.as_ref().map(|error| error.to_string()); let sizing = images_to_show.len(); - let rows = (sizing + cols - 1) / cols; // ceil division + let rows = sizing.div_ceil(cols); // ceil division let row_height = px(250.); let col_width = width.div(cols as f32).sub(px(12.)); //remove gap diff --git a/src/scenes/home/project_info.rs b/src/scenes/home/project_info.rs index cfb4dbc..197e063 100644 --- a/src/scenes/home/project_info.rs +++ b/src/scenes/home/project_info.rs @@ -8,12 +8,12 @@ use gpui_kit::prelude::FluentBuilder; use gpui_kit::*; use crate::config::AppConfig; +use crate::files::prelude::open_in_file_explorer; use crate::scenes::app::MyApp; use crate::scenes::edit::view::EditPage; use crate::utils::app_icons::AppIcons; use crate::utils::app_projects::AppProjectInfo; use crate::utils::git::GitRepoInfo; -use crate::utils::prelude::open_in_file_explorer; pub enum ProjectInfoEvent { Delete(PathBuf), } @@ -170,7 +170,7 @@ impl ProjectInfo { } fn title(&self, cx: &mut Context) -> impl IntoElement { let name = self.name.clone(); - let index = self.index.clone(); + let index = self.index; let danger = cx.theme().danger; div().child( div() @@ -193,7 +193,7 @@ impl ProjectInfo { fn body(&self, cx: &mut App) -> impl IntoElement { let name = self.name.clone(); let path = self.path.clone(); - let index = self.index.clone(); + let index = self.index; let last_accessed_datetime = self.last_accessed_datetime.clone(); let app = self.app.clone(); let repo_info = self.repo_info.clone(); @@ -258,7 +258,7 @@ impl ProjectInfo { .label(t!("label.open_in_editor")) .primary() .on_click(move |_, window, cx| { - let is_dirty = repo_info.as_ref().map_or(false, |r| r.dirty); + let is_dirty = repo_info.as_ref().is_some_and(|r| r.dirty); let app_config = AppConfig::load(); if is_dirty && app_config.git_setting.auto_commit { let name = name.clone(); diff --git a/src/scenes/home/view.rs b/src/scenes/home/view.rs index b48220e..3e49fdc 100644 --- a/src/scenes/home/view.rs +++ b/src/scenes/home/view.rs @@ -71,7 +71,7 @@ impl HomePage { loading: true, projects: Vec::new(), error: None, - add_project_dialog: add_project_dialog, + add_project_dialog, _project_saved_subscription, _project_delete_subscriptions: Vec::new(), _sort_button_subscription, @@ -150,7 +150,7 @@ impl HomePage { let path = std::path::Path::new(&project.path).to_path_buf(); ProjectInfo { - app: app, + app, index: index as u16, name: project.name.clone(), repo_info: get_git_repo_info(&path), @@ -240,7 +240,9 @@ impl Render for HomePage { .ghost() .child(AppIcons::Settings) .on_click(move |_, window, cx| { - open_settings(SettingDefaultOpen::General, window, cx); + window.defer(cx, |window, cx| { + open_settings(SettingDefaultOpen::General, window, cx); + }); }), ), ) diff --git a/src/scenes/settings/capture_page.rs b/src/scenes/settings/capture_page.rs index 352f412..31d95da 100644 --- a/src/scenes/settings/capture_page.rs +++ b/src/scenes/settings/capture_page.rs @@ -103,7 +103,7 @@ impl CapturePage { let debouncer = debouncer.clone(); move |val: SharedString, cx: &mut App| { debouncer.config.borrow_mut().capture_setting.image_format = - ImageFormat::from_value(&val.to_string()); + ImageFormat::from_value(val.as_ref()); debouncer.schedule(cx); } diff --git a/src/scenes/settings/general_page.rs b/src/scenes/settings/general_page.rs index 1f4895d..f1aaaa3 100644 --- a/src/scenes/settings/general_page.rs +++ b/src/scenes/settings/general_page.rs @@ -1,8 +1,17 @@ +use gpui_kit::component::{Sizable, WindowExt}; +use gpui_kit::*; use gpui_kit::{ - App, SharedString, - component::setting::{SettingField, SettingGroup, SettingItem, SettingPage}, + App, ParentElement, SharedString, Styled, + base::StyledExt, + component::{ + button::Button, + setting::{SettingField, SettingGroup, SettingItem, SettingPage}, + }, + div, }; +use crate::files::prelude::open_in_file_explorer; +use crate::utils::logging; use crate::{ config::AppConfig, utils::{ @@ -134,7 +143,34 @@ impl GeneralPage { }, ) .default_value(app_config_default.date_format.id()), - )), + )) + .item(SettingItem::render(|options, _, _| { + div() + .h_flex() + .w_full() + .justify_between() + .child(t!("label.export_logs")) + .child( + Button::new("action") + .label(t!("label.download")) + .with_size(options.size()) + .on_click(|_, window, cx| match logging::export_logs() { + Ok(path) => { + window + .push_notification(t!("success.logs_exported"), cx); + let _ = open_in_file_explorer(&path); + } + Err(e) => { + tracing::error!("{:?}", e); + window.push_notification( + t!("error.failed_to_export_logs"), + cx, + ); + } + }), + ) + .into_any_element() + })), ) } } diff --git a/src/utils/app_icons.rs b/src/utils/app_icons.rs index 157c869..df198da 100644 --- a/src/utils/app_icons.rs +++ b/src/utils/app_icons.rs @@ -69,7 +69,9 @@ impl RenderOnce for AppIcons { AppIcons::ListSortAscending => Icon::default().path("list-sort-ascending.svg"), AppIcons::ListSortDescending => Icon::default().path("list-sort-descending.svg"), AppIcons::Info => Icon::default().path("info.svg"), - AppIcons::SquareArrowOutUpRight => Icon::default().path("square-arrow-out-up-right.svg"), + AppIcons::SquareArrowOutUpRight => { + Icon::default().path("square-arrow-out-up-right.svg") + } } } } diff --git a/src/utils/app_projects.rs b/src/utils/app_projects.rs index 786aa27..9dd0d1f 100644 --- a/src/utils/app_projects.rs +++ b/src/utils/app_projects.rs @@ -1,5 +1,5 @@ use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{Context, Error, Result}; use serde::{Deserialize, Serialize}; @@ -123,7 +123,7 @@ impl AppProjects { pub fn set_projects(&mut self, projects: Vec) { self.projects = projects; } - pub fn remove_project(path: &PathBuf) -> Result<(), AppProjectError> { + pub fn remove_project(path: &Path) -> Result<(), AppProjectError> { let (mut projects, error) = Self::load(); if let Some(error) = error { return Err(AppProjectError::Other(error)); diff --git a/src/utils/asset_source.rs b/src/utils/asset_source.rs index 0ee77a9..97bbd9d 100644 --- a/src/utils/asset_source.rs +++ b/src/utils/asset_source.rs @@ -129,7 +129,7 @@ impl Assets { } impl AssetSource for Assets { fn load(&self, path: &str) -> Result>> { - if path.len() < 1 { + if path.is_empty() { return Ok(None); } let bytes = match self.find(path) { diff --git a/src/utils/git.rs b/src/utils/git.rs index c7877fc..e2b0e9f 100644 --- a/src/utils/git.rs +++ b/src/utils/git.rs @@ -129,7 +129,7 @@ fn parse_message(message: &str) -> String { ) .to_string(); } - return t!(message, locales = app_config.language.name_short()).to_string(); + t!(message, locales = app_config.language.name_short()).to_string() } /// Adds a path to the git index and creates a commit with the specified message. /// diff --git a/src/utils/logging.rs b/src/utils/logging.rs new file mode 100644 index 0000000..6c739e6 --- /dev/null +++ b/src/utils/logging.rs @@ -0,0 +1,95 @@ +use std::path::Path; +use tracing_appender::{non_blocking, non_blocking::WorkerGuard, rolling}; +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; + +pub fn init_logging(log_dir: &Path) -> WorkerGuard { + let file = rolling::Builder::new() + .rotation(rolling::Rotation::DAILY) + .filename_prefix("dockeep") + .filename_suffix("log") + .max_log_files(7) // keep a week, delete older + .build(log_dir) + .expect("failed to create log file"); + + let (file_writer, guard) = non_blocking(file); + + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + + tracing_subscriber::registry() + .with(filter) + .with(fmt::layer().with_writer(file_writer).with_ansi(false)) // no color codes in files + .with(cfg!(debug_assertions).then(fmt::layer)) // console only in debug + .init(); + + // log panics too, otherwise they vanish with windows_subsystem = "windows" + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + tracing::error!( + "panic: {info}\n{}", + std::backtrace::Backtrace::force_capture() + ); + default_hook(info); + })); + + guard +} + +use std::{ + fs, + io::{self, Write}, + path::PathBuf, +}; + +use crate::config::AppConfig; + +/// Bundles all log files into one file in the Downloads folder. +/// Returns the path of the file it created. +pub fn export_logs() -> io::Result { + let log_dir = AppConfig::log_dir(); + + let dest_dir = dirs::download_dir() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no Downloads folder found"))?; + fs::create_dir_all(&dest_dir)?; + + let mut files: Vec = fs::read_dir(log_dir)? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.is_file() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("dockeep") && name.ends_with(".log")) + }) + .collect(); + // daily file names contain the date, so name order is chronological + files.sort(); + + if files.is_empty() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "no log files found", + )); + } + + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let dest = dest_dir.join(format!("dockeep-logs-{stamp}.txt")); + let mut out = io::BufWriter::new(fs::File::create(&dest)?); + + writeln!( + out, + "Dockeep {} on {} {}", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH + )?; + + for file in files { + let name = file.file_name().unwrap_or_default().to_string_lossy(); + writeln!(out, "\n===== {name} =====")?; + io::copy(&mut fs::File::open(&file)?, &mut out)?; + } + out.flush()?; + + Ok(dest) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 51d4905..77724b1 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -5,6 +5,7 @@ pub mod asset_source; pub mod date_format; pub mod git; pub mod lanuages; +pub mod logging; pub mod prelude; pub mod random; pub mod save_debouncer; diff --git a/src/utils/prelude.rs b/src/utils/prelude.rs index 05c5cba..3734dc8 100644 --- a/src/utils/prelude.rs +++ b/src/utils/prelude.rs @@ -1,6 +1,5 @@ -use std::ops::Div; -use std::process::Command; use std::{ + ops::Div, path::{Path, PathBuf}, time::{SystemTime, UNIX_EPOCH}, }; @@ -17,17 +16,16 @@ use crate::utils::time_format::TimeFormat; pub fn open_settings(default_open: SettingDefaultOpen, window: &mut Window, cx: &mut App) -> bool { let win_size = window.bounds().size.div(1.5); - cx.open_window( + let open_result = cx.open_window( WindowOptions { - window_bounds: Some(WindowBounds::centered(win_size, &cx)), + window_bounds: Some(WindowBounds::centered(win_size, cx)), window_decorations: Some(WindowDecorations::Client), // no WM frame titlebar: Some(TitlebarOptions { title: Some(SharedString::new("DocKeep Settings")), - + appears_transparent: true, // hides the native bar on Windows/macOS ..Default::default() }), app_id: Some("dockeep".into()), - is_resizable: true, ..Default::default() }, @@ -35,8 +33,14 @@ pub fn open_settings(default_open: SettingDefaultOpen, window: &mut Window, cx: let view = cx.new(|_| SettingsPage::new(default_open)); cx.new(|cx| Root::new(view, window, cx).bordered(false)) }, - ) - .is_ok() + ); + match open_result { + Ok(_) => true, + Err(e) => { + tracing::error!("Failed to open settings window: {:?}", e); + false + } + } } pub fn path_exists_or_none(path: &str) -> Option { @@ -112,33 +116,3 @@ fn timestamp_to_parts(ts: u64) -> (i32, u32, u32, u32, u32) { (year, month, day, hour, minute) } - -pub fn open_in_file_explorer(path: &Path) -> std::io::Result<()> { - #[cfg(target_os = "windows")] - { - // /select, highlights the file itself instead of just opening the folder - Command::new("explorer") - .args(["/select,", &path.to_string_lossy()]) - .spawn()?; - } - - #[cfg(target_os = "macos")] - { - Command::new("open") - .args(["-R", &path.to_string_lossy()]) - .spawn()?; - } - - #[cfg(target_os = "linux")] - { - // Linux has no universal "reveal and select" — fall back to opening the containing folder - let dir = if path.is_dir() { - path - } else { - path.parent().unwrap_or(path) - }; - Command::new("xdg-open").arg(dir).spawn()?; - } - - Ok(()) -}