Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 126 additions & 21 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ struct GraphNode {
id: String,
name: String,
label: String,
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
properties: HashMap<String, String>,
#[serde(rename = "tableId", skip_serializing_if = "Option::is_none")]
table_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -286,13 +288,91 @@ fn value_to_string(val: &Value) -> String {
Value::Int32(n) => n.to_string(),
Value::Int16(n) => n.to_string(),
Value::Int8(n) => n.to_string(),
Value::UInt64(n) => n.to_string(),
Value::UInt32(n) => n.to_string(),
Value::UInt16(n) => n.to_string(),
Value::UInt8(n) => n.to_string(),
Value::Double(n) => n.to_string(),
Value::Float(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => format!("{}", val),
}
}

fn non_empty_value_to_string(val: &Value) -> Option<String> {
let value = value_to_string(val);
if value.trim().is_empty() {
None
} else {
Some(value)
}
}

fn node_display_name(props: &[(String, Value)]) -> String {
const PREFERRED_KEYS: &[&str] = &[
"name",
"title",
"display_name",
"displayname",
"label",
"summary",
"subject",
"key",
"slug",
"path",
"filename",
"file",
"url",
"uri",
"email",
"username",
"user_name",
];

for preferred_key in PREFERRED_KEYS {
if let Some((_, value)) = props
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(preferred_key))
{
if let Some(name) = non_empty_value_to_string(value) {
return name;
}
}
}

if let Some((_, value)) = props
.iter()
.find(|(key, value)| !key.eq_ignore_ascii_case("id") && matches!(value, Value::String(_)))
{
if let Some(name) = non_empty_value_to_string(value) {
return name;
}
}

if let Some((_, value)) = props
.iter()
.find(|(key, value)| key.eq_ignore_ascii_case("id") && matches!(value, Value::String(_)))
{
if let Some(name) = non_empty_value_to_string(value) {
return name;
}
}

props
.iter()
.find_map(|(_, value)| non_empty_value_to_string(value))
.unwrap_or_else(|| "Node".to_string())
}

fn node_display_properties(props: &[(String, Value)]) -> HashMap<String, String> {
props
.iter()
.filter_map(|(key, value)| {
non_empty_value_to_string(value).map(|value| (key.clone(), value))
})
.collect()
}

fn value_to_score(val: &Value) -> f64 {
match val {
Value::Double(n) => *n,
Expand All @@ -315,18 +395,14 @@ fn graph_node_from_value(val: &Value) -> Option<GraphNode> {
};

let props = node_val.get_properties();
let name = props
.iter()
.find(|(key, _)| key == "name")
.or_else(|| props.iter().find(|(key, _)| key == "id"))
.or_else(|| props.iter().find(|(key, _)| key == "title"))
.map(|(_, prop_val)| value_to_string(prop_val))
.unwrap_or_else(|| "Node".to_string());
let name = node_display_name(props);
let properties = node_display_properties(props);

Some(GraphNode {
id: id_to_string(node_val.get_node_id()),
name,
label: node_val.get_label_name().clone(),
properties,
table_id: Some(node_val.get_node_id().table_id),
rowid: Some(node_val.get_node_id().offset),
community: None,
Expand Down Expand Up @@ -432,6 +508,7 @@ fn make_expander_node(parent_id: &str, hidden_count: usize, offset: usize) -> Gr
id: format!("{EXPANDER_PREFIX}node:{parent_id}:{offset}"),
name: format!("+{hidden_count}"),
label: "More".to_string(),
properties: HashMap::new(),
table_id: None,
rowid: None,
community: None,
Expand Down Expand Up @@ -986,6 +1063,7 @@ fn compute_cluster_levels(
id: index.to_string(),
name: format!("Cluster {index}"),
label: "Cluster".to_string(),
properties: HashMap::new(),
table_id: None,
rowid: None,
community: Some(index as u64),
Expand Down Expand Up @@ -1204,19 +1282,15 @@ fn collect_edge_graph(conn: &Connection, limit: usize) -> Result<GraphData, Stri
let target = id_to_string(rel.get_dst_node());
for node_val in [source_node, target_node] {
let props = node_val.get_properties();
let name = props
.iter()
.find(|(k, _)| k == "name")
.or_else(|| props.iter().find(|(k, _)| k == "id"))
.or_else(|| props.iter().find(|(k, _)| k == "title"))
.map(|(_, val)| value_to_string(val))
.unwrap_or_else(|| "Node".to_string());
let name = node_display_name(props);
let properties = node_display_properties(props);
merge_node(
&mut nodes,
GraphNode {
id: id_to_string(node_val.get_node_id()),
name,
label: node_val.get_label_name().clone(),
properties,
table_id: Some(node_val.get_node_id().table_id),
rowid: Some(node_val.get_node_id().offset),
community: None,
Expand Down Expand Up @@ -1794,20 +1868,16 @@ fn execute_query(
if !node_id_set.contains(&node_id) {
node_id_set.insert(node_id.clone());
let props = node_val.get_properties();
let name = props
.iter()
.find(|(k, _)| k == "name")
.or_else(|| props.iter().find(|(k, _)| k == "id"))
.or_else(|| props.iter().find(|(k, _)| k == "title"))
.map(|(_, v)| value_to_string(v))
.unwrap_or_else(|| "Node".to_string());
let name = node_display_name(props);
let properties = node_display_properties(props);

let label = node_val.get_label_name().clone();

nodes.push(GraphNode {
id: node_id,
name,
label,
properties,
table_id: Some(node_val.get_node_id().table_id),
rowid: Some(node_val.get_node_id().offset),
community: None,
Expand Down Expand Up @@ -1890,3 +1960,38 @@ pub fn run() {
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn node_display_name_prefers_non_id_string_over_integer_id() {
let props = vec![
("id".to_string(), Value::Int64(42)),
("bug_key".to_string(), Value::String("BUG-42".to_string())),
];

assert_eq!(node_display_name(&props), "BUG-42");
}

#[test]
fn node_display_name_prefers_named_columns_before_id() {
let props = vec![
("id".to_string(), Value::String("42".to_string())),
(
"title".to_string(),
Value::String("Crash on launch".to_string()),
),
];

assert_eq!(node_display_name(&props), "Crash on launch");
}

#[test]
fn node_display_name_uses_string_id_when_it_is_best_available_name() {
let props = vec![("id".to_string(), Value::String("BUG-42".to_string()))];

assert_eq!(node_display_name(&props), "BUG-42");
}
}
43 changes: 43 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,49 @@ body {
border-top: 1px solid var(--border-color);
}

.display-column-settings {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
}

.display-column-list {
display: flex;
flex-direction: column;
gap: 8px;
}

.display-column-row {
display: grid;
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
align-items: center;
gap: 8px;
color: var(--text-primary);
font-size: 13px;
}

.display-column-row span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.display-column-row select {
min-width: 0;
width: 100%;
padding: 7px 8px;
background-color: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 13px;
}

.display-column-row select:focus {
outline: none;
border-color: var(--accent-color);
}

.llm-cluster-settings {
margin-top: 20px;
padding-top: 16px;
Expand Down
Loading
Loading