beatrina 0.8.6 → 0.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/beatrina.mjs +7 -5
- package/bin/shortcut.mjs +34 -6
- package/build-info.json +1 -1
- package/carmar_V0.8.7.html +1522 -0
- package/failsafe/serve.R +130 -5
- package/failsafe/session-documents.R +104 -0
- package/host/engine-js.mjs +4 -0
- package/host/engine-pool.mjs +8 -2
- package/host/engine-r.mjs +20 -13
- package/host/engine-stdio.mjs +10 -2
- package/host/main.mjs +28 -4
- package/host/planes/latex.mjs +27 -1
- package/host/planes/sessions.mjs +9 -5
- package/host/server.mjs +49 -4
- package/host/session-keep.mjs +70 -0
- package/host/worker-plane.mjs +47 -2
- package/kernel/kernel-version +1 -1
- package/kernel/kernel.R +8 -5
- package/kernel/latex.R +305 -4
- package/kernel/worker.R +85 -9
- package/kernel/workspace-keep.R +188 -0
- package/package.json +2 -2
- package/carmar_V0.8.6.html +0 -1310
package/kernel/latex.R
CHANGED
|
@@ -603,7 +603,308 @@ latex_citation_problems <- function(tex_lines, bib_lines, engine, fetch = FALSE)
|
|
|
603
603
|
|
|
604
604
|
# ── the rapid preview ──────────────────────────────────────────────────────
|
|
605
605
|
#
|
|
606
|
-
#
|
|
607
|
-
# the
|
|
608
|
-
#
|
|
609
|
-
#
|
|
606
|
+
# A .tex through pandoc to HTML, fast enough to keep up with typing. This is
|
|
607
|
+
# the "live rich preview" of the retired prototype (spike/latex-editor.html +
|
|
608
|
+
# spike/latex-workbench.mjs, harvested and deleted 2026-09-06), which was
|
|
609
|
+
# scoped here as WP5 in docs/latex-plan.md and cut for byte budget before it
|
|
610
|
+
# was built. The argv is the prototype's, unchanged, because it was already
|
|
611
|
+
# right; what is new is where it runs and what it is allowed to touch.
|
|
612
|
+
#
|
|
613
|
+
# IT IS NOT A COMPILE, and every difference follows from that. A compile is a
|
|
614
|
+
# JOB — detached, streamed, minutes long, and it produces the artefact you
|
|
615
|
+
# publish. This answers one request in under a second and produces something
|
|
616
|
+
# to look at while you write. So it is answered in the supervisor beside
|
|
617
|
+
# `cite` rather than spawned as a job child: a job that outlives the keystroke
|
|
618
|
+
# that asked for it is exactly what must not happen here.
|
|
619
|
+
#
|
|
620
|
+
# WHAT IT MAY READ is the root's own directory and below, and nothing else.
|
|
621
|
+
# TeX itself reads relative to the document, so that bound is the document's
|
|
622
|
+
# own, not an invention — and it is checked after canonicalisation, because a
|
|
623
|
+
# string-prefix test on an unresolved path is how confinement bugs happen
|
|
624
|
+
# (spike/worker.R within_root says the same thing about the same mistake).
|
|
625
|
+
|
|
626
|
+
LATEX_PREVIEW_TIMEOUT_MS <- 8000L
|
|
627
|
+
LATEX_PREVIEW_MAX_SOURCE <- 2e6 # the .tex the page sends
|
|
628
|
+
LATEX_PREVIEW_MAX_HTML <- 6e6 # what pandoc may print back
|
|
629
|
+
LATEX_PREVIEW_MAX_IMAGE <- 1.5e6 # one figure, before base64
|
|
630
|
+
LATEX_PREVIEW_MAX_ASSETS <- 8e6 # every figure together
|
|
631
|
+
LATEX_PREVIEW_MAX_FILES <- 40L # buffers, and includes followed
|
|
632
|
+
LATEX_PREVIEW_DEPTH <- 6L
|
|
633
|
+
|
|
634
|
+
# What an <img> can actually show. A .pdf or .eps figure is normal in a real
|
|
635
|
+
# paper and cannot be inlined, so it is REPORTED rather than dropped — the
|
|
636
|
+
# pane says "3 figures are only in the PDF", which is true and actionable,
|
|
637
|
+
# where a blank space says nothing.
|
|
638
|
+
LATEX_PREVIEW_IMAGE_TYPES <- c(png = "image/png", jpg = "image/jpeg",
|
|
639
|
+
jpeg = "image/jpeg", gif = "image/gif",
|
|
640
|
+
svg = "image/svg+xml", webp = "image/webp")
|
|
641
|
+
|
|
642
|
+
#' Is `path` inside `dir`, after both are canonicalised?
|
|
643
|
+
#'
|
|
644
|
+
#' The trailing separator is load-bearing: without it, /p/figs would also
|
|
645
|
+
#' admit /p/figs-private. Mirrors plugin_contained() in spike/plugins.R.
|
|
646
|
+
#' @param dir Directory that bounds the read.
|
|
647
|
+
#' @param path Candidate file.
|
|
648
|
+
#' @return The resolved path, or "" when it is outside or absent.
|
|
649
|
+
latex_within <- function(dir, path) {
|
|
650
|
+
if (!latex_scalar(dir) || !latex_scalar(path) || !dir.exists(dir)) return("")
|
|
651
|
+
root <- normalizePath(dir, winslash = "/", mustWork = TRUE)
|
|
652
|
+
full <- if (grepl("^([A-Za-z]:)?[/\\\\]", path)) path else file.path(root, path)
|
|
653
|
+
if (!file.exists(full)) return("")
|
|
654
|
+
full <- normalizePath(full, winslash = "/", mustWork = TRUE)
|
|
655
|
+
if (!identical(full, root) && !startsWith(full, paste0(root, "/"))) return("")
|
|
656
|
+
full
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
#' Pull `\input`/`\include`/`\subfile` bodies into one source, so the preview
|
|
660
|
+
#' shows the whole paper rather than its first chapter.
|
|
661
|
+
#'
|
|
662
|
+
#' Buffers the page sent win over the disk copy, because the point of a live
|
|
663
|
+
#' preview is UNSAVED text. Bounded by depth and by file count, and a file
|
|
664
|
+
#' already on the stack is skipped rather than followed — an include cycle is
|
|
665
|
+
#' a document someone is halfway through writing, not an attack, and it must
|
|
666
|
+
#' cost a stack frame instead of the session.
|
|
667
|
+
#' @param source The root's text.
|
|
668
|
+
#' @param dir Directory the root lives in.
|
|
669
|
+
#' @param buffers Named list of relative path -> text, from open editors.
|
|
670
|
+
#' @param seen Paths already on this branch.
|
|
671
|
+
#' @param depth Remaining depth.
|
|
672
|
+
#' @return One string.
|
|
673
|
+
latex_preview_expand <- function(source, dir, buffers = list(), seen = character(0), depth = LATEX_PREVIEW_DEPTH) {
|
|
674
|
+
if (depth <= 0L) return(source)
|
|
675
|
+
pattern <- "\\\\(?:input|include|subfile)\\{([^{}]{1,200})\\}"
|
|
676
|
+
m <- gregexpr(pattern, source, perl = TRUE)[[1L]]
|
|
677
|
+
if (identical(as.integer(m[1L]), -1L)) return(source)
|
|
678
|
+
starts <- as.integer(m)
|
|
679
|
+
lens <- attr(m, "match.length")
|
|
680
|
+
out <- character(0)
|
|
681
|
+
at <- 1L
|
|
682
|
+
for (i in seq_along(starts)) { # one splice per include
|
|
683
|
+
whole <- substr(source, starts[i], starts[i] + lens[i] - 1L)
|
|
684
|
+
rel <- sub(pattern, "\\1", whole, perl = TRUE)
|
|
685
|
+
if (!grepl("\\.[A-Za-z0-9]{1,5}$", rel)) rel <- paste0(rel, ".tex")
|
|
686
|
+
body <- NULL
|
|
687
|
+
if (!rel %in% seen && length(seen) < LATEX_PREVIEW_MAX_FILES) {
|
|
688
|
+
if (!is.null(buffers[[rel]])) body <- buffers[[rel]]
|
|
689
|
+
else {
|
|
690
|
+
hit <- latex_within(dir, rel)
|
|
691
|
+
if (nzchar(hit) && file.info(hit)$size <= LATEX_PREVIEW_MAX_SOURCE) {
|
|
692
|
+
body <- tryCatch(paste(readLines(hit, warn = FALSE), collapse = "\n"),
|
|
693
|
+
error = function(e) NULL)
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
out <- c(out, substr(source, at, starts[i] - 1L),
|
|
698
|
+
if (is.null(body)) "" else
|
|
699
|
+
latex_preview_expand(body, dir, buffers, c(seen, rel), depth - 1L))
|
|
700
|
+
at <- starts[i] + lens[i]
|
|
701
|
+
}
|
|
702
|
+
paste0(paste(out, collapse = ""), substr(source, at, nchar(source)))
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
#' The .bib files a source names, resolved and bounded.
|
|
706
|
+
#' @param source Expanded LaTeX.
|
|
707
|
+
#' @param dir Directory that bounds the read.
|
|
708
|
+
#' @return Character vector of absolute paths, possibly empty.
|
|
709
|
+
latex_preview_bibliography <- function(source, dir) {
|
|
710
|
+
m <- regmatches(source, gregexpr("\\\\(?:bibliography|addbibresource)(?:\\[[^]]*\\])?\\{([^{}]{1,300})\\}",
|
|
711
|
+
source, perl = TRUE))[[1L]]
|
|
712
|
+
if (!length(m)) return(character(0))
|
|
713
|
+
named <- unlist(lapply(m, function(one) {
|
|
714
|
+
inner <- sub(".*\\{([^{}]*)\\}$", "\\1", one)
|
|
715
|
+
trimws(strsplit(inner, ",", fixed = TRUE)[[1L]])
|
|
716
|
+
}), use.names = FALSE)
|
|
717
|
+
named <- named[nzchar(named)]
|
|
718
|
+
named <- ifelse(grepl("\\.[A-Za-z0-9]{1,5}$", named), named, paste0(named, ".bib"))
|
|
719
|
+
found <- vapply(unique(named), function(one) latex_within(dir, one), character(1))
|
|
720
|
+
unname(found[nzchar(found)])
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
#' Turn every local `<img src>` into a data: URI.
|
|
724
|
+
#'
|
|
725
|
+
#' The kernel does this itself, over the HTML IT just produced, so no image
|
|
726
|
+
#' path ever travels on the wire in either direction — the same rule `job_open`
|
|
727
|
+
#' follows when it names a job rather than a file. `readfile` could not have
|
|
728
|
+
#' served here at all: it decodes bytes as UTF-8 or Latin-1 (spike/worker.R
|
|
729
|
+
#' text_from_bytes), so a PNG through it is not a PNG.
|
|
730
|
+
#' @param html pandoc's output.
|
|
731
|
+
#' @param dir Directory that bounds the read.
|
|
732
|
+
#' @return list(html, inlined, dropped) — dropped names what could not be shown.
|
|
733
|
+
latex_preview_inline <- function(html, dir) {
|
|
734
|
+
hits <- gregexpr('src="([^"]{1,400})"', html, perl = TRUE)[[1L]]
|
|
735
|
+
if (identical(as.integer(hits[1L]), -1L)) return(list(html = html, inlined = 0L, dropped = character(0)))
|
|
736
|
+
starts <- as.integer(hits)
|
|
737
|
+
lens <- attr(hits, "match.length")
|
|
738
|
+
out <- character(0)
|
|
739
|
+
at <- 1L
|
|
740
|
+
inlined <- 0L
|
|
741
|
+
budget <- LATEX_PREVIEW_MAX_ASSETS
|
|
742
|
+
dropped <- character(0)
|
|
743
|
+
for (i in seq_along(starts)) { # one rewrite per image
|
|
744
|
+
whole <- substr(html, starts[i], starts[i] + lens[i] - 1L)
|
|
745
|
+
src <- sub('^src="(.*)"$', "\\1", whole)
|
|
746
|
+
replacement <- whole
|
|
747
|
+
if (!grepl("^(?:data:|https?:|//)", src, perl = TRUE)) {
|
|
748
|
+
ext <- tolower(sub(".*\\.([A-Za-z0-9]+)$", "\\1", src))
|
|
749
|
+
mime <- unname(LATEX_PREVIEW_IMAGE_TYPES[ext])
|
|
750
|
+
# A figure named without its extension is what \includegraphics{fig}
|
|
751
|
+
# writes, and TeX picks the file. Try the types an <img> can show, in
|
|
752
|
+
# the order a person would expect them to win.
|
|
753
|
+
candidates <- if (identical(src, ext) || is.na(mime))
|
|
754
|
+
paste0(sub("\\.[A-Za-z0-9]+$", "", src), ".", names(LATEX_PREVIEW_IMAGE_TYPES)) else src
|
|
755
|
+
hit <- ""
|
|
756
|
+
for (cand in candidates) { # first existing wins
|
|
757
|
+
hit <- latex_within(dir, cand)
|
|
758
|
+
if (nzchar(hit)) { mime <- unname(LATEX_PREVIEW_IMAGE_TYPES[tolower(sub(".*\\.([A-Za-z0-9]+)$", "\\1", cand))]); break }
|
|
759
|
+
}
|
|
760
|
+
if (!nzchar(hit) || is.na(mime)) dropped <- c(dropped, basename(src))
|
|
761
|
+
else {
|
|
762
|
+
size <- file.info(hit)$size
|
|
763
|
+
if (!is.finite(size) || size > LATEX_PREVIEW_MAX_IMAGE || size > budget) {
|
|
764
|
+
dropped <- c(dropped, basename(src))
|
|
765
|
+
} else {
|
|
766
|
+
bytes <- tryCatch(readBin(hit, "raw", n = size), error = function(e) NULL)
|
|
767
|
+
if (is.null(bytes)) dropped <- c(dropped, basename(src))
|
|
768
|
+
else {
|
|
769
|
+
budget <- budget - size
|
|
770
|
+
inlined <- inlined + 1L
|
|
771
|
+
replacement <- paste0('src="data:', mime, ';base64,', jsonlite::base64_enc(bytes), '"')
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
out <- c(out, substr(html, at, starts[i] - 1L), replacement)
|
|
777
|
+
at <- starts[i] + lens[i]
|
|
778
|
+
}
|
|
779
|
+
list(html = paste0(paste(out, collapse = ""), substr(html, at, nchar(html))),
|
|
780
|
+
inlined = inlined, dropped = unique(dropped))
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
#' Highlighted code blocks as plain verbatim, for the PREVIEW only.
|
|
784
|
+
#'
|
|
785
|
+
#' The LaTeX export writes R chunks as fancyvrb `Highlighting` blocks
|
|
786
|
+
#' (lib/md/latex.js `highlighted`) — correct for a TeX engine, but pandoc's
|
|
787
|
+
#' LaTeX reader does not know `\DefineVerbatimEnvironment{Highlighting}` is
|
|
788
|
+
#' verbatim and renders each block as an EMPTY div, so the preview showed a
|
|
789
|
+
#' document with its code missing while the PDF was right (found by the
|
|
790
|
+
#' Beatrix fork, 2026-09-15). Each block is rewritten to `\begin{verbatim}`
|
|
791
|
+
#' with the token macros dropped and lib/md/latex.js CODE_CH undone. The file
|
|
792
|
+
#' on disk and the compile are untouched.
|
|
793
|
+
#'
|
|
794
|
+
#' Escapes become sentinels BEFORE the macro braces are stripped, so a literal
|
|
795
|
+
#' `{` in the code survives. A code line holding `\end{verbatim}` would close
|
|
796
|
+
#' the environment early; it gains a zero-width space after the backslash,
|
|
797
|
+
#' invisible in the preview.
|
|
798
|
+
#'
|
|
799
|
+
#' @param source LaTeX text.
|
|
800
|
+
#' @return The text with every Shaded/Highlighting block replaced.
|
|
801
|
+
latex_preview_code <- function(source) {
|
|
802
|
+
pattern <- paste0("(?s)(?:\\\\begin\\{Shaded\\}[ \\t]*\\n?)?",
|
|
803
|
+
"\\\\begin\\{Highlighting\\}(?:\\[[^]\\n]*\\])?\\n?(.*?)\\n?",
|
|
804
|
+
"\\\\end\\{Highlighting\\}(?:[ \\t]*\\n?\\\\end\\{Shaded\\})?")
|
|
805
|
+
hits <- gregexpr(pattern, source, perl = TRUE)[[1L]]
|
|
806
|
+
if (identical(as.integer(hits[1L]), -1L)) return(source)
|
|
807
|
+
blocks <- regmatches(source, list(hits))[[1L]]
|
|
808
|
+
# Order matters only for \textbackslash{}, whose braces must not be read as
|
|
809
|
+
# a {-} or stripped first; every other entry is a distinct sequence.
|
|
810
|
+
swaps <- c("\\textbackslash{}" = "\001", "\\textasciitilde{}" = "~", "\\^{}" = "^",
|
|
811
|
+
"\\textless{}" = "<", "\\textgreater{}" = ">", "{-}" = "-",
|
|
812
|
+
"\\{" = "\002", "\\}" = "\003", "\\#" = "#", "\\%" = "%",
|
|
813
|
+
"\\_" = "_", "\\&" = "&")
|
|
814
|
+
plain <- vapply(blocks, function(block) {
|
|
815
|
+
body <- sub(pattern, "\\1", block, perl = TRUE)
|
|
816
|
+
body <- Reduce(function(text, from) gsub(from, swaps[[from]], text, fixed = TRUE),
|
|
817
|
+
names(swaps), body)
|
|
818
|
+
body <- gsub("\\\\[A-Za-z]+Tok\\{", "", body, perl = TRUE)
|
|
819
|
+
body <- gsub("[{}]", "", body, perl = TRUE)
|
|
820
|
+
body <- chartr("\001\002\003", "\\{}", body)
|
|
821
|
+
body <- gsub("\\end{verbatim}", "\\\u200bend{verbatim}", body, fixed = TRUE)
|
|
822
|
+
paste0("\\begin{verbatim}\n", body, "\n\\end{verbatim}")
|
|
823
|
+
}, character(1), USE.NAMES = FALSE)
|
|
824
|
+
regmatches(source, list(hits)) <- list(plain)
|
|
825
|
+
source
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
#' Render one LaTeX source to HTML for the preview pane.
|
|
829
|
+
#'
|
|
830
|
+
#' @param source The root's text, as the editor holds it.
|
|
831
|
+
#' @param path Absolute path of the root, for resolving its neighbours.
|
|
832
|
+
#' @param buffers Optional named list of relative path -> unsaved text.
|
|
833
|
+
#' @param timeout_ms Deadline.
|
|
834
|
+
#' @return list(ok, html, warnings, elapsed_ms, inlined, dropped) or
|
|
835
|
+
#' list(ok = FALSE, error).
|
|
836
|
+
latex_preview <- function(source, path, buffers = list(), timeout_ms = LATEX_PREVIEW_TIMEOUT_MS) {
|
|
837
|
+
if (!latex_scalar(source)) return(list(ok = FALSE, error = "No LaTeX source to preview."))
|
|
838
|
+
if (nchar(source, type = "bytes") > LATEX_PREVIEW_MAX_SOURCE) {
|
|
839
|
+
return(list(ok = FALSE, error = "This document is too large to preview while you type."))
|
|
840
|
+
}
|
|
841
|
+
bin <- find_pandoc()
|
|
842
|
+
if (!nzchar(bin)) {
|
|
843
|
+
return(list(ok = FALSE, error = "pandoc is not installed, or CarmaR could not find it. Compile still works; only the live preview needs pandoc."))
|
|
844
|
+
}
|
|
845
|
+
dir <- if (latex_scalar(path) && file.exists(path)) dirname(normalizePath(path, winslash = "/", mustWork = TRUE)) else ""
|
|
846
|
+
keep <- list()
|
|
847
|
+
if (is.list(buffers)) {
|
|
848
|
+
names_ok <- names(buffers) %||% character(0)
|
|
849
|
+
for (nm in utils::head(names_ok[nzchar(names_ok)], LATEX_PREVIEW_MAX_FILES)) {
|
|
850
|
+
if (latex_scalar(buffers[[nm]])) keep[[nm]] <- buffers[[nm]]
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
expanded <- if (nzchar(dir)) latex_preview_expand(source, dir, keep) else source
|
|
854
|
+
expanded <- latex_preview_code(expanded)
|
|
855
|
+
started <- Sys.time()
|
|
856
|
+
args <- c("--from=latex", "--to=html5", "--mathml", "--wrap=none",
|
|
857
|
+
"--standalone", "--sandbox")
|
|
858
|
+
if (nzchar(dir)) {
|
|
859
|
+
for (bib in latex_preview_bibliography(expanded, dir)) {
|
|
860
|
+
args <- c(args, "--citeproc", paste0("--bibliography=", bib))
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
# A FILE, not stdin: processx::run has no `input` argument (it takes a path
|
|
864
|
+
# for `stdin`), and cite_run already writes its document to a 0600 temp file
|
|
865
|
+
# for the same reason. pandoc emits the figure paths unchanged either way —
|
|
866
|
+
# this function resolves them itself, against the ROOT's directory rather
|
|
867
|
+
# than the scratch one, which is what makes the temp file harmless here.
|
|
868
|
+
input <- tempfile("carmar-preview-", fileext = ".tex")
|
|
869
|
+
on.exit(unlink(input), add = TRUE)
|
|
870
|
+
writeLines(expanded, input, useBytes = TRUE)
|
|
871
|
+
Sys.chmod(input, "0600")
|
|
872
|
+
ran <- tryCatch(processx::run(
|
|
873
|
+
bin, c(args, input), timeout = timeout_ms / 1000, error_on_status = FALSE,
|
|
874
|
+
echo = FALSE, cleanup_tree = TRUE,
|
|
875
|
+
# HOME and the data dir moved aside for the same reason cite_run moves
|
|
876
|
+
# them: a render must not read or write this user's pandoc configuration.
|
|
877
|
+
env = c("current", PANDOC_DATA_DIR = tempdir(), HOME = tempdir())
|
|
878
|
+
), error = function(e) e)
|
|
879
|
+
if (inherits(ran, "error")) {
|
|
880
|
+
msg <- conditionMessage(ran)
|
|
881
|
+
return(list(ok = FALSE, error = if (grepl("timeout", msg, ignore.case = TRUE))
|
|
882
|
+
"The preview took too long and was stopped." else paste0("preview: ", msg)))
|
|
883
|
+
}
|
|
884
|
+
if (isTRUE(ran$timeout)) return(list(ok = FALSE, error = "The preview took too long and was stopped."))
|
|
885
|
+
if (!identical(as.integer(ran$status), 0L)) {
|
|
886
|
+
first <- strsplit(ran$stderr %||% "", "\n", fixed = TRUE)[[1L]][1L] %||% ""
|
|
887
|
+
# pandoc names the file it was reading, and that file is our scratch copy.
|
|
888
|
+
# Printing "/var/folders/.../carmar-preview-8b42.tex (line 2, column 2)"
|
|
889
|
+
# at a person tells them nothing and looks like a fault in CarmaR; the
|
|
890
|
+
# line and column are the useful half, so the path becomes the document's
|
|
891
|
+
# own name. An unclosed group is the ORDINARY state of a document being
|
|
892
|
+
# typed, which is why this path is a reported note and not a failure —
|
|
893
|
+
# lib/latex-preview.js keeps the last good render behind it.
|
|
894
|
+
shown <- if (latex_scalar(path) && nzchar(path)) basename(path) else "the document"
|
|
895
|
+
first <- gsub(input, shown, first, fixed = TRUE)
|
|
896
|
+
return(list(ok = FALSE, partial = TRUE,
|
|
897
|
+
error = paste0("pandoc could not read this document",
|
|
898
|
+
if (nzchar(first)) paste0(": ", first) else ".")))
|
|
899
|
+
}
|
|
900
|
+
html <- ran$stdout %||% ""
|
|
901
|
+
if (nchar(html, type = "bytes") > LATEX_PREVIEW_MAX_HTML) {
|
|
902
|
+
return(list(ok = FALSE, error = "The rendered preview is too large to show."))
|
|
903
|
+
}
|
|
904
|
+
figures <- if (nzchar(dir)) latex_preview_inline(html, dir)
|
|
905
|
+
else list(html = html, inlined = 0L, dropped = character(0))
|
|
906
|
+
list(ok = TRUE, html = figures$html,
|
|
907
|
+
warnings = trimws(ran$stderr %||% ""),
|
|
908
|
+
elapsed_ms = as.numeric(difftime(Sys.time(), started, units = "secs")) * 1000,
|
|
909
|
+
inlined = figures$inlined, dropped = figures$dropped)
|
|
910
|
+
}
|
package/kernel/worker.R
CHANGED
|
@@ -120,7 +120,7 @@ import_sources <- local({
|
|
|
120
120
|
else if (length(file_arg)) {
|
|
121
121
|
dirname(normalizePath(sub("^--file=", "", file_arg[1L]), mustWork = FALSE))
|
|
122
122
|
} else getwd()
|
|
123
|
-
file.path(here, c("fileio.R", "sniff.R", "project.R"))
|
|
123
|
+
file.path(here, c("fileio.R", "sniff.R", "project.R", "workspace-keep.R"))
|
|
124
124
|
})
|
|
125
125
|
# environment(), not parent.frame(): inside a nested local() the parent frame
|
|
126
126
|
# is the eval machinery's, not this file's private scope, and the functions
|
|
@@ -471,6 +471,29 @@ if (!(INPUT_SHADOW %in% search())) {
|
|
|
471
471
|
name = INPUT_SHADOW, warn.conflicts = FALSE)
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
+
#' Never ask "Hit <Return> to see next plot".
|
|
475
|
+
#'
|
|
476
|
+
#' This worker is interactive, so a plot that turns page prompting on —
|
|
477
|
+
#' tna's plot() for cliques does `par(ask = TRUE)` by default — makes the
|
|
478
|
+
#' graphics engine read the console before each new page. That read is not
|
|
479
|
+
#' readline(), so nothing announces it: the chunk spun forever and every
|
|
480
|
+
#' chunk behind it queued (test/lab-rmd.e2e, the lab's `plot(cliques_of_two,
|
|
481
|
+
#' 4)`). Every page is captured as its own figure here, so there is no page to
|
|
482
|
+
#' wait for. The hooks run before the engine's check on every new page, base
|
|
483
|
+
#' graphics and grid alike; a package's own `par(ask = TRUE)` is left in place
|
|
484
|
+
#' and simply never gets to ask.
|
|
485
|
+
#'
|
|
486
|
+
#' @return Invisibly NULL.
|
|
487
|
+
carmar_no_page_prompt <- function() {
|
|
488
|
+
if (isTRUE(grDevices::devAskNewPage())) grDevices::devAskNewPage(FALSE)
|
|
489
|
+
invisible(NULL)
|
|
490
|
+
}
|
|
491
|
+
local({
|
|
492
|
+
already <- function(hook) any(vapply(getHook(hook), identical, logical(1), carmar_no_page_prompt))
|
|
493
|
+
if (!already("before.plot.new")) setHook("before.plot.new", carmar_no_page_prompt)
|
|
494
|
+
if (!already("before.grid.newpage")) setHook("before.grid.newpage", carmar_no_page_prompt)
|
|
495
|
+
})
|
|
496
|
+
|
|
474
497
|
#' A function's formals as one display string, defaults included.
|
|
475
498
|
#'
|
|
476
499
|
#' `formals()` is NULL for primitives like `sum`; `args()` still knows their
|
|
@@ -1110,6 +1133,57 @@ completion_columns <- function(data, max_items = MAX_COMPLETIONS) {
|
|
|
1110
1133
|
lapply(cols, function(nm) list(value = nm, type = class(df[[nm]])[1L]))
|
|
1111
1134
|
}
|
|
1112
1135
|
|
|
1136
|
+
#' A progress callback for keep_save()/keep_resume() that emits id-less
|
|
1137
|
+
#' `keep_progress` frames — the supervisor broadcasts an id-less frame to every
|
|
1138
|
+
#' page, which is what lets the page that asked AND a page that follows a
|
|
1139
|
+
#' handoff both draw it. Throttled to four a second; the last one always goes.
|
|
1140
|
+
#' @param phase "save" or "restore".
|
|
1141
|
+
#' @return function(done, total, bytes, name).
|
|
1142
|
+
keep_progress_emitter <- function(phase) {
|
|
1143
|
+
last <- 0
|
|
1144
|
+
function(done, total, bytes = 0, name = "") {
|
|
1145
|
+
now <- as.numeric(Sys.time())
|
|
1146
|
+
if (done < total && now - last < 0.25) return(invisible(NULL))
|
|
1147
|
+
last <<- now
|
|
1148
|
+
emit(list(type = "keep_progress", phase = phase, done = done, total = total,
|
|
1149
|
+
bytes = as.numeric(bytes %||% 0), name = as.character(name)[1L]))
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
#' "Restart, keep variables", step one: save the global environment
|
|
1154
|
+
#' (spike/workspace-keep.R). No deadline — Stop (the supervisor's
|
|
1155
|
+
#' suspend_cancel) interrupts it, and the partial copy is removed.
|
|
1156
|
+
#' @param id Request id.
|
|
1157
|
+
#' @return Invisibly NULL. Emits one `suspend` frame: token, saved, bytes,
|
|
1158
|
+
#' skipped — or cancelled, or error.
|
|
1159
|
+
emit_suspend <- function(id) {
|
|
1160
|
+
out <- tryCatch(keep_save(globalenv(), progress = keep_progress_emitter("save")),
|
|
1161
|
+
error = function(e) list(error = conditionMessage(e)),
|
|
1162
|
+
interrupt = function(i) list(cancelled = TRUE))
|
|
1163
|
+
emit(c(list(type = "suspend", id = id), out))
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
#' "Restart, keep variables", step two, in the NEW worker: load the kept
|
|
1167
|
+
#' objects back and restore the working directory. Asked by the supervisor
|
|
1168
|
+
#' only, never by a page (it is not in FORWARDED), with a token the save minted.
|
|
1169
|
+
#' @param id Request id.
|
|
1170
|
+
#' @param token The kept workspace's token.
|
|
1171
|
+
#' @return Invisibly NULL. Emits one `resume` frame: restored, failed, skipped,
|
|
1172
|
+
#' cwd — or error.
|
|
1173
|
+
emit_resume <- function(id, token) {
|
|
1174
|
+
out <- tryCatch(keep_resume(token, globalenv(), progress = keep_progress_emitter("restore")),
|
|
1175
|
+
error = function(e) list(error = conditionMessage(e)),
|
|
1176
|
+
interrupt = function(i) list(error = "The restore was interrupted."))
|
|
1177
|
+
# The directory R was in comes back too — unless it is gone (the old R's
|
|
1178
|
+
# tempdir() dies with the old R), which the report then says in words.
|
|
1179
|
+
if (is.character(out$wd) && nzchar(out$wd)) {
|
|
1180
|
+
moved <- dir.exists(out$wd) && !inherits(try(setwd(out$wd), silent = TRUE), "try-error")
|
|
1181
|
+
if (!moved) out$wd_missing <- out$wd
|
|
1182
|
+
}
|
|
1183
|
+
out$wd <- NULL
|
|
1184
|
+
emit(c(list(type = "resume", id = id, cwd = getwd()), out))
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1113
1187
|
#' Coerce a wire-supplied count, falling back rather than erroring: a malformed
|
|
1114
1188
|
#' offset from a client must degrade to the default, not kill the pane.
|
|
1115
1189
|
#'
|
|
@@ -3308,13 +3382,13 @@ if (identical(unname(Sys.info()[["sysname"]]), "Darwin") &&
|
|
|
3308
3382
|
# Advertising the vocabulary lets a client know instantly. Kernels older than
|
|
3309
3383
|
# this simply omit the field, and clients fall back to probing.
|
|
3310
3384
|
#
|
|
3311
|
-
# A
|
|
3312
|
-
#
|
|
3313
|
-
#
|
|
3314
|
-
#
|
|
3315
|
-
#
|
|
3316
|
-
#
|
|
3317
|
-
#
|
|
3385
|
+
# A worker starts as a fresh R. Until 7.59 a "Restart into" handoff asked this
|
|
3386
|
+
# worker to `save.image()` and the successor loaded one file back inside a
|
|
3387
|
+
# deadline; that failed on large sessions and was removed ("restore work has
|
|
3388
|
+
# been abysmally bad"). Since 0.8.5 "Restart, keep variables" is an explicit
|
|
3389
|
+
# choice built differently (spike/workspace-keep.R): the page asks `suspend`
|
|
3390
|
+
# with no deadline, the new worker is asked `resume` by the supervisor after
|
|
3391
|
+
# `ready`, one file per object, and the report names what did not come back.
|
|
3318
3392
|
emit(list(type = "ready", pid = Sys.getpid(), r = R.version.string, cwd = getwd(),
|
|
3319
3393
|
# I(): a single library path must still ship as an array.
|
|
3320
3394
|
home = R.home(), libs = I(.libPaths()),
|
|
@@ -3329,7 +3403,7 @@ emit(list(type = "ready", pid = Sys.getpid(), r = R.version.string, cwd = getwd(
|
|
|
3329
3403
|
"help", "hover", "wd", "files", "sniff",
|
|
3330
3404
|
"import", "readfile", "writefile", "writefiles_atomic", "view", "colstats",
|
|
3331
3405
|
"mkdir", "renamepath", "deletepath", "copypath", "revealpath",
|
|
3332
|
-
"rm",
|
|
3406
|
+
"rm", "suspend",
|
|
3333
3407
|
if (identical(WORKER_MODE, "interactive")) "debug_breaks"))))
|
|
3334
3408
|
# (No package count here on purpose: installed.packages() reads every
|
|
3335
3409
|
# package's DESCRIPTION — 0.3–1.8 s on a big library — and no client ever
|
|
@@ -3379,6 +3453,8 @@ carmar_dispatch <- function(cmd) {
|
|
|
3379
3453
|
if (identical(cmd$type, "doctor")) emit_doctor(cmd$id)
|
|
3380
3454
|
if (identical(cmd$type, "complete")) emit_complete(cmd$id, cmd$line, cmd$cursor,
|
|
3381
3455
|
fn = cmd$fn, data = cmd$data)
|
|
3456
|
+
if (identical(cmd$type, "suspend")) emit_suspend(cmd$id)
|
|
3457
|
+
if (identical(cmd$type, "resume")) emit_resume(cmd$id, cmd$token)
|
|
3382
3458
|
if (identical(cmd$type, "packages")) emit_packages(cmd$id, cmd$scope)
|
|
3383
3459
|
if (identical(cmd$type, "package_action")) emit_package_action(cmd$id, cmd$action, cmd$name, cmd$lib)
|
|
3384
3460
|
if (identical(cmd$type, "package_help")) emit_package_help(cmd$id, cmd$name)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# workspace-keep.R — "Restart, keep variables": the global environment carried
|
|
2
|
+
# across a restart, the way RStudio carries it.
|
|
3
|
+
#
|
|
4
|
+
# Sourced into the WORKER's private scope (worker.R's import_sources), and
|
|
5
|
+
# testable without a worker: spike/test-workspace-keep.R sources this file
|
|
6
|
+
# alone.
|
|
7
|
+
#
|
|
8
|
+
# This replaces a removed design, and the difference is the point. Until 7.59
|
|
9
|
+
# a restart ran save.image() inside a handoff with a deadline and load()ed one
|
|
10
|
+
# file back; large sessions missed the deadline, one unreadable object sank the
|
|
11
|
+
# whole file, and nothing said what was lost. The owner removed it ("restore
|
|
12
|
+
# work has been abysmally bad"). What is here instead:
|
|
13
|
+
#
|
|
14
|
+
# * NO DEADLINE. The save is a worker command like any other: progress frames
|
|
15
|
+
# while it runs, Interrupt (Stop) cancels it, and the restart only happens
|
|
16
|
+
# once the save has answered.
|
|
17
|
+
# * ONE FILE PER OBJECT. An object that fails to save or to load is that
|
|
18
|
+
# object, named in the report — never the whole workspace.
|
|
19
|
+
# * PACKAGES ARE NOT RECORDED. A notebook's library() calls live in its setup
|
|
20
|
+
# chunk, and the page runs setup before the first chunk of a fresh R
|
|
21
|
+
# (lib/document-execution.js) — RStudio's own answer, and the reason this
|
|
22
|
+
# file keeps variables only.
|
|
23
|
+
# * WHAT CANNOT TRAVEL IS SAID. A connection or an external pointer
|
|
24
|
+
# serialises to a dead handle; it is skipped at save time and listed.
|
|
25
|
+
#
|
|
26
|
+
# Known limit, stated rather than hidden: objects are saved separately, so two
|
|
27
|
+
# objects that SHARE an environment (R6 objects pointing at one another) come
|
|
28
|
+
# back as copies that no longer share it.
|
|
29
|
+
#
|
|
30
|
+
# The token names a directory under the keep root and is the only thing that
|
|
31
|
+
# travels: the page and the supervisor never pass a path, so a resume can only
|
|
32
|
+
# ever read a directory this code wrote.
|
|
33
|
+
|
|
34
|
+
#' Where kept workspaces live: 0700, per user. `CARMAR_KEEP_DIR` relocates it
|
|
35
|
+
#' for tests.
|
|
36
|
+
#' @return A directory path (not created).
|
|
37
|
+
keep_root <- function() {
|
|
38
|
+
env <- Sys.getenv("CARMAR_KEEP_DIR", "")
|
|
39
|
+
if (nzchar(env)) env else file.path(tools::R_user_dir("carmar", "data"), "kept-workspace")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
KEEP_TOKEN_RE <- "^[0-9]{14}-[0-9a-f]{16}$"
|
|
43
|
+
|
|
44
|
+
#' Is `x` a token this file could have minted?
|
|
45
|
+
#' @param x Anything the wire delivered.
|
|
46
|
+
#' @return TRUE for one well-formed token.
|
|
47
|
+
keep_token_ok <- function(x) {
|
|
48
|
+
is.character(x) && length(x) == 1L && !is.na(x) && grepl(KEEP_TOKEN_RE, x)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#' A fresh token: a timestamp (sorts, and ages out) plus 64 random bits.
|
|
52
|
+
#' @return A token matching KEEP_TOKEN_RE.
|
|
53
|
+
keep_token_new <- function() {
|
|
54
|
+
bytes <- tryCatch({
|
|
55
|
+
con <- file("/dev/urandom", "rb", raw = TRUE)
|
|
56
|
+
on.exit(close(con), add = TRUE)
|
|
57
|
+
readBin(con, "raw", n = 8L)
|
|
58
|
+
}, error = function(e) as.raw(sample.int(256L, 8L, replace = TRUE) - 1L))
|
|
59
|
+
paste0(format(Sys.time(), "%Y%m%d%H%M%S"), "-", paste(format(bytes), collapse = ""))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#' Why an object cannot travel, or NULL when it can.
|
|
63
|
+
#'
|
|
64
|
+
#' A connection is an integer index into THIS process's connection table, and
|
|
65
|
+
#' an external pointer is an address in this process's memory: both serialise
|
|
66
|
+
#' without complaint and come back as something that looks right and is dead.
|
|
67
|
+
#' @param x The object.
|
|
68
|
+
#' @return NULL, or a short phrase for the report.
|
|
69
|
+
keep_unsaveable <- function(x) {
|
|
70
|
+
if (inherits(x, "connection")) return("a connection (a file or URL handle)")
|
|
71
|
+
if (inherits(x, "DBIConnection")) return("a database connection")
|
|
72
|
+
if (typeof(x) == "externalptr") return("an external pointer")
|
|
73
|
+
NULL
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#' Delete kept workspaces older than `max_age_s` — a restart that never
|
|
77
|
+
#' resumed (a crash, a closed laptop) must not keep a copy of someone's data
|
|
78
|
+
#' forever.
|
|
79
|
+
#' @param root The keep root.
|
|
80
|
+
#' @param max_age_s Age in seconds.
|
|
81
|
+
#' @return Invisibly, the tokens removed.
|
|
82
|
+
keep_prune <- function(root = keep_root(), max_age_s = 86400) {
|
|
83
|
+
if (!dir.exists(root)) return(invisible(character(0)))
|
|
84
|
+
tokens <- Filter(keep_token_ok, list.files(root))
|
|
85
|
+
old <- Filter(function(t) {
|
|
86
|
+
age <- as.numeric(difftime(Sys.time(), file.info(file.path(root, t))$mtime, units = "secs"))
|
|
87
|
+
isTRUE(age > max_age_s)
|
|
88
|
+
}, tokens)
|
|
89
|
+
invisible(vapply(old, function(t) { unlink(file.path(root, t), recursive = TRUE); t }, character(1)))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#' Save every object of `env` into a new kept workspace.
|
|
93
|
+
#'
|
|
94
|
+
#' @param env The environment to keep (the worker passes globalenv()).
|
|
95
|
+
#' @param progress function(done, total, bytes, name) called after each object.
|
|
96
|
+
#' @param root The keep root.
|
|
97
|
+
#' @return list(token, saved, bytes, skipped = list of {name, why}).
|
|
98
|
+
#' An interrupt removes the partial directory and propagates, so the caller
|
|
99
|
+
#' reports "cancelled" and nothing half-written is ever resumed.
|
|
100
|
+
keep_save <- function(env = globalenv(), progress = function(...) NULL, root = keep_root()) {
|
|
101
|
+
stopifnot("`env` must be an environment" = is.environment(env))
|
|
102
|
+
dir.create(root, recursive = TRUE, showWarnings = FALSE, mode = "0700")
|
|
103
|
+
Sys.chmod(root, mode = "0700")
|
|
104
|
+
keep_prune(root)
|
|
105
|
+
token <- keep_token_new()
|
|
106
|
+
dir <- file.path(root, token)
|
|
107
|
+
dir.create(dir, mode = "0700")
|
|
108
|
+
names_ <- sort(ls(env, all.names = TRUE))
|
|
109
|
+
total <- length(names_)
|
|
110
|
+
bytes <- 0
|
|
111
|
+
done <- FALSE
|
|
112
|
+
on.exit(if (!done) unlink(dir, recursive = TRUE), add = TRUE)
|
|
113
|
+
# Objects are indexed, never named, on disk: an object name may hold any
|
|
114
|
+
# character, and a file name must not.
|
|
115
|
+
entries <- lapply(seq_along(names_), function(i) {
|
|
116
|
+
name <- names_[[i]]
|
|
117
|
+
value <- get(name, envir = env, inherits = FALSE)
|
|
118
|
+
why <- keep_unsaveable(value)
|
|
119
|
+
entry <- if (!is.null(why)) list(name = name, status = "skipped", why = why)
|
|
120
|
+
else {
|
|
121
|
+
file <- sprintf("%06d.rds", i)
|
|
122
|
+
failed <- tryCatch({ saveRDS(value, file.path(dir, file), compress = FALSE); NULL },
|
|
123
|
+
error = function(e) conditionMessage(e))
|
|
124
|
+
if (is.null(failed)) {
|
|
125
|
+
bytes <<- bytes + file.size(file.path(dir, file))
|
|
126
|
+
list(name = name, status = "saved", file = file, class = class(value)[1L])
|
|
127
|
+
} else {
|
|
128
|
+
unlink(file.path(dir, file))
|
|
129
|
+
list(name = name, status = "skipped", why = paste("could not be saved:", failed))
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
progress(i, total, bytes, name)
|
|
133
|
+
entry
|
|
134
|
+
})
|
|
135
|
+
manifest <- list(version = 1L, created = format(Sys.time(), "%Y-%m-%dT%H:%M:%S"),
|
|
136
|
+
wd = getwd(), entries = entries)
|
|
137
|
+
writeLines(jsonlite::toJSON(manifest, auto_unbox = TRUE), file.path(dir, "manifest.json"))
|
|
138
|
+
Sys.chmod(list.files(dir, full.names = TRUE), mode = "0600")
|
|
139
|
+
done <- TRUE
|
|
140
|
+
status <- vapply(entries, `[[`, "", "status")
|
|
141
|
+
list(token = token, saved = sum(status == "saved"), bytes = bytes,
|
|
142
|
+
skipped = lapply(entries[status == "skipped"], function(e) list(name = e$name, why = e$why)))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#' Load a kept workspace into `env`, then delete it.
|
|
146
|
+
#'
|
|
147
|
+
#' Each object is read on its own; one that fails is reported and the rest
|
|
148
|
+
#' still arrive. The directory is removed afterwards whatever happened — a
|
|
149
|
+
#' kept workspace is used once.
|
|
150
|
+
#' @param token A token from keep_save().
|
|
151
|
+
#' @param env Where the objects go (the worker passes globalenv()).
|
|
152
|
+
#' @param progress function(done, total, bytes, name) called after each object
|
|
153
|
+
#' (the same shape keep_save() calls, so one callback serves both; bytes is 0).
|
|
154
|
+
#' @param root The keep root.
|
|
155
|
+
#' @return list(restored, failed = list of {name, why}, skipped = the save's
|
|
156
|
+
#' skips, carried so the one report says everything), or list(error).
|
|
157
|
+
keep_resume <- function(token, env = globalenv(), progress = function(...) NULL, root = keep_root()) {
|
|
158
|
+
if (!keep_token_ok(token)) return(list(error = "That is not a kept workspace."))
|
|
159
|
+
dir <- file.path(root, token)
|
|
160
|
+
manifest_file <- file.path(dir, "manifest.json")
|
|
161
|
+
if (!file.exists(manifest_file)) {
|
|
162
|
+
return(list(error = "The kept workspace is gone — it was already restored or removed."))
|
|
163
|
+
}
|
|
164
|
+
on.exit(unlink(dir, recursive = TRUE), add = TRUE)
|
|
165
|
+
manifest <- tryCatch(jsonlite::fromJSON(manifest_file, simplifyVector = FALSE),
|
|
166
|
+
error = function(e) NULL)
|
|
167
|
+
if (is.null(manifest) || !is.list(manifest$entries)) {
|
|
168
|
+
return(list(error = "The kept workspace's record could not be read."))
|
|
169
|
+
}
|
|
170
|
+
saved <- Filter(function(e) identical(e$status, "saved"), manifest$entries)
|
|
171
|
+
total <- length(saved)
|
|
172
|
+
outcomes <- lapply(seq_along(saved), function(i) {
|
|
173
|
+
e <- saved[[i]]
|
|
174
|
+
# The file name is re-checked, never trusted: only NNNNNN.rds inside dir.
|
|
175
|
+
why <- if (!is.character(e$file) || !grepl("^[0-9]{6}\\.rds$", e$file)) "its record is malformed"
|
|
176
|
+
else tryCatch({
|
|
177
|
+
assign(e$name, readRDS(file.path(dir, e$file)), envir = env)
|
|
178
|
+
NULL
|
|
179
|
+
}, error = function(err) conditionMessage(err))
|
|
180
|
+
progress(i, total, 0, e$name)
|
|
181
|
+
if (is.null(why)) NULL else list(name = e$name, why = paste("could not be restored:", why))
|
|
182
|
+
})
|
|
183
|
+
failed <- Filter(Negate(is.null), outcomes)
|
|
184
|
+
skipped <- lapply(Filter(function(e) identical(e$status, "skipped"), manifest$entries),
|
|
185
|
+
function(e) list(name = e$name, why = e$why))
|
|
186
|
+
list(restored = total - length(failed), failed = failed, skipped = skipped,
|
|
187
|
+
wd = if (is.character(manifest$wd)) manifest$wd else "")
|
|
188
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beatrina",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.7",
|
|
4
4
|
"description": "Beatrina is a modern, powerful, feature-rich integrated development environment (IDE) for R, Python, and JavaScript.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"lib",
|
|
25
25
|
"check",
|
|
26
26
|
"build-info.json",
|
|
27
|
-
"carmar_V0.8.
|
|
27
|
+
"carmar_V0.8.7.html",
|
|
28
28
|
"README.md",
|
|
29
29
|
"LICENSE",
|
|
30
30
|
"NOTICES"
|