diff --git a/.gitmodules b/.gitmodules index 371e04521..be99304b0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -65,3 +65,9 @@ [submodule "libs/codetracer-python-recorder"] path = libs/codetracer-python-recorder url = https://github.com/metacraft-labs/codetracer-python-recorder.git +[submodule "libs/nimcrypto"] + path = libs/nimcrypto + url = https://github.com/cheatfate/nimcrypto.git +[submodule "libs/zip"] + path = libs/zip + url = git@github.com:nim-lang/zip.git diff --git a/libs/nimcrypto b/libs/nimcrypto new file mode 160000 index 000000000..69eec0375 --- /dev/null +++ b/libs/nimcrypto @@ -0,0 +1 @@ +Subproject commit 69eec0375dd146aede41f920c702c531bfe89c6b diff --git a/libs/zip b/libs/zip new file mode 160000 index 000000000..06f5b0a07 --- /dev/null +++ b/libs/zip @@ -0,0 +1 @@ +Subproject commit 06f5b0a0767b14c7595ed168611782be69e61543 diff --git a/nim.cfg b/nim.cfg index 97e65ae68..224d88e69 100644 --- a/nim.cfg +++ b/nim.cfg @@ -23,5 +23,7 @@ path:"libs/chronos" path:"libs/parsetoml/src" path:"libs/nim-result" path:"libs/nim-confutils" +path:"libs/nimcrypto" +path:"libs/zip" gcc.options.debug = "-O0 -g3" diff --git a/nix/shells/main.nix b/nix/shells/main.nix index e8e7bb219..e0ffdf63c 100644 --- a/nix/shells/main.nix +++ b/nix/shells/main.nix @@ -84,6 +84,7 @@ in unixtools.killall # zip # unzip + libzip # curl # for pgrep at least @@ -157,7 +158,7 @@ in # copied case for libstdc++.so (needed by better-sqlite3) from # https://discourse.nixos.org/t/what-package-provides-libstdc-so-6/18707/4: # gcc.cc.lib .. - export CT_LD_LIBRARY_PATH="${sqlite.out}/lib/:${pcre.out}/lib:${glib.out}/lib:${openssl.out}/lib:${gcc.cc.lib}/lib"; + export CT_LD_LIBRARY_PATH="${sqlite.out}/lib/:${pcre.out}/lib:${glib.out}/lib:${openssl.out}/lib:${gcc.cc.lib}/lib:${libzip.out}/lib"; export RUST_LOG=info diff --git a/src/common/common_trace_index.nim b/src/common/common_trace_index.nim index b5ad1901a..c34a253fc 100644 --- a/src/common/common_trace_index.nim +++ b/src/common/common_trace_index.nim @@ -55,7 +55,8 @@ const SQL_INITIAL_INSERT_STATEMENTS = @[ const SQL_ALTER_TABLE_STATEMENTS: seq[string] = @[ # example: adding a new column - """ALTER TABLE traces ADD COLUMN calltraceMode text;""", - """ALTER TABLE traces RENAME COLUMN callgraph TO calltrace""" + """ALTER TABLE traces ADD COLUMN remoteShareDownloadId text;""", + """ALTER TABLE traces ADD COLUMN remoteShareControlId text;""", + """ALTER TABLE traces ADD COLUMN remoteShareExpireTime INTEGER DEFAULT -1;""" # """ALTER TABLE traces ADD COLUMN love integer;""" ] diff --git a/src/common/common_types.nim b/src/common/common_types.nim index 0640b8898..d8d2f71e9 100644 --- a/src/common/common_types.nim +++ b/src/common/common_types.nim @@ -153,6 +153,9 @@ type rrPid*: int exitCode*: int calltraceMode*: CalltraceMode + downloadKey*: langstring + controlId*: langstring + onlineExpireTime*: int CalltraceMode* {.pure.} = enum NoInstrumentation, CallKeyOnly, RawRecordNoValues, FullRecord @@ -1341,6 +1344,19 @@ type BugReportArg* = object ## BugReport arg title*: langstring description*: langstring + + UploadTraceArg* = object + trace*: Trace + programName*: langstring + + UploadedTraceData* = object + downloadKey*: langstring + controlId*: langstring + expireTime*: langstring + + DeleteTraceArg* = object + traceId*: int + controlId*: langstring DbEventKind* {.pure.} = enum Record, Trace, History diff --git a/src/common/config.nim b/src/common/config.nim index c0d6e23e5..764c49261 100644 --- a/src/common/config.nim +++ b/src/common/config.nim @@ -28,7 +28,11 @@ type shortcutMap* {.defaultVal: ShortcutMap().}: ShortcutMap defaultBuild*: string showMinimap*: bool - webApiRoot*: string + baseUrl*: string + downloadApi*: string + uploadApi*: string + deleteApi*: string + traceSharingEnabled*: bool Config* = ref ConfigObject diff --git a/src/common/trace_index.nim b/src/common/trace_index.nim index c8474e3b3..d66bd9cd7 100644 --- a/src/common/trace_index.nim +++ b/src/common/trace_index.nim @@ -64,6 +64,47 @@ proc ensureDB(test: bool): DBConn = globalDbMap[test.int] = db db +proc updateField*( + id: int, + fieldName: string, + fieldValue: string, + test: bool +) = + let db = ensureDB(test) + db.exec( + sql(&"UPDATE traces SET {fieldName} = ? WHERE id = ?"), + fieldValue, id + ) + db.close() + +proc updateField*( + id: int, + fieldName: string, + fieldValue: int, + test: bool +) = + let db = ensureDB(test) + db.exec( + sql(&"UPDATE traces SET {fieldName} = ? WHERE id = ?"), + fieldValue, id + ) + db.close() + +proc getField*( + id: int, + fieldName: string, + test: bool +): string = + let db = ensureDB(test) + let res = db.getAllRows( + sql(&"SELECT {fieldName} FROM traces WHERE id = ? LIMIT 1"), + id + ) + db.close() + if res.len > 0: + return res[0][0] + return "" + proc recordTrace*( id: int, program: string, @@ -81,7 +122,8 @@ proc recordTrace*( exitCode: int, calltrace: bool, calltraceMode: CalltraceMode, - test: bool): Trace = + test: bool, + downloadKey: string = ""): Trace = # TODO pass here a Trace value and instead if neeeded construct it from other helpers let currentDate: DateTime = now() @@ -107,19 +149,19 @@ proc recordTrace*( sourceFolders, lowLevelFolder, outputFolder, lang, imported, shellID, rrPid, exitCode, - calltrace, calltraceMode, date) + calltrace, calltraceMode, date, remoteShareDownloadId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?)""", + ?, ?, ?, ?)""", $id, program, args.join(" "), compileCommand, env, workdir, "", # <- output sourceFolders, lowLevelFolder, outputFolder, $(lang.int), $(imported.int), $shellID, $rrPid, $exitCode, - ord(calltrace), $calltraceMode, $traceDate) + ord(calltrace), $calltraceMode, $traceDate, downloadKey) break except DbError: echo "error: ", getCurrentExceptionMsg() @@ -177,6 +219,12 @@ proc loadCalltraceMode*(raw: string, lang: Lang): CalltraceMode = proc loadTrace(trace: Row, test: bool): Trace = try: let lang = trace[10].parseInt.Lang + var expireTime = -1 + try: + expireTime = trace[20].parseInt + except: + discard + result = Trace( id: trace[0].parseInt, program: trace[1], @@ -196,7 +244,10 @@ proc loadTrace(trace: Row, test: bool): Trace = shellID: trace[14].parseInt, calltrace: trace[15].parseInt != 0, - calltraceMode: loadCalltraceMode(trace[16], lang)) + calltraceMode: loadCalltraceMode(trace[16], lang), + downloadKey: trace[18], + controlId: trace[19], + onlineExpireTime: expireTime) except CatchableError as e: # assume db schema change? echo "internal error: ", e.msg @@ -314,9 +365,17 @@ proc findByRecordProcessId*(pid: int, test: bool): Trace = proc findRecentTraces*(limit: int, test: bool): seq[Trace] = let db = ensureDB(test) - let traces = db.getAllRows( - sql("SELECT * FROM traces ORDER BY id DESC LIMIT ?"), - $limit) + let traces = + if limit == -1: + db.getAllRows( + sql("SELECT * FROM traces ORDER BY id DESC LIMIT ?"), + $limit + ) + else: + db.getAllRows( + sql("SELECT * FROM traces ORDER BY id DESC") + ) + if traces.len > 0: result = traces.mapIt(it.loadTrace(test)) diff --git a/src/config/default_config.yaml b/src/config/default_config.yaml index f1c47253a..bb49716a3 100644 --- a/src/config/default_config.yaml +++ b/src/config/default_config.yaml @@ -10,6 +10,7 @@ events: true # history: experimental support history: true repl: true +traceSharingEnabled: false # === feature settings @@ -54,7 +55,11 @@ defaultBuild: "" showMinimap: true # for now local setup -webApiRoot: http://100.87.206.30:57103/api/codetracer +baseUrl: http://localhost:55500/api/codetracer + +downloadApi: /download +uploadApi: /upload +deleteApi: /delete # # you can use KEY+OTHER # # use PageUp, PageDown, CTRL, ALT, SHIFT diff --git a/src/ct/codetracerconf.nim b/src/ct/codetracerconf.nim index 7fcfa0ff1..7d302c21f 100644 --- a/src/ct/codetracerconf.nim +++ b/src/ct/codetracerconf.nim @@ -14,6 +14,7 @@ type install, upload, download, + cmdDelete, # build, record, console, @@ -264,7 +265,16 @@ type of download: traceRegistryId* {. argument, - desc: "the trace registry unique id: # e.g. a.rb#5" + desc: "the trace registry unique id: //// e.g. noir//1234//asd" + .}: string + of cmdDelete: + traceId* {. + name: "trace-id" + desc: "trace trace unique id" + .}: int + controlId* {. + name: "control-id", + desc: "the trace control id to delete the online trace" .}: string of start_core: coreTraceArg* {. diff --git a/src/ct/globals.nim b/src/ct/globals.nim index 62bb2a61c..f9bbdbecb 100644 --- a/src/ct/globals.nim +++ b/src/ct/globals.nim @@ -13,3 +13,10 @@ let var replayInteractive* = false var electronPid*: int = -1 var rrPid* = -1 # global, so it can be updated immediately on starting a process, and then used in `onInterrupt` if needed + +# for now hardcode: files are usually useful and +# probably much less perf/size compared to actual traces +# it's still good to have an option/opt-out, so we leave that +# as a flag in the internals, but not exposed to user yet +# that's why for now it's hardcoded for db +const DB_SELF_CONTAINED_DEFAULT* = true diff --git a/src/ct/launch/launch.nim b/src/ct/launch/launch.nim index 18e6f8827..3000e7f80 100644 --- a/src/ct/launch/launch.nim +++ b/src/ct/launch/launch.nim @@ -2,6 +2,7 @@ import std/[strutils, strformat, osproc], ../../common/[ paths, types, intel_fix, install_utils, trace_index, start_utils ], ../utilities/[ git, env ], + ../online_sharing/trace_manager, ../cli/[ logging, list, help ], ../trace/[ replay, record, run, metadata ], ../codetracerconf, @@ -101,15 +102,15 @@ proc runInitial*(conf: CodetracerConf) = notSupportedCommand($conf.cmd) of StartupCommand.upload: # similar to replay/console - notSupportedCommand($conf.cmd) - # eventually enable? - # uploadCommand( - # conf.uploadLastTraceMatchingPattern, - # conf.uploadTraceId, - # conf.uploadTraceFolder, - # replayInteractive) + uploadCommand( + conf.uploadLastTraceMatchingPattern, + conf.uploadTraceId, + conf.uploadTraceFolder, + replayInteractive) of StartupCommand.download: - notSupportedCommand($conf.cmd) + downloadTraceCommand(conf.traceRegistryId) + of StartupCommand.cmdDelete: + deleteTraceCommand(conf.traceId, conf.controlId) # eventually enable? # downloadCommand(conf.traceRegistryId) # of StartupCommand.build: diff --git a/src/ct/online_sharing/security_upload.nim b/src/ct/online_sharing/security_upload.nim new file mode 100644 index 000000000..7237d4d8e --- /dev/null +++ b/src/ct/online_sharing/security_upload.nim @@ -0,0 +1,83 @@ +import nimcrypto, zip/zipfiles, std/[ sequtils, strutils, strformat, os, httpclient, mimetypes, uri ] +import ../../common/[ config ] + +proc generateSecurePassword*(): string = + var key: array[32, byte] + discard randomBytes(key) + + result = key.mapIt(it.toHex(2)).join("") + return result + +proc pkcs7Pad*(data: seq[byte], blockSize: int): seq[byte] = + let padLen = blockSize - (data.len mod blockSize) + result = data & repeat(cast[byte](padLen), padLen) + +proc pkcs7Unpad*(data: seq[byte]): seq[byte] = + if data.len == 0: + raise newException(ValueError, "Data is empty, cannot unpad") + + let padLen = int64(data[^1]) # Convert last byte to int64 safely + if padLen <= 0 or padLen > data.len: + raise newException(ValueError, "Invalid padding") + + result = data[0 ..< data.len - padLen] + +func toBytes*(s: string): seq[byte] = + ## Convert a string to the corresponding byte sequence - since strings in + ## nim essentially are byte sequences without any particular encoding, this + ## simply copies the bytes without a null terminator + when nimvm: + var r = newSeq[byte](s.len) + for i, c in s: + r[i] = cast[byte](c) + r + else: + @(s.toOpenArrayByte(0, s.high)) + +proc encryptZip(zipFile, password: string) = + var iv: seq[byte] = password.toBytes()[0..15] + + var aes: CBC[aes256] + aes.init(password.toOpenArrayByte(0, len(password) - 1), iv) + + var zipData = readFile(zipFile).toBytes() + var paddedData = pkcs7Pad(zipData, 16) + var encrypted = newSeq[byte](paddedData.len) + + aes.encrypt(paddedData, encrypted.toOpenArray(0, len(encrypted) - 1)) + writeFile(zipFile & ".enc", encrypted) + +proc zipFileWithEncryption*(inputFile: string, outputZip: string, password: string) = + var zip: ZipArchive + if not zip.open(outputZip, fmWrite): + raise newException(IOError, "Failed to create zip file: " & outputZip) + + for file in walkDirRec(inputFile): + let relPath = file.relativePath(inputFile) + zip.addFile(relPath, file) + + zip.close() + encryptZip(outputZip, password) + +proc uploadEncyptedZip*(file: string): (string, int) = + let config = loadConfig(folder=getCurrentDir(), inTest=false) + var exitCode = 0 + var response = "" + + var client = newHttpClient() + let mimes = newMimetypes() + var data = newMultipartData() + + data.addFiles({"file": file & ".enc"}, mimeDb = mimes) + + try: + response = client.postContent(fmt"{parseUri(config.baseUrl) / config.uploadApi}", multipart=data) + exitCode = 0 + except CatchableError as e: + echo fmt"error: can't upload to API: {e.msg}" + response = "" + exitCode = 1 + finally: + client.close() + + (response, exitCode) diff --git a/src/ct/online_sharing/trace_manager.nim b/src/ct/online_sharing/trace_manager.nim new file mode 100644 index 000000000..25c911c2a --- /dev/null +++ b/src/ct/online_sharing/trace_manager.nim @@ -0,0 +1,117 @@ +import std / [ options, strutils, os, osproc, strformat, json, httpclient, uri ], ../trace/replay, ../codetracerconf, zip/zipfiles, nimcrypto +import ../../common/[ config, trace_index, lang, paths ] +import ../utilities/language_detection +import ../trace/[ storage_and_import, record ] +import security_upload +import ../globals + +proc uploadCommand*( + patternArg: Option[string], + traceIdArg: Option[int], + traceFolderArg: Option[string], + interactive: bool +) = + discard internalReplayOrUpload(patternArg, traceIdArg, traceFolderArg, interactive, command=StartupCommand.upload) + + +proc decryptZip(encryptedFile: string, password: string, outputFile: string) = + var encData = readFile(encryptedFile).toBytes() + if encData.len < 16: + raise newException(ValueError, "Invalid encrypted data (too short)") + + let iv = password.toBytes()[0 ..< 16] + let ciphertext = encData[16 .. ^1] + let key = password.toBytes() + + var aes: CBC[aes256] + aes.init(key, iv) + + var decrypted = newSeq[byte](encData.len) + aes.decrypt(encData, decrypted.toOpenArray(0, len(decrypted) - 1)) + + var depaddedData = pkcs7Unpad(decrypted) + writeFile(outputFile, depaddedData) + +proc unzipFile(zipFile: string, outputDir: string): (string, int) = + var zip: ZipArchive + if not zip.open(zipFile, fmRead): + raise newException(IOError, "Failed to open decrypted ZIP: " & zipFile) + + let traceId = trace_index.newID(false) + let outPath = outputDir / "trace-" & $traceId + + createDir(outPath) + zip.extractAll(outPath) + + zip.close() + return (outPath, traceId) + +proc downloadTraceCommand*(traceRegistryId: string) = + # We expect a traceRegistryId to have :: + let stringSplit = traceRegistryId.split("//") + if stringSplit.len() != 3: + echo "error: Invalid download key! Should be ////" + quit(1) + else: + let downloadId = stringSplit[1] + let password = stringSplit[2] + let zipPath = codetracerTmpPath / &"{downloadId}.zip" + let config = loadConfig(folder=getCurrentDir(), inTest=false) + let localPath = codetracerTmpPath / &"{downloadId}.zip.enc" + + var client = newHttpClient() + var exitCode = 0 + + try: + client.downloadFile(fmt"{parseUri(config.baseUrl) / config.downloadApi}?DownloadId={downloadId}", localPath) + + decryptZip(localPath, password, zipPath) + + let (traceFolder, traceId) = unzipFile(zipPath, codetracerTraceDir) + let tracePath = traceFolder / "trace.json" + let traceJson = parseJson(readFile(tracePath)) + let traceMetadataPath = traceFolder / "trace_metadata.json" + + var pathValue = "" + + for item in traceJson: + if item.hasKey("Path"): + pathValue = item["Path"].getStr("") + break + + let lang = detectLang(pathValue, LangUnknown) + discard importDbTrace(traceMetadataPath, traceId, lang, DB_SELF_CONTAINED_DEFAULT, traceRegistryId) + + echo traceId + + except CatchableError as e: + echo fmt"error: downloading file '{e.msg}'" + exitCode = 1 + + finally: + removeFile(localPath) + removeFile(zipPath) + + quit(exitCode) + +proc deleteTraceCommand*(id: int, controlId: string) = + let config = loadConfig(folder=getCurrentDir(), inTest=false) + let test = false + var exitCode = 0 + + var client = newHttpClient() + + try: + discard client.getContent(fmt"{parseUri(config.baseUrl) / config.deleteApi}?ControlId={controlId}") + + updateField(id, "remoteShareDownloadId", "", test) + updateField(id, "remoteShareControlId", "", test) + updateField(id, "remoteShareExpireTime", -1, test) + exitCode = 0 + except CatchableError as e: + echo fmt"error: can't delete trace {e.msg}" + exitCode = 1 + finally: + client.close() + + quit(exitCode) diff --git a/src/ct/trace/record.nim b/src/ct/trace/record.nim index f7d51eb60..6c3112092 100644 --- a/src/ct/trace/record.nim +++ b/src/ct/trace/record.nim @@ -49,13 +49,6 @@ proc recordSymbols(sourceDir: string, outputFolder: string, lang: Lang) = echo "WARNING: Can't extract symbols. Some functionality may not work correctly!" echo "" -# for now hardcode: files are usually useful and -# probably much less perf/size compared to actual traces -# it's still good to have an option/opt-out, so we leave that -# as a flag in the internals, but not exposed to user yet -# that's why for now it's hardcoded for db -const DB_SELF_CONTAINED_DEFAULT = true - # rr patches for ruby/other vm-s: not supported now, instead # in db backend support only direct traces diff --git a/src/ct/trace/replay.nim b/src/ct/trace/replay.nim index 1a23a76c9..1474616a1 100644 --- a/src/ct/trace/replay.nim +++ b/src/ct/trace/replay.nim @@ -6,7 +6,7 @@ import std/[options ], shell, run -proc internalReplayOrUpload( +proc internalReplayOrUpload*( patternArg: Option[string], traceIdArg: Option[int], traceFolderArg: Option[string], diff --git a/src/ct/trace/storage_and_import.nim b/src/ct/trace/storage_and_import.nim index 9c0164ca9..a354bc34e 100644 --- a/src/ct/trace/storage_and_import.nim +++ b/src/ct/trace/storage_and_import.nim @@ -2,6 +2,7 @@ import std/[os, json, strutils, strformat, sets, algorithm], ../../common/[trace_index, lang, types, paths], ../utilities/git, + ../online_sharing/security_upload, json_serialization proc storeTraceFiles(paths: seq[string], traceFolder: string, lang: Lang) = @@ -78,10 +79,12 @@ proc processSourceFoldersList*(folderSet: HashSet[string], programDir: string = proc importDbTrace*( - traceMetadataPath: string, - traceIdArg: int, - lang: Lang = LangNoir, - selfContained: bool = true): Trace = + traceMetadataPath: string, + traceIdArg: int, + lang: Lang = LangNoir, + selfContained: bool = true, + downloadKey: string = "" +): Trace = let rawTraceMetadata = readFile(traceMetadataPath) let untypedJson = parseJson(rawTraceMetadata) let program = untypedJson{"program"}.getStr() @@ -155,8 +158,52 @@ proc importDbTrace*( calltrace = true, # for now always use FullRecord for db-backend # and ignore possible env var override - calltraceMode = CalltraceMode.FullRecord) + calltraceMode = CalltraceMode.FullRecord, + downloadKey = downloadKey) + +proc getFolderSize(folderPath: string): int64 = + var totalSize: int64 = 0 + for kind, path in walkDir(folderPath): + if kind == pcFile: + totalSize += getFileSize(path) + return totalSize proc uploadTrace*(trace: Trace) = - echo "error: uploading traces not supported currently!" - quit(1) + let outputZip = trace.outputFolder / "tmp.zip" + let aesKey = generateSecurePassword() + + var (output, exitCode) = ("", 0) + + if getFolderSize(trace.outputFolder) > 4_000_000_000: + quit(153) + + try: + zipFileWithEncryption(trace.outputFolder, outputZip, aesKey) + + (output, exitCode) = uploadEncyptedZip(outputZip) + let jsonMessage = parseJson(output) + let downloadKey = trace.program & "//" & jsonMessage["DownloadId"].getStr("") & "//" & aesKey + + if jsonMessage["Successful"].getBool(false): + + updateField(trace.id, "remoteShareDownloadId", downloadKey, false) + updateField(trace.id, "remoteShareControlId", jsonMessage["ControlId"].getStr(""), false) + updateField(trace.id, "remoteShareExpireTime", jsonMessage["Expires"].getInt(), false) + + echo downloadKey + echo jsonMessage["ControlId"].getStr("") + echo jsonMessage["Expires"].getInt() + + else: + exitCode = 1 + + except CatchableError as e: + echo fmt"error: can't delete trace {e.msg}" + removeFile(outputZip) + removeFile(outputZip & ".enc") + exitCode = 1 + + removeFile(outputZip) + removeFile(outputZip & ".enc") + + quit(exitCode) diff --git a/src/frontend/index.nim b/src/frontend/index.nim index 3b8579c52..5f611b6bc 100644 --- a/src/frontend/index.nim +++ b/src/frontend/index.nim @@ -27,6 +27,8 @@ data.start = now() var close = false proc showOpenDialog(dialog: JsObject, browserWindow: JsObject, options: JsObject): Future[JsObject] {.importjs: "#.showOpenDialog(#,#)".} +proc loadExistingRecord(traceId: int) {.async.} +proc prepareForLoadingTrace(traceId: int, pid: int) {.async.} proc isCtInstalled: bool @@ -804,6 +806,75 @@ proc onSearchProgram(sender: js, query: cstring) {.async.} = proc onLoadStepLines(sender: js, response: LoadStepLinesArg) {.async.} = discard debugger.loadStepLines(response) +proc onUploadTraceFile(sender: js, response: UploadTraceArg) {.async.} = + let res = await readProcessOutput( + codetracerExe.cstring, + @[ + j"upload", + j"--trace-folder=" & response.trace.outputFolder + ] + ) + + if res.isOk: + let splitData = res.v.split("\n") + if splitData.len() == 4: + let uploadData = UploadedTraceData( + downloadKey: splitData[0], + controlId: splitData[1], + expireTime: splitData[2] + ) + mainWindow.webContents.send( + "CODETRACER::upload-trace-file-received", + js{ + "argId": j(response.trace.program & ":" & $response.trace.id), + "value": uploadData + } + ) + else: + let uploadData = UploadedTraceData( + downloadKey: "Errored" + ) + mainWindow.webContents.send( + "CODETRACER::uploaded-trace-received", + js{ + "argId": j(response.trace.program & ":" & $response.trace.id), + "value": uploadData + } + ) + +proc onDownloadTraceFile(sender: js, response: jsobject(downloadKey = seq[cstring])) {.async.} = + let res = await readProcessOutput( + codetracerExe.cstring, + @[j"download"].concat(response.downloadKey) + ) + + if res.isOk: + let traceId = parseInt($res.v.trim()) + await prepareForLoadingTrace(traceId, nodeProcess.pid.to(int)) + await loadExistingRecord(traceId) + mainWindow.webContents.send "CODETRACER::successful-download" + else: + mainWindow.webContents.send "CODETRACER::failed-download", + js{errorMessage: cstring"codetracer server down or wrong download key"} + +proc onDeleteOnlineTraceFile(sender: js, response: DeleteTraceArg) {.async.} = + let res = await readProcessOutput( + codetracerExe.cstring, + @[ + j"cmdDelete", + j"--trace-id=" & $response.traceId, + j"--control-id=" & response.controlId + ] + ) + + mainWindow.webContents.send( + "CODETRACER::delete-online-trace-file-received", + js{ + "argId": j($response.traceId & ":" & response.controlId), + "value": res.isOk + } + ) + proc onSendBugReportAndLogs(sender: js, response: BugReportArg) {.async.} = let process = await runProcess( codetracerExe.cstring, @@ -1302,6 +1373,11 @@ proc configureIpcMain = "show-in-debug-instance" "send-bug-report-and-logs" + # Upload/Download + "upload-trace-file" + "download-trace-file" + "delete-online-trace-file" + "restart" # "debug-gdb" @@ -1315,6 +1391,9 @@ when not defined(server): app.quit(0) +const NO_LIMIT = (-1) + + proc init(data: var ServerData, config: Config, layout: js, helpers: Helpers) {.async.} = debugPrint "index: init" let bypass = true @@ -1420,7 +1499,7 @@ proc init(data: var ServerData, config: Config, layout: js, helpers: Helpers) {. save: save } else: - let recentTraces = await app.findRecentTracesWithCodetracer(limit=4) + let recentTraces = await app.findRecentTracesWithCodetracer(limit=NO_LIMIT) mainWindow.webContents.send "CODETRACER::welcome-screen", js{ home: paths.home.cstring, layout: layout, diff --git a/src/frontend/renderer.nim b/src/frontend/renderer.nim index 95f831636..da0ce9801 100644 --- a/src/frontend/renderer.nim +++ b/src/frontend/renderer.nim @@ -488,6 +488,12 @@ proc onContextStartHistory*(sender: js, response: jsobject(inState=bool, express proc onLoadParsedExprsReceived*(sender: js, response: jsobject(argId=cstring, value=JsAssoc[cstring, seq[FlowExpression]])) = jsAsFunction[proc(response: JsAssoc[cstring, seq[FlowExpression]]): void](data.network.futures["load-parsed-exprs"][response.argId])(response.value) +proc onUploadTraceFileReceived*(sender: js, response: jsobject(argId=cstring, value=UploadedTraceData)) = + jsAsFunction[proc(response: UploadedTraceData): void](data.network.futures["upload-trace-file"][response.argId])(response.value) + +proc onDeleteOnlineTraceFileReceived*(sender: js, response: jsobject(argId=cstring, value=bool)) = + jsAsFunction[proc(response: bool): void](data.network.futures["delete-online-trace-file"][response.argId])(response.value) + # TODO: make some kind of dsl? # locals proc onLoadLocalsReceived*(sender: js, response: jsobject(argId=cstring, value=JsAssoc[cstring, Value])) = diff --git a/src/frontend/styles/components/welcome_screen.styl b/src/frontend/styles/components/welcome_screen.styl index bfdacf88d..e99ce29a1 100644 --- a/src/frontend/styles/components/welcome_screen.styl +++ b/src/frontend/styles/components/welcome_screen.styl @@ -161,7 +161,7 @@ padding: 6px padding-left: 14px padding-right: 14px - border-radius: 4px + border-radius: 6px &.hovered // color: WELCOME_SCREEN_OPTIONS_TEXT_COLOR_HOVERED @@ -187,24 +187,33 @@ padding: 8px margin-top: 48px margin-bottom: 48px + padding-right: 0px + + .recent-traces-title + text-align: start + font-size: 18px + font-weight: BOLD_WEIGHT + line-height: 18px + letter-spacing: -0.18px + font-family: 'SpaceGrotesk' + margin-bottom: 6px - .recent-traces-title - text-align: start - font-size: 18px - font-weight: BOLD_WEIGHT - line-height: 18px - letter-spacing: -0.18px - font-family: 'SpaceGrotesk' + .recent-traces-list + max-height: 172px + overflow-y: scroll + overflow-x: visible .recent-trace color: WELCOME_SCREEN_RECENT_TRACE_COLOR - background-color: WELCOME_SCREEN_RECENT_PROJECT_BACKGROUND_COLOR; - height: 28px; - margin-top: 8px; + background-color: WELCOME_SCREEN_RECENT_PROJECT_BACKGROUND_COLOR + height: 28px + margin-top: 2px + margin-bottom: 6px display: flex position: relative text-align: left border-radius: 4px + width: -webkit-fill-available &:hover cursor: pointer @@ -234,7 +243,7 @@ .recent-trace-title-content // seems to be required, so ellipsis can work - width: 500px + max-width: 45ch // this could put an ellipsis in the beginning if long // but it seems it has problems with punctuation, and indeed with // path separators e.g. `/` going in the end @@ -418,7 +427,8 @@ background-repeat: no-repeat background-size: 40px 40px background-position: center - margin-bottom: 8px + margin-top: 2px + margin-bottom: 6px &.in-progress background-image: url("../../public/resources/welcome/loading_sign_dots_128.svg") @@ -440,3 +450,70 @@ height: 30px font-family: "FiraCode" font-size: 20px + +.online-functionality-buttons + display: flex + padding-right: 2px + + .recent-trace-buttons + margin-left: 8px + margin-top: 2px + margin-bottom: 6px + height: 28px + width: 28px + background-color: DEBUG_BUTTON_BORDER_COLOR + border-radius: 4px + cursor: pointer + + &:hover + outline: 1px solid WELCOME_SCREEN_SEPARATE_BAR_COLOR + + &:active + background-color: DEBUG_BUTTON_ACTIVE_BACKGROUND + + &.trace-info-button-active + background-color: WELCOME_SCREEN_ACTIVE_BACKGROUND + + .recent-trace-buttons-image + background-repeat: no-repeat + background-size: 16px 16px + background-position: center + height: 28px + + #trace-upload-button + background-image: WELCOME_SCREEN_UPLOAD_BUTTON + + #trace-copy-button + background-image: WELCOME_SCREEN_COPY_BUTTON + + #trace-delete-button + background-image: WELCOME_SCREEN_DELETE_BUTTON + + #trace-info-button + background-image: WELCOME_SCREEN_INFO_BUTTON + + #trace-info-button-active + background-image: WELCOME_SCREEN_INFO_ACTIVE_BUTTON + + .welcome-path-active + opacity: 1 + visibility: visible + height: 24px + left: calc(100% + 8px) + position: absolute + width: fit-content + + &:after + top: 18% + left: -14px + border-color: transparent TOOLTIP_BACKGROUND transparent transparent !important + +.recent-trace-container + display: flex + align-items: center + justify-content: space-between + +.new-online-trace-form + input + padding-left: 8px + background-image: none diff --git a/src/frontend/styles/default_dark_theme.styl b/src/frontend/styles/default_dark_theme.styl index 1a943cbc5..341c57f71 100644 --- a/src/frontend/styles/default_dark_theme.styl +++ b/src/frontend/styles/default_dark_theme.styl @@ -56,6 +56,15 @@ WELCOME_SCREEN_WINDOW_MINIMIZE_ICON_PATH = url('../../public/resources/window/mi WELCOME_SCREEN_WINDOW_MAXIMIZE_ICON_PATH = url('../../public/resources/window/maximize_16_dark.svg') WELCOME_SCREEN_WINDOW_CLOSE_ICON_PATH = url('../../public/resources/window/close_16_dark.svg') +WELCOME_SCREEN_COPY_BUTTON = url('../../public/resources/welcome/welcome_screen_copy_button_dark.svg') +WELCOME_SCREEN_DELETE_BUTTON = url('../../public/resources/welcome/welcome_screen_delete_button_dark.svg') +WELCOME_SCREEN_UPLOAD_BUTTON = url('../../public/resources/welcome/welcome_screen_upload_button_dark.svg') +WELCOME_SCREEN_INFO_BUTTON = url('../../public/resources/welcome/welcome_screen_info_button_dark.svg') +WELCOME_SCREEN_INFO_ACTIVE_BUTTON = url('../../public/resources/welcome/welcome_screen_info_active_button_dark.svg') +WELCOME_SCREEN_ACTIVE_BACKGROUND = #FB923C + +WELCOME_SCREEN_EXPIRE_TIME_COLOR = #919191 + LAYOUT_MINIMIZE_ICON_PATH = url('../../public/resources/menu/minimize_16_dark.svg') LAYOUT_MAXIMIZE_ICON_PATH = url('../../public/resources/menu/maximize_16_dark.svg') LAYOUT_CLOSE_ICON_PATH = url('../../public/resources/menu/close_16_dark.svg') diff --git a/src/frontend/styles/default_white_theme.styl b/src/frontend/styles/default_white_theme.styl index 576e0784b..67e742e6a 100644 --- a/src/frontend/styles/default_white_theme.styl +++ b/src/frontend/styles/default_white_theme.styl @@ -46,6 +46,15 @@ WELCOME_SCREEN_WINDOW_MINIMIZE_ICON_PATH = url('../../public/resources/window/mi WELCOME_SCREEN_WINDOW_MAXIMIZE_ICON_PATH = url('../../public/resources/window/maximize_16_grey.svg') WELCOME_SCREEN_WINDOW_CLOSE_ICON_PATH = url('../../public/resources/window/close_16_grey.svg') +WELCOME_SCREEN_COPY_BUTTON = url('../../public/resources/welcome/welcome_screen_copy_button_dark.svg') +WELCOME_SCREEN_DELETE_BUTTON = url('../../public/resources/welcome/welcome_screen_delete_button_dark.svg') +WELCOME_SCREEN_UPLOAD_BUTTON = url('../../public/resources/welcome/welcome_screen_upload_button_dark.svg') +WELCOME_SCREEN_INFO_BUTTON = url('../../public/resources/welcome/welcome_screen_info_button_dark.svg') +WELCOME_SCREEN_INFO_ACTIVE_BUTTON = url('../../public/resources/welcome/welcome_screen_info_active_button_dark.svg') +WELCOME_SCREEN_ACTIVE_BACKGROUND = #FB923C + +WELCOME_SCREEN_EXPIRE_TIME_COLOR = #919191 + LAYOUT_MINIMIZE_ICON_PATH = url('../../public/resources/menu/minimize_16_white.svg') LAYOUT_MAXIMIZE_ICON_PATH = url('../../public/resources/menu/maximize_16_white.svg') LAYOUT_CLOSE_ICON_PATH = url('../../public/resources/menu/close_16_white.svg') diff --git a/src/frontend/types.nim b/src/frontend/types.nim index 2251ce2f0..6dd53ea36 100644 --- a/src/frontend/types.nim +++ b/src/frontend/types.nim @@ -88,6 +88,8 @@ type EventDropDownBox* = enum Filter, OnlyTrace, OnlyRecordedEvent, EnableDisable + ExpireTraceState* = enum ThreeDaysLeft, Expired, NotExpiringSoon, NoExpireState + # works great yes WithLocation* = concept a a.path is cstring @@ -1094,6 +1096,9 @@ type kind*: RecordStatusKind errorMessage*: cstring + NewDownloadRecord* = ref object + args*: seq[cstring] + status*: RecordStatus NewTraceRecord* = ref object kit*: cstring @@ -1126,14 +1131,21 @@ type hovered*: bool inactive*: bool # grayed out by default (lower opacity) + MessageKind* = enum UploadError, DeleteError, ResetMessage WelcomeScreenComponent* = ref object of Component options*: seq[WelcomeScreenOption] welcomeScreen*: bool newRecordScreen*: bool + openOnlineTrace*: bool newRecord*: NewTraceRecord + newDownload*: NewDownloadRecord loading*: bool loadingTrace*: Trace + recentTracesScroll*: int + copyMessageActive*: JsAssoc[int, bool] + infoMessageActive*: JsAssoc[int, bool] + errorMessageActive*: JsAssoc[int, MessageKind] ReplComponent* = ref object of Component history*: seq[DebugInteraction] @@ -1453,6 +1465,7 @@ type shortcutMap*: ShortcutMap defaultBuild*: cstring showMinimap*: bool + traceSharingEnabled*: bool BreakpointSave* = ref object of js # Serialized breakpoint diff --git a/src/frontend/ui/editor.nim b/src/frontend/ui/editor.nim index c5fcc141a..8c2198eca 100644 --- a/src/frontend/ui/editor.nim +++ b/src/frontend/ui/editor.nim @@ -311,9 +311,7 @@ proc styleLines(self: EditorViewComponent, editor: MonacoEditor, lines: seq[Mona self.decorations.mapIt(it[0])) if not self.data.ui.welcomeScreen.isNil: - self.data.ui.welcomeScreen.loading = false - self.data.ui.welcomeScreen.welcomeScreen = false - self.data.ui.welcomeScreen.newRecordScreen = false + self.data.ui.welcomeScreen.resetView() proc lineActionClick(self: EditorViewComponent, tabInfo: TabInfo, line: js) = var element = line diff --git a/src/frontend/ui/welcome_screen.nim b/src/frontend/ui/welcome_screen.nim index 288334ad6..9d75e19b5 100644 --- a/src/frontend/ui/welcome_screen.nim +++ b/src/frontend/ui/welcome_screen.nim @@ -2,36 +2,187 @@ import ../ui_helpers, ../../ct/version, ui_imports, ../types +import std/options +import std/enumerate +import std/times except now + +const PROGRAM_NAME_LIMIT = 45 +const NO_EXPIRE_TIME = -1 +const EMPTY_STRING = "" +const ERROR_DOWNLOAD_KEY = "Errored" + +proc uploadTrace(self: WelcomeScreenComponent, trace: Trace) {.async.} = + var uploadedData = await self.data.asyncSend( + "upload-trace-file", + UploadTraceArg( + trace: trace, + programName: trace.program + ), + &"{trace.program}:{trace.id}", UploadedTraceData + ) + + if uploadedData.downloadKey != "Errored": + trace.downloadKey = uploadedData.downloadKey + trace.controlId = uploadedData.controlId + trace.onlineExpireTime = ($uploadedData.expireTime).parseInt() + else: + trace.downloadKey = uploadedData.downloadKey + self.errorMessageActive[trace.id] = UploadError + + self.data.redraw() + +proc deleteUploadedTrace(self: WelcomeScreenComponent, trace: Trace) {.async.} = + var deleted = await self.data.asyncSend( + "delete-online-trace-file", + DeleteTraceArg( + traceId: trace.id, + controlId: trace.controlId + ), + &"{trace.id}:{trace.controlId}", bool + ) + + if deleted: + trace.controlId = EMPTY_STRING + trace.downloadKey = EMPTY_STRING + trace.onlineExpireTime = NO_EXPIRE_TIME + else: + self.errorMessageActive[trace.id] = DeleteError + + self.data.redraw() + +proc recentProjectView(self: WelcomeScreenComponent, trace: Trace, position: int): VNode = + let featureFlag = data.config.traceSharingEnabled + let tooltipTopPosition = (position + 1) * 36 - self.recentTracesScroll + let activeClass = if self.copyMessageActive.hasKey(trace.id) and self.copyMessageActive[trace.id]: "welcome-path-active" else: "" + let infoActive = if self.infoMessageActive.hasKey(trace.id) and self.infoMessageActive[trace.id]: "welcome-path-active" else: "" + let uploadErrorClass = if self.errorMessageActive.hasKey(trace.id) and self.errorMessageActive[trace.id] == UploadError: "welcome-path-active" else: "" + let deleteErrorClass = if self.errorMessageActive.hasKey(trace.id) and self.errorMessageActive[trace.id] == DeleteError: "welcome-path-active" else: "" + if self.errorMessageActive.hasKey(trace.id) and self.errorMessageActive[trace.id] in @[UploadError, DeleteError]: + discard setTimeout(proc() = + self.errorMessageActive[trace.id] = ResetMessage + if self.errorMessageActive[trace.id] == UploadError: + trace.downloadKey = "" + self.data.redraw(), + 2000 + ) + + let currentTime = cast[int](getTime().toJs.seconds) + let oneWeek = cast[int]((3.days).toJs.seconds) + let remainingTime = if trace.onlineExpireTime != NO_EXPIRE_TIME: trace.onlineExpireTime - currentTime else: 0 + var (expireState, expireId) = + if trace.onlineExpireTime == NO_EXPIRE_TIME: + (NoExpireState, "trace-info-button") + elif remainingTime > oneWeek: + (NotExpiringSoon, "trace-info-button") + elif remainingTime < 0: + (Expired, "trace-info-button-active") + else: + (ThreeDaysLeft, "trace-info-button-active") -proc recentProjectView(self: WelcomeScreenComponent, trace: Trace): VNode = buildHtml( + tdiv(class = "recent-trace-container") + ): tdiv( class = "recent-trace", - onclick = proc = + onclick = proc (ev: Event, tg: VNode) = self.loading = true self.loadingTrace = trace + ev.target.focus() data.redraw() self.data.ipc.send "CODETRACER::load-recent-trace", js{ traceId: trace.id } - ) - ): - let programLimitName = 45 - let limitedProgramName = if trace.program.len > programLimitName: - ".." & ($trace.program)[^programLimitName..^1] - else: - $trace.program - - tdiv(class = "recent-trace-title"): - span(class = "recent-trace-title-id"): - text fmt"ID: {trace.id}" - separateBar() - span(class = "recent-trace-title-content"): - text limitedProgramName # TODO: tippy - # tdiv(class = "recent-trace-info"): - # tdiv(class = "recent-trace-date"): - # text trace.date - # if not trace.duration.isNil: - # tdiv(class = "recent-trace-duration"): - # text trace.duration + ): + let programLimitName = PROGRAM_NAME_LIMIT + let limitedProgramName = if trace.program.len > programLimitName: + ".." & ($trace.program)[^programLimitName..^1] + else: + $trace.program + + tdiv(class = "recent-trace-title"): + span(class = "recent-trace-title-id"): + text fmt"ID: {trace.id}" + separateBar() + span(class = "recent-trace-title-content"): + text limitedProgramName # TODO: tippy + if featureFlag: + tdiv(class = "online-functionality-buttons"): + if (trace.downloadKey == "" and trace.onlineExpireTime == NO_EXPIRE_TIME) or expireState == ExpireTraceState.Expired or trace.downloadKey == ERROR_DOWNLOAD_KEY: + tdiv(class = "recent-trace-buttons", id = "upload-button"): + tdiv( + class = "recent-trace-buttons-image", + id = "trace-upload-button", + onclick = proc(ev: Event, tg: VNode) = + ev.stopPropagation() + ev.target.focus() + discard self.uploadTrace(trace) + ): + tdiv(class = fmt"custom-tooltip {uploadErrorClass}", id = &"tooltip-{trace.id}", + style = style(StyleAttr.top, &"{tooltipTopPosition}px") + ): + text "Server error or maximum file size reached (4GB)" + if trace.controlId != EMPTY_STRING and expireState != Expired: + tdiv(class = "recent-trace-buttons", id = "delete-button"): + tdiv( + class = "recent-trace-buttons-image", + id = "trace-delete-button", + onclick = proc(ev: Event, tg: VNode) = + ev.stopPropagation() + ev.target.focus() + discard self.deleteUploadedTrace(trace) + ): + tdiv(class = fmt"custom-tooltip {deleteErrorClass}", id = &"tooltip-{trace.id}", + style = style(StyleAttr.top, &"{tooltipTopPosition}px") + ): + text "Server error when deleting" + if trace.downloadKey != EMPTY_STRING and expireState != Expired and trace.downloadKey != ERROR_DOWNLOAD_KEY: + tdiv(class = "recent-trace-buttons"): + tdiv( + class = "recent-trace-buttons-image", + id = "trace-copy-button", + onclick = proc(ev: Event, tg: VNode) = + ev.stopPropagation() + clipboardCopy(trace.downloadKey) + self.copyMessageActive[trace.id] = true + ev.target.focus() + self.data.redraw() + discard setTimeout(proc() = + self.copyMessageActive[trace.id] = false + self.data.redraw(), + 2000 + ) + ): + tdiv(class = fmt"custom-tooltip {activeClass}", id = &"tooltip-{trace.id}", + style = style(StyleAttr.top, &"{tooltipTopPosition}px") + ): + text "Download key copied to clipboard" + if expireState != NoExpireState or expireState in @[Expired, ThreeDaysLeft]: + let dt = fromUnix(trace.onlineExpireTime) + let time = dt.format("dd MM yyyy") + let formatted = time.replace(" ", ".") + tdiv(class = &"recent-trace-buttons {expireId}"): + tdiv( + class = "recent-trace-buttons-image", + id = &"{expireId}", + onclick = proc(ev: Event, tg: VNode) = + ev.stopPropagation() + clipboardCopy(trace.downloadKey) + self.infoMessageActive[trace.id] = if self.infoMessageActive.hasKey(trace.id): not self.infoMessageActive[trace.id] else: true + if self.copyMessageActive.hasKey(trace.id) and self.copyMessageActive[trace.id]: + self.copyMessageActive[trace.id] = false + ev.target.parentNode.focus() + self.data.redraw(), + onmouseleave = proc(ev: Event, tg: VNode) = + self.infoMessageActive[trace.id] = false + ): + tdiv(class = fmt"custom-tooltip {infoActive}", id = &"tooltip-{trace.id}", + style = style(StyleAttr.top, &"{tooltipTopPosition}px") + ): + case expireState: + of ThreeDaysLeft: + text &"The key will expire on {formatted}" + of Expired: + text "The key has expired" + else: + text &"The online share key expires on {formatted}" proc recentProjectsView(self: WelcomeScreenComponent): VNode = buildHtml( @@ -39,12 +190,17 @@ proc recentProjectsView(self: WelcomeScreenComponent): VNode = ): tdiv(class = "recent-traces-title"): text "RECENT TRACES" - if self.data.recentTraces.len > 0: - for trace in self.data.recentTraces: - recentProjectView(self, trace) - else: - tdiv(class = "no-recent-traces"): - text "No traces yet." + tdiv( + class = "recent-traces-list", + onscroll = proc(ev: Event, tg: VNode) = + self.recentTracesScroll = cast[int](ev.target.scrollTop) + ): + if self.data.recentTraces.len > 0: + for (i, trace) in enumerate(self.data.recentTraces): + recentProjectView(self, trace, i) + else: + tdiv(class = "no-recent-traces"): + text "No traces yet." proc renderOption(self: WelcomeScreenComponent, option: WelcomeScreenOption): VNode = let optionClass = toLowerAscii($(option.name)).split().join("-") @@ -155,10 +311,11 @@ proc chooseExecutable(self: WelcomeScreenComponent) = proc chooseDir(self: WelcomeScreenComponent, fieldName: cstring) = self.data.ipc.send "CODETRACER::choose-dir", js{ fieldName: fieldName } -proc renderRecordResult(self: WelcomeScreenComponent): VNode = +proc renderRecordResult(self: WelcomeScreenComponent, status: RecordStatus, isDownload: bool = false): VNode = var containerClass = "new-record-result" var iconClass = "new-record-status-icon" - case self.newRecord.status.kind: + let name = if isDownload: "Download" else: "Record" + case status.kind: of RecordInit: containerClass = containerClass & " empty" iconClass = iconClass & " empty" @@ -179,16 +336,16 @@ proc renderRecordResult(self: WelcomeScreenComponent): VNode = tdiv(class = containerClass) ): tdiv(class = iconClass) - tdiv(class = &"new-record-{self.newRecord.status.kind}-message"): - case self.newRecord.status.kind: + tdiv(class = &"new-record-{status.kind}-message"): + case status.kind: of InProgress: - text &"Recording..." + text &"{name}ing..." of RecordError: - text &"Record failed. Error: {self.newRecord.status.errorMessage}" + text &"{name} failed. Error: {status.errorMessage}" of RecordSuccess: - text &"Record successful! Opening..." + text &"{name} successful! Opening..." else: discard @@ -205,6 +362,48 @@ proc prepareArgs(self: WelcomeScreenComponent): seq[cstring] = return args.concat(self.newRecord.args) +proc onlineFormView(self: WelcomeScreenComponent): VNode = + proc handler(ev: Event, tg: VNode) = + ev.preventDefault() + # TODO: Implement progress bar? + self.newDownload.status.kind = InProgress + self.data.ipc.send( + "CODETRACER::download-trace-file", js{ + downloadKey: concat(self.newDownload.args), + } + ) + + buildHtml( + tdiv(class = "new-record-form new-online-trace-form") + ): + renderInputRow( + "args", + "Download ID with password", + "", + proc(ev: Event, tg: VNode) = discard, + proc(ev: Event, tg: VNode) = + self.newDownload.args = ev.target.value.split(" ") + handler(ev, tg), + hasButton = false, + inputText = self.newDownload.args.join(j" ") + ) + renderRecordResult(self, self.newDownload.status, true) + tdiv(class = "new-record-form-row"): + button( + class = "cancel-button", + onclick = proc(ev: Event, tg: VNode) = + ev.preventDefault() + self.welcomeScreen = true + self.openOnlineTrace = false + self.newDownload = nil + ): + text "Back" + button( + class = "confirmation-button", + onclick = handler + ): + text "Download" + proc newRecordFormView(self: WelcomeScreenComponent): VNode = buildHtml( tdiv(class = "new-record-form") @@ -280,7 +479,7 @@ proc newRecordFormView(self: WelcomeScreenComponent): VNode = validInput = self.newRecord.formValidator.validOutputFolder, disabled = self.newRecord.defaultOutputFolder ) - renderRecordResult(self) + renderRecordResult(self, self.newRecord.status) case self.newRecord.status.kind: of RecordInit, RecordError: tdiv(class = "new-record-form-row"): @@ -338,6 +537,16 @@ proc newRecordView(self: WelcomeScreenComponent): VNode = text "Start Debugger" newRecordFormView(self) +proc onlineTraceView(self: WelcomeScreenComponent): VNode = + buildHtml( + tdiv(class = "new-record-screen") + ): + tdiv(class = "new-record-screen-content"): + tdiv(class = "welcome-logo") + tdiv(class = "new-record-title"): + text "Download and open online trace" + onlineFormView(self) + proc loadInitialOptions(self: WelcomeScreenComponent) = self.options = @[ WelcomeScreenOption( @@ -372,8 +581,14 @@ proc loadInitialOptions(self: WelcomeScreenComponent) = ), WelcomeScreenOption( name: "Open online trace", - inactive: true, - command: proc = discard + inactive: not data.config.traceSharingEnabled, + command: proc = + self.openOnlineTrace = true + self.welcomeScreen = false + self.newDownload = NewDownloadRecord( + args: @[], + status: RecordStatus(kind: RecordInit) + ) ), WelcomeScreenOption( name: "CodeTracer shell", @@ -401,7 +616,7 @@ proc welcomeScreenView(self: WelcomeScreenComponent): VNode = tdiv(class = "welcome-logo") text "Welcome to CodeTracer IDE" tdiv(class = "welcome-version"): - text fmt"Version {CodeTracerVersionStr}" # TODO include dynamically from e.g. version.nim + text fmt"Version {CodeTracerVersionStr}" tdiv(class = "welcome-content"): recentProjectsView(self) renderStartOptions(self) @@ -421,13 +636,15 @@ method render*(self: WelcomeScreenComponent): VNode = self.loadInitialOptions() buildHtml(tdiv()): - if self.welcomeScreen or self.newRecordScreen: + if self.welcomeScreen or self.newRecordScreen or self.openOnlineTrace: tdiv(class = "welcome-screen-wrapper"): windowMenu(data, true) if self.welcomeScreen: welcomeScreenView(self) elif self.newRecordScreen: newRecordView(self) + elif self.openOnlineTrace: + onlineTraceView(self) if self.loading: loadingOverlay(self) diff --git a/src/frontend/ui_js.nim b/src/frontend/ui_js.nim index 62c3b209d..3578eca55 100644 --- a/src/frontend/ui_js.nim +++ b/src/frontend/ui_js.nim @@ -814,6 +814,20 @@ proc onLoadingTrace( data.ui.welcomeScreen.loadingTrace = response.trace redrawAll() +proc onFailedDownload( + sender: js, + response: jsobject(errorMessage=cstring) +) = + data.ui.welcomeScreen.newDownload.status.kind = RecordError + data.ui.welcomeScreen.newDownload.status.errorMessage = response.errorMessage + redrawAll() + +proc onSuccessfulDownload( + sender: js, + response: jsobject() +) = + data.ui.welcomeScreen.newDownload.status.kind = RecordSuccess + redrawAll() proc onWelcomeScreen( sender: js, @@ -1087,9 +1101,14 @@ proc configureIPC(data: Data) = "finished": JsObject => debugger "error": DebuggerError => [debugger, ui] + "failed-download" + "successful-download" "follow-history" + "upload-trace-file-received" + "delete-online-trace-file-received" + duration("configureIPCRun") proc zoomInEditors*(data: Data) = diff --git a/src/frontend/utils.nim b/src/frontend/utils.nim index 716822083..da207abe4 100644 --- a/src/frontend/utils.nim +++ b/src/frontend/utils.nim @@ -163,7 +163,10 @@ proc makeShellComponent*(data: Data, id: int): ShellComponent = proc makeWelcomeScreenComponent*(data: Data): WelcomeScreenComponent = result = WelcomeScreenComponent( id: data.generateId(Content.WelcomeScreen), - welcomeScreen: true + welcomeScreen: true, + copyMessageActive: JsAssoc[int, bool]{}, + infoMessageActive: JsAssoc[int, bool]{}, + errorMessageActive: JsAssoc[int, MessageKind]{} ) data.ui.welcomeScreen = result data.registerComponent(result, Content.WelcomeScreen) @@ -1312,3 +1315,9 @@ proc clearViewZones*(self: EditorViewComponent) = self.monacoEditor.changeViewZones do (view: js): for viewZone in self.viewZones: view.removeZone(viewZone) + +proc resetView*(self: WelcomeScreenComponent) = + self.loading = false + self.welcomeScreen = false + self.newRecordScreen = false + self.openOnlineTrace = false diff --git a/src/public/resources/welcome/welcome_screen_copy_button_dark.svg b/src/public/resources/welcome/welcome_screen_copy_button_dark.svg new file mode 100644 index 000000000..08bee490b --- /dev/null +++ b/src/public/resources/welcome/welcome_screen_copy_button_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/public/resources/welcome/welcome_screen_delete_button_dark.svg b/src/public/resources/welcome/welcome_screen_delete_button_dark.svg new file mode 100644 index 000000000..5b3fafd91 --- /dev/null +++ b/src/public/resources/welcome/welcome_screen_delete_button_dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/public/resources/welcome/welcome_screen_info_active_button_dark.svg b/src/public/resources/welcome/welcome_screen_info_active_button_dark.svg new file mode 100644 index 000000000..2258167ce --- /dev/null +++ b/src/public/resources/welcome/welcome_screen_info_active_button_dark.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/public/resources/welcome/welcome_screen_info_button_dark.svg b/src/public/resources/welcome/welcome_screen_info_button_dark.svg new file mode 100644 index 000000000..e72c0eb55 --- /dev/null +++ b/src/public/resources/welcome/welcome_screen_info_button_dark.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/public/resources/welcome/welcome_screen_upload_button_dark.svg b/src/public/resources/welcome/welcome_screen_upload_button_dark.svg new file mode 100644 index 000000000..10dbbebb7 --- /dev/null +++ b/src/public/resources/welcome/welcome_screen_upload_button_dark.svg @@ -0,0 +1,9 @@ + + + + + + + + +