diff --git a/apps/desktop/src/components/graph/GraphResultView.vue b/apps/desktop/src/components/graph/GraphResultView.vue index a47e2b84db..bfbfe35e3d 100644 --- a/apps/desktop/src/components/graph/GraphResultView.vue +++ b/apps/desktop/src/components/graph/GraphResultView.vue @@ -291,11 +291,25 @@ function exportImage() { watch(query, highlightSearch); watch( () => props.graph, - () => { - expanded.value = undefined; - hidden.value = new Set(); - pinned.value = new Set(); - void nextTick(() => renderGraph()); + (next, previous) => { + // Appending a result page reuses the same cells and only adds new ones; + // keep the view state and re-render with preserved positions instead of + // wiping selection/pins/hidden nodes and re-running a full layout. + const appended = + !!previous && + previous.cells.length < next.cells.length && + (() => { + const nextCellKeys = new Set(next.cells.map((cell) => `${cell.row}:${cell.column}`)); + if (previous.cells.some((cell) => !nextCellKeys.has(`${cell.row}:${cell.column}`))) return false; + const nextNodeIds = new Set(next.nodes.map((node) => node.id)); + return previous.nodes.every((node) => nextNodeIds.has(node.id)); + })(); + if (!appended) { + expanded.value = undefined; + hidden.value = new Set(); + pinned.value = new Set(); + } + void nextTick(() => renderGraph(appended)); }, ); watch(selectedId, () => { diff --git a/crates/dbx-core/src/data/query_result_export.rs b/crates/dbx-core/src/data/query_result_export.rs index 634c35438c..ca245725d5 100644 --- a/crates/dbx-core/src/data/query_result_export.rs +++ b/crates/dbx-core/src/data/query_result_export.rs @@ -1100,6 +1100,9 @@ async fn export_query_result_core_inner( result.rows.truncate(this_page); } let row_count = result.rows.len(); + if request.database_type == DatabaseType::Nebula { + crate::text_export::normalize_nebula_export_rows(&mut result.rows); + } let formatted_rows = crate::temporal_format::format_temporal_export_rows_with_string_types_cow( &result.rows, &page_column_types, diff --git a/crates/dbx-formats/src/text_export.rs b/crates/dbx-formats/src/text_export.rs index 6c8d033bcd..99055dbe89 100644 --- a/crates/dbx-formats/src/text_export.rs +++ b/crates/dbx-formats/src/text_export.rs @@ -12,6 +12,20 @@ pub struct QueryResultTextExportData { pub rows: Vec>, } +pub fn normalize_nebula_export_rows(rows: &mut [Vec]) { + for value in rows.iter_mut().flatten() { + let graph_cell = value.get("__dbx_graph_cell").and_then(Value::as_str) == Some("nebula-v1") + && value.get("kind").is_some_and(Value::is_string) + && value.get("nodes").is_some_and(Value::is_array) + && value.get("edges").is_some_and(Value::is_array); + if graph_cell { + if let Some(display) = value.get("display").and_then(Value::as_str) { + *value = Value::String(display.to_owned()); + } + } + } +} + pub fn format_json(data: &QueryResultTextExportData) -> Result { let rows = data .rows @@ -428,7 +442,55 @@ fn chrono_local_now() -> String { mod tests { use serde_json::{json, Value}; - use super::{format_html, format_json, format_markdown, QueryResultTextExportData}; + use super::{format_html, format_json, format_markdown, normalize_nebula_export_rows, QueryResultTextExportData}; + + #[test] + fn nebula_exports_use_display_cells_without_leaking_graph_metadata() { + let mut rows = vec![ + vec![ + json!({ + "__dbx_graph_cell": "nebula-v1", "kind": "vertex", "display": "(:\"player\" {\"name\":\"Tim\"})", + "nodes": [{"id": "vid:1", "labels": ["player"], "properties": [{"name": "name", "value": "Tim"}]}], "edges": [] + }), + json!(false), + Value::Null, + ], + vec![ + json!({"__dbx_graph_cell": "nebula-v1", "kind": "edge", "display": "[:follow]", "nodes": [], "edges": [{"type": "follow"}]}), + json!(7), + json!("plain"), + ], + ]; + normalize_nebula_export_rows(&mut rows); + assert_eq!(rows[0], vec![json!("(:\"player\" {\"name\":\"Tim\"})"), json!(false), Value::Null]); + assert_eq!(rows[1], vec![json!("[:follow]"), json!(7), json!("plain")]); + let data = QueryResultTextExportData { + title: None, + columns: vec!["entity".into(), "value".into(), "note".into()], + rows, + }; + let exported: Value = serde_json::from_str(&format_json(&data).unwrap()).unwrap(); + assert_eq!(exported[0]["entity"], json!("(:\"player\" {\"name\":\"Tim\"})")); + assert!(!format_markdown(&data).contains("__dbx_graph_cell")); + } + + #[test] + fn nebula_export_normalization_preserves_ordinary_and_unknown_objects() { + let mut rows = vec![vec![ + json!({"display": "ordinary", "properties": []}), + json!({"__dbx_graph_cell": "neo4j-v1", "kind": "vertex", "display": "neo4j", "nodes": [], "edges": []}), + json!({"__dbx_graph_cell": "nebula-v2", "kind": "vertex", "display": "future", "nodes": [], "edges": []}), + json!({"__dbx_graph_cell": "nebula-v1", "kind": "vertex", "display": 42, "nodes": [], "edges": []}), + json!({"__dbx_graph_cell": "nebula-v1", "kind": "vertex", "display": "malformed", "nodes": null, "edges": []}), + ]]; + let original = rows.clone(); + normalize_nebula_export_rows(&mut rows); + assert_eq!(rows[0][0], json!({"display": "ordinary", "properties": []})); + assert_eq!(rows[0][1], original[0][1]); + assert_eq!(rows[0][2], original[0][2]); + assert_eq!(rows[0][3], original[0][3]); + assert_eq!(rows[0][4], original[0][4]); + } #[test] fn formats_json_rows_as_objects() {