diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3ef1055..22021cc 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -8,6 +8,7 @@ use crate::bridge_manager::SharedBridgeManager; use crate::config::{ClaudeStartOptions, HikariConfig}; use crate::stats::UsageStats; use crate::temp_manager::SharedTempFileManager; +use crate::utils::normalize_path_separators; const CONFIG_STORE_KEY: &str = "config"; @@ -126,7 +127,7 @@ pub async fn validate_directory( // Expand ~ to home directory let expanded_path = if path.starts_with("~") { - if let Some(home) = std::env::var_os("HOME") { + if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) { let home_path = Path::new(&home); if path == Path::new("~") { home_path.to_path_buf() @@ -165,14 +166,7 @@ pub async fn validate_directory( // Return the canonicalized (absolute) path with forward slashes expanded_path .canonicalize() - .map(|p| { - // Convert to string and normalize path separators to forward slashes - let path_str = p.to_string_lossy().to_string(); - // On Windows, replace backslashes with forward slashes - #[cfg(target_os = "windows")] - let path_str = path_str.replace('\\', "/"); - path_str - }) + .map(|p| normalize_path_separators(&p.to_string_lossy())) .map_err(|e| format!("Failed to resolve path: {}", e)) } @@ -212,9 +206,10 @@ pub async fn list_skills() -> Result, String> { use std::fs; use std::path::Path; - // Get the home directory - let home = - std::env::var_os("HOME").ok_or_else(|| "Could not determine home directory".to_string())?; + // Get the home directory - use HOME on Unix, USERPROFILE on Windows + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .ok_or_else(|| "Could not determine home directory".to_string())?; let skills_dir = Path::new(&home).join(".claude").join("skills"); @@ -444,10 +439,7 @@ pub async fn list_directory(path: String) -> Result, String> { continue; } - let path_str = path.to_string_lossy().to_string(); - // On Windows, replace backslashes with forward slashes - #[cfg(target_os = "windows")] - let path_str = path_str.replace('\\', "/"); + let path_str = normalize_path_separators(&path.to_string_lossy()); file_entries.push(FileEntry { name, @@ -826,4 +818,25 @@ mod tests { assert!(json.contains("/tmp/test.txt")); assert!(json.contains("test.txt")); } + + #[test] + fn test_validate_directory_normalizes_backslashes() { + // This test ensures that paths with backslashes are normalized + // Create a temp directory for testing + let temp_dir = TempDir::new().unwrap(); + + // On Windows, the canonicalized path will have backslashes + // Our normalize_path_separators should convert them to forward slashes + let result = run_async(validate_directory( + temp_dir.path().to_string_lossy().to_string(), + None, + )); + + assert!(result.is_ok()); + let normalized_path = result.unwrap(); + + // The result should never contain backslashes + assert!(!normalized_path.contains('\\'), + "Path should not contain backslashes: {}", normalized_path); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6db2820..f4f442b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod stats; mod temp_manager; mod tray; mod types; +mod utils; mod vbs_notification; mod windows_toast; mod wsl_bridge; diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs new file mode 100644 index 0000000..8aee403 --- /dev/null +++ b/src-tauri/src/utils.rs @@ -0,0 +1,71 @@ +/// Utility functions for cross-platform compatibility + +/// Normalize path separators to forward slashes for consistent handling across platforms +/// This always normalizes backslashes to forward slashes, regardless of platform, +/// because Windows paths may be passed to WSL which expects Unix-style paths +pub fn normalize_path_separators(path: &str) -> String { + path.replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(target_os = "windows")] + fn test_normalize_path_windows() { + assert_eq!(normalize_path_separators("C:\\Users\\test"), "C:/Users/test"); + assert_eq!(normalize_path_separators("path\\to\\file"), "path/to/file"); + assert_eq!(normalize_path_separators("already/forward"), "already/forward"); + assert_eq!(normalize_path_separators("mixed\\path/file"), "mixed/path/file"); + } + + #[test] + #[cfg(not(target_os = "windows"))] + fn test_normalize_path_unix() { + assert_eq!(normalize_path_separators("/home/user"), "/home/user"); + assert_eq!(normalize_path_separators("path/to/file"), "path/to/file"); + // Even on Unix, we normalize backslashes since paths may come from Windows + assert_eq!(normalize_path_separators("weird\\path"), "weird/path"); + assert_eq!(normalize_path_separators("/home/user\\file"), "/home/user/file"); + } + + #[test] + fn test_normalize_wsl_paths() { + // Test the exact issue that was happening + assert_eq!( + normalize_path_separators("/home/naomi/code/naomi/portfolio\\.."), + "/home/naomi/code/naomi/portfolio/.." + ); + + // Test mixed separators in WSL paths + assert_eq!( + normalize_path_separators("/mnt/c\\Users\\naomi\\Documents"), + "/mnt/c/Users/naomi/Documents" + ); + + // Test Windows paths that might be passed to WSL + assert_eq!( + normalize_path_separators("C:\\Users\\naomi\\.claude\\skills"), + "C:/Users/naomi/.claude/skills" + ); + + // Test that forward slashes remain unchanged + assert_eq!( + normalize_path_separators("/home/naomi/.claude/skills"), + "/home/naomi/.claude/skills" + ); + + // Test empty string + assert_eq!(normalize_path_separators(""), ""); + + // Test single backslash + assert_eq!(normalize_path_separators("\\"), "/"); + + // Test multiple consecutive backslashes + assert_eq!( + normalize_path_separators("path\\\\to\\\\file"), + "path//to//file" + ); + } +} \ No newline at end of file diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index d1062ab..39005b7 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -16,6 +16,7 @@ use crate::types::{ PermissionPromptEvent, QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent, WorkingDirectoryEvent, }; +use crate::utils::normalize_path_separators; use parking_lot::RwLock; const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]; @@ -137,11 +138,7 @@ impl WslBridge { let working_dir = &options.working_dir; // Normalize path separators to forward slashes - #[cfg(target_os = "windows")] - let normalized_dir = working_dir.replace('\\', "/"); - #[cfg(not(target_os = "windows"))] - let normalized_dir = working_dir.clone(); - self.working_directory = normalized_dir; + self.working_directory = normalize_path_separators(working_dir); emit_connection_status( &app, diff --git a/src/lib/stores/conversations.ts b/src/lib/stores/conversations.ts index ab3b1a3..8de729d 100644 --- a/src/lib/stores/conversations.ts +++ b/src/lib/stores/conversations.ts @@ -8,6 +8,7 @@ import type { } from "$lib/types/messages"; import type { CharacterState } from "$lib/types/states"; import { cleanupConversationTracking } from "$lib/tauri"; +import { normalizePath } from "$lib/utils/paths"; import { characterState } from "$lib/stores/character"; import { sessionsStore } from "$lib/stores/sessions"; @@ -388,7 +389,7 @@ function createConversationsStore() { conversations.update((convs) => { const conv = convs.get(activeId); if (conv) { - conv.workingDirectory = dir; + conv.workingDirectory = normalizePath(dir); conv.lastActivityAt = new Date(); } return convs; @@ -399,7 +400,7 @@ function createConversationsStore() { conversations.update((convs) => { const conv = convs.get(conversationId); if (conv) { - conv.workingDirectory = dir; + conv.workingDirectory = normalizePath(dir); conv.lastActivityAt = new Date(); } return convs; diff --git a/src/lib/stores/editor.ts b/src/lib/stores/editor.ts index 7ed374e..745330b 100644 --- a/src/lib/stores/editor.ts +++ b/src/lib/stores/editor.ts @@ -1,6 +1,7 @@ import { writable, derived, get } from "svelte/store"; import { invoke } from "@tauri-apps/api/core"; import type { EditorState, EditorTab, FileEntry } from "$lib/types/editor"; +import { normalizePath, joinPath, getParentPath, getFilename } from "$lib/utils/paths"; const defaultState: EditorState = { tabs: [], @@ -158,7 +159,7 @@ function createEditorStore() { try { const content = await invoke("read_file_content", { path: filePath }); - const fileName = filePath.split(/[/\\]/).pop() || "untitled"; + const fileName = getFilename(filePath) || "untitled"; const language = getLanguageFromPath(filePath); const newTab: EditorTab = { id: generateTabId(), @@ -247,7 +248,7 @@ function createEditorStore() { } async function createFile(parentPath: string, fileName: string): Promise { - const filePath = `${parentPath}/${fileName}`; + const filePath = joinPath(parentPath, fileName); try { await invoke("create_file", { path: filePath }); // Refresh the parent directory @@ -261,7 +262,7 @@ function createEditorStore() { } async function createDirectory(parentPath: string, dirName: string): Promise { - const dirPath = `${parentPath}/${dirName}`; + const dirPath = joinPath(parentPath, dirName); try { await invoke("create_directory", { path: dirPath }); // Refresh the parent directory @@ -284,7 +285,7 @@ function createEditorStore() { closeTab(openTab.id); } // Refresh the parent directory - const parentPath = filePath.substring(0, filePath.lastIndexOf("/")); + const parentPath = getParentPath(filePath); await refreshDirectory(parentPath); return true; } catch (error) { @@ -299,10 +300,11 @@ function createEditorStore() { await invoke("delete_directory", { path: dirPath }); // Close any tabs that are in this directory const currentState = get(state); - const tabsToClose = currentState.tabs.filter((t) => t.filePath.startsWith(dirPath + "/")); + const normalizedDirPath = normalizePath(dirPath); + const tabsToClose = currentState.tabs.filter((t) => normalizePath(t.filePath).startsWith(normalizedDirPath + "/")); tabsToClose.forEach((tab) => closeTab(tab.id)); // Refresh the parent directory - const parentPath = dirPath.substring(0, dirPath.lastIndexOf("/")); + const parentPath = getParentPath(dirPath); await refreshDirectory(parentPath); return true; } catch (error) { @@ -341,8 +343,8 @@ function createEditorStore() { } async function renamePath(oldPath: string, newName: string): Promise { - const parentPath = oldPath.substring(0, oldPath.lastIndexOf("/")); - const newPath = `${parentPath}/${newName}`; + const parentPath = getParentPath(oldPath); + const newPath = joinPath(parentPath, newName); try { await invoke("rename_path", { oldPath, newPath }); @@ -359,12 +361,14 @@ function createEditorStore() { fileName: newName, }; } - if (t.filePath.startsWith(oldPath + "/")) { + const normalizedOldPath = normalizePath(oldPath); + const normalizedFilePath = normalizePath(t.filePath); + if (normalizedFilePath.startsWith(normalizedOldPath + "/")) { // File is inside a renamed directory - const relativePath = t.filePath.substring(oldPath.length); + const relativePath = normalizedFilePath.substring(normalizedOldPath.length); return { ...t, - filePath: newPath + relativePath, + filePath: normalizePath(newPath + relativePath), }; } return t; diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index d14ded8..61630ca 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -18,6 +18,7 @@ import { handleConnectionStatusChange, handleNewUserMessage, } from "$lib/notifications/rules"; +import { normalizePath } from "$lib/utils/paths"; interface StateChangePayload { state: CharacterState; @@ -290,12 +291,15 @@ export async function initializeTauriListeners() { const cwdUnlisten = await listen("claude:cwd", (event) => { const { directory, conversation_id } = event.payload; + // Normalize path separators to forward slashes + const normalizedDirectory = normalizePath(directory); + // Store working directory for the correct conversation if (conversation_id) { - claudeStore.setWorkingDirectoryForConversation(conversation_id, directory); + claudeStore.setWorkingDirectoryForConversation(conversation_id, normalizedDirectory); } else { // Fallback to active conversation if no conversation_id - claudeStore.setWorkingDirectory(directory); + claudeStore.setWorkingDirectory(normalizedDirectory); } }); unlisteners.push(cwdUnlisten); diff --git a/src/lib/utils/paths.test.ts b/src/lib/utils/paths.test.ts new file mode 100644 index 0000000..5e05ccc --- /dev/null +++ b/src/lib/utils/paths.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { normalizePath, joinPath, getParentPath, getFilename } from "./paths"; + +describe("paths utilities", () => { + describe("normalizePath", () => { + it("should normalize Windows paths", () => { + expect(normalizePath("C:\\Users\\test")).toBe("C:/Users/test"); + expect(normalizePath("path\\to\\file")).toBe("path/to/file"); + expect(normalizePath("C:\\Users\\test\\file.txt")).toBe("C:/Users/test/file.txt"); + }); + + it("should leave Unix paths unchanged", () => { + expect(normalizePath("/home/user")).toBe("/home/user"); + expect(normalizePath("path/to/file")).toBe("path/to/file"); + expect(normalizePath("/usr/local/bin")).toBe("/usr/local/bin"); + }); + + it("should handle mixed separators", () => { + expect(normalizePath("mixed\\path/file")).toBe("mixed/path/file"); + expect(normalizePath("C:\\Users/test\\mixed/path")).toBe("C:/Users/test/mixed/path"); + }); + + it("should handle empty strings", () => { + expect(normalizePath("")).toBe(""); + }); + }); + + describe("joinPath", () => { + it("should join path segments with forward slashes", () => { + expect(joinPath("parent", "child")).toBe("parent/child"); + expect(joinPath("/home", "user", "documents")).toBe("/home/user/documents"); + expect(joinPath("C:", "Users", "test")).toBe("C:/Users/test"); + }); + + it("should filter out empty segments", () => { + expect(joinPath("parent", "", "child")).toBe("parent/child"); + expect(joinPath("", "home", "")).toBe("home"); + expect(joinPath("", "", "")).toBe(""); + }); + + it("should normalize mixed separators in segments", () => { + expect(joinPath("parent\\dir", "child")).toBe("parent/dir/child"); + expect(joinPath("C:\\Users", "test\\file")).toBe("C:/Users/test/file"); + }); + }); + + describe("getParentPath", () => { + it("should get parent directory of Unix paths", () => { + expect(getParentPath("/home/user/file.txt")).toBe("/home/user"); + expect(getParentPath("/home/user/")).toBe("/home"); + expect(getParentPath("/home")).toBe("/"); + expect(getParentPath("/")).toBe("/"); + }); + + it("should get parent directory of Windows paths", () => { + expect(getParentPath("C:\\Users\\test\\file.txt")).toBe("C:/Users/test"); + expect(getParentPath("C:\\Users\\test")).toBe("C:/Users"); + expect(getParentPath("C:\\")).toBe("C:"); + }); + + it("should handle relative paths", () => { + expect(getParentPath("parent/child/file.txt")).toBe("parent/child"); + expect(getParentPath("file.txt")).toBe("."); + expect(getParentPath("")).toBe("."); + }); + + it("should handle paths with trailing slashes", () => { + expect(getParentPath("/home/user/")).toBe("/home"); + expect(getParentPath("parent/child/")).toBe("parent"); + }); + }); + + describe("getFilename", () => { + it("should extract filename from Unix paths", () => { + expect(getFilename("/home/user/file.txt")).toBe("file.txt"); + expect(getFilename("/usr/local/bin/node")).toBe("node"); + expect(getFilename("/home/user/")).toBe(""); + }); + + it("should extract filename from Windows paths", () => { + expect(getFilename("C:\\Users\\test\\file.txt")).toBe("file.txt"); + expect(getFilename("C:\\Program Files\\app.exe")).toBe("app.exe"); + expect(getFilename("D:\\folder\\")).toBe(""); + }); + + it("should handle relative paths", () => { + expect(getFilename("parent/child/file.txt")).toBe("file.txt"); + expect(getFilename("file.txt")).toBe("file.txt"); + expect(getFilename("")).toBe(""); + }); + + it("should handle paths without filename", () => { + expect(getFilename("/home/user/")).toBe(""); + expect(getFilename("folder/")).toBe(""); + expect(getFilename("/")).toBe(""); + }); + }); +}); \ No newline at end of file diff --git a/src/lib/utils/paths.ts b/src/lib/utils/paths.ts new file mode 100644 index 0000000..85d0689 --- /dev/null +++ b/src/lib/utils/paths.ts @@ -0,0 +1,67 @@ +/** + * Normalize path separators to forward slashes for consistent handling across platforms + */ +export function normalizePath(path: string): string { + // Replace all backslashes with forward slashes + return path.replace(/\\/g, "/"); +} + +/** + * Join path segments with forward slashes + */ +export function joinPath(...segments: string[]): string { + // Filter out empty segments + const filtered = segments.filter((s) => s); + // Join with forward slash and normalize + return normalizePath(filtered.join("/")); +} + +/** + * Get the parent directory of a path + */ +export function getParentPath(path: string): string { + let normalized = normalizePath(path); + + // Remove trailing slash if present (unless it's the root) + if (normalized.endsWith("/") && normalized.length > 1) { + normalized = normalized.slice(0, -1); + } + + // Handle Windows drive roots (e.g., "C:" or "C:/") + if (/^[A-Za-z]:$/.test(normalized)) { + return normalized; // Drive root has no parent + } + + const lastSlash = normalized.lastIndexOf("/"); + if (lastSlash === -1) { + // No slash found - could be just a filename or Windows drive letter + if (/^[A-Za-z]:/.test(normalized)) { + // It's a Windows path like "C:file.txt" - parent is "C:" + return normalized.substring(0, 2); + } + return "."; + } + if (lastSlash === 0) { + return "/"; + } + + // Check if parent would be a Windows drive root + const parent = normalized.substring(0, lastSlash); + if (/^[A-Za-z]:$/.test(parent)) { + return parent; + } + + return parent; +} + +/** + * Get the filename from a path + */ +export function getFilename(path: string): string { + const normalized = normalizePath(path); + const lastSlash = normalized.lastIndexOf("/"); + if (lastSlash === -1) { + return normalized; + } + return normalized.substring(lastSlash + 1); +} \ No newline at end of file