diff --git a/stdlib/REPL/src/History/History.jl b/stdlib/REPL/src/History/History.jl index 3a7ff97543688..803ae81f45990 100644 --- a/stdlib/REPL/src/History/History.jl +++ b/stdlib/REPL/src/History/History.jl @@ -10,7 +10,7 @@ using Base.Threads using Dates using InteractiveUtils: clipboard -export HistoryFile, HistEntry, update!, runsearch +export HistoryFile, HistEntry, HistUpdate, update!, runsearch const FACES = ( :REPL_History_search_separator => Face(foreground=:blue), diff --git a/stdlib/REPL/src/History/display.jl b/stdlib/REPL/src/History/display.jl index 54397fa0e0545..f796c9e478637 100644 --- a/stdlib/REPL/src/History/display.jl +++ b/stdlib/REPL/src/History/display.jl @@ -161,6 +161,13 @@ const MODE_FACES = Dict( :help => :yellow, ) +const MODE_KEYS = Dict( + :julia => ' ', + :shell => ';', + :pkg => ']', + :help => '?', +) + """ redisplay_prompt(io::IO, oldstate::SelectorState, newstate::SelectorState, pstate::PromptState) @@ -446,13 +453,45 @@ julia> humanage(4000) ``` """ function humanage(seconds::Integer) - unit, count = :s, seconds + unit, count, rem = :s, seconds, 0 for (dunit, dsecs) in pairs(DURATIONS) - n = seconds ÷ dsecs + n, rem = divrem(seconds, dsecs) n == 0 && break unit, count = dunit, n end - "$count$unit" + "$count$unit", rem +end + +function humanage(seconds::AbstractFloat) + if seconds >= 10 + isec = round(Int, seconds) + frem = seconds - isec + age, irem = humanage(isec) + age, irem + frem + elseif seconds >= 0.1 + rsec = round(seconds, digits=1) + "$(rsec)s", seconds - rsec + elseif seconds >= 0.001 + msec = round(Int, seconds * 1000) + "$(msec)ms", seconds - msec / 1000 + else + μsec = round(Int, seconds * 1_000_000) + "$(μsec)μs", seconds - μsec / 1_000_000 + end +end + +function humanage2(seconds::AbstractFloat) + if seconds > 60 + part1, rem1 = humanage(seconds) + part2, rem2 = if rem1 < 1 + "", rem1 + else + humanage(rem1) + end + part1 * part2, rem2 + else + humanage(seconds) + end end """ @@ -463,35 +502,40 @@ Render one history entry line with markers, mode hint, age, and highlighted cont Truncates and focuses on matches to fit `width`. """ function print_candidate(io::IO, search::FilterSpec, cand::HistEntry, width::Int; selected::Bool, hover::Bool) - print(io, ' ', if selected + print(io, ' ', + if selected LIST_MARKERS.selected elseif hover LIST_MARKERS.hover else LIST_MARKERS.unselected - end, ' ') - age = humanage(floor(Int, ((now(UTC) - cand.date)::Millisecond).value ÷ 1000)) + end) + age, _ = humanage(floor(Int, ((now(UTC) - cand.date)::Millisecond).value ÷ 1000)) agedec = S" {shadow,light,italic:$age}" - modehint = if cand.mode == BASE_MODE - S"" + modeprefix = if cand.mode == BASE_MODE + S" " else modeface = get(MODE_FACES, cand.mode, :grey) - if hover - S"{region: {bold,inverse,$modeface: $(cand.mode) }}" - elseif ncodeunits(age) == 2 - S" {$modeface:◼} " - else - S" {$modeface:◼} " - end + modekey = get(MODE_KEYS, cand.mode, ' ') + S"{bold,$modeface:\e]66;n=1:d=1:w=2:h=2;$modekey\a}" + end + errordot = if cand.errored && hover + S"{region: {error:●} }" + elseif cand.errored + S" {error:●} " + else + S"" end - decorationlen = 3 #= spc + marker + spc =# + textwidth(modehint) + textwidth(agedec) + 1 #= spc =# + decorationlen = 4 #= spc + marker + 2*spc =# + + textwidth(errordot) + textwidth(agedec) + 1 #= spc =# flatcand = replace(highlightcand(cand), r"\r?\n\s*" => NEWLINE_MARKER) candstr = focus_matches(search, flatcand, width - decorationlen) if hover face!(candstr, :region) face!(agedec, :region) end - println(io, candstr, modehint, agedec, ' ') + println(io, modeprefix, + candstr, errordot, agedec, ' ') end """ @@ -682,7 +726,19 @@ function redisplay_preview(io::IO, oldstate::SelectorState, oldrows::Int, newsta mcolor = get(MODE_FACES, hovcand.mode, :grey) hovcontent = S"{bold,$mcolor:$(hovcand.mode)>} " * hovcontent end - boxedcontent(io, hovcontent, newstate.area.width, newrows - 2) + badge = if iszero(hovcand.elapsed) && isempty(hovcand.result) + S"" + elseif isempty(hovcand.result) + S"{yellow:▐{inverse:$(first(humanage2(hovcand.elapsed)))}▍}" + else + status = ifelse(hovcand.errored, :error, :yellow) + S"{region:{$status:▌}$(hovcand.result){$status:▐}} \ + {light,$status:$(first(humanage2(hovcand.elapsed)))} " + # else + # S"{region:{$(ifelse(hovcand.errored, :error, :yellow)):▍}$(hovcand.result){yellow:▐}}\ + # {yellow,inverse:$(first(humanage2(hovcand.elapsed)))}{yellow:▍}" + end + boxedcontent(io, hovcontent, newstate.area.width, newrows - 2, badge) else 0 end @@ -696,25 +752,36 @@ function redisplay_preview(io::IO, oldstate::SelectorState, oldrows::Int, newsta else linesprinted = 0 seltexts = AnnotatedString{String}[] + selduration = 0.0 for idx in getselidxs(newstate) entry = getcand(newstate, idx) + selduration += entry.elapsed content = highlightcand(entry) ishover(newstate, idx) && face!(content, :region) push!(seltexts, content) end + badge = S"{yellow:▐{inverse:$(first(humanage2(selduration)))}▍}" linecount = sum(t -> 1 + count('\n', String(t)), seltexts, init=0) for (i, content) in enumerate(seltexts) clines = 1 + count('\n', String(content)) if linesprinted + clines < newrows - 2 || (i == length(seltexts) && linesprinted + clines == newrows - 2) - for line in eachsplit(content, '\n') - println(io, bar, ' ', rtruncpad(line, innerwidth - 2), ' ', bar) + for (l, line) in enumerate(eachsplit(content, '\n')) + if i == 1 && l == 1 + println(io, bar, ' ', rtruncpad(line, innerwidth - textwidth(badge) - 3), ' ', badge, ' ', bar) + else + println(io, bar, ' ', rtruncpad(line, innerwidth - 2), ' ', bar) + end end linesprinted += clines else remaininglines = newrows - 2 - linesprinted - for (i, line) in enumerate(eachsplit(content, '\n')) - i == remaininglines && break - println(io, bar, ' ', rtruncpad(line, innerwidth - 2), ' ', bar) + for (l, line) in enumerate(eachsplit(content, '\n')) + l == remaininglines && break + if i == 1 && l == 1 + println(io, bar, ' ', rtruncpad(line, innerwidth - textwidth(badge) - 3), ' ', badge, ' ', bar) + else + println(io, bar, ' ', rtruncpad(line, innerwidth - 2), ' ', bar) + end end msg = S"{julia_comment:⋮ {italic:$(linecount - newrows + 3) lines hidden}}" println(io, bar, ' ', rtruncpad(msg, innerwidth - 2), ' ', bar) @@ -743,7 +810,7 @@ Draw `content` inside a Unicode box, wrapping or truncating to `width` and `maxl Returns the number of printed lines. """ -function boxedcontent(io::IO, content::AnnotatedString{String}, width::Int, maxlines::Int) +function boxedcontent(io::IO, content::AnnotatedString{String}, width::Int, maxlines::Int, badge::AnnotatedString{String} = S"") function breaklines(content::AnnotatedString{String}, maxwidth::Int) textwidth(content) <= maxwidth && return [content] spans = AnnotatedString{String}[] @@ -764,11 +831,13 @@ function boxedcontent(io::IO, content::AnnotatedString{String}, width::Int, maxl spans end left, right = S"{shadow:│} ", S" {shadow:│}" + badgeright = badge * S"{shadow:│}" leftcont, rightcont = S"{shadow:┊▸}", S"{shadow:◂┊}" if maxlines == 1 + bwidth = width - textwidth(badge) - 3 println(io, left, - rpad(rtruncate(content, width - 4, LINE_ELLIPSIS), width - 4), - right) + rpad(rtruncate(content, bwidth, LINE_ELLIPSIS), bwidth), + badge, right) return 1 end printedlines = 0 @@ -776,11 +845,15 @@ function boxedcontent(io::IO, content::AnnotatedString{String}, width::Int, maxl content = AnnotatedString(rtruncate(content, width * maxlines, ' ')) end lines = split(content, '\n') - innerwidth = width - 4 for (i, line) in enumerate(lines) printedlines >= maxlines && break + innerwidth, bright = if i == 1 + width - textwidth(badge) - 3, badgeright + else + width - 4, right + end if textwidth(line) <= innerwidth - println(io, left, rpad(line, innerwidth), right) + println(io, left, rpad(line, innerwidth), bright) printedlines += 1 continue end @@ -801,10 +874,13 @@ function boxedcontent(io::IO, content::AnnotatedString{String}, width::Int, maxl LINE_ELLIPSIS, LINE_ELLIPSIS end printedlines += 1 + if printedlines == 1 + innerwidth, bright = width - 4, right + end println(io, ifelse(i == 1, left, leftcont), ' ' ^ indent, prefix, rpad(span, innerwidth - 2 - indent), suffix, ifelse(i == length(spans) || printedlines == maxlines, - right, rightcont)) + bright, rightcont)) printedlines >= maxlines && break end end diff --git a/stdlib/REPL/src/History/histfile.jl b/stdlib/REPL/src/History/histfile.jl index b62dfeae504e2..ebbb5f7cf2942 100644 --- a/stdlib/REPL/src/History/histfile.jl +++ b/stdlib/REPL/src/History/histfile.jl @@ -14,17 +14,23 @@ const HIST_OPEN_FLAGS = Base.Filesystem.JL_O_CLOEXEC struct HistEntry + # Original fields mode::Symbol date::DateTime - # cwd::String content::String - # resulttype::String - # session::UInt64 - index::UInt32 - # sindex::UInt16 - # error::Bool + # Extended fields (ordered for better packing) + session::UInt64 + cwd::String + result::String + elapsed::Float32 + index::UInt32 # computed + sid::UInt32 + errored::Bool end +HistEntry(session::UInt64, sid::Integer, cwd::String, mode::Symbol, date::DateTime, content::String) = + HistEntry(mode, date, content, session, cwd, "", 0.0, 0, UInt32(sid), false) + """ HistoryFile(path::String) -> HistoryFile @@ -69,8 +75,11 @@ function ensureopen(hist::HistoryFile) end end +Base.isopen(hist::HistoryFile) = isopen(hist.file) Base.close(hist::HistoryFile) = close(hist.file) +# Reading entries + """ update!(hist::HistoryFile) -> HistoryFile @@ -96,19 +105,6 @@ function update!(hist::HistoryFile) try lock(hist) bytes = read(file) - function findnext(data::Vector{UInt8}, index::Int, byte::UInt8, limit::Int = length(data)) - for i in index:limit - data[i] == byte && return i - end - limit - end - function isstrmatch(data::Vector{UInt8}, at::Int, str::String) - at + ncodeunits(str) <= length(data) || return false - for (i, byte) in enumerate(codeunits(str)) - data[at + i - 1] == byte || return false - end - true - end histindex = if isempty(hist.records) 0 else @@ -122,25 +118,32 @@ function update!(hist::HistoryFile) @warn S"Malformed history entry: expected meta-line starting with {success:'#'} at byte {emphasis:$(offset + pos - 1)} in \ {(underline=grey),link=$(Base.Filesystem.uripath(hist.path)):$(contractuser(hist.path))}, but found \ {error:$(sprint(show, Char(bytes[pos])))} instead" _id=:invalid_history_entry maxlog=3 _file=nothing _line=nothing - pos = findnext(bytes, pos, UInt8('\n')) + 1 + pos = findbyte(bytes, pos, UInt8('\n')) + 1 continue end - time, mode = zero(DateTime), :julia + time, mode = zero(DateTime), :julia # Original attributes + session, sid, cwd = zero(UInt64), zero(UInt16), "" # Extended attributes while pos < length(bytes) && bytes[pos] == UInt8('#') - pos += 1 - while pos < length(bytes) && bytes[pos] == UInt8(' ') - pos += 1 - end + pos = skipspaces(bytes, pos + 1) metastart = pos - metaend = findnext(bytes, pos, UInt8(':')) - pos = metaend + 1 - while pos < length(bytes) && bytes[pos] == UInt8(' ') - pos += 1 - end + metaend = findbyte(bytes, pos, UInt8(':')) + pos = skipspaces(bytes, metaend + 1) valstart = pos - valend = findnext(bytes, pos, UInt8('\n')) + valend = findbyte(bytes, pos, UInt8('\n')) + while valend > valstart && bytes[valend - 1] == UInt8(' ') + valend -= 1 + end pos = valend + 1 - if isstrmatch(bytes, metastart, "mode:") + if isstrmatch(bytes, metastart, "id:") + slashpos = findbyte(bytes, valstart, UInt8('/')) + if slashpos < valend + sessionval = tryparse(UInt64, String(bytes[valstart:slashpos-1]), base=62) + sidval = tryparse(UInt32, String(bytes[slashpos+1:valend-1])) + if !isnothing(sessionval) && !isnothing(sidval) + session, sid = sessionval, sidval + end + end + elseif isstrmatch(bytes, metastart, "mode:") mode = if isstrmatch(bytes, valstart, "julia") && bytes[valstart + ncodeunits("julia")] ∈ (UInt8('\n'), UInt8('\r')) :julia elseif isstrmatch(bytes, valstart, "help") && bytes[valstart + ncodeunits("help")] ∈ (UInt8('\n'), UInt8('\r')) @@ -157,6 +160,10 @@ function update!(hist::HistoryFile) if !isnothing(timeval) time = timeval end + elseif isstrmatch(bytes, metastart, "cwd:") + cwd = String(bytes[valstart:valend-1]) + elseif isstrmatch(bytes, metastart, "result:") + updateresult!(records, bytes, valstart, valend - 1) end end if pos >= length(bytes) @@ -178,7 +185,7 @@ function update!(hist::HistoryFile) contentstart = pos nlines = 0 while true - pos = findnext(bytes, pos, UInt8('\n')) + pos = findbyte(bytes, pos, UInt8('\n')) nlines += 1 if pos < length(bytes) && bytes[pos+1] == UInt8('\t') pos += 1 @@ -190,13 +197,16 @@ function update!(hist::HistoryFile) content = Vector{UInt8}(undef, contentend - contentstart - nlines) bytescopied = 0 while pos < contentend - lineend = findnext(bytes, pos, UInt8('\n')) + lineend = findbyte(bytes, pos, UInt8('\n')) nbytes = lineend - pos - (lineend == contentend) copyto!(content, bytescopied + 1, bytes, pos + 1, nbytes) bytescopied += nbytes pos = lineend + 1 end - entry = HistEntry(mode, time, String(content), histindex += 1) + if isempty(cwd) + cwd = getrecent(records, session, :cwd, "") + end + entry = HistEntry(mode, time, String(content), session, cwd, "", zero(Float32), histindex += 1, sid, false) push!(records, entry) end seek(file, offset + pos - 1) @@ -206,7 +216,89 @@ function update!(hist::HistoryFile) hist end +function findbyte(data::Vector{UInt8}, index::Int, byte::UInt8, limit::Int = length(data)) + for i in index:limit + data[i] == byte && return i + end + limit +end + +function isstrmatch(data::Vector{UInt8}, at::Int, str::String) + at + ncodeunits(str) <= length(data) || return false + for (i, byte) in enumerate(codeunits(str)) + data[at + i - 1] == byte || return false + end + true +end + +function skipspaces(data::AbstractVector{UInt8}, at::Int) + while at < length(data) && data[at] == UInt8(' ') + at += 1 + end + at +end + +function updateresult!(recs::Vector{HistEntry}, resbytes::AbstractVector{UInt8}, pos::Int, resend::Int) + # Cautiously parse the result value: + # [error][type] in [elapsed]s ([session]/[sid]) + errored = false + if resbytes[pos] == UInt8('!') + errored = true + pos += 1 + end + pnext = findbyte(resbytes, pos, UInt8(' ')) + pnext < resend || return + restype = String(resbytes[pos:pnext-1]) + pos = skipspaces(resbytes, pnext) + isstrmatch(resbytes, pos, "in") || return + pos = skipspaces(resbytes, pos + ncodeunits("in")) + pnext = findbyte(resbytes, pos, UInt8('s')) + pnext < resend || return + elapsedval = tryparse(Float32, String(resbytes[pos:pnext-1])) + isnothing(elapsedval) && return + elapsed = elapsedval + pos = skipspaces(resbytes, pnext + 1) + 1 + pos < resend && resbytes[pos - 1] == UInt8('(') || return + pnext = findbyte(resbytes, pos, UInt8('/')) + pnext < resend || return + sessionval = tryparse(UInt64, String(resbytes[pos:pnext-1]), base=62) + pos = findbyte(resbytes, pnext + 1, UInt8(')')) + pnext < resend || return + sidval = tryparse(UInt32, String(resbytes[pnext+1:pos-1])) + !isnothing(sessionval) && !isnothing(sidval) || return + session, sid = sessionval, sidval + # Now find and update the matching record + for i in Iterators.reverse(eachindex(recs)) + rec = recs[i] + rec.session == session && rec.sid == sid || continue + recs[i] = HistEntry( + rec.mode, + rec.date, + rec.content, + rec.session, + rec.cwd, + restype, + elapsed, + rec.index, + rec.sid, + errored) + return + end +end + +function getrecent(records::Vector{HistEntry}, session::UInt64, attr::Symbol, default = nothing) + for rec in Iterators.reverse(records) + rec.session == session || continue + val = getfield(rec, attr) + return val + end + default +end + +# Adding and updating entries + function Base.push!(hist::HistoryFile, entry::HistEntry) + prevwd = getrecent(hist.records, entry.session, :cwd, "") try lock(hist) update!(hist) @@ -218,13 +310,23 @@ function Base.push!(hist::HistoryFile, entry::HistEntry) end, round(entry.date, Dates.Second), entry.content, - length(hist.records) + 1) + entry.session, + ifelse(prevwd == entry.cwd, prevwd, entry.cwd), + "", + zero(Float32), + length(hist.records) + 1, + entry.sid, + false) push!(hist.records, entry) isopen(hist.file) || return hist content = IOBuffer() - write(content, "# time: ", - Dates.format(entry.date, REPL_DATE_FORMAT), "Z\n", + write(content, + "# id: ", string(entry.session, base=62), '/', string(entry.sid), '\n', + "# time: ", Dates.format(entry.date, REPL_DATE_FORMAT), "Z\n", "# mode: ", String(entry.mode), '\n') + if prevwd != entry.cwd + write(content, "# cwd: ", entry.cwd, '\n') + end replace(content, entry.content, r"^"ms => "\t") write(content, '\n') # Short version: @@ -286,3 +388,47 @@ function Base.push!(hist::HistoryFile, entry::HistEntry) end hist end + +struct HistUpdate + session::UInt64 + result::Symbol + iserror::Bool + elapsed::Float32 +end + +function Base.push!(hist::HistoryFile, update::HistUpdate) + sid = zero(UInt16) + for i in Iterators.reverse(eachindex(hist.records)) + rec = hist.records[i] + rec.session == update.session || continue + # This must correspond to the most recent entry from this session + hist.records[i] = HistEntry( + rec.mode, + rec.date, + rec.content, + rec.session, + rec.cwd, + String(update.result), + update.elapsed, + rec.index, + rec.sid, + update.iserror) + sid = rec.sid + break + end + isopen(hist) && !iszero(sid) || return hist + try + lock(hist) + content = IOBuffer() + write(content, "# result: ", + ifelse(update.iserror, "!", ""), + update.result, " in ", + string(round(update.elapsed, sigdigits=4)), + "s (", string(update.session, base=62), + '/', string(sid), ")\n") + unsafe_write(hist.file, pointer(content.data), position(content) % UInt, Int64(-1)) + finally + unlock(hist) + end + hist +end diff --git a/stdlib/REPL/src/History/resumablefiltering.jl b/stdlib/REPL/src/History/resumablefiltering.jl index 51a781550fc1f..028d44cb35cc2 100644 --- a/stdlib/REPL/src/History/resumablefiltering.jl +++ b/stdlib/REPL/src/History/resumablefiltering.jl @@ -155,7 +155,7 @@ function ConditionSet(spec::S) where {S <: AbstractString} elseif chr == '\\' escaped = true elseif chr == FILTER_SEPARATOR - str = SubString(spec, mark:pos - 1) + str = SubString(spec, mark:prevind(spec, pos)) if !isempty(dropbytes) str = SubString(convert(S, String(deleteat!(collect(codeunits(str)), dropbytes)))) empty!(dropbytes) @@ -166,7 +166,7 @@ function ConditionSet(spec::S) where {S <: AbstractString} pos = nextind(spec, pos) end if mark <= lastind - str = SubString(spec, mark:pos - 1) + str = SubString(spec, mark:prevind(spec, pos)) if !isempty(dropbytes) str = SubString(convert(S, String(deleteat!(collect(codeunits(str)), dropbytes)))) end diff --git a/stdlib/REPL/src/REPL.jl b/stdlib/REPL/src/REPL.jl index 6a9775c718d84..9b453b908185c 100644 --- a/stdlib/REPL/src/REPL.jl +++ b/stdlib/REPL/src/REPL.jl @@ -115,6 +115,11 @@ const PKG_PROMPT = "pkg> " const SHELL_PROMPT = "shell> " const HELP_PROMPT = "help?> " +mutable struct HistorySessionRef + session::UInt64 + histfile::HistoryFile +end + mutable struct REPLBackend "channel for AST" repl_channel::Channel{Any} @@ -124,11 +129,14 @@ mutable struct REPLBackend in_eval::Bool "transformation functions to apply before evaluating expressions" ast_transforms::Vector{Any} + "a record of historical information, contained in a file, very interesting" + hist::HistorySessionRef "current backend task" backend_task::Task - REPLBackend(repl_channel, response_channel, in_eval, ast_transforms=copy(repl_ast_transforms)) = - new(repl_channel, response_channel, in_eval, ast_transforms) + REPLBackend(repl_channel, response_channel, in_eval, ast_transforms=copy(repl_ast_transforms), + hist=HistorySessionRef(zero(UInt64), HistoryFile())) = + new(repl_channel, response_channel, in_eval, ast_transforms, hist) end REPLBackend() = REPLBackend(Channel(1), Channel(1), false) @@ -136,8 +144,9 @@ REPLBackend() = REPLBackend(Channel(1), Channel(1), false) struct REPLBackendRef repl_channel::Channel{Any} response_channel::Channel{Any} + hist::HistorySessionRef end -REPLBackendRef(backend::REPLBackend) = REPLBackendRef(backend.repl_channel, backend.response_channel) +REPLBackendRef(backend::REPLBackend) = REPLBackendRef(backend.repl_channel, backend.response_channel, backend.hist) function destroy(ref::REPLBackendRef, state::Task) if istaskfailed(state) @@ -317,19 +326,24 @@ function toplevel_eval_with_hooks(mod::Module, @nospecialize(ast), toplevel_file if !isexpr(ast, :toplevel) ast = invokelatest(__repl_entry_lower_with_loc, mod, ast, toplevel_file, toplevel_line) check_for_missing_packages_and_run_hooks(ast) - return invokelatest(__repl_entry_eval_expanded_with_loc, mod, ast, toplevel_file, toplevel_line) + eval_start = time() + value = invokelatest(__repl_entry_eval_expanded_with_loc, mod, ast, toplevel_file, toplevel_line) + return time() - eval_start, value end local value=nothing + local elapsed=0.0 for i = 1:length(ast.args) - value = toplevel_eval_with_hooks(mod, ast.args[i], toplevel_file, toplevel_line) + dur, value = toplevel_eval_with_hooks(mod, ast.args[i], toplevel_file, toplevel_line) + elapsed += dur end - return value + return elapsed, value end function eval_user_input(@nospecialize(ast), backend::REPLBackend, mod::Module) lasterr = nothing Base.sigatomic_begin() while true + eval_start = time() try Base.sigatomic_end() if lasterr !== nothing @@ -339,10 +353,13 @@ function eval_user_input(@nospecialize(ast), backend::REPLBackend, mod::Module) for xf in backend.ast_transforms ast = Base.invokelatest(xf, ast) end - value = toplevel_eval_with_hooks(mod, ast) + eval_duration, value = toplevel_eval_with_hooks(mod, ast) backend.in_eval = false setglobal!(Base.MainInclude, :ans, value) put!(backend.response_channel, Pair{Any, Bool}(value, false)) + push!(backend.hist.histfile, + HistUpdate(backend.hist.session, nameof(typeof(value)), + false, eval_duration)) end break catch err @@ -351,6 +368,13 @@ function eval_user_input(@nospecialize(ast), backend::REPLBackend, mod::Module) println(err) end lasterr = current_exceptions() + errtype = Exception + if lasterr isa Base.ExceptionStack && !isempty(lasterr.stack) + errtype = typeof(first(lasterr.stack).exception) + end + push!(backend.hist.histfile, + HistUpdate(backend.hist.session, nameof(errtype), + true, time() - eval_start)) end end Base.sigatomic_end() @@ -876,6 +900,8 @@ end mutable struct REPLHistoryProvider <: HistoryProvider history::HistoryFile + session::UInt64 + session_idx::Int start_idx::Int cur_idx::Int last_idx::Int @@ -884,7 +910,8 @@ mutable struct REPLHistoryProvider <: HistoryProvider mode_mapping::Dict{Symbol,Prompt} end REPLHistoryProvider(mode_mapping::Dict{Symbol}) = - REPLHistoryProvider(HistoryFile(), 0, 0, -1, IOBuffer(), + REPLHistoryProvider(HistoryFile(), rand(UInt64), + 0, 0, 0, -1, IOBuffer(), nothing, mode_mapping) function add_history(hist::REPLHistoryProvider, s::PromptState) @@ -893,7 +920,8 @@ function add_history(hist::REPLHistoryProvider, s::PromptState) mode = mode_idx(hist, LineEdit.mode(s)) !isempty(hist.history) && isequal(mode, hist.history[end].mode) && str == hist.history[end].content && return - entry = HistEntry(mode, now(UTC), str, 0) + entry = HistEntry(hist.session, hist.session_idx += 1, pwd(), + mode, now(UTC), String(str)) push!(hist.history, entry) nothing end @@ -917,7 +945,13 @@ function history_move(s::Union{LineEdit.MIState,LineEdit.PrefixSearchState}, his mode_idx(hist, LineEdit.mode(s)), oldrec.date, LineEdit.input_string(s), - oldrec.index) + oldrec.session, + oldrec.cwd, + "", # Invalidated result type + zero(Float32), # Invalidated duration + oldrec.index, + oldrec.sid, + false) # Invalidated error state end # load the saved line @@ -1655,6 +1689,9 @@ function run_frontend(repl::LineEditREPL, backend::REPLBackendRef) dopushdisplay && pushdisplay(d) if !isdefined(repl,:interface) interface = repl.interface = setup_interface(repl) + hp = repl.interface.modes[1].hist + backend.hist.histfile = hp.history + backend.hist.session = hp.session else interface = repl.interface end @@ -1822,10 +1859,6 @@ using ..REPL __current_ast_transforms() = Base.active_repl_backend !== nothing ? Base.active_repl_backend.ast_transforms : REPL.repl_ast_transforms -function repl_eval_counter(hp) - return length(hp.history) - hp.start_idx -end - function out_transform(@nospecialize(x), n::Ref{Int}) return Expr(:block, # avoid line numbers or scope that would leak into the output and change the meaning of x :(local __temp_val_a72df459 = $x), @@ -1860,7 +1893,7 @@ end function set_prompt(repl::LineEditREPL, n::Ref{Int}) julia_prompt = repl.interface.modes[1] julia_prompt.prompt = REPL.contextual_prompt(repl, function() - n[] = repl_eval_counter(julia_prompt.hist)+1 + n[] = julia_prompt.hist.session_idx + 1 string("In [", n[], "]: ") end) nothing diff --git a/stdlib/REPL/test/history.jl b/stdlib/REPL/test/history.jl index 48106132a8b60..1509908162d5a 100644 --- a/stdlib/REPL/test/history.jl +++ b/stdlib/REPL/test/history.jl @@ -65,6 +65,9 @@ const HISTORY_SAMPLE_INCOMPLETE = """ # mode: julia """ +basichist(mode::Symbol, date::DateTime, content::String, index::Int = 0) = + HistEntry(mode, date, content, 0, "", "", 0.0, index, 0, false) + @testset "Histfile" begin hpath = tempname() mkpath(dirname(hpath)) @@ -81,8 +84,8 @@ const HISTORY_SAMPLE_INCOMPLETE = """ hist = HistoryFile(hpath) update!(hist) @test length(hist) == 5 - @test hist[1] == HistEntry(:julia, DateTime("2020-10-31T05:16:39"), "cos", 1) - @test hist[2] == HistEntry(:help, DateTime("2020-10-31T05:16:40"), "cos", 2) + @test hist[1] == basichist(:julia, DateTime("2020-10-31T05:16:39"), "cos", 1) + @test hist[2] == basichist(:help, DateTime("2020-10-31T05:16:40"), "cos", 2) funccontent = """ function is_leap_year(year) if year % 4 == 0 && (! year % 100 == 0 || year % 400 == 0) @@ -91,9 +94,9 @@ const HISTORY_SAMPLE_INCOMPLETE = """ return false end end""" - @test hist[3] == HistEntry(:julia, DateTime("2021-03-12T09:03:06"), funccontent, 3) - @test hist[4] == HistEntry(:julia, DateTime("2021-03-23T16:48:55"), "L²norm(x -> x^2, ℐ)", 4) - @test hist[5] == HistEntry(:julia, DateTime("2021-03-23T16:49:06"), "L²norm(x -> 9x, ℐ)", 5) + @test hist[3] == basichist(:julia, DateTime("2021-03-12T09:03:06"), funccontent, 3) + @test hist[4] == basichist(:julia, DateTime("2021-03-23T16:48:55"), "L²norm(x -> x^2, ℐ)", 4) + @test hist[5] == basichist(:julia, DateTime("2021-03-23T16:49:06"), "L²norm(x -> 9x, ℐ)", 5) close(hist) end @testset "Format 2" begin @@ -101,9 +104,9 @@ const HISTORY_SAMPLE_INCOMPLETE = """ hist = HistoryFile(hpath) update!(hist) @test length(hist) == 3 - @test hist[1] == HistEntry(:julia, DateTime("2025-10-18T18:21:03"), "Iterators.partition([1,2,3,4,5,6,7], 2) |> eltype", 1) - @test hist[2] == HistEntry(:julia, DateTime("2025-10-19T06:27:10"), "using Chairmarks", 2) - @test hist[3] == HistEntry(:julia, DateTime("2025-10-19T06:27:18"), "@b REPL.History.HistoryFile(\"/home/tec/.julia/logs/repl_history.jl\") REPL.History.update!", 3) + @test hist[1] == basichist(:julia, DateTime("2025-10-18T18:21:03"), "Iterators.partition([1,2,3,4,5,6,7], 2) |> eltype", 1) + @test hist[2] == basichist(:julia, DateTime("2025-10-19T06:27:10"), "using Chairmarks", 2) + @test hist[3] == basichist(:julia, DateTime("2025-10-19T06:27:18"), "@b REPL.History.HistoryFile(\"/home/tec/.julia/logs/repl_history.jl\") REPL.History.update!", 3) close(hist) end @testset "Malformed" begin @@ -125,7 +128,7 @@ const HISTORY_SAMPLE_INCOMPLETE = """ hist = HistoryFile(hpath) @test_nowarn update!(hist) @test length(hist) == 1 - @test hist[1] == HistEntry(:julia, DateTime("2025-05-10T12:34:56"), "foo()", 1) + @test hist[1] == basichist(:julia, DateTime("2025-05-10T12:34:56"), "foo()", 1) close(hist) end end @@ -134,9 +137,9 @@ const HISTORY_SAMPLE_INCOMPLETE = """ write(hpath, "") hist = HistoryFile(hpath) entries = [ - HistEntry(:julia, DateTime("2024-06-01T10:00:00"), "println(\"Hello, World!\")", 0), - HistEntry(:shell, DateTime("2024-06-01T10:05:00"), "ls -la", 0), - HistEntry(:help, DateTime("2024-06-01T10:10:00"), "? println", 0), + basichist(:julia, DateTime("2024-06-01T10:00:00"), "println(\"Hello, World!\")", 0), + basichist(:shell, DateTime("2024-06-01T10:05:00"), "ls -la", 0), + basichist(:help, DateTime("2024-06-01T10:10:00"), "? println", 0), ] for entry in entries push!(hist, entry) @@ -161,12 +164,12 @@ const HISTORY_SAMPLE_INCOMPLETE = """ update!(hist_a) update!(hist_b) @test length(hist_b) == 5 - push!(hist_a, HistEntry(:julia, now(UTC), "2 + 2", 0)) + push!(hist_a, basichist(:julia, now(UTC), "2 + 2", 0)) @test length(hist_a) == 6 update!(hist_b) @test length(hist_b) == 6 @test hist_b[end] == hist_a[end] - push!(hist_b, HistEntry(:shell, now(UTC), "echo 'Hello'", 0)) + push!(hist_b, basichist(:shell, now(UTC), "echo 'Hello'", 0)) @test length(hist_b) == 7 update!(hist_a) @test length(hist_a) == 7 @@ -264,15 +267,15 @@ end end @testset "Matching" begin entries = [ - HistEntry(:julia, now(UTC), "println(\"hello world\")", 1), - HistEntry(:julia, now(UTC), "log2(1234.5)", 1), - HistEntry(:julia, now(UTC), "test case", 1), - HistEntry(:help, now(UTC), "cos", 1), - HistEntry(:julia, now(UTC), "cos(2π)", 1), - HistEntry(:julia, now(UTC), "case of tests", 1), - HistEntry(:shell, now(UTC), "echo 'Hello World'", 4), - HistEntry(:julia, now(UTC), "foo_bar(2, 7)", 5), - HistEntry(:julia, now(UTC), "test_fun()", 5), + basichist(:julia, now(UTC), "println(\"hello world\")", 1), + basichist(:julia, now(UTC), "log2(1234.5)", 1), + basichist(:julia, now(UTC), "test case", 1), + basichist(:help, now(UTC), "cos", 1), + basichist(:julia, now(UTC), "cos(2π)", 1), + basichist(:julia, now(UTC), "case of tests", 1), + basichist(:shell, now(UTC), "echo 'Hello World'", 4), + basichist(:julia, now(UTC), "foo_bar(2, 7)", 5), + basichist(:julia, now(UTC), "test_fun()", 5), ] results = HistEntry[] @testset "Words" begin @@ -360,14 +363,14 @@ end end @testset "Display calculations" begin - entries = [HistEntry(:julia, now(UTC), "test_$i", i) for i in 1:20] + entries = [basichist(:julia, now(UTC), "test_$i", i) for i in 1:20] @testset "componentrows" begin @testset "Standard terminal" begin state = SelectorState((30, 80), "", FilterSpec(), entries) @test componentrows(state) == (candidates = 13, preview = 6) state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [1, 3], gathered = HistEntry[]), 1) @test componentrows(state) == (candidates = 13, preview = 6) - gathered = [HistEntry(:julia, now(UTC), "old", i) for i in 21:22] + gathered = [basichist(:julia, now(UTC), "old", i) for i in 21:22] state = SelectorState((30, 80), "", FilterSpec(), entries, gathered) @test componentrows(state) == (candidates = 13, preview = 6) end @@ -379,7 +382,7 @@ end end @testset "Preview clamping" begin multiline = join(["line$i" for i in 1:20], '\n') - state = SelectorState((30, 80), "", FilterSpec(), [HistEntry(:julia, now(UTC), multiline, 1)], 0, (active = [1], gathered = HistEntry[]), 1) + state = SelectorState((30, 80), "", FilterSpec(), [basichist(:julia, now(UTC), multiline, 1)], 0, (active = [1], gathered = HistEntry[]), 1) @test componentrows(state) == (candidates = 7, preview = 12) end end @@ -392,14 +395,14 @@ end end @testset "Multi-line entries" begin code = "begin\n x = 10\n y = 20\n x + y\nend" - state = SelectorState((30, 80), "", FilterSpec(), [HistEntry(:julia, now(UTC), code, 1)], 0, (active = [1], gathered = HistEntry[]), 1) + state = SelectorState((30, 80), "", FilterSpec(), [basichist(:julia, now(UTC), code, 1)], 0, (active = [1], gathered = HistEntry[]), 1) @test countlines_selected(state) == 5 huge = join(["line" for _ in 1:1000], '\n') - state = SelectorState((30, 80), "", FilterSpec(), [HistEntry(:julia, now(UTC), huge, 1)], 0, (active = [1], gathered = HistEntry[]), 1) + state = SelectorState((30, 80), "", FilterSpec(), [basichist(:julia, now(UTC), huge, 1)], 0, (active = [1], gathered = HistEntry[]), 1) @test countlines_selected(state) == 1000 end @testset "With gathered entries" begin - gathered = [HistEntry(:julia, now(UTC), "old", i) for i in 21:22] + gathered = [basichist(:julia, now(UTC), "old", i) for i in 21:22] state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [1], gathered), 1) @test countlines_selected(state) == 4 end @@ -412,7 +415,7 @@ end @test gethover(state) == entries[18] end @testset "With gathered entries" begin - gathered = [HistEntry(:julia, now(UTC), "old_$i", i) for i in 21:22] + gathered = [basichist(:julia, now(UTC), "old_$i", i) for i in 21:22] state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered), -2) @test gethover(state) == gathered[2] end @@ -439,7 +442,7 @@ end @test cands.active.selected == [-5, 5, 8] end @testset "With gathered entries" begin - gathered = [HistEntry(:julia, now(UTC), "gathered_$i", 20+i) for i in 1:2] + gathered = [basichist(:julia, now(UTC), "gathered_$i", 20+i) for i in 1:2] state = SelectorState((30, 80), "", FilterSpec(), entries, gathered) state = SelectorState(state.area, state.query, state.filter, state.candidates, -2, state.selection, 1) cands = candidates(state, 10) @@ -459,13 +462,13 @@ end cands = candidates(state, 10) @test isempty(cands.active.entries) @test cands.active.rows == 10 - gathered = [HistEntry(:julia, now(UTC), "old_$i", 20+i) for i in 1:15] + gathered = [basichist(:julia, now(UTC), "old_$i", 20+i) for i in 1:15] state = SelectorState((30, 80), "", FilterSpec(), entries, gathered) state = SelectorState(state.area, state.query, state.filter, state.candidates, -10, state.selection, -1) cands = candidates(state, 8) @test cands.gathered.rows == 7 @test cands.active.rows == 0 - few = [HistEntry(:julia, now(UTC), "entry_$i", i) for i in 1:3] + few = [basichist(:julia, now(UTC), "entry_$i", i) for i in 1:3] state = SelectorState((30, 80), "", FilterSpec(), few) cands = candidates(state, 20) @test cands.active.entries == few @@ -474,7 +477,7 @@ end end @testset "Search state manipulation" begin - entries = [HistEntry(:julia, now(UTC), "test_$i", i) for i in 1:20] + entries = [basichist(:julia, now(UTC), "test_$i", i) for i in 1:20] @testset "movehover" begin @testset "Single step moves" begin state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 5) @@ -493,7 +496,7 @@ end @test movehover(bottom, false, false).hover == 1 end @testset "With gathered entries" begin - gathered = [HistEntry(:julia, now(UTC), "old_cmd", 21)] + gathered = [basichist(:julia, now(UTC), "old_cmd", 21)] state = SelectorState((30, 80), "", FilterSpec(), entries, gathered) state = SelectorState(state.area, state.query, state.filter, state.candidates, -1, state.selection, 1) @test movehover(state, false, false).hover == -1 @@ -507,14 +510,14 @@ end state = SelectorState((30, 80), "", FilterSpec(), HistEntry[]) @test movehover(state, true, false).hover == 1 @test movehover(state, false, false).hover == 1 - gathered = [HistEntry(:julia, now(UTC), "old_cmd", 1)] + gathered = [basichist(:julia, now(UTC), "old_cmd", 1)] state = SelectorState((30, 80), "", FilterSpec(), HistEntry[], gathered) state = SelectorState(state.area, state.query, state.filter, state.candidates, -1, state.selection, -1) @test movehover(state, true, false).hover == 1 @test movehover(state, false, false).hover == -1 end @testset "Single candidate" begin - one = [HistEntry(:julia, now(UTC), "only", 1)] + one = [basichist(:julia, now(UTC), "only", 1)] state = SelectorState((30, 80), "", FilterSpec(), one) @test movehover(state, true, false).hover == 1 @test movehover(state, false, false).hover == 1 @@ -537,7 +540,7 @@ end @test state.selection.active == [18, 20] end @testset "Gathered entries" begin - gathered = [HistEntry(:julia, now(UTC), "old_$i", 20+i) for i in 1:2] + gathered = [basichist(:julia, now(UTC), "old_$i", 20+i) for i in 1:2] state = SelectorState((30, 80), "", FilterSpec(), entries, -1, (active = Int[], gathered), -1) @test toggleselection(state).selection.gathered == [gathered[2]] end @@ -559,10 +562,10 @@ end end @testset "fullselection" begin entries = [ - HistEntry(:julia, now(UTC), "using DataFrames", 1), - HistEntry(:julia, now(UTC), "df = load_data()", 2), - HistEntry(:shell, now(UTC), "cat data.csv", 3), - HistEntry(:julia, now(UTC), "describe(df)", 4), + basichist(:julia, now(UTC), "using DataFrames", 1), + basichist(:julia, now(UTC), "df = load_data()", 2), + basichist(:shell, now(UTC), "cat data.csv", 3), + basichist(:julia, now(UTC), "describe(df)", 4), ] @testset "No selection" begin state = SelectorState((30, 80), "", FilterSpec(), entries) @@ -577,7 +580,7 @@ end @test fullselection(state) == (mode = :julia, text = "using DataFrames\ncat data.csv\ndescribe(df)") end @testset "With gathered entries" begin - gathered = [HistEntry(:julia, now(UTC), "ENV[\"COLUMNS\"] = 120", 0)] + gathered = [basichist(:julia, now(UTC), "ENV[\"COLUMNS\"] = 120", 0)] state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [2], gathered), 1) @test fullselection(state) == (mode = :julia, text = "ENV[\"COLUMNS\"] = 120\ndf = load_data()") end @@ -588,7 +591,7 @@ end @test fullselection(state) == (mode = nothing, text = "") state = SelectorState((30, 80), "", FilterSpec(), HistEntry[], 0, (active = Int[], gathered = HistEntry[]), -1) @test fullselection(state) == (mode = nothing, text = "") - gathered = [HistEntry(:julia, now(UTC), "old_1", 1)] + gathered = [basichist(:julia, now(UTC), "old_1", 1)] state = SelectorState((30, 80), "", FilterSpec(), HistEntry[], 0, (active = Int[], gathered), -1) @test fullselection(state) == (mode = :julia, text = "old_1") end