Skip to content

Commit babb283

Browse files
committed
feat(search): add node label search and source filtering
1 parent 38822ec commit babb283

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+5871
-1477
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ All notable changes to Pi Session Manager will be documented in this file.
66

77
### Added
88

9+
- **Pi tree node-label search and source filtering** — labels are now first-class searchable node metadata in global full-text search and the in-session tree
10+
- Added Pi-specific raw label parsing with latest-wins / empty-label-clears semantics
11+
- Indexed resolved labels as `source_type = "label"` hits tied to target nodes
12+
- Added global `sourceFilter` modes: `all`, `labels_only`, `content_only`
13+
- Added browse-all-labels behavior for `labels_only + empty query`
14+
- Added backend/runtime `get_session_labels` lookup for chunked session trees
15+
- Added label-aware session-tree display, local search, labeled-only filtering, and target-node navigation
16+
917
- **Pi Live session integration** — real-time session sync with pi agent
1018
- Unified TypeScript type definitions for live sessions
1119
- Live session indicator in sidebar and dashboard

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
## Highlights
2121

2222
- Session browser with list/project/kanban views, favorites, tags, rename, delete, and export.
23-
- Full-text search via SQLite FTS + Tantivy-backed indexing/search flows.
23+
- Full-text search via SQLite FTS + Tantivy-backed indexing/search flows, including tree node label search and node content vs. label source filtering.
2424
- In-session message search with inline highlights, current-match navigation, and keyboard-friendly close/reset behavior. `Cmd/Ctrl + F` behavior is configurable (search vs. sidebar toggle).
2525
- Built-in terminal (PTY) and one-click resume of Pi sessions.
2626
- **External Sessions** — scan and browse sessions from other coding agents (Claude, OpenCode, etc.) with unified settings UI for scan control and default resume targets.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
},
5252
"devDependencies": {
5353
"@tauri-apps/cli": "^2.1.0",
54+
"@testing-library/react": "^16.3.2",
5455
"@types/highlight.js": "^10.1.0",
5556
"@types/node": "^25.1.0",
5657
"@types/react": "^18.3.12",
@@ -60,12 +61,14 @@
6061
"autoprefixer": "^10.4.23",
6162
"code-inspector-plugin": "^1.4.2",
6263
"husky": "^9.1.7",
64+
"jsdom": "^29.0.2",
6365
"less": "^4.6.4",
6466
"postcss": "^8.5.6",
6567
"tailwindcss": "^3.4.0",
6668
"tau-mirror": "^1.0.7",
6769
"typescript": "^5.6.3",
6870
"vite": "^5.4.11",
69-
"vite-plugin-pwa": "^1.2.0"
71+
"vite-plugin-pwa": "^1.2.0",
72+
"vitest": "^4.1.4"
7073
}
7174
}

src-tauri-cli/src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,7 @@ async fn dispatch_command(
449449
.unwrap_or(fallback)))
450450
}
451451
"get_available_shells" => Ok(serde_json::json!(terminal::scan_shells())),
452-
_ => pi_session_manager::dispatch::dispatch(command, payload).await,
452+
_ => pi_session_manager::dispatch::dispatch_without_app_state(command, payload).await,
453453
}
454454
}
455455

src-tauri/examples/sql_index_bench.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ async fn run_once(
333333
args.page_size,
334334
case.match_mode.map(str::to_string),
335335
None,
336+
None,
336337
)
337338
.await
338339
.map_err(boxed_error)?;

src-tauri/src/api_readonly.rs

Lines changed: 78 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ pub struct FullTextSearchRequest {
9797
pub page_size: Option<usize>,
9898
#[serde(default)]
9999
pub match_mode: Option<String>,
100+
#[serde(default)]
101+
pub sort_order: Option<String>,
102+
#[serde(default)]
103+
pub source_filter: Option<String>,
100104
}
101105

102106
#[derive(Debug, Clone, Deserialize)]
@@ -310,35 +314,86 @@ where
310314
D: Fn(&'static str, Value) -> Fut,
311315
Fut: Future<Output = Result<Value, String>>,
312316
{
313-
if req.query.trim().is_empty() {
317+
let source_filter = req.source_filter.clone();
318+
let allows_empty_query = matches!(source_filter.as_deref(), Some("labels_only"));
319+
if req.query.trim().is_empty() && !allows_empty_query {
314320
return Err(ApiReadonlyError::bad_request("query is required"));
315321
}
316322

317323
let from = parse_time_opt(&req.from)?;
318324
let to = parse_time_opt(&req.to)?;
319-
let effective_glob = effective_glob(req.glob_pattern.clone(), req.project.as_deref());
320-
let payload = serde_json::json!({
321-
"query": req.query,
322-
"role_filter": req.role_filter.unwrap_or_else(|| "all".to_string()),
323-
"glob_pattern": effective_glob,
324-
"page": req.page.unwrap_or(0),
325-
"page_size": req.page_size.unwrap_or(20),
326-
"match_mode": req.match_mode,
327-
});
325+
let effective_glob = effective_glob(req.glob_pattern.clone(), None);
326+
let requested_page = req.page.unwrap_or(0);
327+
let requested_page_size = req.page_size.unwrap_or(20);
328+
let role_filter = req.role_filter.unwrap_or_else(|| "all".to_string());
329+
let project_path = req.project.clone();
330+
let needs_post_filter = from.is_some() || to.is_some();
331+
332+
let build_payload = |page: usize, page_size: usize| {
333+
serde_json::json!({
334+
"query": req.query,
335+
"role_filter": role_filter,
336+
"glob_pattern": effective_glob,
337+
"project_path": project_path,
338+
"page": page,
339+
"page_size": page_size,
340+
"match_mode": req.match_mode,
341+
"sort_order": req.sort_order,
342+
"source_filter": source_filter,
343+
})
344+
};
328345

329-
let data = dispatch("full_text_search", payload)
330-
.await
331-
.map_err(ApiReadonlyError::bad_request)?;
332-
let mut response: FullTextSearchResponse = serde_json::from_value(data)
333-
.map_err(|e| ApiReadonlyError::internal(format!("Invalid search response: {e}")))?;
346+
if needs_post_filter && normalize_filtered {
347+
let mut backend_page = 0usize;
348+
let fetch_page_size = requested_page_size.max(100);
349+
let mut filtered_hits = Vec::new();
350+
351+
loop {
352+
let data = dispatch(
353+
"full_text_search",
354+
build_payload(backend_page, fetch_page_size),
355+
)
356+
.await
357+
.map_err(ApiReadonlyError::bad_request)?;
358+
let response: FullTextSearchResponse = serde_json::from_value(data)
359+
.map_err(|e| ApiReadonlyError::internal(format!("Invalid search response: {e}")))?;
360+
361+
filtered_hits.extend(
362+
response
363+
.hits
364+
.into_iter()
365+
.filter(|hit| hit_matches_scope(hit, None, from, to)),
366+
);
367+
368+
if !response.has_more {
369+
break;
370+
}
371+
backend_page += 1;
372+
}
334373

335-
response
336-
.hits
337-
.retain(|hit| hit_matches_scope(hit, req.project.as_deref(), from, to));
338-
response.total_hits = response.hits.len();
339-
if normalize_filtered {
340-
response.has_more = false;
374+
let total_hits = filtered_hits.len();
375+
let start = requested_page.saturating_mul(requested_page_size);
376+
let hits = filtered_hits
377+
.into_iter()
378+
.skip(start)
379+
.take(requested_page_size)
380+
.collect::<Vec<_>>();
381+
return Ok(FullTextSearchResponse {
382+
has_more: start.saturating_add(hits.len()) < total_hits,
383+
total_hits,
384+
hits,
385+
});
341386
}
387+
388+
let data = dispatch(
389+
"full_text_search",
390+
build_payload(requested_page, requested_page_size),
391+
)
392+
.await
393+
.map_err(ApiReadonlyError::bad_request)?;
394+
let response: FullTextSearchResponse = serde_json::from_value(data)
395+
.map_err(|e| ApiReadonlyError::internal(format!("Invalid search response: {e}")))?;
396+
342397
Ok(response)
343398
}
344399

@@ -363,6 +418,8 @@ where
363418
page: Some(0),
364419
page_size: Some(top_k),
365420
match_mode: Some("any".to_string()),
421+
sort_order: None,
422+
source_filter: None,
366423
},
367424
true,
368425
)

0 commit comments

Comments
 (0)