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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ jobs:
run: npm ci

- name: Build and Publish Tauri Release
uses: tauri-apps/tauri-action@v1
uses: tauri-apps/tauri-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# To enable code signing & notarization, configure the secrets in GitHub and uncomment:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
*.tsbuildinfo

# Editor directories and files
.vscode
Expand Down
72 changes: 25 additions & 47 deletions crates/codelaunch-core/src/ide/terminal/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ pub fn render_for_os(
output_dir: &Path,
os: TargetOs,
) -> Result<RenderedWorkspace> {
let slug = slugify(&project.name);
let project_dir = output_dir.join(format!("{slug}-terminal"));
let project_dir = output_dir.join(project.id.to_string());
if project_dir.exists() {
let _ = fs::remove_dir_all(&project_dir);
}
fs::create_dir_all(&project_dir)?;

let mut generated_files = Vec::new();
Expand Down Expand Up @@ -347,9 +349,6 @@ fn generate_macos_terminal_script(term: &Terminal, folder: &Folder) -> String {
script.push_str("#!/bin/zsh\n");
script.push_str("[ -f \"$HOME/.zprofile\" ] && source \"$HOME/.zprofile\" 2>/dev/null\n");
script.push_str("[ -f \"$HOME/.zshrc\" ] && source \"$HOME/.zshrc\" 2>/dev/null\n");
script.push_str("#!/bin/zsh\n");
script.push_str("[ -f \"$HOME/.zprofile\" ] && source \"$HOME/.zprofile\" 2>/dev/null\n");
script.push_str("[ -f \"$HOME/.zshrc\" ] && source \"$HOME/.zshrc\" 2>/dev/null\n");
script.push_str(&format!("cd \"{}\" || exit 1\n", folder_path));
script.push_str(&format!("printf '\\033]0;%s\\007' \"{}\"\n", title));

Expand Down Expand Up @@ -510,8 +509,6 @@ fn generate_linux_terminal_script(term: &Terminal, folder: &Folder) -> String {
script.push_str("#!/usr/bin/env bash\n");
script.push_str("[ -f \"$HOME/.profile\" ] && source \"$HOME/.profile\" 2>/dev/null\n");
script.push_str("[ -f \"$HOME/.bashrc\" ] && source \"$HOME/.bashrc\" 2>/dev/null\n");
script.push_str("[ -f \"$HOME/.profile\" ] && source \"$HOME/.profile\" 2>/dev/null\n");
script.push_str("[ -f \"$HOME/.bashrc\" ] && source \"$HOME/.bashrc\" 2>/dev/null\n");
script.push_str(&format!("cd \"{}\" || exit 1\n", folder_path));
script.push_str(&format!("printf '\\033]0;%s\\007' \"{}\"\n", title));

Expand Down Expand Up @@ -821,11 +818,6 @@ mod tests {
assert!(master.contains("create window with default profile"));
assert!(master.contains("create tab with default profile"));
// Terminal.app fallback branch
// iTerm2 branch
assert!(master.contains("tell application \"iTerm\"\n activate"));
assert!(master.contains("create window with default profile"));
assert!(master.contains("create tab with default profile"));
// Terminal.app fallback branch
assert!(master.contains("tell application \"Terminal\"\n activate"));
assert!(master.contains("do script \"exec"));
// Second terminal in the same group creates a tab in that window
Expand Down Expand Up @@ -863,7 +855,6 @@ mod tests {

let master = fs::read_to_string(&rendered.entry_path).unwrap();
// Both groups have a second terminal added as a tab via Cmd+T keystroke in Terminal.app
// Both groups have a second terminal added as a tab via Cmd+T keystroke in Terminal.app
let tab_adds = master
.matches("keystroke \"t\" using {command down}")
.count();
Expand All @@ -885,18 +876,12 @@ mod tests {
assert_eq!(rendered.generated_files.len(), 2);
assert!(rendered.entry_path.ends_with("launch.sh"));

assert!(rendered.entry_path.ends_with("launch.sh"));

let master = fs::read_to_string(&rendered.entry_path).unwrap();
assert!(master.contains("TERMINAL_BIN"));
// Modern detection: $TERMINAL, xdg-terminal-exec, x-terminal-emulator
assert!(master.contains("$TERMINAL"));
assert!(master.contains("xdg-terminal-exec"));
assert!(master.contains("x-terminal-emulator"));
// Modern detection: $TERMINAL, xdg-terminal-exec, x-terminal-emulator
assert!(master.contains("$TERMINAL"));
assert!(master.contains("xdg-terminal-exec"));
assert!(master.contains("x-terminal-emulator"));
// GNOME / Ptyxis / MATE
assert!(master.contains("--window --title=\"API Server\""));
assert!(master.contains("--tab --title=\"Database Migrations\""));
Expand All @@ -919,18 +904,6 @@ mod tests {
let term2_content = fs::read_to_string(&rendered.generated_files[1]).unwrap();
assert!(term2_content.contains("cargo run --bin migrate"));
assert!(!term2_content.contains("exec \"${SHELL:-bash}\" -l"));

// Verify Linux terminal scripts load user environment (~/.profile and ~/.bashrc)
let term1_content = fs::read_to_string(&rendered.generated_files[0]).unwrap();
assert!(term1_content.contains("#!/usr/bin/env bash"));
assert!(term1_content.contains("[ -f \"$HOME/.profile\" ] && source \"$HOME/.profile\""));
assert!(term1_content.contains("[ -f \"$HOME/.bashrc\" ] && source \"$HOME/.bashrc\""));
assert!(term1_content.contains("cd \"/path/to/backend\""));
assert!(term1_content.contains("trap : INT; npm run dev; exec \"${SHELL:-bash}\" -l"));

let term2_content = fs::read_to_string(&rendered.generated_files[1]).unwrap();
assert!(term2_content.contains("cargo run --bin migrate"));
assert!(!term2_content.contains("exec \"${SHELL:-bash}\" -l"));
}

#[test]
Expand All @@ -942,8 +915,6 @@ mod tests {
assert_eq!(rendered.generated_files.len(), 2);
assert!(rendered.entry_path.ends_with("launch.bat"));

assert!(rendered.entry_path.ends_with("launch.bat"));

let master = fs::read_to_string(&rendered.entry_path).unwrap();
assert!(master.contains("where wt.exe"));
assert!(master.contains("-w new new-tab --title \"API Server\""));
Expand All @@ -967,20 +938,6 @@ mod tests {
assert!(term2_content.contains("title Database Migrations"));
assert!(term2_content.contains("call cargo run --bin migrate"));
assert!(term2_content.contains("exit"));

// Verify Windows individual terminal scripts
let term1_content = fs::read_to_string(&rendered.generated_files[0]).unwrap();
assert!(term1_content.contains("@echo off"));
assert!(term1_content.contains("title API Server"));
assert!(term1_content.contains("cd /d \"/path/to/backend\""));
assert!(term1_content.contains("call npm run dev"));
assert!(!term1_content.contains("exit"));

let term2_content = fs::read_to_string(&rendered.generated_files[1]).unwrap();
assert!(term2_content.contains("@echo off"));
assert!(term2_content.contains("title Database Migrations"));
assert!(term2_content.contains("call cargo run --bin migrate"));
assert!(term2_content.contains("exit"));
}

#[test]
Expand All @@ -995,4 +952,25 @@ mod tests {
assert!(preview.contains("launch.sh"));
assert!(preview.contains("launch.bat"));
}

#[test]
fn render_isolates_terminal_workspaces_by_project_id_preventing_collisions() {
let tmp = tempfile::tempdir().unwrap();
let project1 = create_test_project();
let project2 = create_test_project();
assert_ne!(project1.id, project2.id);

let rendered1 = render_for_os(&project1, tmp.path(), TargetOs::MacOs).unwrap();
let rendered2 = render_for_os(&project2, tmp.path(), TargetOs::MacOs).unwrap();

assert_ne!(rendered1.entry_path, rendered2.entry_path);
assert!(rendered1.entry_path.is_file());
assert!(rendered2.entry_path.is_file());
assert!(rendered1
.entry_path
.starts_with(tmp.path().join(project1.id.to_string())));
assert!(rendered2
.entry_path
.starts_with(tmp.path().join(project2.id.to_string())));
}
}
42 changes: 37 additions & 5 deletions crates/codelaunch-core/src/model/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ use super::project::Project;
pub enum ValidationError {
#[error("project name must not be empty")]
EmptyName,
#[error("folder name must not be empty")]
EmptyFolderName,
#[error("duplicate folder name: {0}")]
DuplicateFolderName(String),
#[error("terminal label must not be empty")]
EmptyTerminalLabel,
#[error("terminal {0} references a folder that does not exist in this project")]
DanglingFolderReference(String),
#[error("duplicate terminal label: {0} (VS Code task labels must be unique project-wide)")]
Expand All @@ -26,21 +30,26 @@ pub fn validate(project: &Project) -> Result<(), Vec<ValidationError>> {

let mut seen_names = std::collections::HashSet::new();
for folder in &project.folders {
if !seen_names.insert(&folder.name) {
if folder.name.trim().is_empty() {
errors.push(ValidationError::EmptyFolderName);
} else if !seen_names.insert(&folder.name) {
errors.push(ValidationError::DuplicateFolderName(folder.name.clone()));
}
}

let mut seen_labels = std::collections::HashSet::new();
for group in &project.terminal_groups {
for terminal in &group.terminals {
if project.find_folder(terminal.folder_id).is_none() {
errors.push(ValidationError::DanglingFolderReference(
if terminal.label.trim().is_empty() {
errors.push(ValidationError::EmptyTerminalLabel);
} else if !seen_labels.insert(&terminal.label) {
errors.push(ValidationError::DuplicateTerminalLabel(
terminal.label.clone(),
));
}
if !seen_labels.insert(&terminal.label) {
errors.push(ValidationError::DuplicateTerminalLabel(

if project.find_folder(terminal.folder_id).is_none() {
errors.push(ValidationError::DanglingFolderReference(
terminal.label.clone(),
));
}
Expand Down Expand Up @@ -118,4 +127,27 @@ mod tests {

assert!(validate(&project).is_ok());
}

#[test]
fn rejects_empty_folder_name() {
let mut project = Project::new("Test", IdeKind::VsCode);
project.folders.push(Folder::new(" ", "/tmp/backend"));
let errors = validate(&project).unwrap_err();
assert_eq!(errors, vec![ValidationError::EmptyFolderName]);
}

#[test]
fn rejects_empty_terminal_label() {
let mut project = Project::new("Test", IdeKind::VsCode);
let folder = Folder::new("BACKEND", "/tmp/backend");
let folder_id = folder.id;
project.folders.push(folder);

let mut group = TerminalGroup::new("group1", 0);
group.terminals.push(Terminal::new(" ", folder_id, 0));
project.terminal_groups.push(group);

let errors = validate(&project).unwrap_err();
assert_eq!(errors, vec![ValidationError::EmptyTerminalLabel]);
}
}
108 changes: 102 additions & 6 deletions crates/codelaunch-core/src/storage/project_repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,66 @@ impl FsProjectRepository {
}
}

#[derive(serde::Deserialize)]
struct ProjectListEntry {
id: Uuid,
name: String,
#[serde(default)]
group: Option<String>,
ide: crate::model::IdeKind,
#[serde(default)]
folders: Vec<serde::de::IgnoredAny>,
#[serde(default)]
terminal_groups: Vec<serde::de::IgnoredAny>,
updated_at: chrono::DateTime<chrono::Utc>,
}

impl ProjectRepository for FsProjectRepository {
fn list(&self) -> Result<Vec<ProjectSummary>> {
let mut summaries = Vec::new();
for entry in fs::read_dir(&self.base_dir)? {
let entry = entry?;
let Ok(entry) = entry else {
continue;
};
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let raw = fs::read_to_string(&path)?;
let value: serde_json::Value = serde_json::from_str(&raw)?;
let raw = match fs::read_to_string(&path) {
Ok(content) => content,
Err(err) => {
eprintln!("Failed to read project file {}: {}", path.display(), err);
continue;
}
};
let value: serde_json::Value = match serde_json::from_str(&raw) {
Ok(val) => val,
Err(err) => {
eprintln!("Failed to parse JSON in {}: {}", path.display(), err);
continue;
}
};
let value = migrate_schema(value);
let project: Project = serde_json::from_value(value)?;
summaries.push(ProjectSummary::from(&project));
match serde_json::from_value::<ProjectListEntry>(value) {
Ok(entry) => {
summaries.push(ProjectSummary {
id: entry.id,
name: entry.name,
group: entry.group,
ide: entry.ide,
folder_count: entry.folders.len(),
terminal_group_count: entry.terminal_groups.len(),
updated_at: entry.updated_at,
});
}
Err(err) => {
eprintln!(
"Failed to deserialize project summary in {}: {}",
path.display(),
err
);
}
}
}
summaries.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
Ok(summaries)
Expand All @@ -75,8 +121,16 @@ impl ProjectRepository for FsProjectRepository {

fn save(&self, project: &Project) -> Result<()> {
let path = self.path_for(project.id);
let tmp_path = self.base_dir.join(format!("{}.json.tmp", project.id));
let json = serde_json::to_string_pretty(project)?;
fs::write(path, json)?;
if let Err(err) = fs::write(&tmp_path, json) {
let _ = fs::remove_file(&tmp_path);
return Err(err.into());
}
if let Err(err) = fs::rename(&tmp_path, &path) {
let _ = fs::remove_file(&tmp_path);
return Err(err.into());
}
Ok(())
}

Expand All @@ -85,6 +139,10 @@ impl ProjectRepository for FsProjectRepository {
if path.exists() {
fs::remove_file(path)?;
}
let tmp_path = self.base_dir.join(format!("{id}.json.tmp"));
if tmp_path.exists() {
let _ = fs::remove_file(tmp_path);
}
Ok(())
}
}
Expand Down Expand Up @@ -112,11 +170,49 @@ mod tests {
let summaries = repo.list().unwrap();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].id, project.id);
assert_eq!(summaries[0].folder_count, 0);
assert_eq!(summaries[0].terminal_group_count, 0);

repo.delete(project.id).unwrap();
assert!(repo.load(project.id).is_err());
}

#[test]
fn save_is_atomic_and_cleans_up_tmp_file() {
let tmp = tempfile::tempdir().unwrap();
let repo = FsProjectRepository::new(tmp.path()).unwrap();

let project = Project::new("Atomic Project", IdeKind::VsCode);
repo.save(&project).unwrap();

let final_path = tmp.path().join(format!("{}.json", project.id));
let tmp_path = tmp.path().join(format!("{}.json.tmp", project.id));
assert!(final_path.is_file());
assert!(!tmp_path.exists());
}

#[test]
fn list_skips_corrupted_json_files_gracefully() {
let tmp = tempfile::tempdir().unwrap();
let repo = FsProjectRepository::new(tmp.path()).unwrap();

let valid_project = Project::new("Healthy Project", IdeKind::VsCode);
repo.save(&valid_project).unwrap();

// Write a corrupt JSON file in the same repository directory
let corrupt_path = tmp.path().join("corrupt-uuid.json");
fs::write(corrupt_path, "{ broken json content ...").unwrap();

// Write a non-json file that should also be ignored
let text_path = tmp.path().join("notes.txt");
fs::write(text_path, "some notes").unwrap();

let summaries = repo.list().unwrap();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].id, valid_project.id);
assert_eq!(summaries[0].name, "Healthy Project");
}

#[test]
fn load_missing_project_errors() {
let tmp = tempfile::tempdir().unwrap();
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build": "tsc -b && vite build",
"preview": "vite preview",
"tauri": "tauri",
"prepare": "husky"
Expand Down
Loading
Loading