diff --git a/src/app.cpp b/src/app.cpp index 716ada6..64ed7c0 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -16,6 +16,7 @@ void App::init(SDL_Window* window) { TRACE_FUNCTION_CAT("app"); window_ = window; toolbar_.set_window(window); + stats_.set_window(window); load_settings(); if (platform::supports_vsync()) { SDL_GL_SetSwapInterval(vsync_ ? 1 : 0); diff --git a/src/platform/INDEX.md b/src/platform/INDEX.md index 93ad06d..a5c8139 100644 --- a/src/platform/INDEX.md +++ b/src/platform/INDEX.md @@ -13,6 +13,7 @@ void run_main_loop(void (*step)(), bool* running); std::string settings_path(); bool supports_vsync(); void open_file_dialog(SDL_Window*); +void save_file_dialog(SDL_Window*, const std::string& default_name, const std::string& content); void handle_file_drop(const char* path); bool has_pending_file(); PendingFile take_pending_file(); diff --git a/src/platform/platform.h b/src/platform/platform.h index cf7f210..969ed92 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -27,8 +27,9 @@ void run_main_loop(void (*step)(), bool* running); std::string settings_path(); // empty = no persistence bool supports_vsync(); -// File dialog +// File dialogs void open_file_dialog(SDL_Window* window); +void save_file_dialog(SDL_Window* window, const std::string& default_name, const std::string& content); // File drop handling (called from SDL event loop) void handle_file_drop(const char* path); diff --git a/src/platform/platform_desktop.cpp b/src/platform/platform_desktop.cpp index 331f606..d7b71e4 100644 --- a/src/platform/platform_desktop.cpp +++ b/src/platform/platform_desktop.cpp @@ -12,6 +12,19 @@ static void file_dialog_callback(void* /*userdata*/, const char* const* filelist } } +static std::string g_save_content; + +static void save_dialog_callback(void* /*userdata*/, const char* const* filelist, int /*filter*/) { + if (filelist && filelist[0]) { + SDL_IOStream* io = SDL_IOFromFile(filelist[0], "w"); + if (io) { + SDL_WriteIO(io, g_save_content.data(), g_save_content.size()); + SDL_CloseIO(io); + } + g_save_content.clear(); + } +} + void platform::set_gl_attributes() { TRACE_FUNCTION_CAT("platform"); SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0); @@ -67,6 +80,17 @@ void platform::open_file_dialog(SDL_Window* window) { SDL_ShowOpenFileDialog(file_dialog_callback, nullptr, window, filters, 2, nullptr, false); } +void platform::save_file_dialog(SDL_Window* window, const std::string& default_name, const std::string& content) { + if (!window) return; + static const SDL_DialogFileFilter filters[] = { + {"CSV Files", "csv"}, + {"TSV Files", "tsv"}, + {"All Files", "*"}, + }; + g_save_content = content; + SDL_ShowSaveFileDialog(save_dialog_callback, nullptr, window, filters, 3, default_name.c_str()); +} + void platform::handle_file_drop(const char* path) { TRACE_FUNCTION_CAT("platform"); g_pending.path = path; diff --git a/src/platform/platform_wasm.cpp b/src/platform/platform_wasm.cpp index 0c216b7..e001b27 100644 --- a/src/platform/platform_wasm.cpp +++ b/src/platform/platform_wasm.cpp @@ -69,6 +69,28 @@ bool platform::supports_vsync() { return false; } +void platform::save_file_dialog(SDL_Window* /*window*/, const std::string& default_name, const std::string& content) { + // Trigger browser download via JS blob + EM_ASM( + { + var name = UTF8ToString($0); + var data = UTF8ToString($1); + var blob = new Blob([data], { + type: + 'text/plain' + }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, + default_name.c_str(), content.c_str()); +} + void platform::open_file_dialog(SDL_Window* /*window*/) { trigger_file_input(); } diff --git a/src/ui/INDEX.md b/src/ui/INDEX.md index 868c66b..e53a107 100644 --- a/src/ui/INDEX.md +++ b/src/ui/INDEX.md @@ -81,9 +81,10 @@ void on_model_changed(); void render(const TraceModel&, ViewState&); ``` -## stats_panel.h / stats_panel.cpp — SQL editor + result table + visual query builder; tabs serialized to JSON +## stats_panel.h / stats_panel.cpp — SQL editor + result table + visual query builder; tabs serialized to JSON; CSV/TSV export ``` void render(const TraceModel&, QueryDb&, ViewState&); +void set_window(SDL_Window*); nlohmann::json save_tabs() const; void load_tabs(const nlohmann::json&); // QueryBuilderState @@ -91,6 +92,11 @@ void reset(); std::string build_sql(const char* const* columns, int num_columns) const; ``` +## export_utils.h — export query results to CSV or TSV format +``` +std::string export_result(const QueryDb::QueryResult&, char delimiter); +``` + ## flame_graph_panel.h / flame_graph_panel.cpp — per-thread icicle charts with flat node pool; filterable sidebar, zoom, search highlighting, context menu ``` void render(const TraceModel&, ViewState&); diff --git a/src/ui/export_utils.h b/src/ui/export_utils.h new file mode 100644 index 0000000..41032ed --- /dev/null +++ b/src/ui/export_utils.h @@ -0,0 +1,41 @@ +#pragma once +#include "model/query_db.h" +#include +#include + +// Export query results as delimited text (CSV with ',' or TSV with '\t'). +// CSV quoting follows RFC 4180: fields containing the delimiter, quotes, or +// newlines are wrapped in double-quotes with internal quotes escaped. +inline std::string export_result(const QueryDb::QueryResult& result, char delimiter) { + auto needs_quoting = [&](const std::string& s) { + if (delimiter != ',') return false; + return s.find_first_of(",\"\n") != std::string::npos; + }; + auto write_field = [&](std::ostringstream& out, const std::string& s) { + if (needs_quoting(s)) { + out << '"'; + for (char ch : s) { + if (ch == '"') out << '"'; + out << ch; + } + out << '"'; + } else { + out << s; + } + }; + + std::ostringstream out; + for (size_t c = 0; c < result.columns.size(); c++) { + if (c > 0) out << delimiter; + write_field(out, result.columns[c]); + } + out << '\n'; + for (const auto& row : result.rows) { + for (size_t c = 0; c < row.size(); c++) { + if (c > 0) out << delimiter; + write_field(out, row[c]); + } + out << '\n'; + } + return out.str(); +} diff --git a/src/ui/stats_panel.cpp b/src/ui/stats_panel.cpp index 480d5a3..0a39734 100644 --- a/src/ui/stats_panel.cpp +++ b/src/ui/stats_panel.cpp @@ -1,7 +1,9 @@ #include "stats_panel.h" +#include "export_utils.h" #include "format_time.h" #include "sort_utils.h" #include "tracing.h" +#include "platform/platform.h" #include "imgui.h" #include #include @@ -400,6 +402,16 @@ void StatsPanel::render_tab(QueryTab& tab, const TraceModel& model, QueryDb& db, if (!tab.result.ok || tab.result.columns.empty()) return; ImGui::Text("%zu rows", tab.result.rows.size()); + ImGui::SameLine(); + if (ImGui::SmallButton("Export CSV")) { + std::string content = export_result(tab.result, ','); + platform::save_file_dialog(window_, tab.title + ".csv", content); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Export TSV")) { + std::string content = export_result(tab.result, '\t'); + platform::save_file_dialog(window_, tab.title + ".tsv", content); + } // Find the "name" column index for click-to-browse int name_col = -1; diff --git a/src/ui/stats_panel.h b/src/ui/stats_panel.h index ccc59ca..1bb1738 100644 --- a/src/ui/stats_panel.h +++ b/src/ui/stats_panel.h @@ -6,6 +6,8 @@ #include #include +struct SDL_Window; + struct QueryTab { std::string title = "Query"; std::string query; @@ -72,6 +74,7 @@ struct QueryBuilderState { class StatsPanel { public: void render(const TraceModel& model, QueryDb& db, ViewState& view); + void set_window(SDL_Window* window) { window_ = window; } nlohmann::json save_tabs() const; void load_tabs(const nlohmann::json& j); @@ -89,6 +92,8 @@ class StatsPanel { int active_tab_ = 0; float sql_height_ = 0.0f; // draggable SQL editor height (0 = use default) + SDL_Window* window_ = nullptr; + bool show_schema_ = false; bool show_builder_ = false; QueryBuilderState builder_; diff --git a/tests/test_export_utils.cpp b/tests/test_export_utils.cpp new file mode 100644 index 0000000..6b36e98 --- /dev/null +++ b/tests/test_export_utils.cpp @@ -0,0 +1,58 @@ +#include +#include "ui/export_utils.h" + +static QueryDb::QueryResult make_result(std::vector cols, std::vector> rows) { + QueryDb::QueryResult r; + r.columns = std::move(cols); + r.rows = std::move(rows); + r.ok = true; + return r; +} + +TEST(ExportUtils, CsvBasic) { + auto r = make_result({"name", "count"}, {{"foo", "3"}, {"bar", "7"}}); + std::string csv = export_result(r, ','); + EXPECT_EQ(csv, "name,count\nfoo,3\nbar,7\n"); +} + +TEST(ExportUtils, TsvBasic) { + auto r = make_result({"name", "count"}, {{"foo", "3"}, {"bar", "7"}}); + std::string tsv = export_result(r, '\t'); + EXPECT_EQ(tsv, "name\tcount\nfoo\t3\nbar\t7\n"); +} + +TEST(ExportUtils, CsvQuotesFieldsWithCommas) { + auto r = make_result({"val"}, {{"hello, world"}, {"plain"}}); + std::string csv = export_result(r, ','); + EXPECT_EQ(csv, "val\n\"hello, world\"\nplain\n"); +} + +TEST(ExportUtils, CsvQuotesFieldsWithQuotes) { + auto r = make_result({"val"}, {{"say \"hi\""}}); + std::string csv = export_result(r, ','); + EXPECT_EQ(csv, "val\n\"say \"\"hi\"\"\"\n"); +} + +TEST(ExportUtils, CsvQuotesFieldsWithNewlines) { + auto r = make_result({"val"}, {{"line1\nline2"}}); + std::string csv = export_result(r, ','); + EXPECT_EQ(csv, "val\n\"line1\nline2\"\n"); +} + +TEST(ExportUtils, TsvNoQuoting) { + // TSV should not quote fields even if they contain commas or quotes + auto r = make_result({"val"}, {{"hello, world"}, {"say \"hi\""}}); + std::string tsv = export_result(r, '\t'); + EXPECT_EQ(tsv, "val\nhello, world\nsay \"hi\"\n"); +} + +TEST(ExportUtils, EmptyResult) { + auto r = make_result({"a", "b"}, {}); + EXPECT_EQ(export_result(r, ','), "a,b\n"); + EXPECT_EQ(export_result(r, '\t'), "a\tb\n"); +} + +TEST(ExportUtils, SingleColumn) { + auto r = make_result({"x"}, {{"1"}, {"2"}}); + EXPECT_EQ(export_result(r, ','), "x\n1\n2\n"); +}