From 70ebdb17cb73a581e0ac5efb2de22497bee5a678 Mon Sep 17 00:00:00 2001 From: olivroy Date: Fri, 25 Oct 2024 12:08:16 -0400 Subject: [PATCH] * Added `file_move_temp_auto()` to automatically move a Downloads file to your project directory. * Added `active_rs_doc_move('dir')` as a shortcut for `rename_files2(fs::path('dir', fs::path_file(active_rs_doc()), active_rs_doc())`. * Added `file_rename_auto()` as a shortcut for `file.rename(fs::path(fs::path_dir(old_file), new_file, ext = fs::path_ext(old_file)), old_file)` * Added `file_move_auto()` as a shortcut for `file.rename(fs::path(new_dir, fs::path_file(old_file)), old_file)` --- DESCRIPTION | 2 +- NAMESPACE | 4 + NEWS.md | 8 + R/move.R | 115 +++++ R/open.R | 907 +++++++++++++++++++------------------ man/active_rs_doc_copy.Rd | 3 +- man/active_rs_doc_move.Rd | 34 ++ man/file_move_temp_auto.Rd | 17 + man/file_rename_auto.Rd | 34 ++ reuseme.Rproj | 1 + 10 files changed, 683 insertions(+), 442 deletions(-) create mode 100644 R/move.R create mode 100644 man/active_rs_doc_move.Rd create mode 100644 man/file_move_temp_auto.Rd create mode 100644 man/file_rename_auto.Rd diff --git a/DESCRIPTION b/DESCRIPTION index 69f08f9..38a2b55 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: reuseme Title: Collections of Utility Functions to Work Across Projects -Version: 0.0.2.9006 +Version: 0.0.2.9007 Authors@R: person("Olivier", "Roy", , "olivierroy71@hotmail.com", role = c("aut", "cre")) Description: Allows you to browse current projects, rename files safely, diff --git a/NAMESPACE b/NAMESPACE index 85964ff..b0fa660 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -4,6 +4,7 @@ S3method(print,outline_report) export(active_rs_doc) export(active_rs_doc_copy) export(active_rs_doc_delete) +export(active_rs_doc_move) export(active_rs_doc_nav) export(arrange_identity) export(browse_pkg) @@ -15,7 +16,10 @@ export(count_pct) export(dir_outline) export(distinct_identity) export(extract_cell_value) +export(file_move_auto) +export(file_move_temp_auto) export(file_outline) +export(file_rename_auto) export(filter_detect) export(filter_identity) export(filter_if_any) diff --git a/NEWS.md b/NEWS.md index 762278e..85e339d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -21,6 +21,14 @@ that will passed on to `proj_list()` * `use_todo("global::todo")` no longer works out of the box. You need to set `options(reuseme.global_todo = fs::path("Documents"))` explicitly (in .Rprofile) for example to make sure reuseme can write in a directory. + +* Added `file_move_temp_auto()` to automatically move a Downloads file to your project directory. + +* Added `active_rs_doc_move('dir')` as a shortcut for `rename_files2(fs::path('dir', fs::path_file(active_rs_doc()), active_rs_doc())`. + +* Added `file_rename_auto()` as a shortcut for `file.rename(fs::path(fs::path_dir(old_file), new_file, ext = fs::path_ext(old_file)), old_file)` + +* Added `file_move_auto()` as a shortcut for `file.rename(fs::path(new_dir, fs::path_file(old_file)), old_file)` ## Fixes diff --git a/R/move.R b/R/move.R new file mode 100644 index 0000000..351b45c --- /dev/null +++ b/R/move.R @@ -0,0 +1,115 @@ +#' Move temporary file automatically from the R console +#' +#' It works well when you have no API to download a file, but still want a fast R implementation. +#' +#' @param destdir The desired directory to send this to +#' @export +#' @seealso [file_rename_auto()] +file_move_temp_auto <- function(destdir) { + rlang::check_required(destdir) + if (dir.exists(destdir)) { + destdir <- fs::path_expand_r(destdir) + } + if (!fs::is_dir(destdir)) { + cli::cli_abort(c( + "Can't copy file to destdir", + i = "{.path {destdir}} doesn't exist." + )) + } + most_recent_file_in_downloads_folder <- fs::dir_info(c("~/Downloads", "~/Desktop")) + source_file <- most_recent_file_in_downloads_folder[[ + which.max(most_recent_file_in_downloads_folder$modification_time), + "path" + ]] + diff_time <- difftime(Sys.time(), file.mtime(source_file), units = "mins") + if (is.na(diff_time) || diff_time > 60) { + rlang::check_installed("prettyunits") + cli::cli_abort("{.file {source_file}} was created {prettyunits::pretty_dt(diff_time)} ago.") + } + destfile <- fs::path(destdir, fs::path_file(source_file)) + fs::file_move(source_file, destfile) + cli::cli_inform(c( + v = "Successfully moved {.file {source_file}} to {.file {destfile}}.", + "Open with {.run fs::file_show('{as.character(destfile)}')}", + "For new name, call `reuseme::file_rename_auto('better-name-sans-ext')` immediately." + )) + invisible(destfile) +} + +#' Move file automatically between folders +#' +#' @description +#' * `file_rename_auto()` automatically renames your file to a better name while keeping the same folder structure +#' +#' +#' # Advantages +#' +#' Instead of calling `fs::file_move("path/to/dir/file.R", "path/to/dir/new-file.R")`, you can just call +#' `file_rename_auto("new-file", "path/to/dir/file.R")` +#' +#' Instead of calling `fs::file_move("path/to/dir/file.R", "path/to/new-dir/file.R")`, you can just call +#' `file_move_auto("new-dir", "path/to/dir/file.R")` +#' +#' If the functions are used in conjunction with [file_move_temp_auto()], +#' +#' @export +#' @param new_name,new_dir New directory or file name (without extension) +#' @param old_file The old file name +#' +#' @returns The new full path name, invisibly, allowing you to call the functions another time. +file_rename_auto <- function(new_name, old_file = .Last.value) { + if (!fs::is_file(old_name)) { + cli::cli_abort("incorrect context for .rename_temp.") + } + # path_dir() behaves weirdly if not an fs path + # fs::path_dir("~/") + # fs::path_dir(fs::path("~)) + # Workaround r-lib/fs#459 + old_path <- fs::path_real(old_name) + ext <- fs::path_ext(old_name) + # TRICK {{wf}} path_ext_set replaces or appends extension + new_name <- fs::path_ext_set(new_name, ext) + new_path <- fs::path( + fs::path_dir(old_path), + fs::path_file(new_name) + ) + fs::file_move(old_path, new_path) + cli::cli_inform(c( + v = "Successfully moved {.file {old_name}} to {.val {new_name}}.", + "Open with {.run fs::file_show('{as.character(new_path)}')}", + "For new name, call `reuseme::file_rename_auto('better-name-sans-ext')` immediately.", + "For new dir, call `reuseme::file_move_auto('better-name-sans-ext')` immediately." + + )) + invisible(new_path) +} + +#' @export +#' @rdname file_rename_auto +file_move_auto <- function(new_dir, old_file = .Last.value) { + if (!fs::file_exists(old_file) || fs::is_dir(old_fil)) { + cli::cli_abort("Incorrect usage. {.arg old_file} = {.file {old_file}} doesn't exist.") + } + + old_file_name <- fs::path_file(old_file) + new_file_name <- fs::path(new_dir, old_file_name) + fs::file_move( + old_file, + new_file_name + ) + cli::cli_inform(c( + v = "Successfully moved {.val {old_file_name}} to {.file {new_file_name}}.", + "i" = "Call `reuseme::file_rename_auto('new-file-with-no-ext')` to rename" + )) + invisible(new_file_name) +} + +# 1. file_move shortcuts +# file_move_auto() is a wrapper of file_move_dir() +# file_rename() +# file_move_dir() +# move dir -> same name +# rename (same dir) + + + diff --git a/R/open.R b/R/open.R index 65fc794..2f101d6 100644 --- a/R/open.R +++ b/R/open.R @@ -1,440 +1,467 @@ -#' Open a Document in RStudio -#' -#' Wrapper around [rstudioapi::documentOpen()], but with `fs paths`, for consistency. -#' If the file could not be opened, a clickable hyperlink is displayed. -#' -#' * `active_rs_doc()` is a wrapper around [rstudioapi::documentPath()] that handles -#' unsaved files gracefully -#' @inheritParams rstudioapi::documentOpen -#' @param move_cursor Boolean; move the cursor to the requested location after -#' opening the document? -#' @return Invisibly returns the document id -#' @export -#' @examples -#' if (FALSE) { -#' # open the fictious file.R at line 5 -#' open_rs_doc("file.R", line = 5) -#' } -#' -open_rs_doc <- function(path, line = -1L, col = -1L, move_cursor = TRUE) { - path <- fs::path_real(path) - check_number_whole(line) - check_number_whole(col) - - if (col != -1L && line == -1L) { - cli::cli_abort("Internal error, you can't specify col only.") - } - - if (is_rstudio() && rstudioapi::hasFun("documentOpen")) { - doc_id <- rstudioapi::documentOpen(path = path, line = line, col = col, moveCursor = move_cursor) - if (is.null(doc_id)) { - # FIXME why is this code like this? - file_pos_string <- path - if (line != -1L) pos_string <- paste0(pos_string, ":", line) - if (col != -1L) pos_string <- paste0(pos_string, ":", col) - cli::cli_bullets() - } - return(invisible(doc_id)) - } - - # Fallback if rstudioapi not available - utils::file.edit(path) - if (line != -1L || col != -1L) { - cli::cli_inform(c( - "Jump to {.file {path}:{line}:{col}}" - )) - } - invisible(path) -} - -active_rs_proj <- function() { - NULL -} - -#' @name open_rs_doc -#' @export -active_rs_doc <- function() { - if (!interactive() && !is_rstudio()) { - return("Non-existing doc") - } - - if (is_rstudio(f = "documentPath")) { - # Not yet supported in Positron - unsaved_doc <- tryCatch(rstudioapi::documentPath(), error = function(e) TRUE) - if (isTRUE(unsaved_doc)) { - return(NULL) - } - path <- tryCatch(rstudioapi::documentPath(), error = function(e) { - cli::cli_abort("Either RStudio is not available or you are trying to map an unsaved file") - }) - path <- fs::path_expand_r(path) - } else if (is_rstudio(f = "getSourceEditorContext")) { - # Will work for Positron >= 2024.11 - # https://github.com/posit-dev/positron/issues/5112 - path <- rstudioapi::getSourceEditorContext()$path - - # Handle Positron unsaved docs. - if (grepl("Untitled", path)) { - return(NULL) - } - } else { - cli::cli_abort("Not in RStudio or Positron. rstudioapi problem.") - } - - - active_proj <- proj_get2() - if (is.null(active_proj)) { - return(invisible(path)) - } - if (isTRUE(fs::path_has_parent(path, active_proj))) { - path <- fs::path_rel(path) - } - path - # likely not hapenning on RStudio >= 2023.06.2 -} - -#' Copy the active document to the same location -#' -#' The goal is to provide things that RStudio or usethis doesn't provide natively. -#' -#' For example, `active_rs_doc_rename()` will not happen, because it is already easy -#' to do so via the RStudio IDE. -#' -#' @param new The new file name, that will be copied in the same -#' directory as the [active document][active_rs_doc()] -#' @param old The old name, defaults to the active document. -#' @inheritParams rlang::args_dots_empty -#' @returns The new file name -#' @family document manipulation helpers -#' @seealso [rename_files2()] -#' @export -active_rs_doc_copy <- function(new = NULL, ..., old = NULL) { - rlang::check_dots_empty() - old <- old %||% active_rs_doc() - - if (is.null(old)) { - cli::cli_abort("Unsaved document, focus on the saved doc you want to save.") - } - - if (!fs::path_ext(old) %in% c("md", "R", "qmd", "Rmd")) { - cli::cli_abort("Only R and md docs for now") - } - old_path_file <- fs::path_ext_remove(fs::path_file(old)) - - if (grepl("r-profile|Rprofile", old)) { - cli::cli_abort("Attempting to copy Rprofile (focus on the document you want)") - } - if (is.null(new)) { - new_name <- paste0(old_path_file, "-new") - } else { - new_name <- sub("\\.R|\\.[Rq]?md$", "", new) - } - # Hack to ensure file/file.R will be correctly renamed. - new_path <- sub(paste0(old_path_file, "\\."), paste0(new_name, "."), old) - - copied <- file.copy(old, new_path, overwrite = FALSE) - if (copied) { - cli::cli_inform(c( - v = "Copied {.file {old}}", - i = "Edit {.file {new_path}}" - )) - } else { - cli::cli_abort(c( - "Did not overwrite the file {.file {new_path}}.", - i = "Set {.arg new} explicitly or use {.fn fs::file_copy}." - )) - } - invisible(new_path) -} - -#' Delete the active RStudio document safely -#' -#' @description -#' `r lifecycle::badge('experimental')` -#' -#' Gathers informative summary about the document you are about to delete. -#' -#' -#' Will delete more easily if file name starts with `temp-`, if file is untracked and recent. -#' @return Called for side-effects. The document content invisibly if deleting and reason. -#' @export -#' @family document manipulation helpers -#' @examplesIf FALSE -#' active_rs_doc_delete() -active_rs_doc_delete <- function() { - if (!rlang::is_interactive() || !is_rstudio()) { - cli::cli_abort(c("Can't delete files in non-interactive sessions.")) - } - doc <- active_rs_doc() - reasons_deleting <- NULL - reasons_not_deleting <- NULL - will_delete <- NULL - if (is.null(doc)) { - cli::cli_abort(c("Can't delete an unsaved file.", i = "Save the file first.")) - } - - elems <- normalize_proj_and_path(doc) - - if (fs::is_dir(elems$full_path)) { - cli::cli_abort("Must be a file", .internal = TRUE) - } - if (interactive() && is_rstudio()) { - rstudioapi::documentSave() - } - cli::cli_inform(c( - "i" = "Checking if active file can be deleted safely." - )) - if (!is.na(elems$project)) { - is_git <- is_git(elems$project) - if (!is_git) { - cli::cli_abort("Can't delete a file in non-git directory.") - } - } else { - is_git <- FALSE - } - - if (is_git) { - rlang::check_installed("gert") - stat_files <- gert::git_stat_files(elems$rel_path, repo = elems$project) - is_untracked <- is.na(stat_files$modified) - } else { - stat_files <- data.frame(modified = NA) - is_untracked <- NA - } - - if (!is.na(stat_files$modified)) { - print(stat_files) - file_status <- gert::git_status(pathspec = elems$rel_path, repo = elems$project) - if (nrow(file_status) > 0) { - print(file_status) - - if (all(file_status$status == "conflicted")) { - will_delete <- append(will_delete, TRUE) - reasons_deleting <- c(reasons_deleting, "the file is a renamed git conflict") - } else { - will_delete <- append(will_delete, FALSE) - reasons_not_deleting <- c(reasons_not_deleting, "the file is tracked with git") - } - } - file_info <- fs::file_info(elems$rel_path) - } else { - if (is_git) { - will_delete <- append(will_delete, TRUE) - reasons_deleting <- c(reasons_deleting, "file is untracked") - } else { - # ? - } - file_status <- NULL - outline <- withCallingHandlers( - file_outline(path = elems$full_path), - error = function(e) { - cli::cli_warn("File outline failed somehow. Please report.", parent = e) - NA - } - ) - if (!is.null(outline) && !identical(outline, NA)) { - will_delete <- append(will_delete, FALSE) - reasons_not_deleting <- c( - reasons_not_deleting, "couldn't explore the outline, worth taking a look." - ) - outline <- NULL - } else if (!is.null(outline) && nrow(outline) > 0) { - print(utils::head(outline)) - will_delete <- append(will_delete, FALSE) # perhaps worth taking a look - reasons_not_deleting <- c(reasons_not_deleting, "it has contents") - } else { - reasons_deleting <- append(reasons_deleting, "empty outline") - } - if (!is.na(elems$project)) { - file_info <- fs::file_info(elems$rel_path) - } else { - file_info <- fs::file_info(elems$full_path) - } - } - - parent_dir <- fs::path_file(fs::path_dir(elems$full_path)) - - if (grepl("^temp", fs::path_file(elems$rel_path)) || - (!parent_dir %in% c("tests", "testthat") && grepl("^test-", fs::path_file(elems$rel_path)))) { - reasons_deleting <- c(reasons_deleting, "it has the temp- prefix.") - will_delete <- append(will_delete, TRUE) - } - if (parent_dir %in% c("Downloads", "Desktop")) { - # Consider that files in the Downloads or Desktop are temp files. - will_delete <- append(will_delete, TRUE) - reasons_deleting <- c(reasons_deleting, "in the ~/Downloads or ~/Desktop folder.") - } - - if (isTRUE(is_untracked)) { - # file created in the last hour - creation_recent <- - difftime(Sys.time(), file_info$birth_time, units = "hours") < 1 - - if (creation_recent) { - reasons_deleting <- c(reasons_deleting, "very recent") - - will_delete <- append(will_delete, TRUE) - } else { - reasons_not_deleting <- c(reasons_not_deleting, "older untracked file, better to look at outline to see if not important.") - will_delete <- append(will_delete, FALSE) - } - } - - # TODO structure and summarise information. - file_info <- dplyr::select(file_info, path, size, dplyr::ends_with("time")) - file_info <- dplyr::select(file_info, !dplyr::where(\(x) all(is.na(x)))) - file_info <- dplyr::select(file_info, !dplyr::any_of(rm_duplicate_columns(file_info))) - if (!is.null(file_info$size) && all(file_info$size == 0)) { - will_delete <- append(will_delete, c(TRUE, TRUE)) - reasons_deleting <- c(reasons_deleting, "file is empty") - file_info$size <- NULL - } - pillar::glimpse(file_info) - - # defaults to FALSE if equality :) - # print(table(will_delete)) - will_delete_decision <- as.logical(names(which.max(table(will_delete)))) - # only true or false acceptable! - check_bool(will_delete_decision) - if (isTRUE(will_delete_decision)) { - cli::cli_inform(c( - "v" = "Deleted the active document {.val {elems$rel_path}} because {reasons_deleting}.", - # FIXME (upstream) the color div doesn't go all the way r-lib/cli#694 - "i" = paste(cli::col_grey("The deleted file"), "{.path {elems$full_path}}", cli::col_grey("contents are returned invisibly in case you need them.")) - )) - contents <- readLines(elems$full_path, encoding = "UTF-8") - fs::file_delete(elems$full_path) - return(invisible(contents)) - } - - cli::cli_abort(c( - "Can't delete the active document {.path {elems$rel_path}}, because {reasons_not_deleting}.", - "It outweighs the reasons for deleting: {reasons_deleting}." - )) -} - -active_rs_doc_sitrep <- function() { - # The goal is to prepare it for action. - # print file outline - # git status (untracked, modified, staged, etc.) - # git history. - # git compare with previous state - # mod time - # git mod time - # print ssh short commit id.. - list( - staged = NA, - # etc. - ) -} - -active_rs_doc_undo_local_changes <- function() { - # When active_rs_doc_delete is mature, create this one! -} - - -is.POSIXct <- function(x) inherits(x, "POSIXct") -rm_duplicate_columns <- function(x) { - x_date <- dplyr::select( - x, - dplyr::where(is.POSIXct) - ) - - if (ncol(x_date) <= 1) { - return(integer(0)) - } - - which <- integer(0) - for (i in 2:ncol(x_date)) { - # numeric rounds to seconds - diff <- difftime( - x_date[[1]], - x_date[[i]], - units = "secs" - ) - diff <- abs(diff) - # consider the same if time within 1 second - if (all(diff < 1)) { - which <- c(which, i) - } - } - if (length(which) > 0) { - names(x_date)[which] - } else { - integer(0) - } -} - -path_metadata <- function() { - characteristics <- NULL -} - -normalize_proj_and_path <- function(path, call = caller_env()) { - full_path <- fs::path_real(fs::path_expand_r(path)) - if (!fs::is_file(full_path)) { - cli::cli_abort("{.path {path}} does not exist.", call = call) - } - project <- - tryCatch( - rprojroot::find_root_file(criterion = rprojroot::is_rstudio_project, path = full_path), - warning = function(e) NULL, - error = function(e) NULL, - message = function(e) NULL - ) - if (is.null(project)) { - return( - list( - project = NA, - rel_path = fs::path_file(full_path), - full_path = full_path - ) - ) - } - - rel_path <- fs::path_rel(full_path, start = project) - - if (grepl("../", rel_path, fixed = TRUE)) { - cli::cli_abort(c( - "Something went wrong in path normalization.", - "With path = {path}, detected project = {project}, derived full_path = {full_path} and rel_path = {rel_path}" - ), call = call) - } - list( - project = project, - rel_path = rel_path, - full_path = full_path - ) -} - -#' Open Files Pane at current document location -#' -#' Easily navigate to active file document. -#' -#' Wrapper around [executeCommand("activateFiles")][rstudioapi::executeCommand()] + -#' [rstudioapi::filesPaneNavigate()] + [rstudioapi::getActiveDocumentContext()] -#' -#' @param path A path to file to navigate to (default active document). -#' -#' @returns NULL, called for its side effects. -#' @export -active_rs_doc_nav <- function(path = active_rs_doc()) { - if (!is_rstudio(f = "filesPaneNavigate") || !interactive()) { - cli::cli_abort("Must use in RStudio interactive sessions.") - } - if (is.null(path)) { - cli::cli_abort("Can't navigate to an unsaved file!") - } - if (fs::is_file(path)) { - dir <- fs::path_dir(path) - } else if (fs::is_dir(path)) { - dir <- path - } else { - cli::cli_abort("{.arg path} must be an existing file or directory.") - } - rstudioapi::executeCommand("activateFiles") - rstudioapi::filesPaneNavigate(dir) - cli::cli_inform(c( - "v" = "Navigated to {.path {dir}} in RStudio Files Pane." - )) - invisible() -} +#' Open a Document in RStudio +#' +#' Wrapper around [rstudioapi::documentOpen()], but with `fs paths`, for consistency. +#' If the file could not be opened, a clickable hyperlink is displayed. +#' +#' * `active_rs_doc()` is a wrapper around [rstudioapi::documentPath()] that handles +#' unsaved files gracefully +#' @inheritParams rstudioapi::documentOpen +#' @param move_cursor Boolean; move the cursor to the requested location after +#' opening the document? +#' @return Invisibly returns the document id +#' @export +#' @examples +#' if (FALSE) { +#' # open the fictious file.R at line 5 +#' open_rs_doc("file.R", line = 5) +#' } +#' +open_rs_doc <- function(path, line = -1L, col = -1L, move_cursor = TRUE) { + path <- fs::path_real(path) + check_number_whole(line) + check_number_whole(col) + + if (col != -1L && line == -1L) { + cli::cli_abort("Internal error, you can't specify col only.") + } + + if (is_rstudio() && rstudioapi::hasFun("documentOpen")) { + doc_id <- rstudioapi::documentOpen(path = path, line = line, col = col, moveCursor = move_cursor) + if (is.null(doc_id)) { + # FIXME why is this code like this? + file_pos_string <- path + if (line != -1L) pos_string <- paste0(pos_string, ":", line) + if (col != -1L) pos_string <- paste0(pos_string, ":", col) + cli::cli_bullets() + } + return(invisible(doc_id)) + } + + # Fallback if rstudioapi not available + utils::file.edit(path) + if (line != -1L || col != -1L) { + cli::cli_inform(c( + "Jump to {.file {path}:{line}:{col}}" + )) + } + invisible(path) +} + +active_rs_proj <- function() { + NULL +} + +#' @name open_rs_doc +#' @export +active_rs_doc <- function() { + if (!interactive() && !is_rstudio()) { + return("Non-existing doc") + } + + if (is_rstudio(f = "documentPath")) { + # Not yet supported in Positron + unsaved_doc <- tryCatch(rstudioapi::documentPath(), error = function(e) TRUE) + if (isTRUE(unsaved_doc)) { + return(NULL) + } + path <- tryCatch(rstudioapi::documentPath(), error = function(e) { + cli::cli_abort("Either RStudio is not available or you are trying to map an unsaved file") + }) + path <- fs::path_expand_r(path) + } else if (is_rstudio(f = "getSourceEditorContext")) { + # Will work for Positron >= 2024.11 + # https://github.com/posit-dev/positron/issues/5112 + path <- rstudioapi::getSourceEditorContext()$path + + # Handle Positron unsaved docs. + if (grepl("Untitled", path)) { + return(NULL) + } + } else { + cli::cli_abort("Not in RStudio or Positron. rstudioapi problem.") + } + + + active_proj <- proj_get2() + if (is.null(active_proj)) { + return(invisible(path)) + } + if (isTRUE(fs::path_has_parent(path, active_proj))) { + path <- fs::path_rel(path) + } + path + # likely not hapenning on RStudio >= 2023.06.2 +} + +#' Copy the active document to the same location +#' +#' The goal is to provide things that RStudio or usethis doesn't provide natively. +#' +#' For example, `active_rs_doc_rename()` will not happen, because it is already easy +#' to do so via the RStudio IDE. +#' +#' @param new The new file name, that will be copied in the same +#' directory as the [active document][active_rs_doc()] +#' For `active_rs_doc_move()`, a directory. +#' @param old The old name, defaults to the active document. +#' @inheritParams rlang::args_dots_empty +#' @returns The new file name +#' @family document manipulation helpers +#' @seealso [rename_files2()] +#' @export +active_rs_doc_copy <- function(new = NULL, ..., old = NULL) { + rlang::check_dots_empty() + rlang::check_dots_empty() + old <- old %||% active_rs_doc() + + if (is.null(old)) { + cli::cli_abort("Unsaved document, focus on the saved doc you want to save.") + } + + if (!fs::path_ext(old) %in% c("md", "R", "qmd", "Rmd")) { + cli::cli_abort("Only R and md docs for now") + } + old_path_file <- fs::path_ext_remove(fs::path_file(old)) + + if (grepl("r-profile|Rprofile", old)) { + cli::cli_abort("Attempting to copy Rprofile (focus on the document you want)") + } + if (is.null(new)) { + new_name <- paste0(old_path_file, "-new") + } else { + new_name <- sub("\\.R|\\.[Rq]?md$", "", new) + } + # Hack to ensure file/file.R will be correctly renamed. + new_path <- sub(paste0(old_path_file, "\\."), paste0(new_name, "."), old) + + copied <- file.copy(old, new_path, overwrite = FALSE) + if (copied) { + cli::cli_inform(c( + v = "Copied {.file {old}}", + i = "Edit {.file {new_path}}" + )) + } else { + cli::cli_abort(c( + "Did not overwrite the file {.file {new_path}}.", + i = "Set {.arg new} explicitly or use {.fn fs::file_copy}." + )) + } + invisible(new_path) +} + +#' Move the active document to another directory +#' +#' Wrapper around [rename_files2()], but shortcut to allow renaming the active file. +#' +#' @param new A new directory +#' @param old The old file (defaults to the active RStudio document.) +#' @inheritDotParams rename_files2 -old +#' @export +#' @inherit rename_files2 return +active_rs_doc_move <- function(new = NULL, old = NULL, ...) { + if (is.null(new)) { + cli::cli_abort("{.arg new} must be supplied and be a new directory") + } + rlang::check_dots_empty() + old <- old %||% active_rs_doc() + + if (is.null(old)) { + cli::cli_abort("Unsaved document, focus on the saved doc you want to save.") + } + + old_file_name <- fs::path_file(old) + new_file <- fs::path(new, old_file_name) + rename_files2(new = new_file, old = old, ...) +} + +#' Delete the active RStudio document safely +#' +#' @description +#' `r lifecycle::badge('experimental')` +#' +#' Gathers informative summary about the document you are about to delete. +#' +#' +#' Will delete more easily if file name starts with `temp-`, if file is untracked and recent. +#' @return Called for side-effects. The document content invisibly if deleting and reason. +#' @export +#' @family document manipulation helpers +#' @examplesIf FALSE +#' active_rs_doc_delete() +active_rs_doc_delete <- function() { + if (!rlang::is_interactive() || !is_rstudio()) { + cli::cli_abort(c("Can't delete files in non-interactive sessions.")) + } + doc <- active_rs_doc() + reasons_deleting <- NULL + reasons_not_deleting <- NULL + will_delete <- NULL + if (is.null(doc)) { + cli::cli_abort(c("Can't delete an unsaved file.", i = "Save the file first.")) + } + + elems <- normalize_proj_and_path(doc) + + if (fs::is_dir(elems$full_path)) { + cli::cli_abort("Must be a file", .internal = TRUE) + } + if (interactive() && is_rstudio()) { + rstudioapi::documentSave() + } + cli::cli_inform(c( + "i" = "Checking if active file can be deleted safely." + )) + if (!is.na(elems$project)) { + is_git <- is_git(elems$project) + if (!is_git) { + cli::cli_abort("Can't delete a file in non-git directory.") + } + } else { + is_git <- FALSE + } + + if (is_git) { + rlang::check_installed("gert") + stat_files <- gert::git_stat_files(elems$rel_path, repo = elems$project) + is_untracked <- is.na(stat_files$modified) + } else { + stat_files <- data.frame(modified = NA) + is_untracked <- NA + } + + if (!is.na(stat_files$modified)) { + print(stat_files) + file_status <- gert::git_status(pathspec = elems$rel_path, repo = elems$project) + if (nrow(file_status) > 0) { + print(file_status) + + if (all(file_status$status == "conflicted")) { + will_delete <- append(will_delete, TRUE) + reasons_deleting <- c(reasons_deleting, "the file is a renamed git conflict") + } else { + will_delete <- append(will_delete, FALSE) + reasons_not_deleting <- c(reasons_not_deleting, "the file is tracked with git") + } + } + file_info <- fs::file_info(elems$rel_path) + } else { + if (is_git) { + will_delete <- append(will_delete, TRUE) + reasons_deleting <- c(reasons_deleting, "file is untracked") + } else { + # ? + } + file_status <- NULL + outline <- withCallingHandlers( + file_outline(path = elems$full_path), + error = function(e) { + cli::cli_warn("File outline failed somehow. Please report.", parent = e) + NA + } + ) + if (!is.null(outline) && !identical(outline, NA)) { + will_delete <- append(will_delete, FALSE) + reasons_not_deleting <- c( + reasons_not_deleting, "couldn't explore the outline, worth taking a look." + ) + outline <- NULL + } else if (!is.null(outline) && nrow(outline) > 0) { + print(utils::head(outline)) + will_delete <- append(will_delete, FALSE) # perhaps worth taking a look + reasons_not_deleting <- c(reasons_not_deleting, "it has contents") + } else { + reasons_deleting <- append(reasons_deleting, "empty outline") + } + if (!is.na(elems$project)) { + file_info <- fs::file_info(elems$rel_path) + } else { + file_info <- fs::file_info(elems$full_path) + } + } + + parent_dir <- fs::path_file(fs::path_dir(elems$full_path)) + + if (grepl("^temp", fs::path_file(elems$rel_path)) || + (!parent_dir %in% c("tests", "testthat") && grepl("^test-", fs::path_file(elems$rel_path)))) { + reasons_deleting <- c(reasons_deleting, "it has the temp- prefix.") + will_delete <- append(will_delete, TRUE) + } + if (parent_dir %in% c("Downloads", "Desktop")) { + # Consider that files in the Downloads or Desktop are temp files. + will_delete <- append(will_delete, TRUE) + reasons_deleting <- c(reasons_deleting, "in the ~/Downloads or ~/Desktop folder.") + } + + if (isTRUE(is_untracked)) { + # file created in the last hour + creation_recent <- + difftime(Sys.time(), file_info$birth_time, units = "hours") < 1 + + if (creation_recent) { + reasons_deleting <- c(reasons_deleting, "very recent") + + will_delete <- append(will_delete, TRUE) + } else { + reasons_not_deleting <- c(reasons_not_deleting, "older untracked file, better to look at outline to see if not important.") + will_delete <- append(will_delete, FALSE) + } + } + + # TODO structure and summarise information. + file_info <- dplyr::select(file_info, path, size, dplyr::ends_with("time")) + file_info <- dplyr::select(file_info, !dplyr::where(\(x) all(is.na(x)))) + file_info <- dplyr::select(file_info, !dplyr::any_of(rm_duplicate_columns(file_info))) + if (!is.null(file_info$size) && all(file_info$size == 0)) { + will_delete <- append(will_delete, c(TRUE, TRUE)) + reasons_deleting <- c(reasons_deleting, "file is empty") + file_info$size <- NULL + } + pillar::glimpse(file_info) + + # defaults to FALSE if equality :) + # print(table(will_delete)) + will_delete_decision <- as.logical(names(which.max(table(will_delete)))) + # only true or false acceptable! + check_bool(will_delete_decision) + if (isTRUE(will_delete_decision)) { + cli::cli_inform(c( + "v" = "Deleted the active document {.val {elems$rel_path}} because {reasons_deleting}.", + # FIXME (upstream) the color div doesn't go all the way r-lib/cli#694 + "i" = paste(cli::col_grey("The deleted file"), "{.path {elems$full_path}}", cli::col_grey("contents are returned invisibly in case you need them.")) + )) + contents <- readLines(elems$full_path, encoding = "UTF-8") + fs::file_delete(elems$full_path) + return(invisible(contents)) + } + + cli::cli_abort(c( + "Can't delete the active document {.path {elems$rel_path}}, because {reasons_not_deleting}.", + "It outweighs the reasons for deleting: {reasons_deleting}." + )) +} + +active_rs_doc_sitrep <- function() { + # The goal is to prepare it for action. + # print file outline + # git status (untracked, modified, staged, etc.) + # git history. + # git compare with previous state + # mod time + # git mod time + # print ssh short commit id.. + list( + staged = NA, + # etc. + ) +} + +active_rs_doc_undo_local_changes <- function() { + # When active_rs_doc_delete is mature, create this one! +} + + +is.POSIXct <- function(x) inherits(x, "POSIXct") +rm_duplicate_columns <- function(x) { + x_date <- dplyr::select( + x, + dplyr::where(is.POSIXct) + ) + + if (ncol(x_date) <= 1) { + return(integer(0)) + } + + which <- integer(0) + for (i in 2:ncol(x_date)) { + # numeric rounds to seconds + diff <- difftime( + x_date[[1]], + x_date[[i]], + units = "secs" + ) + diff <- abs(diff) + # consider the same if time within 1 second + if (all(diff < 1)) { + which <- c(which, i) + } + } + if (length(which) > 0) { + names(x_date)[which] + } else { + integer(0) + } +} + +path_metadata <- function() { + characteristics <- NULL +} + +normalize_proj_and_path <- function(path, call = caller_env()) { + full_path <- fs::path_real(fs::path_expand_r(path)) + if (!fs::is_file(full_path)) { + cli::cli_abort("{.path {path}} does not exist.", call = call) + } + project <- + tryCatch( + rprojroot::find_root_file(criterion = rprojroot::is_rstudio_project, path = full_path), + warning = function(e) NULL, + error = function(e) NULL, + message = function(e) NULL + ) + if (is.null(project)) { + return( + list( + project = NA, + rel_path = fs::path_file(full_path), + full_path = full_path + ) + ) + } + + rel_path <- fs::path_rel(full_path, start = project) + + if (grepl("../", rel_path, fixed = TRUE)) { + cli::cli_abort(c( + "Something went wrong in path normalization.", + "With path = {path}, detected project = {project}, derived full_path = {full_path} and rel_path = {rel_path}" + ), call = call) + } + list( + project = project, + rel_path = rel_path, + full_path = full_path + ) +} + +#' Open Files Pane at current document location +#' +#' Easily navigate to active file document. +#' +#' Wrapper around [executeCommand("activateFiles")][rstudioapi::executeCommand()] + +#' [rstudioapi::filesPaneNavigate()] + [rstudioapi::getActiveDocumentContext()] +#' +#' @param path A path to file to navigate to (default active document). +#' +#' @returns NULL, called for its side effects. +#' @export +active_rs_doc_nav <- function(path = active_rs_doc()) { + if (!is_rstudio(f = "filesPaneNavigate") || !interactive()) { + cli::cli_abort("Must use in RStudio interactive sessions.") + } + if (is.null(path)) { + cli::cli_abort("Can't navigate to an unsaved file!") + } + if (fs::is_file(path)) { + dir <- fs::path_dir(path) + } else if (fs::is_dir(path)) { + dir <- path + } else { + cli::cli_abort("{.arg path} must be an existing file or directory.") + } + rstudioapi::executeCommand("activateFiles") + rstudioapi::filesPaneNavigate(dir) + cli::cli_inform(c( + "v" = "Navigated to {.path {dir}} in RStudio Files Pane." + )) + invisible() +} diff --git a/man/active_rs_doc_copy.Rd b/man/active_rs_doc_copy.Rd index b5d9c1a..9384993 100644 --- a/man/active_rs_doc_copy.Rd +++ b/man/active_rs_doc_copy.Rd @@ -8,7 +8,8 @@ active_rs_doc_copy(new = NULL, ..., old = NULL) } \arguments{ \item{new}{The new file name, that will be copied in the same -directory as the \link[=active_rs_doc]{active document}} +directory as the \link[=active_rs_doc]{active document} +For \code{active_rs_doc_move()}, a directory.} \item{...}{These dots are for future extensions and must be empty.} diff --git a/man/active_rs_doc_move.Rd b/man/active_rs_doc_move.Rd new file mode 100644 index 0000000..58fdb48 --- /dev/null +++ b/man/active_rs_doc_move.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/open.R +\name{active_rs_doc_move} +\alias{active_rs_doc_move} +\title{Move the active document to another directory} +\usage{ +active_rs_doc_move(new = NULL, old = NULL, ...) +} +\arguments{ +\item{new}{A new directory} + +\item{old}{The old file (defaults to the active RStudio document.)} + +\item{...}{ + Arguments passed on to \code{\link[=rename_files2]{rename_files2}} + \describe{ + \item{\code{overwrite}}{whether to overwrite \code{new} if it already exists. Be careful.} + \item{\code{force}}{\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#deprecated}{\figure{lifecycle-deprecated.svg}{options: alt='[Deprecated]'}}}{\strong{[Deprecated]}} Use \code{warn_conflicts} instead of \code{force = TRUE}} + \item{\code{action}}{One of \code{"rename"} or \code{"test"}} + \item{\code{warn_conflicts}}{One of +\itemize{ +\item \code{"default"}: will be check more thoroughly depending on the situation. If only moving directory, and \code{"all"} otherwise. +\item \code{"all"} (larger scope: if \code{old = "data/my-streets.csv|my_streets"} will check for objects named \code{my_streets}, other files like \code{my-streets.R}, etc.), +\item \code{"exact"} will only search for \code{"data/my-streets.csv"} in documents +\code{"none"} will not search for references in documents and will rename. +}} + }} +} +\value{ +\code{new} if renaming succeeded. Mostly called for its side-effects +} +\description{ +Wrapper around \code{\link[=rename_files2]{rename_files2()}}, but shortcut to allow renaming the active file. +} diff --git a/man/file_move_temp_auto.Rd b/man/file_move_temp_auto.Rd new file mode 100644 index 0000000..8acae41 --- /dev/null +++ b/man/file_move_temp_auto.Rd @@ -0,0 +1,17 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/move.R +\name{file_move_temp_auto} +\alias{file_move_temp_auto} +\title{Move temporary file automatically from the R console} +\usage{ +file_move_temp_auto(destdir) +} +\arguments{ +\item{destdir}{The desired directory to send this to} +} +\description{ +It works well when you have no API to download a file, but still want a fast R implementation. +} +\seealso{ +\code{\link[=file_rename_auto]{file_rename_auto()}} +} diff --git a/man/file_rename_auto.Rd b/man/file_rename_auto.Rd new file mode 100644 index 0000000..c55b0a8 --- /dev/null +++ b/man/file_rename_auto.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/move.R +\name{file_rename_auto} +\alias{file_rename_auto} +\alias{file_move_auto} +\title{Move file automatically between folders} +\usage{ +file_rename_auto(new_name, old_file = .Last.value) + +file_move_auto(new_dir, old_file = .Last.value) +} +\arguments{ +\item{new_name, new_dir}{New directory or file name (without extension)} + +\item{old_file}{The old file name} +} +\value{ +The new full path name, invisibly, allowing you to call the functions another time. +} +\description{ +\itemize{ +\item \code{file_rename_auto()} automatically renames your file to a better name while keeping the same folder structure +} +} +\section{Advantages}{ +Instead of calling \code{fs::file_move("path/to/dir/file.R", "path/to/dir/new-file.R")}, you can just call +\code{file_rename_auto("new-file", "path/to/dir/file.R")} + +Instead of calling \code{fs::file_move("path/to/dir/file.R", "path/to/new-dir/file.R")}, you can just call +\code{file_move_auto("new-dir", "path/to/dir/file.R")} + +If the functions are used in conjunction with \code{\link[=file_move_temp_auto]{file_move_temp_auto()}}, +} + diff --git a/reuseme.Rproj b/reuseme.Rproj index 59699e9..842d3f3 100644 --- a/reuseme.Rproj +++ b/reuseme.Rproj @@ -1,4 +1,5 @@ Version: 1.0 +ProjectId: 7a0c52b3-130e-4650-9144-0de791627c0b RestoreWorkspace: No SaveWorkspace: No