From c69a7417a0dd5aa78b8c303af0bcefc296f45968 Mon Sep 17 00:00:00 2001 From: Erica Marigold Date: Fri, 13 Dec 2024 16:25:42 +0000 Subject: [PATCH] feat(lib): include optional progress bar Includes an optional progress bar which gets enabled when lib is called using the default convenience `__call` metamethod. --- toolchainlib/src/init.luau | 67 ++++++++++++++++-- toolchainlib/src/utils/progress.luau | 100 +++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 toolchainlib/src/utils/progress.luau diff --git a/toolchainlib/src/init.luau b/toolchainlib/src/init.luau index 94c6ec0..c0bb31b 100644 --- a/toolchainlib/src/init.luau +++ b/toolchainlib/src/init.luau @@ -11,6 +11,7 @@ local net = require("@lune/net") local process = require("@lune/process") local stdio = require("@lune/stdio") local serde = require("@lune/serde") +local task = require("@lune/task") local pathfs = require("../lune_packages/pathfs") local dirs = require("../lune_packages/dirs") @@ -23,6 +24,7 @@ type Option = Option.Option local Github = require("./github") local PlatformDescriptor = require("./platform/descriptor") +local ProgressBar = require("./utils/progress") local compression = require("./compression") local eq = require("./utils/eq") local manifest = require("./manifest") @@ -36,6 +38,8 @@ export type ToolId = { -- TODO: Remove this in a breaking change export type GithubReleases = Github.GithubReleases +local ERROR_PREFIX = `{stdio.color("red")}{stdio.style("bold")}error{stdio.color("reset")}:` +local _INFO_PREFIX = `{stdio.color("green")}{stdio.style("bold")}info{stdio.color("reset")}:` local WARN_PREFIX = `{stdio.color("yellow")}{stdio.style("bold")}warn{stdio.color("reset")}:` local function warn(...) @@ -87,6 +91,13 @@ end local LINK_INSTALL_DIR = (dirs.homeDir() or error("Couldn't get home dir :(")):join(".pesde"):join("bin") local TOOL_STORAGE_DIR = LINK_INSTALL_DIR:join("tool_storage") +local bar = ProgressBar.new() + :withStage("init", "Initializing") + :withStage("fetch", "Fetching release") + :withStage("locate", "Identifying asset") + :withStage("download", "Downloading") + :withStage("install", "Installing") + function runTool(tool: ToolId | pathfs.Path): number -- FIXME: `process.spawn` has a bug where interactive features don't -- forward properly @@ -102,7 +113,36 @@ function runTool(tool: ToolId | pathfs.Path): number }).code end -function installTool(tool: ToolId, installPath: pathfs.Path) +local function makeCondInc(yes: boolean?) + return function() + if yes then + bar:nextStage() + end + end +end + +local function makeCondPause(yes: boolean?) + return function() + if yes then + bar:pause() + end + end +end + +local function makeCondResume(yes: boolean?) + return function() + if yes then + bar:resume() + end + end +end + +function installTool(tool: ToolId, installPath: pathfs.Path, interactive: boolean?) + local next = makeCondInc(interactive) + local pause = makeCondPause(interactive) + local resume = makeCondResume(interactive) + next() + local toolAlias = toolAliasOrDefault(tool) local client = Github.new( tool.repo, @@ -113,6 +153,7 @@ function installTool(tool: ToolId, installPath: pathfs.Path) }) :: Option ) + next() local releases = client:queueTransactions({ "FetchReleases" })[1]:unwrap() :: GithubReleases local assets = tool.version:match({ Some = function(version: string) @@ -122,6 +163,7 @@ function installTool(tool: ToolId, installPath: pathfs.Path) end end + pause() return error(`No release found for version {version}`) end, @@ -130,6 +172,7 @@ function installTool(tool: ToolId, installPath: pathfs.Path) end, }) + next() -- TODO: Use index type fn in solver v2 local matchingAsset: { name: string, @@ -156,8 +199,13 @@ function installTool(tool: ToolId, installPath: pathfs.Path) local binaryPath: pathfs.Path if matchingAsset == nil then + pause() warn("Pesde could not find a matching binary for your system") warn("Will now attempt to download all binaries and find a matching one") + resume() + + next() + for _, asset in assets do local decompressedPath = downloadAndDecompress(asset) if decompressedPath:isSome() then @@ -177,10 +225,12 @@ function installTool(tool: ToolId, installPath: pathfs.Path) local decompressedPath = downloadAndDecompress(matchingAsset):unwrap() binaryPath = decompressedPath:join(aliasPath) if not pathfs.isFile(binaryPath) then + pause() error(`No matching binary found in {decompressedPath}`) end end + next() -- Maintain multiple versions of a tool, and avoid downloading -- the binary for a version again if it's already there local toolDir = Option.from(installPath:parent()):unwrap() @@ -211,6 +261,11 @@ function installTool(tool: ToolId, installPath: pathfs.Path) -- ... ]] + -- Stop the progress bar and clean it up from stdout + bar:stop() + task.cancel(bar.thread :: thread) + stdio.write("\x1b[2K\x1b[0G") + -- NOTE: This is equivalent to `0o755` or `rwxr-xr-x` chmod(installPath, 0b10101010011) runTool(installPath) @@ -218,7 +273,7 @@ end type LibExports = { runTool: (pathfs.Path | ToolId) -> number, - installTool: (ToolId, pathfs.Path) -> never, + installTool: (ToolId, pathfs.Path, boolean?) -> never, } type LibExportsImpl = typeof(setmetatable( {} :: LibExports, @@ -232,10 +287,6 @@ return setmetatable( } :: LibExports, { __call = function(lib: LibExportsImpl, tool: string, pesdeRoot: string?): number - -- TODO: Progress bar maybe? :D - - local ERROR_PREFIX = `{stdio.color("red")}{stdio.style("bold")}error{stdio.color("reset")}:` - local repo, version = string.match(tool, "([^@]+)@?(.*)") if repo == nil or version == nil then stdio.ewrite(`{ERROR_PREFIX} Invalid tool provided\n`) @@ -280,6 +331,7 @@ return setmetatable( return lib.runTool(toolInstallPath) end + bar:start() local ok, err = pcall( lib.installTool, { @@ -287,7 +339,8 @@ return setmetatable( repo = repo, version = Option.Some(versionOrDefault), } :: ToolId, - toolInstallPath + toolInstallPath, + true ) if not ok then diff --git a/toolchainlib/src/utils/progress.luau b/toolchainlib/src/utils/progress.luau new file mode 100644 index 0000000..764d9df --- /dev/null +++ b/toolchainlib/src/utils/progress.luau @@ -0,0 +1,100 @@ +--> Inspired by Rokit's progress bar: https://github.com/rojo-rbx/rokit/blob/a303faf/src/util/progress.rs + +local task = require("@lune/task") +local stdio = require("@lune/stdio") + +local Option = require("../../lune_packages/option") +type Option = Option.Option + +-- FORMAT: {SPINNER} {MESSAGE} {BAR} {STAGE} +local SPINNERS = { "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" } +local BAR_COMPONENT = "▇" +local MAX_BAR_LENGTH = 30 + +local ProgressBar = {} +type ProgressBar = { + stages: { { tag: string, message: string } }, + currentStageIndex: number, + finished: boolean, + thread: thread?, +} +export type ProgressBarImpl = typeof(setmetatable({} :: ProgressBar, { __index = ProgressBar })) + +function ProgressBar.new(): ProgressBarImpl + return setmetatable( + { + stages = {}, + currentStageIndex = 1, + finished = false, + } :: ProgressBar, + { + __index = ProgressBar, + } + ) +end + +function ProgressBar.withStage(self: ProgressBarImpl, tag: string, msg: string): ProgressBarImpl + table.insert(self.stages, { tag = tag, message = msg }) + return self +end + +function ProgressBar.start(self: ProgressBarImpl): () + local BAR_LENGTH = MAX_BAR_LENGTH // #self.stages + local TOTAL_BAR_LENGTH = BAR_LENGTH * #self.stages + local BAR = string.rep(BAR_COMPONENT, BAR_LENGTH) + local MAX_MESSAGE_LENGTH = 0 + for _, stage in self.stages do + local len = #stage.message + if len > MAX_MESSAGE_LENGTH then + MAX_MESSAGE_LENGTH = len + end + end + + self.thread = task.spawn(function() + while not self.finished do + if self.currentStageIndex == #self.stages then + self:stop() + break + end + + for _, spinner in SPINNERS do + local stage = self.stages[self.currentStageIndex] + stdio.write( + `\x1b[2K\x1b[0G{spinner} {stage.message}{string.rep(" ", MAX_MESSAGE_LENGTH - #stage.message)} [{string.rep( + BAR, + self.currentStageIndex + )}{string.rep(" ", TOTAL_BAR_LENGTH - (BAR_LENGTH * self.currentStageIndex))}] {self.currentStageIndex} / {#self.stages}` + ) + + task.wait(0.1) + end + end + + stdio.write("\n") + end) +end + +function ProgressBar.stop(self: ProgressBarImpl): () + self.finished = true +end + +function ProgressBar.pause(self: ProgressBarImpl): () + local _ = self.thread and coroutine.yield(self.thread) +end + +function ProgressBar.resume(self: ProgressBarImpl): () + local _ = self.thread and coroutine.resume(self.thread) +end + +function ProgressBar.nextStage(self: ProgressBarImpl): () + local inc = self.currentStageIndex + 1 + if inc > #self.stages then + -- TODO: Make this a result + self.finished = true + return error("Out of stage bounds") + end + + self.currentStageIndex = inc +end + +return ProgressBar