chasm_cli/commands/
workspace_cmds.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! Workspace listing commands
4
5use anyhow::Result;
6use tabled::{settings::Style, Table, Tabled};
7
8use crate::models::Workspace;
9use crate::storage::read_empty_window_sessions;
10use crate::workspace::discover_workspaces;
11
12#[derive(Tabled)]
13struct WorkspaceRow {
14    #[tabled(rename = "Hash")]
15    hash: String,
16    #[tabled(rename = "Project Path")]
17    project_path: String,
18    #[tabled(rename = "Sessions")]
19    sessions: usize,
20    #[tabled(rename = "Has Chats")]
21    has_chats: String,
22}
23
24#[derive(Tabled)]
25struct SessionRow {
26    #[tabled(rename = "Project Path")]
27    project_path: String,
28    #[tabled(rename = "Session File")]
29    session_file: String,
30    #[tabled(rename = "Last Modified")]
31    last_modified: String,
32    #[tabled(rename = "Messages")]
33    messages: usize,
34}
35
36/// List all VS Code workspaces
37pub fn list_workspaces() -> Result<()> {
38    let workspaces = discover_workspaces()?;
39
40    if workspaces.is_empty() {
41        println!("No workspaces found.");
42        return Ok(());
43    }
44
45    let rows: Vec<WorkspaceRow> = workspaces
46        .iter()
47        .map(|ws| WorkspaceRow {
48            hash: format!("{}...", &ws.hash[..12.min(ws.hash.len())]),
49            project_path: ws
50                .project_path
51                .clone()
52                .unwrap_or_else(|| "(none)".to_string()),
53            sessions: ws.chat_session_count,
54            has_chats: if ws.has_chat_sessions {
55                "Yes".to_string()
56            } else {
57                "No".to_string()
58            },
59        })
60        .collect();
61
62    let table = Table::new(rows).with(Style::ascii_rounded()).to_string();
63
64    println!("{}", table);
65    println!("\nTotal workspaces: {}", workspaces.len());
66
67    // Show empty window sessions count (ALL SESSIONS)
68    if let Ok(empty_count) = crate::storage::count_empty_window_sessions() {
69        if empty_count > 0 {
70            println!("Empty window sessions (ALL SESSIONS): {}", empty_count);
71        }
72    }
73
74    Ok(())
75}
76
77/// List all chat sessions
78pub fn list_sessions(project_path: Option<&str>) -> Result<()> {
79    let workspaces = discover_workspaces()?;
80
81    let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
82        let normalized = crate::workspace::normalize_path(path);
83        workspaces
84            .iter()
85            .filter(|ws| {
86                ws.project_path
87                    .as_ref()
88                    .map(|p| crate::workspace::normalize_path(p) == normalized)
89                    .unwrap_or(false)
90            })
91            .collect()
92    } else {
93        workspaces.iter().collect()
94    };
95
96    let mut rows: Vec<SessionRow> = Vec::new();
97
98    // Add empty window sessions (ALL SESSIONS) if no specific project filter
99    if project_path.is_none() {
100        if let Ok(empty_sessions) = read_empty_window_sessions() {
101            for session in empty_sessions {
102                let modified = chrono::DateTime::from_timestamp_millis(session.last_message_date)
103                    .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
104                    .unwrap_or_else(|| "unknown".to_string());
105
106                let session_id = session.session_id.as_deref().unwrap_or("unknown");
107                rows.push(SessionRow {
108                    project_path: "(ALL SESSIONS)".to_string(),
109                    session_file: format!("{}.json", session_id),
110                    last_modified: modified,
111                    messages: session.request_count(),
112                });
113            }
114        }
115    }
116
117    for ws in filtered_workspaces {
118        if !ws.has_chat_sessions {
119            continue;
120        }
121
122        let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
123
124        for session_with_path in sessions {
125            let modified = session_with_path
126                .path
127                .metadata()
128                .ok()
129                .and_then(|m| m.modified().ok())
130                .map(|t| {
131                    let datetime: chrono::DateTime<chrono::Utc> = t.into();
132                    datetime.format("%Y-%m-%d %H:%M").to_string()
133                })
134                .unwrap_or_else(|| "unknown".to_string());
135
136            rows.push(SessionRow {
137                project_path: ws
138                    .project_path
139                    .clone()
140                    .unwrap_or_else(|| "(none)".to_string()),
141                session_file: session_with_path
142                    .path
143                    .file_name()
144                    .map(|n| n.to_string_lossy().to_string())
145                    .unwrap_or_else(|| "unknown".to_string()),
146                last_modified: modified,
147                messages: session_with_path.session.request_count(),
148            });
149        }
150    }
151
152    if rows.is_empty() {
153        println!("No chat sessions found.");
154        return Ok(());
155    }
156
157    let table = Table::new(&rows).with(Style::ascii_rounded()).to_string();
158
159    println!("{}", table);
160    println!("\nTotal sessions: {}", rows.len());
161
162    Ok(())
163}
164
165/// Find workspaces by search pattern
166pub fn find_workspaces(pattern: &str) -> Result<()> {
167    let workspaces = discover_workspaces()?;
168    let pattern_lower = pattern.to_lowercase();
169
170    let matching: Vec<&Workspace> = workspaces
171        .iter()
172        .filter(|ws| {
173            ws.project_path
174                .as_ref()
175                .map(|p| p.to_lowercase().contains(&pattern_lower))
176                .unwrap_or(false)
177                || ws.hash.to_lowercase().contains(&pattern_lower)
178        })
179        .collect();
180
181    if matching.is_empty() {
182        println!("No workspaces found matching '{}'", pattern);
183        return Ok(());
184    }
185
186    let rows: Vec<WorkspaceRow> = matching
187        .iter()
188        .map(|ws| WorkspaceRow {
189            hash: format!("{}...", &ws.hash[..12.min(ws.hash.len())]),
190            project_path: ws
191                .project_path
192                .clone()
193                .unwrap_or_else(|| "(none)".to_string()),
194            sessions: ws.chat_session_count,
195            has_chats: if ws.has_chat_sessions {
196                "Yes".to_string()
197            } else {
198                "No".to_string()
199            },
200        })
201        .collect();
202
203    let table = Table::new(rows).with(Style::ascii_rounded()).to_string();
204
205    println!("{}", table);
206    println!("\nFound {} matching workspace(s)", matching.len());
207
208    // Show session paths for each matching workspace
209    for ws in &matching {
210        if ws.has_chat_sessions {
211            let project = ws.project_path.as_deref().unwrap_or("(none)");
212            println!("\nSessions for {}:", project);
213
214            if let Ok(sessions) =
215                crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)
216            {
217                for session_with_path in sessions {
218                    println!("  {}", session_with_path.path.display());
219                }
220            }
221        }
222    }
223
224    Ok(())
225}
226
227/// Find sessions by search pattern
228pub fn find_sessions(pattern: &str, project_path: Option<&str>) -> Result<()> {
229    let workspaces = discover_workspaces()?;
230    let pattern_lower = pattern.to_lowercase();
231
232    let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
233        let normalized = crate::workspace::normalize_path(path);
234        workspaces
235            .iter()
236            .filter(|ws| {
237                ws.project_path
238                    .as_ref()
239                    .map(|p| crate::workspace::normalize_path(p) == normalized)
240                    .unwrap_or(false)
241            })
242            .collect()
243    } else {
244        workspaces.iter().collect()
245    };
246
247    let mut rows: Vec<SessionRow> = Vec::new();
248
249    for ws in filtered_workspaces {
250        if !ws.has_chat_sessions {
251            continue;
252        }
253
254        let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
255
256        for session_with_path in sessions {
257            // Check if session matches the pattern
258            let session_id_matches = session_with_path
259                .session
260                .session_id
261                .as_ref()
262                .map(|id| id.to_lowercase().contains(&pattern_lower))
263                .unwrap_or(false);
264            let title_matches = session_with_path
265                .session
266                .title()
267                .to_lowercase()
268                .contains(&pattern_lower);
269            let content_matches = session_with_path.session.requests.iter().any(|r| {
270                r.message
271                    .as_ref()
272                    .map(|m| {
273                        m.text
274                            .as_ref()
275                            .map(|t| t.to_lowercase().contains(&pattern_lower))
276                            .unwrap_or(false)
277                    })
278                    .unwrap_or(false)
279            });
280
281            if !session_id_matches && !title_matches && !content_matches {
282                continue;
283            }
284
285            let modified = session_with_path
286                .path
287                .metadata()
288                .ok()
289                .and_then(|m| m.modified().ok())
290                .map(|t| {
291                    let datetime: chrono::DateTime<chrono::Utc> = t.into();
292                    datetime.format("%Y-%m-%d %H:%M").to_string()
293                })
294                .unwrap_or_else(|| "unknown".to_string());
295
296            rows.push(SessionRow {
297                project_path: ws
298                    .project_path
299                    .clone()
300                    .unwrap_or_else(|| "(none)".to_string()),
301                session_file: session_with_path
302                    .path
303                    .file_name()
304                    .map(|n| n.to_string_lossy().to_string())
305                    .unwrap_or_else(|| "unknown".to_string()),
306                last_modified: modified,
307                messages: session_with_path.session.request_count(),
308            });
309        }
310    }
311
312    if rows.is_empty() {
313        println!("No sessions found matching '{}'", pattern);
314        return Ok(());
315    }
316
317    let table = Table::new(&rows).with(Style::ascii_rounded()).to_string();
318
319    println!("{}", table);
320    println!("\nFound {} matching session(s)", rows.len());
321
322    Ok(())
323}
324
325/// Show workspace details
326pub fn show_workspace(workspace: &str) -> Result<()> {
327    use colored::Colorize;
328
329    let workspaces = discover_workspaces()?;
330    let workspace_lower = workspace.to_lowercase();
331
332    // Find workspace by name or hash
333    let matching: Vec<&Workspace> = workspaces
334        .iter()
335        .filter(|ws| {
336            ws.hash.to_lowercase().contains(&workspace_lower)
337                || ws
338                    .project_path
339                    .as_ref()
340                    .map(|p| p.to_lowercase().contains(&workspace_lower))
341                    .unwrap_or(false)
342        })
343        .collect();
344
345    if matching.is_empty() {
346        println!(
347            "{} No workspace found matching '{}'",
348            "!".yellow(),
349            workspace
350        );
351        return Ok(());
352    }
353
354    for ws in matching {
355        println!("\n{}", "=".repeat(60).bright_blue());
356        println!("{}", "Workspace Details".bright_blue().bold());
357        println!("{}", "=".repeat(60).bright_blue());
358
359        println!("{}: {}", "Hash".bright_white().bold(), ws.hash);
360        println!(
361            "{}: {}",
362            "Path".bright_white().bold(),
363            ws.project_path.as_ref().unwrap_or(&"(none)".to_string())
364        );
365        println!(
366            "{}: {}",
367            "Has Sessions".bright_white().bold(),
368            if ws.has_chat_sessions {
369                "Yes".green()
370            } else {
371                "No".red()
372            }
373        );
374        println!(
375            "{}: {}",
376            "Workspace Path".bright_white().bold(),
377            ws.workspace_path.display()
378        );
379
380        if ws.has_chat_sessions {
381            let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
382            println!(
383                "{}: {}",
384                "Session Count".bright_white().bold(),
385                sessions.len()
386            );
387
388            if !sessions.is_empty() {
389                println!("\n{}", "Sessions:".bright_yellow());
390                for (i, s) in sessions.iter().enumerate() {
391                    let title = s.session.title();
392                    let msg_count = s.session.request_count();
393                    println!(
394                        "  {}. {} ({} messages)",
395                        i + 1,
396                        title.bright_cyan(),
397                        msg_count
398                    );
399                }
400            }
401        }
402    }
403
404    Ok(())
405}
406
407/// Show session details
408pub fn show_session(session_id: &str, project_path: Option<&str>) -> Result<()> {
409    use colored::Colorize;
410
411    let workspaces = discover_workspaces()?;
412    let session_id_lower = session_id.to_lowercase();
413
414    let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
415        let normalized = crate::workspace::normalize_path(path);
416        workspaces
417            .iter()
418            .filter(|ws| {
419                ws.project_path
420                    .as_ref()
421                    .map(|p| crate::workspace::normalize_path(p) == normalized)
422                    .unwrap_or(false)
423            })
424            .collect()
425    } else {
426        workspaces.iter().collect()
427    };
428
429    for ws in filtered_workspaces {
430        if !ws.has_chat_sessions {
431            continue;
432        }
433
434        let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
435
436        for s in sessions {
437            let filename = s
438                .path
439                .file_name()
440                .map(|n| n.to_string_lossy().to_string())
441                .unwrap_or_default();
442
443            let matches = s
444                .session
445                .session_id
446                .as_ref()
447                .map(|id| id.to_lowercase().contains(&session_id_lower))
448                .unwrap_or(false)
449                || filename.to_lowercase().contains(&session_id_lower);
450
451            if matches {
452                println!("\n{}", "=".repeat(60).bright_blue());
453                println!("{}", "Session Details".bright_blue().bold());
454                println!("{}", "=".repeat(60).bright_blue());
455
456                println!(
457                    "{}: {}",
458                    "Title".bright_white().bold(),
459                    s.session.title().bright_cyan()
460                );
461                println!("{}: {}", "File".bright_white().bold(), filename);
462                println!(
463                    "{}: {}",
464                    "Session ID".bright_white().bold(),
465                    s.session
466                        .session_id
467                        .as_ref()
468                        .unwrap_or(&"(none)".to_string())
469                );
470                println!(
471                    "{}: {}",
472                    "Messages".bright_white().bold(),
473                    s.session.request_count()
474                );
475                println!(
476                    "{}: {}",
477                    "Workspace".bright_white().bold(),
478                    ws.project_path.as_ref().unwrap_or(&"(none)".to_string())
479                );
480
481                // Show first few messages as preview
482                println!("\n{}", "Preview:".bright_yellow());
483                for (i, req) in s.session.requests.iter().take(3).enumerate() {
484                    if let Some(msg) = &req.message {
485                        if let Some(text) = &msg.text {
486                            let preview: String = text.chars().take(100).collect();
487                            let truncated = if text.len() > 100 { "..." } else { "" };
488                            println!("  {}. {}{}", i + 1, preview.dimmed(), truncated);
489                        }
490                    }
491                }
492
493                return Ok(());
494            }
495        }
496    }
497
498    println!(
499        "{} No session found matching '{}'",
500        "!".yellow(),
501        session_id
502    );
503    Ok(())
504}