--!nonstrict local OSC_CONFIG = noctalia.expandPath("~/.config/osc/oscrc") local CACHE_FILE = "projects_cache.json" local NAV_FILE = "nav_state.json" local projects = {} local packages = {} local selectedProject = nil local selectedPackage = nil local packageFilter = "" local projectFilter = "" local sortAsc = true local editFilesOpen = false local filesOpen = false local filesPackage = nil local packageFiles = {} local rebuildArchIndex = 0 local rebuildRepos = {} local rebuildRepoIndex = 0 local rebuildConfirm = false local runAllConfirm = false local repoMap = {} local statusMap = {} local savedRebuildRepo = nil local savedRebuildArch = nil local rebuildLoaded = false local isCheckingOut = false local checkoutLog = "" local isActionLoading = false local actionStatus = "" local logSource = "" local loadPackageFiles local resolveCheckoutDir local maintainerMap = {} local ownedProjects = {} local projectsLoading = false local packagesLoading = false local errorText = "" local commandLog = "" local logPackage = nil local pendingProject = nil local removeConfirm = false local removeFileConfirm = false local removeFileTarget = nil local restoreProject = nil local restorePackage = nil local restoreFilesOpen = false local restoreFilesPackage = nil local restoreCommandLog = nil local restoreErrorText = nil local restoreLogSource = nil local saveCache local saveNav local maybeVerify local ownedDone = false local oscUser = nil local oscWhoPending = false local function shellQuote(value) return "'" .. tostring(value):gsub("'", "'\\''") .. "'" end local function commandError(result) return noctalia.string.trim(result.stderr ~= "" and result.stderr or result.stdout) end local function setCommandLog(result, refreshFiles, emptyMsg) local err = commandError(result) if result.exitCode ~= 0 then errorText = err ~= "" and err or ("Command failed (exit " .. tostring(result.exitCode) .. ")") commandLog = "" else errorText = "" commandLog = noctalia.string.trim(result.stdout) if commandLog == "" and emptyMsg then commandLog = emptyMsg end if refreshFiles then local checkoutDir = resolveCheckoutDir() loadPackageFiles(checkoutDir .. "/" .. selectedProject .. "/" .. selectedPackage, selectedPackage) end end logPackage = selectedPackage render() saveNav() end local function showCommandLog(result) setCommandLog(result, false) end local function runActionAsync(cmd, statusText, refreshFiles, pkgDir, pkg, emptyMsg, source) isActionLoading = true actionStatus = statusText logSource = source or "" errorText = "" commandLog = "" render() noctalia.runAsync(cmd, function(result) isActionLoading = false if refreshFiles and result.exitCode == 0 then local dir = pkgDir if not dir then local checkoutDir = resolveCheckoutDir() dir = checkoutDir .. "/" .. selectedProject .. "/" .. selectedPackage end loadPackageFiles(dir, pkg or selectedPackage) end setCommandLog(result, false, emptyMsg) end, 60000) end -- OBS project/package identifiers: reject anything that could escape the -- checkout dir or inject into shell commands (slashes, leading dots, spaces). local function validName(value) return type(value) == "string" and value ~= "" and not value:find("/", 1, true) and value:match("^[%w:.+%-_]+$") ~= nil and value:match("^%.") == nil end resolveCheckoutDir = function() local checkoutDir = noctalia.getConfig("checkout_dir") if type(checkoutDir) ~= "string" or checkoutDir:match("^%s*$") then checkoutDir = "~/OBS" end return noctalia.expandPath(checkoutDir) end -- Estimate the combined width (px) of the two .changes buttons to decide -- whether they fit side-by-side or must stack. local function changesButtonsFit(pkg) -- Rough per-char width at the button font size, plus glyph+padding budget. local charW = 7 local base = 16 + 24 local editW = (#"Edit " + #pkg + #".changes") * charW + base local updateW = (#"Update " + #pkg + #".changes") * charW + base return editW + updateW + 8 <= 396 end local function parseLines(stdout) local out = {} for line in tostring(stdout or ""):gmatch("[^\n]+") do line = noctalia.string.trim(line) if line ~= "" then table.insert(out, line) end end return out end local function filterList(list, filter, ascending) local lower = filter:lower() local filtered if filter == "" then filtered = list else filtered = {} for _, name in ipairs(list) do if name:lower():find(lower, 1, true) then table.insert(filtered, name) end end end table.sort(filtered, function(a, b) if ascending then return a < b else return a > b end end) return filtered end local function configureOsc() noctalia.runInTerminal("osc version") end local function checkoutPackage(project, pkg) if not validName(project) or not validName(pkg) then return end local checkoutDir = resolveCheckoutDir() local pkgPath = checkoutDir .. "/" .. project .. "/" .. pkg isCheckingOut = true checkoutLog = noctalia.tr("panel.checking_out") render() noctalia.runAsync( "cd " .. shellQuote(checkoutDir) .. " && osc co " .. shellQuote(project) .. " " .. shellQuote(pkg), function(result) isCheckingOut = false if result.exitCode == 0 then errorText = "" loadPackageFiles(pkgPath, pkg) else errorText = commandError(result) end render() saveNav() end, 60000 ) end local function editProjectMeta(project) if not validName(project) then return end noctalia.runInTerminal("osc meta prj " .. shellQuote(project) .. " -e") end local function editPackageMeta(project, pkg) if not validName(project) or not validName(pkg) then return end noctalia.runInTerminal("osc meta pkg " .. shellQuote(project) .. " " .. shellQuote(pkg) .. " -e") end local function rebuildPackage(project, pkg, repo, arch) if not validName(project) or not validName(pkg) then return end local cmd = "osc rebuild " .. shellQuote(project) .. " " .. shellQuote(pkg) if repo ~= nil and repo ~= "All" and repo ~= "" then cmd = cmd .. " -r " .. shellQuote(repo) end if arch ~= nil and arch ~= "All" and arch ~= "" then cmd = cmd .. " -a " .. shellQuote(arch) end runActionAsync(cmd, noctalia.tr("panel.rebuilding_pkg"), false, nil, nil, nil, "rebuild") end loadPackageFiles = function(pkgDir, pkg) noctalia.runAsync( "find " .. shellQuote(pkgDir) .. " -maxdepth 1 -not -name '.*' -printf '%f\\n'", function(result) local list = {} if result.exitCode == 0 then for _, f in ipairs(parseLines(result.stdout)) do if f ~= pkg and f ~= ".osc" then table.insert(list, f) end end end table.sort(list) packageFiles = list render() end, 5000 ) -- Re-fetch after a short delay so callers that just created the dir (checkout, -- osc up, service runs) settle into the complete file list. noctalia.runAsync("sleep 1", function() noctalia.runAsync( "find " .. shellQuote(pkgDir) .. " -maxdepth 1 -not -name '.*' -printf '%f\\n'", function(result) local list = {} if result.exitCode == 0 then for _, f in ipairs(parseLines(result.stdout)) do if f ~= pkg and f ~= ".osc" then table.insert(list, f) end end end table.sort(list) packageFiles = list render() end, 5000 ) end, 2000) end local function loadRebuildArches(project, pkg) rebuildLoaded = false rebuildRepos = {} repoMap = {} statusMap = {} noctalia.runAsync("osc results " .. shellQuote(project) .. " " .. shellQuote(pkg), function(result) local map = {} local statuses = {} if result.exitCode == 0 then for line in tostring(result.stdout):gmatch("[^\n]+") do line = noctalia.string.trim(line) local repo, arch, _, status = line:match("^(%S+)%s+(%S+)%s+(%S+)%s+(%S+)") if status then status = status:gsub("%*$", "") end if repo ~= nil and arch ~= nil and status ~= "disabled" then if not map[repo] then map[repo] = {} statuses[repo] = {} end local found = false for _, a in ipairs(map[repo]) do if a == arch then found = true break end end if not found then table.insert(map[repo], arch) end statuses[repo][arch] = status end end end local repos = {} for repo in pairs(map) do table.insert(repos, repo) end table.sort(repos) for repo in pairs(map) do table.sort(map[repo]) end repoMap = map statusMap = statuses rebuildRepos = repos local repoIndex = 0 if savedRebuildRepo then for i, repo in ipairs(repos) do if repo == savedRebuildRepo then repoIndex = i break end end end rebuildRepoIndex = repoIndex local archIndex = 0 local repoArchs = savedRebuildRepo and map[savedRebuildRepo] or {} if savedRebuildArch then for i, arch in ipairs(repoArchs or {}) do if arch == savedRebuildArch then archIndex = i break end end end rebuildArchIndex = archIndex rebuildLoaded = true render() end, 15000) end local STATUS_STYLE = { succeeded = { glyph = "check", color = "secondary", labelKey = "status_succeeded" }, failure = { glyph = "x", color = "error", labelKey = "status_failed" }, broken = { glyph = "alert-circle", color = "error", labelKey = "status_failed" }, unresolvable = { glyph = "alert-circle", color = "error", labelKey = "status_unresolvable" }, building = { glyph = "loader", color = "primary", labelKey = "status_building" }, dispatching = { glyph = "loader", color = "primary", labelKey = "status_building" }, scheduled = { glyph = "clock", color = "on_surface_variant", labelKey = "status_scheduled" }, signing = { glyph = "lock", color = "warning", labelKey = "status_signing" }, blocked = { glyph = "clock", color = "on_surface_variant", labelKey = "status_blocked" }, excluded = { glyph = "ban", color = "on_surface_variant", labelKey = "status_excluded" }, unbuildable = { glyph = "ban", color = "on_surface_variant", labelKey = "status_unbuildable" }, finished = { glyph = "check", color = "secondary", labelKey = "status_succeeded" }, unknown = { glyph = "help-circle", color = "on_surface_variant", labelKey = "status_unknown" }, } local STATUS_GROUPS = { ["succeeded"] = "succeeded", ["success"] = "succeeded", ["finished"] = "succeeded", ["failed"] = "failure", ["broken"] = "failure", ["unresolvable"] = "unresolvable", ["building"] = "building", ["dispatching"] = "building", ["scheduled"] = "scheduled", ["signing"] = "signing", ["blocked"] = "blocked", ["excluded"] = "excluded", ["unbuildable"] = "unbuildable", ["unknown"] = "unknown", ["disabled"] = "disabled", } local function statusPresentation(status) status = status or "unknown" local group = STATUS_GROUPS[status] or status local base = STATUS_STYLE[group] if base then return base.glyph, base.color, noctalia.tr("panel." .. base.labelKey), group end return "help-circle", "on_surface_variant", status, "unknown" end local function removeFileLocal(path, pkgDir, pkg) noctalia.runAsync("rm " .. shellQuote(path), function(result) if result.exitCode == 0 then loadPackageFiles(pkgDir, pkg) else errorText = commandError(result) render() end end, 15000) end local function editFilePlain(path) noctalia.runInTerminal("${EDITOR:-nano} " .. shellQuote(path)) end local function editFile(path, pkgDir, pkg, filename) if not validName(pkg) then return end if filename:match("%.changes$") then noctalia.runInTerminal("cd " .. shellQuote(pkgDir) .. " && osc vc") else editFilePlain(path) end end local function oscAddRemove(pkgDir) runActionAsync( "cd " .. shellQuote(pkgDir) .. " && osc ar", noctalia.tr("panel.adding_removing"), true, pkgDir, nil, noctalia.tr("panel.no_changes", { name = selectedPackage }) ) end local function oscCommit(pkgDir) local cmd = "cd " .. shellQuote(pkgDir) .. " && osc ci" isActionLoading = true actionStatus = noctalia.tr("panel.committing") logSource = "" errorText = "" commandLog = "" render() noctalia.runAsync("cd " .. shellQuote(pkgDir) .. " && osc status", function(result) local output = result.stdout .. "\n" .. result.stderr local hasChanges = result.exitCode == 0 and noctalia.string.trim(output) ~= "" isActionLoading = false if hasChanges then noctalia.runInTerminal(cmd) else noctalia.runAsync(cmd, showCommandLog, 60000) end end, 15000) end local function oscUpdate(pkgDir) runActionAsync( "cd " .. shellQuote(pkgDir) .. " && osc up", noctalia.tr("panel.updating_dir"), true, pkgDir ) end local function oscServiceRun(pkgDir) runActionAsync( "cd " .. shellQuote(pkgDir) .. " && osc service mr", noctalia.tr("panel.running_manual_service"), true, pkgDir ) end local function oscServiceRemoteRun(project, pkg) runActionAsync( "osc service rr " .. shellQuote(project) .. " " .. shellQuote(pkg), noctalia.tr("panel.running_remote_service"), false ) end local function oscServiceLocalRun(pkgDir) runActionAsync( "cd " .. shellQuote(pkgDir) .. " && osc service r", noctalia.tr("panel.running_local_service"), true, pkgDir ) end local function oscServiceRunAll(pkgDir) runActionAsync( "cd " .. shellQuote(pkgDir) .. " && osc service ra", noctalia.tr("panel.running_all_services"), true, pkgDir ) end local function removePackageLocal(project, pkg) if not validName(project) or not validName(pkg) then return end local checkoutDir = resolveCheckoutDir() local projectDir = checkoutDir .. "/" .. project local pkgDir = projectDir .. "/" .. pkg local cmd = "rm -rf " .. shellQuote(pkgDir) .. "\nif ! ls -A " .. shellQuote(projectDir) .. " | grep -q -v '^\\.osc$'; then rm -rf " .. shellQuote(projectDir) .. "; fi" noctalia.runAsync(cmd, function(result) if result.exitCode ~= 0 then errorText = commandError(result) end selectedPackage = nil removeConfirm = false render() saveNav() end, 15000) end local function loadPackages(project) selectedProject = project selectedPackage = nil packageFilter = "" editFilesOpen = false filesOpen = false packageFiles = {} removeConfirm = false rebuildConfirm = false runAllConfirm = false isCheckingOut = false checkoutLog = "" isActionLoading = false actionStatus = "" logSource = "" packages = maintainerMap[project] or {} if #packages == 0 and projectsLoading then pendingProject = project packagesLoading = true render() return end if #packages == 0 and ownedProjects[project] then packagesLoading = true render() noctalia.runAsync("osc ls " .. shellQuote(project), function(result) packagesLoading = false if result.exitCode == 0 then packages = parseLines(result.stdout) maintainerMap[project] = packages saveCache() errorText = "" elseif errorText == "" then errorText = commandError(result) end render() end, 15000) saveNav() return end packagesLoading = false errorText = "" render() saveNav() end local function cachePath() local dir, _ = noctalia.pluginDataDir() if not dir then return nil end return dir .. "/" .. CACHE_FILE end local function navPath() local dir, _ = noctalia.pluginDataDir() if not dir then return nil end return dir .. "/" .. NAV_FILE end saveNav = function() local path = navPath() if not path then return end noctalia.writeFile(path, noctalia.json.encode({ project = selectedProject, package = selectedPackage, filesOpen = filesOpen, filesPackage = filesPackage, commandLog = commandLog, errorText = errorText, logSource = logSource, })) end local function readNav() local path = navPath() if not path or not noctalia.fileExists(path) then return nil, nil, false, nil, nil, nil end local data, _ = noctalia.readFile(path) if not data then return nil, nil, false, nil, nil, nil end local parsed, _ = noctalia.json.decode(data) if type(parsed) ~= "table" then return nil, nil, false, nil, nil, nil end return parsed.project, parsed.package, parsed.filesOpen == true, parsed.filesPackage, parsed.commandLog, parsed.errorText, parsed.logSource end saveCache = function() local path = cachePath() if not path or #projects == 0 then return end local ownedList = {} for project in pairs(ownedProjects) do table.insert(ownedList, project) end noctalia.writeFile(path, noctalia.json.encode({ owned = ownedList, packages = maintainerMap, })) end local function prefetchOwnedPackages() for project in pairs(ownedProjects) do if not maintainerMap[project] then noctalia.runAsync("osc ls " .. shellQuote(project), function(result) if result.exitCode == 0 and not maintainerMap[project] then local list = parseLines(result.stdout) maintainerMap[project] = list saveCache() if selectedProject == project then packages = list packagesLoading = false errorText = "" render() end end end, 15000) end end end local function readCache() local path = cachePath() if not path or not noctalia.fileExists(path) then return false end local data, _ = noctalia.readFile(path) if not data then return false end local parsed, _ = noctalia.json.decode(data) if type(parsed) ~= "table" then return false end local cachedPkgs = parsed.packages local cachedOwned = parsed.owned if type(cachedPkgs) ~= "table" or type(cachedOwned) ~= "table" then return false end for key, list in pairs(cachedPkgs) do if type(key) ~= "string" or type(list) ~= "table" then return false end end maintainerMap = cachedPkgs ownedProjects = {} for _, project in ipairs(cachedOwned) do ownedProjects[project] = true end projects = {} local seen = {} for project in pairs(maintainerMap) do projects[#projects + 1] = project seen[project] = true end for project in pairs(ownedProjects) do if not seen[project] then projects[#projects + 1] = project end end table.sort(projects) return true end local function finalize() local merged = {} for project in pairs(ownedProjects) do merged[project] = true end for project in pairs(maintainerMap) do merged[project] = true end projects = {} for project in pairs(merged) do table.insert(projects, project) end table.sort(projects) saveCache() local deferred = pendingProject pendingProject = nil projectsLoading = false if deferred then loadPackages(deferred) return end render() prefetchOwnedPackages() end local function loadProjects(clearNav) readCache() selectedProject = nil selectedPackage = nil packages = {} projectFilter = "" pendingProject = nil projectsLoading = true errorText = "" ownedDone = false oscUser = nil oscWhoPending = false if restoreProject then local proj = restoreProject local pkg = restorePackage local filesOpenRestore = restoreFilesOpen local filesPackageRestore = restoreFilesPackage restoreProject = nil restorePackage = nil restoreFilesOpen = false restoreFilesPackage = nil if proj and (maintainerMap[proj] or ownedProjects[proj]) then projectsLoading = false loadPackages(proj) if pkg then selectedPackage = pkg filesOpen = filesOpenRestore filesPackage = filesPackageRestore if restoreCommandLog ~= nil then commandLog = restoreCommandLog end if restoreErrorText ~= nil then errorText = restoreErrorText end if restoreLogSource ~= nil then logSource = restoreLogSource end render() saveNav() loadRebuildArches(proj, pkg) if filesOpen then local checkoutDir = resolveCheckoutDir() loadPackageFiles(checkoutDir .. "/" .. proj .. "/" .. pkg, pkg) end return end end end render() if clearNav then saveNav() end noctalia.runAsync("osc search --project --maintainer --csv", function(result) if result.exitCode == 0 then ownedProjects = {} for _, line in ipairs(parseLines(result.stdout)) do ownedProjects[line] = true end end ownedDone = true maybeVerify() end) if oscUser == nil then oscWhoPending = true noctalia.runAsync("osc who | cut -d: -f1", function(userResult) if userResult.exitCode == 0 then oscUser = noctalia.string.trim(userResult.stdout) else oscUser = "" local err = commandError(userResult) if err ~= "" then errorText = err end end oscWhoPending = false maybeVerify() end, 15000) end end maybeVerify = function() if not ownedDone then return end if oscWhoPending then return end if oscUser == nil then finalize() return end if oscUser == "" then finalize() return end local url = "/search/package_id?match=person/@userid='" .. oscUser .. "' and person/@role='maintainer'" noctalia.runAsync("osc api " .. shellQuote(url), function(verifyResult) if verifyResult.exitCode == 0 then maintainerMap = {} for line in tostring(verifyResult.stdout):gmatch("[^\n]+") do local project, name = line:match(" 0 then hadResults = true table.insert( statusChildren, ui.label({ text = repo, fontWeight = "bold", fontSize = 14, color = "on_surface" }) ) for _, arch in ipairs(archs) do local status = statusMap[repo] and statusMap[repo][arch] or nil local glyph, color, label = statusPresentation(status) table.insert( statusChildren, ui.row({ align = "center", gap = 8, paddingL = 12 }, { ui.glyph({ name = glyph, color = color, size = 14 }), ui.label({ text = arch, fontSize = 13, color = "on_surface", flexGrow = 1 }), ui.label({ text = label, fontSize = 13, color = color }), }) ) end end end if not hadResults then table.insert(statusChildren, ui.label({ text = noctalia.tr("panel.no_builds"), color = "on_surface_variant" })) end end table.insert(body, ui.column({ gap = 4 }, statusChildren)) elseif selectedProject then table.insert( body, ui.row({ align = "center", gap = 8 }, { ui.button({ glyph = "arrow-left", variant = "ghost", onClick = function() loadProjects(true) end, }), ui.glyph({ name = "folder-filled", color = "primary", size = 22 }), ui.label({ text = selectedProject, fontWeight = "bold", fontSize = 18, flexGrow = 1 }), }) ) if packagesLoading then table.insert(body, ui.label({ text = noctalia.tr("panel.loading_packages"), color = "on_surface_variant" })) elseif errorText ~= "" then table.insert(body, ui.label({ text = errorText, color = "error", wrap = true })) table.insert( body, ui.button({ text = noctalia.tr("panel.retry"), glyph = "refresh", variant = "default", onClick = function() loadPackages(selectedProject) end, }) ) else table.insert( body, ui.row({ align = "center", gap = 8 }, { ui.input({ key = "packageFilter", placeholder = noctalia.tr("panel.search_packages"), value = packageFilter, flexGrow = 1, onChange = function(value) packageFilter = value render() end, }), ui.button({ key = "pkg-sort-btn", glyph = if sortAsc then "sort-ascending" else "sort-descending", tooltip = if sortAsc then "A-Z" else "Z-A", variant = "default", onClick = function() sortAsc = not sortAsc render() end, }), }) ) local filtered = filterList(packages, packageFilter, sortAsc) if #filtered == 0 then table.insert(body, ui.label({ text = noctalia.tr("panel.no_packages"), color = "on_surface_variant" })) else local checkoutDir = resolveCheckoutDir() for _, name in ipairs(filtered) do local checkedOut = noctalia.fileExists(checkoutDir .. "/" .. selectedProject .. "/" .. name) table.insert( body, ui.row({ key = "pkgrow-" .. name, align = "center", gap = 8 }, { ui.glyph({ name = "package", size = 16, color = checkedOut and "primary" or "on_surface" }), ui.button({ key = "pkgbtn-" .. name, text = name, variant = "default", flexGrow = 1, align = "left", onClick = function() if filesPackage ~= name then filesOpen = false packageFiles = {} filesPackage = name end if logPackage ~= name then errorText = "" commandLog = "" logPackage = name end isActionLoading = false actionStatus = "" selectedPackage = name savedRebuildRepo = nil savedRebuildArch = nil rebuildLoaded = false rebuildConfirm = false runAllConfirm = false render() saveNav() loadRebuildArches(selectedProject, name) end, }), }) ) end end end else table.insert( body, ui.row({ align = "center", gap = 8 }, { ui.glyph({ name = "buildings", color = "primary", size = 22 }), ui.label({ text = noctalia.tr("panel.my_projects"), fontWeight = "bold", fontSize = 18, flexGrow = 1 }), ui.button({ glyph = "refresh", tooltip = noctalia.tr("panel.reload_projects"), variant = "default", onClick = function() loadProjects(true) end, }), }) ) table.insert( body, ui.row({ align = "center", gap = 8 }, { ui.input({ key = "projectFilter", placeholder = noctalia.tr("panel.search_projects"), value = projectFilter, flexGrow = 1, onChange = function(value) projectFilter = value render() end, }), ui.button({ key = "proj-sort-btn", glyph = sortAsc and "sort-ascending" or "sort-descending", tooltip = sortAsc and "A-Z" or "Z-A", variant = "default", onClick = function() sortAsc = not sortAsc render() end, }), }) ) if #projects == 0 then table.insert(body, ui.label({ text = noctalia.tr("panel.no_projects"), color = "on_surface_variant" })) else local filtered = filterList(projects, projectFilter, sortAsc) if #filtered == 0 then table.insert(body, ui.label({ text = noctalia.tr("panel.no_projects"), color = "on_surface_variant" })) else for _, name in ipairs(filtered) do table.insert( body, ui.row({ align = "center", gap = 8 }, { ui.button({ text = name, glyph = "folder-filled", variant = "default", flexGrow = 1, align = "left", onClick = function() loadPackages(name) end, }), ui.button({ glyph = "feather-filled", variant = "default", tooltip = noctalia.tr("panel.edit_project_meta"), onClick = function() editProjectMeta(name) end, }), }) ) end end end end panel.render(ui.column({ flexGrow = 1, gap = 12 }, { ui.scroll({ key = "scroll-" .. tostring(selectedProject) .. "-" .. tostring(selectedPackage), flexGrow = 1 }, { ui.column({ gap = 10, paddingV = 6, paddingH = 12, align = "stretch" }, body), }), })) end function onOpen() if noctalia.commandExists("osc") and noctalia.fileExists(OSC_CONFIG) then restoreProject, restorePackage, restoreFilesOpen, restoreFilesPackage, restoreCommandLog, restoreErrorText, restoreLogSource = readNav() loadProjects() else render() end end