use anyhow::{Context, Result};
use comfy_table::{Cell, ContentArrangement, Table, presets::UTF8_FULL_CONDENSED};
use percent_encoding::percent_decode_str;
use std::fs;
use std::path::PathBuf;
use std::time::SystemTime;
use url::Url;
use super::utils;
use crate::config;
#[derive(Debug)]
struct ProjectLoadWarning {
folder_id: String,
display_path: PathBuf,
message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoteType {
Tunnel,
SshRemote,
DevContainer,
Wsl,
Unknown(String),
}
impl RemoteType {
fn parse(s: &str) -> Self {
match s {
"tunnel" => Self::Tunnel,
"ssh-remote" => Self::SshRemote,
"dev-container" => Self::DevContainer,
"wsl" => Self::Wsl,
other => Self::Unknown(other.to_string()),
}
}
}
impl std::fmt::Display for RemoteType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Tunnel => write!(f, "tunnel"),
Self::SshRemote => write!(f, "ssh"),
Self::DevContainer => write!(f, "container"),
Self::Wsl => write!(f, "wsl"),
Self::Unknown(s) => write!(f, "{}", s),
}
}
}
#[derive(Debug, Clone)]
pub struct RemoteInfo {
pub remote_type: RemoteType,
pub name: String,
}
#[derive(Debug)]
pub struct Project {
pub folder_id: String,
pub path: PathBuf,
pub remote: Option<RemoteInfo>,
pub last_modified: Option<SystemTime>,
pub chat_count: Option<usize>,
}
#[derive(Debug, Default)]
struct ListWarnings {
entries: Vec<ProjectLoadWarning>,
}
impl ListWarnings {
fn push(&mut self, folder_id: String, display_path: PathBuf, message: String) {
self.entries.push(ProjectLoadWarning {
folder_id,
display_path,
message,
});
}
}
fn list(workspace_storage_dir: PathBuf) -> Result<(Vec<Project>, ListWarnings)> {
let mut projects = Vec::new();
let mut warnings = ListWarnings::default();
if !workspace_storage_dir.exists() {
return Ok((projects, warnings));
}
let entries = fs::read_dir(&workspace_storage_dir)
.with_context(|| format!("Failed to read: {}", workspace_storage_dir.display()))?;
for entry in entries.flatten() {
if !entry.file_type()?.is_dir() {
continue;
}
let folder_id = entry.file_name().to_string_lossy().to_string();
let project_dir = entry.path();
let workspace_json_path = project_dir.join("workspace.json");
if !workspace_json_path.exists() {
continue;
}
let target_uri = match crate::cursor::workspace::read_workspace_target_uri(&project_dir)
.with_context(|| format!("Failed to load: {}", workspace_json_path.display()))?
{
Some(uri) => uri,
None => continue,
};
let folder_url = target_uri.as_str();
let parsed = match parse_folder_url(folder_url) {
Some(p) => p,
None => {
eprintln!(
"Warning: Invalid folder URL in {}: {}",
workspace_json_path.display(),
folder_url
);
continue;
}
};
let last_modified = entry.metadata()?.modified().ok();
let chat_count = match utils::count_chat_sessions_if_available(&project_dir) {
Ok(Some(count)) => Some(count),
Ok(None) => {
warnings.push(
folder_id.clone(),
parsed.path.clone(),
"Chat discovery unavailable: no local session registry and global registry could not be read"
.to_string(),
);
None
}
Err(err) => {
warnings.push(folder_id.clone(), parsed.path.clone(), err.to_string());
None
}
};
projects.push(Project {
folder_id,
path: parsed.path,
remote: parsed.remote,
last_modified,
chat_count,
});
}
projects.sort_by(|a, b| {
b.last_modified
.cmp(&a.last_modified)
.then_with(|| a.path.cmp(&b.path))
});
Ok((projects, warnings))
}
pub struct ListOptions {
pub with_id: bool,
pub sort: String,
pub reverse: bool,
pub filter: Option<String>,
pub limit: Option<usize>,
}
pub fn execute(options: ListOptions) -> Result<(String, Option<String>)> {
let workspace_storage_dir = config::workspace_storage_dir()
.context("Failed to determine workspace storage directory")?;
let (mut projects, warnings) = list(workspace_storage_dir)?;
if let Some(ref filter_str) = options.filter {
projects.retain(|p| {
let path_str = p.path.to_string_lossy();
match filter_str.as_str() {
"local" => p.remote.is_none(),
"remote" => p.remote.is_some(),
pattern => path_str.contains(pattern),
}
});
}
match options.sort.as_str() {
"name" => {
projects.sort_by(|a, b| a.path.cmp(&b.path));
}
"chats" => {
projects.sort_by(|a, b| {
b.chat_count
.cmp(&a.chat_count)
.then_with(|| a.path.cmp(&b.path))
});
}
_ => {
}
}
if options.reverse {
projects.reverse();
}
let total_count = projects.len();
if let Some(n) = options.limit {
projects.truncate(n);
}
let mut table = Table::new();
table
.load_preset(UTF8_FULL_CONDENSED)
.set_content_arrangement(ContentArrangement::Dynamic);
let mut header = vec![];
if options.with_id {
header.push(Cell::new("ID"));
}
header.push(Cell::new("Remote"));
header.push(Cell::new("Path"));
header.push(Cell::new("Chats"));
header.push(Cell::new("Modified"));
table.set_header(header);
for project in &projects {
let path_str = project.path.to_string_lossy().to_string();
let chat_str = project
.chat_count
.map(|count| count.to_string())
.unwrap_or_else(|| "unknown".to_string());
let remote_str = match &project.remote {
Some(r) => format!("{}:{}", r.remote_type, r.name),
None => "-".to_string(),
};
let modified_str = project
.last_modified
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| {
let secs = d.as_secs();
let dt = chrono::DateTime::from_timestamp(secs as i64, 0).unwrap_or_default();
dt.format("%Y-%m-%d %H:%M").to_string()
})
.unwrap_or_else(|| "-".to_string());
let mut row = vec![];
if options.with_id {
row.push(Cell::new(&project.folder_id));
}
row.push(Cell::new(remote_str));
row.push(Cell::new(path_str));
row.push(Cell::new(chat_str));
row.push(Cell::new(modified_str));
table.add_row(row);
}
let mut output = table.to_string();
if projects.len() < total_count {
output.push_str(&format!(
"\n\nShowing {} of {} projects",
projects.len(),
total_count
));
} else {
output.push_str(&format!("\n\n{} projects found", total_count));
}
let displayed_folder_ids: std::collections::HashSet<&str> = projects
.iter()
.map(|project| project.folder_id.as_str())
.collect();
let filtered_warning_entries = warnings
.entries
.into_iter()
.filter(|warning| displayed_folder_ids.contains(warning.folder_id.as_str()))
.collect::<Vec<_>>();
let warning_output = if filtered_warning_entries.is_empty() {
None
} else {
Some(
filtered_warning_entries
.iter()
.map(|warning| format!("- {}: {}", warning.display_path.display(), warning.message))
.collect::<Vec<_>>()
.join("\n"),
)
};
Ok((output, warning_output))
}
struct ParsedUrl {
path: PathBuf,
remote: Option<RemoteInfo>,
}
fn parse_folder_url(url_str: &str) -> Option<ParsedUrl> {
let url = Url::parse(url_str).ok()?;
match url.scheme() {
"file" => {
let path = url.to_file_path().ok()?;
Some(ParsedUrl { path, remote: None })
}
"vscode-remote" => {
let username = percent_decode_str(url.username()).decode_utf8_lossy();
let host_encoded = url.host_str()?;
let host = percent_decode_str(host_encoded).decode_utf8_lossy();
let remote = if username.starts_with("dev-container+") {
let name = host.split('+').nth(1).unwrap_or("container").to_string();
Some(RemoteInfo {
remote_type: RemoteType::DevContainer,
name,
})
} else if let Some(plus_pos) = host.find('+') {
let remote_type_str = &host[..plus_pos];
let name = host[plus_pos + 1..].to_string();
Some(RemoteInfo {
remote_type: RemoteType::parse(remote_type_str),
name,
})
} else {
None
};
let path = PathBuf::from(url.path());
Some(ParsedUrl { path, remote })
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[cfg(not(windows))]
#[test]
fn test_parse_local_url() {
let parsed = parse_folder_url("file:///Users/me/projects/myapp").unwrap();
assert_eq!(parsed.path, PathBuf::from("/Users/me/projects/myapp"));
assert!(parsed.remote.is_none());
}
#[cfg(not(windows))]
#[test]
fn test_parse_local_url_with_spaces() {
let parsed = parse_folder_url("file:///Users/me/my%20project").unwrap();
assert_eq!(parsed.path, PathBuf::from("/Users/me/my project"));
assert!(parsed.remote.is_none());
}
#[cfg(windows)]
#[test]
fn test_parse_local_url_windows() {
let parsed = parse_folder_url("file:///C:/Users/me/projects/myapp").unwrap();
assert_eq!(parsed.path, PathBuf::from("C:\\Users\\me\\projects\\myapp"));
assert!(parsed.remote.is_none());
}
#[cfg(windows)]
#[test]
fn test_parse_local_url_with_spaces_windows() {
let parsed = parse_folder_url("file:///C:/Users/me/my%20project").unwrap();
assert_eq!(parsed.path, PathBuf::from("C:\\Users\\me\\my project"));
assert!(parsed.remote.is_none());
}
#[cfg(not(windows))]
#[test]
fn test_parse_local_workspace_file_url() {
let parsed = parse_folder_url("file:///Users/me/projects/dev.code-workspace").unwrap();
assert_eq!(
parsed.path,
PathBuf::from("/Users/me/projects/dev.code-workspace")
);
assert!(parsed.remote.is_none());
}
#[test]
fn test_parse_tunnel_url() {
let parsed =
parse_folder_url("vscode-remote://tunnel+myserver/home/user/data/project").unwrap();
assert_eq!(parsed.path, PathBuf::from("/home/user/data/project"));
let remote = parsed.remote.unwrap();
assert_eq!(remote.remote_type, RemoteType::Tunnel);
assert_eq!(remote.name, "myserver");
}
#[test]
fn test_parse_ssh_remote_url() {
let parsed =
parse_folder_url("vscode-remote://ssh-remote+myhost/home/user/project").unwrap();
assert_eq!(parsed.path, PathBuf::from("/home/user/project"));
let remote = parsed.remote.unwrap();
assert_eq!(remote.remote_type, RemoteType::SshRemote);
assert_eq!(remote.name, "myhost");
}
#[test]
fn test_parse_percent_encoded_tunnel_url() {
let parsed =
parse_folder_url("vscode-remote://tunnel%2Bdev-server/home/user/.config/myapp")
.unwrap();
assert_eq!(parsed.path, PathBuf::from("/home/user/.config/myapp"));
let remote = parsed.remote.unwrap();
assert_eq!(remote.remote_type, RemoteType::Tunnel);
assert_eq!(remote.name, "dev-server");
}
#[test]
fn test_parse_dev_container_on_ssh() {
let parsed = parse_folder_url(
"vscode-remote://dev-container%2Bconfig@ssh-remote%2Bwin11-wsl/workspaces/project",
)
.unwrap();
assert_eq!(parsed.path, PathBuf::from("/workspaces/project"));
let remote = parsed.remote.unwrap();
assert_eq!(remote.remote_type, RemoteType::DevContainer);
assert_eq!(remote.name, "win11-wsl");
}
#[test]
fn test_parse_wsl_url() {
let parsed = parse_folder_url("vscode-remote://wsl+Ubuntu/home/user/project").unwrap();
assert_eq!(parsed.path, PathBuf::from("/home/user/project"));
let remote = parsed.remote.unwrap();
assert_eq!(remote.remote_type, RemoteType::Wsl);
assert_eq!(remote.name, "Ubuntu");
}
#[test]
fn test_parse_invalid_scheme() {
assert!(parse_folder_url("http://example.com/path").is_none());
assert!(parse_folder_url("ftp://server/path").is_none());
}
#[test]
fn test_parse_invalid_url() {
assert!(parse_folder_url("not a url at all").is_none());
}
#[test]
fn test_remote_type_parse() {
assert_eq!(RemoteType::parse("tunnel"), RemoteType::Tunnel);
assert_eq!(RemoteType::parse("ssh-remote"), RemoteType::SshRemote);
assert_eq!(RemoteType::parse("dev-container"), RemoteType::DevContainer);
assert_eq!(RemoteType::parse("wsl"), RemoteType::Wsl);
assert_eq!(
RemoteType::parse("unknown-type"),
RemoteType::Unknown("unknown-type".to_string())
);
}
#[test]
fn test_remote_type_display() {
assert_eq!(format!("{}", RemoteType::Tunnel), "tunnel");
assert_eq!(format!("{}", RemoteType::SshRemote), "ssh");
assert_eq!(format!("{}", RemoteType::DevContainer), "container");
assert_eq!(format!("{}", RemoteType::Wsl), "wsl");
assert_eq!(
format!("{}", RemoteType::Unknown("custom".to_string())),
"custom"
);
}
#[test]
fn test_project_struct_fields() {
let project = Project {
folder_id: "abc123".to_string(),
path: PathBuf::from("/test/path"),
remote: Some(RemoteInfo {
remote_type: RemoteType::Tunnel,
name: "myserver".to_string(),
}),
last_modified: None,
chat_count: Some(5),
};
assert_eq!(project.folder_id, "abc123");
assert_eq!(project.chat_count, Some(5));
assert!(project.remote.is_some());
}
#[test]
fn test_chat_count_unknown_displays_as_unknown() {
let project = Project {
folder_id: "abc123".to_string(),
path: PathBuf::from("/test/path"),
remote: None,
last_modified: None,
chat_count: None,
};
let chat_str = project
.chat_count
.map(|count| count.to_string())
.unwrap_or_else(|| "unknown".to_string());
assert_eq!(chat_str, "unknown");
}
#[test]
fn test_sort_by_chats_places_unknown_last() {
let mut projects = [
Project {
folder_id: "a".to_string(),
path: PathBuf::from("/a"),
remote: None,
last_modified: None,
chat_count: None,
},
Project {
folder_id: "b".to_string(),
path: PathBuf::from("/b"),
remote: None,
last_modified: None,
chat_count: Some(2),
},
];
projects.sort_by(|a, b| {
b.chat_count
.cmp(&a.chat_count)
.then_with(|| a.path.cmp(&b.path))
});
assert_eq!(projects[0].folder_id, "b");
assert_eq!(projects[1].folder_id, "a");
}
#[test]
fn test_list_includes_multi_root_workspace_entries() {
let temp_dir = TempDir::new().unwrap();
let workspace_storage = temp_dir.path().join("workspaceStorage");
let workspace_dir = workspace_storage.join("abc123");
let workspace_file = temp_dir.path().join("dev.code-workspace");
fs::create_dir_all(&workspace_dir).unwrap();
fs::write(&workspace_file, "{}\n").unwrap();
fs::write(
workspace_dir.join("workspace.json"),
format!(
r#"{{"workspace":"{}"}}"#,
url::Url::from_file_path(&workspace_file).unwrap()
),
)
.unwrap();
let (projects, warnings) = list(workspace_storage).unwrap();
assert!(warnings.entries.is_empty());
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].folder_id, "abc123");
assert_eq!(projects[0].path, workspace_file);
assert!(projects[0].remote.is_none());
}
}