feat: implement path normalization across commands
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
use crate::{DesktopRuntime, SettingsStore};
|
use crate::{DesktopRuntime, SettingsStore};
|
||||||
|
use crate::path_utils::expand_tilde_path;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashSet, VecDeque},
|
collections::{HashSet, VecDeque},
|
||||||
@@ -346,7 +347,7 @@ async fn resolve_sandboxed_path(
|
|||||||
.filter(|value| !value.is_empty());
|
.filter(|value| !value.is_empty());
|
||||||
|
|
||||||
let candidate_path = match (candidate_input, workspace_root) {
|
let candidate_path = match (candidate_input, workspace_root) {
|
||||||
(Some(value), _) => PathBuf::from(value),
|
(Some(value), _) => expand_tilde_path(value),
|
||||||
(None, Some(root)) => root.clone(),
|
(None, Some(root)) => root.clone(),
|
||||||
(None, None) => default_home_directory(),
|
(None, None) => default_home_directory(),
|
||||||
};
|
};
|
||||||
@@ -376,7 +377,7 @@ async fn resolve_creatable_path(
|
|||||||
path: &str,
|
path: &str,
|
||||||
workspace_root: Option<&PathBuf>,
|
workspace_root: Option<&PathBuf>,
|
||||||
) -> Result<PathBuf, FsCommandError> {
|
) -> Result<PathBuf, FsCommandError> {
|
||||||
let candidate = PathBuf::from(path);
|
let candidate = expand_tilde_path(path);
|
||||||
if candidate.as_os_str().is_empty() {
|
if candidate.as_os_str().is_empty() {
|
||||||
return Err(FsCommandError::Other("Path is required".to_string()));
|
return Err(FsCommandError::Other("Path is required".to_string()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::{DesktopRuntime, SettingsStore};
|
use crate::{DesktopRuntime, SettingsStore};
|
||||||
|
use crate::path_utils::expand_tilde_path;
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
@@ -357,7 +358,7 @@ fn append_git_option_map(args: &mut Vec<String>, map: &serde_json::Map<String, V
|
|||||||
// Removed unused resolve_workspace_root function
|
// Removed unused resolve_workspace_root function
|
||||||
|
|
||||||
async fn validate_git_path(path: &str, _settings: &SettingsStore) -> Result<PathBuf> {
|
async fn validate_git_path(path: &str, _settings: &SettingsStore) -> Result<PathBuf> {
|
||||||
let path_buf = PathBuf::from(path);
|
let path_buf = expand_tilde_path(path);
|
||||||
if !path_buf.exists() {
|
if !path_buf.exists() {
|
||||||
return Err(anyhow!("Directory does not exist: {}", path));
|
return Err(anyhow!("Directory does not exist: {}", path));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use tauri::AppHandle;
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::DesktopRuntime;
|
use crate::DesktopRuntime;
|
||||||
|
use crate::path_utils::expand_tilde_path;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -34,10 +35,12 @@ pub async fn process_directory_selection(
|
|||||||
path: String,
|
path: String,
|
||||||
state: State<'_, DesktopRuntime>,
|
state: State<'_, DesktopRuntime>,
|
||||||
) -> Result<DirectoryPermissionResult, String> {
|
) -> Result<DirectoryPermissionResult, String> {
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
// Validate directory exists
|
// Validate directory exists
|
||||||
let path_buf = PathBuf::from(&path);
|
let mut path_buf = expand_tilde_path(&path);
|
||||||
|
if let Ok(canonicalized) = std::fs::canonicalize(&path_buf) {
|
||||||
|
path_buf = canonicalized;
|
||||||
|
}
|
||||||
|
let normalized_path = path_buf.to_string_lossy().to_string();
|
||||||
if !path_buf.exists() {
|
if !path_buf.exists() {
|
||||||
return Ok(DirectoryPermissionResult {
|
return Ok(DirectoryPermissionResult {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -64,7 +67,7 @@ pub async fn process_directory_selection(
|
|||||||
if let Some(obj) = settings.as_object_mut() {
|
if let Some(obj) = settings.as_object_mut() {
|
||||||
obj.insert(
|
obj.insert(
|
||||||
"lastDirectory".to_string(),
|
"lastDirectory".to_string(),
|
||||||
serde_json::Value::String(path.clone()),
|
serde_json::Value::String(normalized_path.clone()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,12 +79,12 @@ pub async fn process_directory_selection(
|
|||||||
|
|
||||||
info!(
|
info!(
|
||||||
"[permissions] Updated settings with lastDirectory: {}",
|
"[permissions] Updated settings with lastDirectory: {}",
|
||||||
path
|
normalized_path
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(DirectoryPermissionResult {
|
Ok(DirectoryPermissionResult {
|
||||||
success: true,
|
success: true,
|
||||||
path: Some(path),
|
path: Some(normalized_path),
|
||||||
error: None,
|
error: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -110,7 +113,11 @@ pub async fn request_directory_access(
|
|||||||
) -> Result<DirectoryPermissionResult, String> {
|
) -> Result<DirectoryPermissionResult, String> {
|
||||||
let path = request.path;
|
let path = request.path;
|
||||||
|
|
||||||
let path_buf = std::path::PathBuf::from(&path);
|
let mut path_buf = expand_tilde_path(&path);
|
||||||
|
if let Ok(canonicalized) = std::fs::canonicalize(&path_buf) {
|
||||||
|
path_buf = canonicalized;
|
||||||
|
}
|
||||||
|
let normalized_path = path_buf.to_string_lossy().to_string();
|
||||||
if !path_buf.exists() {
|
if !path_buf.exists() {
|
||||||
return Ok(DirectoryPermissionResult {
|
return Ok(DirectoryPermissionResult {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -131,7 +138,7 @@ pub async fn request_directory_access(
|
|||||||
match std::fs::read_dir(&path_buf) {
|
match std::fs::read_dir(&path_buf) {
|
||||||
Ok(_) => Ok(DirectoryPermissionResult {
|
Ok(_) => Ok(DirectoryPermissionResult {
|
||||||
success: true,
|
success: true,
|
||||||
path: Some(path),
|
path: Some(normalized_path),
|
||||||
error: None,
|
error: None,
|
||||||
}),
|
}),
|
||||||
Err(e) => Ok(DirectoryPermissionResult {
|
Err(e) => Ok(DirectoryPermissionResult {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::collections::HashSet;
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::DesktopRuntime;
|
use crate::DesktopRuntime;
|
||||||
|
use crate::path_utils::expand_tilde_path;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -105,12 +106,14 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
|||||||
}
|
}
|
||||||
if let Some(Value::String(s)) = obj.get("lastDirectory") {
|
if let Some(Value::String(s)) = obj.get("lastDirectory") {
|
||||||
if !s.is_empty() {
|
if !s.is_empty() {
|
||||||
result_obj.insert("lastDirectory".to_string(), json!(s));
|
let expanded = expand_tilde_path(s).to_string_lossy().to_string();
|
||||||
|
result_obj.insert("lastDirectory".to_string(), json!(expanded));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(Value::String(s)) = obj.get("homeDirectory") {
|
if let Some(Value::String(s)) = obj.get("homeDirectory") {
|
||||||
if !s.is_empty() {
|
if !s.is_empty() {
|
||||||
result_obj.insert("homeDirectory".to_string(), json!(s));
|
let expanded = expand_tilde_path(s).to_string_lossy().to_string();
|
||||||
|
result_obj.insert("homeDirectory".to_string(), json!(expanded));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(Value::String(s)) = obj.get("uiFont") {
|
if let Some(Value::String(s)) = obj.get("uiFont") {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ mod session_activity;
|
|||||||
mod opencode_config;
|
mod opencode_config;
|
||||||
mod opencode_manager;
|
mod opencode_manager;
|
||||||
mod window_state;
|
mod window_state;
|
||||||
|
mod path_utils;
|
||||||
|
|
||||||
use std::{collections::HashMap, path::PathBuf, sync::Arc, time::{Duration, Instant}};
|
use std::{collections::HashMap, path::PathBuf, sync::Arc, time::{Duration, Instant}};
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ use tokio::{
|
|||||||
};
|
};
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
use window_state::{load_window_state, persist_window_state, WindowStateManager};
|
use window_state::{load_window_state, persist_window_state, WindowStateManager};
|
||||||
|
use path_utils::expand_tilde_path;
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -1485,7 +1487,10 @@ async fn change_directory_handler(
|
|||||||
return Err(StatusCode::BAD_REQUEST);
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolved_path = PathBuf::from(requested_path);
|
let mut resolved_path = expand_tilde_path(requested_path);
|
||||||
|
if !resolved_path.is_absolute() {
|
||||||
|
resolved_path = state.opencode.get_working_directory().join(resolved_path);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate directory exists and is accessible
|
// Validate directory exists and is accessible
|
||||||
match fs::metadata(&resolved_path).await {
|
match fs::metadata(&resolved_path).await {
|
||||||
@@ -1507,6 +1512,10 @@ async fn change_directory_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Ok(canonicalized) = fs::canonicalize(&resolved_path).await {
|
||||||
|
resolved_path = canonicalized;
|
||||||
|
}
|
||||||
|
|
||||||
let current_dir = state.opencode.get_working_directory();
|
let current_dir = state.opencode.get_working_directory();
|
||||||
let is_running = state.opencode.current_port().is_some();
|
let is_running = state.opencode.current_port().is_some();
|
||||||
|
|
||||||
@@ -1682,7 +1691,7 @@ impl SettingsStore {
|
|||||||
.and_then(|value| value.as_str())
|
.and_then(|value| value.as_str())
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.map(PathBuf::from);
|
.map(expand_tilde_path);
|
||||||
Ok(candidate)
|
Ok(candidate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub fn expand_tilde_path(value: &str) -> PathBuf {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return PathBuf::from(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
|
||||||
|
|
||||||
|
if trimmed == "~" {
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed.starts_with("~/") || trimmed.starts_with("~\\") {
|
||||||
|
return home.join(&trimmed[2..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
PathBuf::from(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -79,6 +79,34 @@ const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeCon
|
|||||||
|
|
||||||
const normalizeFsPath = (value: string) => value.replace(/\\/g, '/');
|
const normalizeFsPath = (value: string) => value.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
const expandTildePath = (value: string) => {
|
||||||
|
const trimmed = (value || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === '~') {
|
||||||
|
return os.homedir();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
||||||
|
return path.join(os.homedir(), trimmed.slice(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveUserPath = (value: string, baseDirectory: string) => {
|
||||||
|
const expanded = expandTildePath(value);
|
||||||
|
if (!expanded) {
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
|
if (path.isAbsolute(expanded)) {
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
|
return path.resolve(baseDirectory, expanded);
|
||||||
|
};
|
||||||
|
|
||||||
const listDirectoryEntries = async (dirPath: string) => {
|
const listDirectoryEntries = async (dirPath: string) => {
|
||||||
const uri = vscode.Uri.file(dirPath);
|
const uri = vscode.Uri.file(dirPath);
|
||||||
const entries = await vscode.workspace.fs.readDirectory(uri);
|
const entries = await vscode.workspace.fs.readDirectory(uri);
|
||||||
@@ -187,7 +215,10 @@ const searchFilesystemFiles = async (rootPath: string, query: string, limit: num
|
|||||||
};
|
};
|
||||||
|
|
||||||
const searchDirectory = async (directory: string, query: string, limit = 60) => {
|
const searchDirectory = async (directory: string, query: string, limit = 60) => {
|
||||||
const rootPath = directory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||||
|
const rootPath = directory
|
||||||
|
? resolveUserPath(directory, workspaceRoot)
|
||||||
|
: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||||
if (!rootPath) return [];
|
if (!rootPath) return [];
|
||||||
|
|
||||||
const sanitizedQuery = query?.trim() || '';
|
const sanitizedQuery = query?.trim() || '';
|
||||||
@@ -327,14 +358,16 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
|
|
||||||
case 'files:list': {
|
case 'files:list': {
|
||||||
const { path: dirPath } = payload as { path: string };
|
const { path: dirPath } = payload as { path: string };
|
||||||
const uri = vscode.Uri.file(dirPath);
|
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||||
|
const resolvedPath = resolveUserPath(dirPath, workspaceRoot);
|
||||||
|
const uri = vscode.Uri.file(resolvedPath);
|
||||||
const entries = await vscode.workspace.fs.readDirectory(uri);
|
const entries = await vscode.workspace.fs.readDirectory(uri);
|
||||||
const result: FileEntry[] = entries.map(([name, fileType]) => ({
|
const result: FileEntry[] = entries.map(([name, fileType]) => ({
|
||||||
name,
|
name,
|
||||||
path: vscode.Uri.joinPath(uri, name).fsPath,
|
path: vscode.Uri.joinPath(uri, name).fsPath,
|
||||||
isDirectory: fileType === vscode.FileType.Directory,
|
isDirectory: fileType === vscode.FileType.Directory,
|
||||||
}));
|
}));
|
||||||
return { id, type, success: true, data: { directory: dirPath, entries: result } };
|
return { id, type, success: true, data: { directory: normalizeFsPath(resolvedPath), entries: result } };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'files:search': {
|
case 'files:search': {
|
||||||
@@ -360,9 +393,12 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'api:fs:list': {
|
case 'api:fs:list': {
|
||||||
const target = (payload as { path?: string })?.path || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||||
const entries = await listDirectoryEntries(target);
|
const target = (payload as { path?: string })?.path || workspaceRoot;
|
||||||
return { id, type, success: true, data: { entries, directory: target } };
|
const resolvedPath = resolveUserPath(target, workspaceRoot) || workspaceRoot;
|
||||||
|
const entries = await listDirectoryEntries(resolvedPath);
|
||||||
|
const normalized = normalizeFsPath(resolvedPath);
|
||||||
|
return { id, type, success: true, data: { entries, directory: normalized, path: normalized } };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'api:fs:search': {
|
case 'api:fs:search': {
|
||||||
@@ -376,8 +412,10 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
return { id, type, success: false, error: 'Path is required' };
|
return { id, type, success: false, error: 'Path is required' };
|
||||||
}
|
}
|
||||||
await vscode.workspace.fs.createDirectory(vscode.Uri.file(target));
|
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||||
return { id, type, success: true, data: { success: true, path: normalizeFsPath(target) } };
|
const resolvedPath = resolveUserPath(target, workspaceRoot);
|
||||||
|
await vscode.workspace.fs.createDirectory(vscode.Uri.file(resolvedPath));
|
||||||
|
return { id, type, success: true, data: { success: true, path: normalizeFsPath(resolvedPath) } };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'api:fs/home': {
|
case 'api:fs/home': {
|
||||||
@@ -615,7 +653,10 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
return { id, type, success: false, error: 'Path is required' };
|
return { id, type, success: false, error: 'Path is required' };
|
||||||
}
|
}
|
||||||
const result = await ctx?.manager?.setWorkingDirectory(target);
|
const baseDirectory =
|
||||||
|
ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||||
|
const resolvedPath = resolveUserPath(target, baseDirectory);
|
||||||
|
const result = await ctx?.manager?.setWorkingDirectory(resolvedPath);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return { id, type, success: false, error: 'OpenCode manager unavailable' };
|
return { id, type, success: false, error: 'OpenCode manager unavailable' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,27 @@ const FILE_SEARCH_EXCLUDED_DIRS = new Set([
|
|||||||
'logs'
|
'logs'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const normalizeDirectoryPath = (value) => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === '~') {
|
||||||
|
return os.homedir();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
||||||
|
return path.join(os.homedir(), trimmed.slice(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeRelativeSearchPath = (rootPath, targetPath) => {
|
const normalizeRelativeSearchPath = (rootPath, targetPath) => {
|
||||||
const relative = path.relative(rootPath, targetPath) || path.basename(targetPath);
|
const relative = path.relative(rootPath, targetPath) || path.basename(targetPath);
|
||||||
return relative.split(path.sep).join('/') || targetPath;
|
return relative.split(path.sep).join('/') || targetPath;
|
||||||
@@ -2675,8 +2696,11 @@ async function main(options = {}) {
|
|||||||
const worktrees = await getWorktrees(directory);
|
const worktrees = await getWorktrees(directory);
|
||||||
res.json(worktrees);
|
res.json(worktrees);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get worktrees:', error);
|
// Worktrees are an optional feature. Avoid repeated 500s (and repeated client retries)
|
||||||
res.status(500).json({ error: error.message || 'Failed to get worktrees' });
|
// when the directory isn't a git repo or uses shell shorthand like "~/".
|
||||||
|
console.warn('Failed to get worktrees, returning empty list:', error?.message || error);
|
||||||
|
res.setHeader('X-OpenChamber-Warning', 'git worktrees unavailable');
|
||||||
|
res.json([]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2815,15 +2839,17 @@ async function main(options = {}) {
|
|||||||
return res.status(400).json({ error: 'Path is required' });
|
return res.status(400).json({ error: 'Path is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedPath = path.normalize(dirPath);
|
const expandedPath = normalizeDirectoryPath(dirPath);
|
||||||
|
const normalizedPath = path.normalize(expandedPath);
|
||||||
if (normalizedPath.includes('..')) {
|
if (normalizedPath.includes('..')) {
|
||||||
return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
|
return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(dirPath, { recursive: true });
|
const resolvedPath = path.resolve(expandedPath);
|
||||||
console.log(`Created directory: ${dirPath}`);
|
fs.mkdirSync(resolvedPath, { recursive: true });
|
||||||
|
console.log(`Created directory: ${resolvedPath}`);
|
||||||
|
|
||||||
res.json({ success: true, path: dirPath });
|
res.json({ success: true, path: resolvedPath });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create directory:', error);
|
console.error('Failed to create directory:', error);
|
||||||
res.status(500).json({ error: error.message || 'Failed to create directory' });
|
res.status(500).json({ error: error.message || 'Failed to create directory' });
|
||||||
@@ -2837,7 +2863,7 @@ async function main(options = {}) {
|
|||||||
return res.status(400).json({ error: 'Path is required' });
|
return res.status(400).json({ error: 'Path is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedPath = path.resolve(requestedPath);
|
const resolvedPath = path.resolve(normalizeDirectoryPath(requestedPath));
|
||||||
let stats;
|
let stats;
|
||||||
try {
|
try {
|
||||||
stats = await fsPromises.stat(resolvedPath);
|
stats = await fsPromises.stat(resolvedPath);
|
||||||
@@ -2883,7 +2909,7 @@ async function main(options = {}) {
|
|||||||
: os.homedir();
|
: os.homedir();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resolvedPath = path.resolve(rawPath);
|
const resolvedPath = path.resolve(normalizeDirectoryPath(rawPath));
|
||||||
|
|
||||||
const stats = await fsPromises.stat(resolvedPath);
|
const stats = await fsPromises.stat(resolvedPath);
|
||||||
if (!stats.isDirectory()) {
|
if (!stats.isDirectory()) {
|
||||||
@@ -2948,7 +2974,7 @@ async function main(options = {}) {
|
|||||||
const limit = Math.max(1, Math.min(parsedLimit, MAX_FILE_SEARCH_LIMIT));
|
const limit = Math.max(1, Math.min(parsedLimit, MAX_FILE_SEARCH_LIMIT));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resolvedRoot = path.resolve(rawRoot);
|
const resolvedRoot = path.resolve(normalizeDirectoryPath(rawRoot));
|
||||||
const stats = await fsPromises.stat(resolvedRoot);
|
const stats = await fsPromises.stat(resolvedRoot);
|
||||||
if (!stats.isDirectory()) {
|
if (!stats.isDirectory()) {
|
||||||
return res.status(400).json({ error: 'Specified root is not a directory' });
|
return res.status(400).json({ error: 'Specified root is not a directory' });
|
||||||
|
|||||||
@@ -1,27 +1,51 @@
|
|||||||
import simpleGit from 'simple-git';
|
import simpleGit from 'simple-git';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
import { execFile } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
|
|
||||||
const fsp = fs.promises;
|
const fsp = fs.promises;
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const normalizeDirectoryPath = (value) => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === '~') {
|
||||||
|
return os.homedir();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
||||||
|
return path.join(os.homedir(), trimmed.slice(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
export async function isGitRepository(directory) {
|
export async function isGitRepository(directory) {
|
||||||
if (!directory || !fs.existsSync(directory)) {
|
const directoryPath = normalizeDirectoryPath(directory);
|
||||||
|
if (!directoryPath || !fs.existsSync(directoryPath)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gitDir = path.join(directory, '.git');
|
const gitDir = path.join(directoryPath, '.git');
|
||||||
return fs.existsSync(gitDir);
|
return fs.existsSync(gitDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureOpenChamberIgnored(directory) {
|
export async function ensureOpenChamberIgnored(directory) {
|
||||||
if (!directory || !fs.existsSync(directory)) {
|
const directoryPath = normalizeDirectoryPath(directory);
|
||||||
|
if (!directoryPath || !fs.existsSync(directoryPath)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gitDir = path.join(directory, '.git');
|
const gitDir = path.join(directoryPath, '.git');
|
||||||
if (!fs.existsSync(gitDir)) {
|
if (!fs.existsSync(gitDir)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -78,7 +102,7 @@ export async function getGlobalIdentity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCurrentIdentity(directory) {
|
export async function getCurrentIdentity(directory) {
|
||||||
const git = simpleGit(directory);
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
@@ -110,7 +134,7 @@ export async function getCurrentIdentity(directory) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function setLocalIdentity(directory, profile) {
|
export async function setLocalIdentity(directory, profile) {
|
||||||
const git = simpleGit(directory);
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
@@ -134,7 +158,8 @@ export async function setLocalIdentity(directory, profile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getStatus(directory) {
|
export async function getStatus(directory) {
|
||||||
const git = simpleGit(directory);
|
const directoryPath = normalizeDirectoryPath(directory);
|
||||||
|
const git = simpleGit(directoryPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use -uall to show all untracked files individually, not just directories
|
// Use -uall to show all untracked files individually, not just directories
|
||||||
@@ -194,7 +219,7 @@ export async function getStatus(directory) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const absolutePath = path.join(directory, file.path);
|
const absolutePath = path.join(directoryPath, file.path);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stat = await fsp.stat(absolutePath);
|
const stat = await fsp.stat(absolutePath);
|
||||||
@@ -266,7 +291,7 @@ export async function getStatus(directory) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getDiff(directory, { path, staged = false, contextLines = 3 } = {}) {
|
export async function getDiff(directory, { path, staged = false, contextLines = 3 } = {}) {
|
||||||
const git = simpleGit(directory);
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const args = ['diff', '--no-color'];
|
const args = ['diff', '--no-color'];
|
||||||
@@ -338,7 +363,8 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
|
|||||||
throw new Error('directory and path are required for getFileDiff');
|
throw new Error('directory and path are required for getFileDiff');
|
||||||
}
|
}
|
||||||
|
|
||||||
const git = simpleGit(directory);
|
const directoryPath = normalizeDirectoryPath(directory);
|
||||||
|
const git = simpleGit(directoryPath);
|
||||||
const isImage = isImageFile(filePath);
|
const isImage = isImageFile(filePath);
|
||||||
const mimeType = isImage ? getImageMimeType(filePath) : null;
|
const mimeType = isImage ? getImageMimeType(filePath) : null;
|
||||||
|
|
||||||
@@ -348,7 +374,7 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
|
|||||||
// For images, use git show with raw output and convert to base64
|
// For images, use git show with raw output and convert to base64
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync('git', ['show', `HEAD:${filePath}`], {
|
const { stdout } = await execFileAsync('git', ['show', `HEAD:${filePath}`], {
|
||||||
cwd: directory,
|
cwd: directoryPath,
|
||||||
encoding: 'buffer',
|
encoding: 'buffer',
|
||||||
maxBuffer: 50 * 1024 * 1024, // 50MB max
|
maxBuffer: 50 * 1024 * 1024, // 50MB max
|
||||||
});
|
});
|
||||||
@@ -365,7 +391,7 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
|
|||||||
original = '';
|
original = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullPath = path.join(directory, filePath);
|
const fullPath = path.join(directoryPath, filePath);
|
||||||
let modified = '';
|
let modified = '';
|
||||||
try {
|
try {
|
||||||
const stat = await fsp.stat(fullPath);
|
const stat = await fsp.stat(fullPath);
|
||||||
@@ -395,8 +421,9 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function revertFile(directory, filePath) {
|
export async function revertFile(directory, filePath) {
|
||||||
const git = simpleGit(directory);
|
const directoryPath = normalizeDirectoryPath(directory);
|
||||||
const repoRoot = path.resolve(directory);
|
const git = simpleGit(directoryPath);
|
||||||
|
const repoRoot = path.resolve(directoryPath);
|
||||||
const absoluteTarget = path.resolve(repoRoot, filePath);
|
const absoluteTarget = path.resolve(repoRoot, filePath);
|
||||||
|
|
||||||
if (!absoluteTarget.startsWith(repoRoot + path.sep) && absoluteTarget !== repoRoot) {
|
if (!absoluteTarget.startsWith(repoRoot + path.sep) && absoluteTarget !== repoRoot) {
|
||||||
@@ -460,7 +487,7 @@ export async function collectDiffs(directory, files = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function pull(directory, options = {}) {
|
export async function pull(directory, options = {}) {
|
||||||
const git = simpleGit(directory);
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await git.pull(
|
const result = await git.pull(
|
||||||
@@ -483,7 +510,7 @@ export async function pull(directory, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function push(directory, options = {}) {
|
export async function push(directory, options = {}) {
|
||||||
const git = simpleGit(directory);
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await git.push(
|
const result = await git.push(
|
||||||
@@ -510,7 +537,7 @@ export async function deleteRemoteBranch(directory, options = {}) {
|
|||||||
throw new Error('branch is required to delete remote branch');
|
throw new Error('branch is required to delete remote branch');
|
||||||
}
|
}
|
||||||
|
|
||||||
const git = simpleGit(directory);
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
const targetBranch = branch.startsWith('refs/heads/')
|
const targetBranch = branch.startsWith('refs/heads/')
|
||||||
? branch.substring('refs/heads/'.length)
|
? branch.substring('refs/heads/'.length)
|
||||||
: branch;
|
: branch;
|
||||||
@@ -526,7 +553,7 @@ export async function deleteRemoteBranch(directory, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetch(directory, options = {}) {
|
export async function fetch(directory, options = {}) {
|
||||||
const git = simpleGit(directory);
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await git.fetch(
|
await git.fetch(
|
||||||
@@ -543,7 +570,7 @@ export async function fetch(directory, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function commit(directory, message, options = {}) {
|
export async function commit(directory, message, options = {}) {
|
||||||
const git = simpleGit(directory);
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
@@ -573,7 +600,7 @@ export async function commit(directory, message, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getBranches(directory) {
|
export async function getBranches(directory) {
|
||||||
const git = simpleGit(directory);
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await git.branch();
|
const result = await git.branch();
|
||||||
@@ -627,7 +654,7 @@ async function filterActiveRemoteBranches(git, remoteBranches) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createBranch(directory, branchName, options = {}) {
|
export async function createBranch(directory, branchName, options = {}) {
|
||||||
const git = simpleGit(directory);
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await git.checkoutBranch(branchName, options.startPoint || 'HEAD');
|
await git.checkoutBranch(branchName, options.startPoint || 'HEAD');
|
||||||
@@ -639,7 +666,7 @@ export async function createBranch(directory, branchName, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function checkoutBranch(directory, branchName) {
|
export async function checkoutBranch(directory, branchName) {
|
||||||
const git = simpleGit(directory);
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await git.checkout(branchName);
|
await git.checkout(branchName);
|
||||||
@@ -651,7 +678,12 @@ export async function checkoutBranch(directory, branchName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getWorktrees(directory) {
|
export async function getWorktrees(directory) {
|
||||||
const git = simpleGit(directory);
|
const directoryPath = normalizeDirectoryPath(directory);
|
||||||
|
if (!directoryPath || !fs.existsSync(directoryPath) || !fs.existsSync(path.join(directoryPath, '.git'))) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const git = simpleGit(directoryPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await git.raw(['worktree', 'list', '--porcelain']);
|
const result = await git.raw(['worktree', 'list', '--porcelain']);
|
||||||
@@ -684,13 +716,13 @@ export async function getWorktrees(directory) {
|
|||||||
|
|
||||||
return worktrees;
|
return worktrees;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to list worktrees:', error);
|
console.warn('Failed to list worktrees, returning empty list:', error?.message || error);
|
||||||
throw error;
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addWorktree(directory, worktreePath, branch, options = {}) {
|
export async function addWorktree(directory, worktreePath, branch, options = {}) {
|
||||||
const git = simpleGit(directory);
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const args = ['worktree', 'add'];
|
const args = ['worktree', 'add'];
|
||||||
@@ -719,7 +751,7 @@ export async function addWorktree(directory, worktreePath, branch, options = {})
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function removeWorktree(directory, worktreePath, options = {}) {
|
export async function removeWorktree(directory, worktreePath, options = {}) {
|
||||||
const git = simpleGit(directory);
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const args = ['worktree', 'remove', worktreePath];
|
const args = ['worktree', 'remove', worktreePath];
|
||||||
@@ -738,7 +770,7 @@ export async function removeWorktree(directory, worktreePath, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBranch(directory, branch, options = {}) {
|
export async function deleteBranch(directory, branch, options = {}) {
|
||||||
const git = simpleGit(directory);
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const branchName = branch.startsWith('refs/heads/')
|
const branchName = branch.startsWith('refs/heads/')
|
||||||
@@ -754,7 +786,7 @@ export async function deleteBranch(directory, branch, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getLog(directory, options = {}) {
|
export async function getLog(directory, options = {}) {
|
||||||
const git = simpleGit(directory);
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const maxCount = options.maxCount || 50;
|
const maxCount = options.maxCount || 50;
|
||||||
@@ -852,7 +884,7 @@ export async function getLog(directory, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function isLinkedWorktree(directory) {
|
export async function isLinkedWorktree(directory) {
|
||||||
const git = simpleGit(directory);
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
try {
|
try {
|
||||||
const [gitDir, gitCommonDir] = await Promise.all([
|
const [gitDir, gitCommonDir] = await Promise.all([
|
||||||
git.raw(['rev-parse', '--git-dir']).then((output) => output.trim()),
|
git.raw(['rev-parse', '--git-dir']).then((output) => output.trim()),
|
||||||
@@ -866,7 +898,7 @@ export async function isLinkedWorktree(directory) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCommitFiles(directory, commitHash) {
|
export async function getCommitFiles(directory, commitHash) {
|
||||||
const git = simpleGit(directory);
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user