diff --git a/.gitignore b/.gitignore index a757a9427..cc60eeaaf 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ dist-ssr /release-artifacts CodexMonitor.zip .codex-worktrees/ +.codexmonitor/ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6f2831d07..89aa1f74f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -568,6 +568,7 @@ dependencies = [ "fix-path-env", "git2", "ignore", + "libc", "portable-pty", "reqwest", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f00e6e18e..764c66769 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" cpal = "0.15" whisper-rs = "0.12" sha2 = "0.10" +libc = "0.2" [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] tauri-plugin-updater = "2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9b6c3f68e..df2e15ffe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -180,6 +180,12 @@ pub fn run() { codex::account_rate_limits, codex::skills_list, prompts::prompts_list, + prompts::prompts_create, + prompts::prompts_update, + prompts::prompts_delete, + prompts::prompts_move, + prompts::prompts_workspace_dir, + prompts::prompts_global_dir, terminal::terminal_open, terminal::terminal_write, terminal::terminal_resize, diff --git a/src-tauri/src/prompts.rs b/src-tauri/src/prompts.rs index caa09663c..26e541f31 100644 --- a/src-tauri/src/prompts.rs +++ b/src-tauri/src/prompts.rs @@ -1,8 +1,13 @@ use serde::Serialize; +use std::collections::HashMap; use std::env; use std::fs; use std::path::{Path, PathBuf}; use tokio::task; +use tauri::State; + +use crate::state::AppState; +use crate::types::WorkspaceEntry; #[derive(Serialize, Clone)] pub(crate) struct CustomPromptEntry { @@ -12,6 +17,8 @@ pub(crate) struct CustomPromptEntry { #[serde(rename = "argumentHint")] pub(crate) argument_hint: Option, pub(crate) content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) scope: Option, } fn resolve_home_dir() -> Option { @@ -45,6 +52,82 @@ fn default_prompts_dir() -> Option { resolve_codex_home().map(|home| home.join("prompts")) } +fn require_workspace_entry( + workspaces: &HashMap, + workspace_id: &str, +) -> Result { + workspaces + .get(workspace_id) + .cloned() + .ok_or_else(|| "workspace not found".to_string()) +} + +fn app_data_dir(state: &State<'_, AppState>) -> Result { + state + .settings_path + .parent() + .map(|path| path.to_path_buf()) + .ok_or_else(|| "Unable to resolve app data dir.".to_string()) +} + +fn workspace_prompts_dir( + state: &State<'_, AppState>, + entry: &WorkspaceEntry, +) -> Result { + let data_dir = app_data_dir(state)?; + Ok(data_dir + .join("workspaces") + .join(&entry.id) + .join("prompts")) +} + +fn prompt_roots_for_workspace( + state: &State<'_, AppState>, + entry: &WorkspaceEntry, +) -> Result, String> { + let mut roots = Vec::new(); + roots.push(workspace_prompts_dir(state, entry)?); + if let Some(global_dir) = default_prompts_dir() { + roots.push(global_dir); + } + Ok(roots) +} + +fn ensure_path_within_roots(path: &Path, roots: &[PathBuf]) -> Result<(), String> { + let canonical_path = path + .canonicalize() + .map_err(|_| "Invalid prompt path.".to_string())?; + for root in roots { + if let Ok(canonical_root) = root.canonicalize() { + if canonical_path.starts_with(&canonical_root) { + return Ok(()); + } + } + } + Err("Prompt path is not within allowed directories.".to_string()) +} + +#[cfg(unix)] +fn is_cross_device_error(err: &std::io::Error) -> bool { + err.raw_os_error() == Some(libc::EXDEV) +} + +#[cfg(not(unix))] +fn is_cross_device_error(_err: &std::io::Error) -> bool { + false +} + +fn move_file(src: &Path, dest: &Path) -> Result<(), String> { + match fs::rename(src, dest) { + Ok(()) => Ok(()), + Err(err) if is_cross_device_error(&err) => { + fs::copy(src, dest).map_err(|err| err.to_string())?; + fs::remove_file(src).map_err(|err| err.to_string()) + } + Err(err) => Err(err.to_string()), + } +} + fn parse_frontmatter(content: &str) -> (Option, Option, String) { let mut segments = content.split_inclusive('\n'); let Some(first_segment) = segments.next() else { @@ -107,7 +190,54 @@ fn parse_frontmatter(content: &str) -> (Option, Option, String) (description, argument_hint, body) } -fn discover_prompts_in(dir: &Path) -> Vec { +fn build_prompt_contents( + description: Option, + argument_hint: Option, + content: String, +) -> String { + let has_meta = description.as_ref().is_some_and(|value| !value.trim().is_empty()) + || argument_hint + .as_ref() + .is_some_and(|value| !value.trim().is_empty()); + if !has_meta { + return content; + } + let mut output = String::from("---\n"); + if let Some(description) = description { + let trimmed = description.trim(); + if !trimmed.is_empty() { + output.push_str(&format!("description: \"{}\"\n", trimmed.replace('"', "\\\""))); + } + } + if let Some(argument_hint) = argument_hint { + let trimmed = argument_hint.trim(); + if !trimmed.is_empty() { + output.push_str(&format!( + "argument-hint: \"{}\"\n", + trimmed.replace('"', "\\\"") + )); + } + } + output.push_str("---\n"); + output.push_str(&content); + output +} + +fn sanitize_prompt_name(name: &str) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("Prompt name is required.".to_string()); + } + if trimmed.chars().any(|ch| ch.is_whitespace()) { + return Err("Prompt name cannot include whitespace.".to_string()); + } + if trimmed.contains('/') || trimmed.contains('\\') { + return Err("Prompt name cannot include path separators.".to_string()); + } + Ok(trimmed.to_string()) +} + +fn discover_prompts_in(dir: &Path, scope: Option<&str>) -> Vec { let mut out: Vec = Vec::new(); let entries = match fs::read_dir(dir) { Ok(entries) => entries, @@ -146,6 +276,7 @@ fn discover_prompts_in(dir: &Path) -> Vec { description, argument_hint, content: body, + scope: scope.map(|value| value.to_string()), }); } @@ -154,11 +285,227 @@ fn discover_prompts_in(dir: &Path) -> Vec { } #[tauri::command] -pub(crate) async fn prompts_list(_workspace_id: String) -> Result, String> { - let Some(dir) = default_prompts_dir() else { - return Ok(Vec::new()); +pub(crate) async fn prompts_list( + state: State<'_, AppState>, + workspace_id: String, +) -> Result, String> { + let (workspace_dir, global_dir) = { + let workspaces = state.workspaces.lock().await; + let entry = workspaces.get(&workspace_id).cloned(); + let workspace_dir = entry + .as_ref() + .and_then(|entry| workspace_prompts_dir(&state, entry).ok()); + (workspace_dir, default_prompts_dir()) + }; + + task::spawn_blocking(move || { + let mut out = Vec::new(); + if let Some(dir) = workspace_dir { + let _ = fs::create_dir_all(&dir); + out.extend(discover_prompts_in(&dir, Some("workspace"))); + } + if let Some(dir) = global_dir { + let _ = fs::create_dir_all(&dir); + out.extend(discover_prompts_in(&dir, Some("global"))); + } + out + }) + .await + .map_err(|_| "prompt discovery failed".to_string()) +} + +#[tauri::command] +pub(crate) async fn prompts_workspace_dir( + state: State<'_, AppState>, + workspace_id: String, +) -> Result { + let dir = { + let workspaces = state.workspaces.lock().await; + let entry = require_workspace_entry(&workspaces, &workspace_id)?; + workspace_prompts_dir(&state, &entry)? + }; + fs::create_dir_all(&dir).map_err(|err| err.to_string())?; + Ok(dir.to_string_lossy().to_string()) +} + +#[tauri::command] +pub(crate) async fn prompts_global_dir() -> Result { + let dir = default_prompts_dir().ok_or("Unable to resolve CODEX_HOME".to_string())?; + fs::create_dir_all(&dir).map_err(|err| err.to_string())?; + Ok(dir.to_string_lossy().to_string()) +} + +#[tauri::command] +pub(crate) async fn prompts_create( + state: State<'_, AppState>, + workspace_id: String, + scope: String, + name: String, + description: Option, + argument_hint: Option, + content: String, +) -> Result { + let name = sanitize_prompt_name(&name)?; + let (target_dir, resolved_scope) = { + let workspaces = state.workspaces.lock().await; + let entry = require_workspace_entry(&workspaces, &workspace_id)?; + match scope.as_str() { + "workspace" => { + let dir = workspace_prompts_dir(&state, &entry)?; + (dir, "workspace") + } + "global" => { + let dir = default_prompts_dir().ok_or("Unable to resolve CODEX_HOME".to_string())?; + (dir, "global") + } + _ => return Err("Invalid scope.".to_string()), + } }; - task::spawn_blocking(move || discover_prompts_in(&dir)) - .await - .map_err(|_| "prompt discovery failed".to_string()) + let path = target_dir.join(format!("{name}.md")); + if path.exists() { + return Err("Prompt already exists.".to_string()); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| err.to_string())?; + } + let body = build_prompt_contents(description.clone(), argument_hint.clone(), content.clone()); + fs::write(&path, body).map_err(|err| err.to_string())?; + Ok(CustomPromptEntry { + name, + path: path.to_string_lossy().to_string(), + description, + argument_hint, + content, + scope: Some(resolved_scope.to_string()), + }) +} + +#[tauri::command] +pub(crate) async fn prompts_update( + state: State<'_, AppState>, + workspace_id: String, + path: String, + name: String, + description: Option, + argument_hint: Option, + content: String, +) -> Result { + let name = sanitize_prompt_name(&name)?; + let target_path = PathBuf::from(&path); + if !target_path.exists() { + return Err("Prompt not found.".to_string()); + } + { + let workspaces = state.workspaces.lock().await; + let entry = require_workspace_entry(&workspaces, &workspace_id)?; + let roots = prompt_roots_for_workspace(&state, &entry)?; + ensure_path_within_roots(&target_path, &roots)?; + } + let dir = target_path + .parent() + .ok_or("Unable to resolve prompt directory.".to_string())?; + let next_path = dir.join(format!("{name}.md")); + if next_path != target_path && next_path.exists() { + return Err("Prompt with that name already exists.".to_string()); + } + let body = build_prompt_contents(description.clone(), argument_hint.clone(), content.clone()); + fs::write(&next_path, body).map_err(|err| err.to_string())?; + if next_path != target_path { + fs::remove_file(&target_path).map_err(|err| err.to_string())?; + } + let scope = { + let workspaces = state.workspaces.lock().await; + let entry = require_workspace_entry(&workspaces, &workspace_id)?; + let workspace_dir = workspace_prompts_dir(&state, &entry)?; + if next_path.starts_with(&workspace_dir) { + Some("workspace".to_string()) + } else { + Some("global".to_string()) + } + }; + Ok(CustomPromptEntry { + name, + path: next_path.to_string_lossy().to_string(), + description, + argument_hint, + content, + scope, + }) +} + +#[tauri::command] +pub(crate) async fn prompts_delete( + state: State<'_, AppState>, + workspace_id: String, + path: String, +) -> Result<(), String> { + let target = PathBuf::from(path); + if !target.exists() { + return Ok(()); + } + { + let workspaces = state.workspaces.lock().await; + let entry = require_workspace_entry(&workspaces, &workspace_id)?; + let roots = prompt_roots_for_workspace(&state, &entry)?; + ensure_path_within_roots(&target, &roots)?; + } + fs::remove_file(&target).map_err(|err| err.to_string()) +} + +#[tauri::command] +pub(crate) async fn prompts_move( + state: State<'_, AppState>, + workspace_id: String, + path: String, + scope: String, +) -> Result { + let target_path = PathBuf::from(&path); + if !target_path.exists() { + return Err("Prompt not found.".to_string()); + } + let roots = { + let workspaces = state.workspaces.lock().await; + let entry = require_workspace_entry(&workspaces, &workspace_id)?; + prompt_roots_for_workspace(&state, &entry)? + }; + ensure_path_within_roots(&target_path, &roots)?; + let file_name = target_path + .file_name() + .and_then(|value| value.to_str()) + .ok_or("Invalid prompt path.".to_string())?; + let target_dir = { + let workspaces = state.workspaces.lock().await; + let entry = require_workspace_entry(&workspaces, &workspace_id)?; + match scope.as_str() { + "workspace" => workspace_prompts_dir(&state, &entry)?, + "global" => default_prompts_dir().ok_or("Unable to resolve CODEX_HOME".to_string())?, + _ => return Err("Invalid scope.".to_string()), + } + }; + let next_path = target_dir.join(file_name); + if next_path == target_path { + return Err("Prompt is already in that scope.".to_string()); + } + if next_path.exists() { + return Err("Prompt with that name already exists.".to_string()); + } + if let Some(parent) = next_path.parent() { + fs::create_dir_all(parent).map_err(|err| err.to_string())?; + } + move_file(&target_path, &next_path)?; + let content = fs::read_to_string(&next_path).unwrap_or_default(); + let (description, argument_hint, body) = parse_frontmatter(&content); + let name = next_path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_string(); + Ok(CustomPromptEntry { + name, + path: next_path.to_string_lossy().to_string(), + description, + argument_hint, + content: body, + scope: Some(scope), + }) } diff --git a/src/App.tsx b/src/App.tsx index 843ad4eb7..b8fd92bb7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,8 @@ import "./styles/composer.css"; import "./styles/diff.css"; import "./styles/diff-viewer.css"; import "./styles/file-tree.css"; +import "./styles/panel-tabs.css"; +import "./styles/prompts.css"; import "./styles/debug.css"; import "./styles/terminal.css"; import "./styles/plan.css"; @@ -56,6 +58,7 @@ import { useLayoutMode } from "./features/layout/hooks/useLayoutMode"; import { useSidebarToggles } from "./features/layout/hooks/useSidebarToggles"; import { useTransparencyPreference } from "./features/layout/hooks/useTransparencyPreference"; import { useWindowLabel } from "./features/layout/hooks/useWindowLabel"; +import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { RightPanelCollapseButton, SidebarCollapseButton, @@ -162,7 +165,9 @@ function MainApp() { const [gitPanelMode, setGitPanelMode] = useState< "diff" | "log" | "issues" | "prs" >("diff"); - const [filePanelMode, setFilePanelMode] = useState<"git" | "files">("git"); + const [filePanelMode, setFilePanelMode] = useState< + "git" | "files" | "prompts" + >("git"); const [selectedPullRequest, setSelectedPullRequest] = useState(null); const [diffSource, setDiffSource] = useState<"local" | "pr">("local"); @@ -414,7 +419,15 @@ function MainApp() { onDebug: addDebugEntry, }); const { skills } = useSkills({ activeWorkspace, onDebug: addDebugEntry }); - const { prompts } = useCustomPrompts({ activeWorkspace, onDebug: addDebugEntry }); + const { + prompts, + createPrompt, + updatePrompt, + deletePrompt, + movePrompt, + getWorkspacePromptsDir, + getGlobalPromptsDir, + } = useCustomPrompts({ activeWorkspace, onDebug: addDebugEntry }); const { files, isLoading: isFilesLoading } = useWorkspaceFiles({ activeWorkspace, onDebug: addDebugEntry, @@ -583,6 +596,7 @@ function MainApp() { listThreadsForWorkspace, loadOlderThreadsForWorkspace, sendUserMessage, + sendUserMessageToThread, startReview, handleApprovalDecision } = useThreads({ @@ -738,6 +752,114 @@ function MainApp() { }, [activeThreadId] ); + + const handleSendPrompt = useCallback( + (text: string) => { + if (!text.trim()) { + return; + } + void handleSend(text, []); + }, + [handleSend], + ); + + const handleSendPromptToNewAgent = useCallback( + async (text: string) => { + const trimmed = text.trim(); + if (!activeWorkspace || !trimmed) { + return; + } + if (!activeWorkspace.connected) { + await connectWorkspace(activeWorkspace); + } + const threadId = await startThreadForWorkspace(activeWorkspace.id, { + activate: false, + }); + if (!threadId) { + return; + } + await sendUserMessageToThread(activeWorkspace, threadId, trimmed, []); + }, + [activeWorkspace, connectWorkspace, sendUserMessageToThread, startThreadForWorkspace], + ); + + const alertError = useCallback((error: unknown) => { + alert(error instanceof Error ? error.message : String(error)); + }, []); + + const handleCreatePrompt = useCallback( + async (data: { + scope: "workspace" | "global"; + name: string; + description?: string | null; + argumentHint?: string | null; + content: string; + }) => { + try { + await createPrompt(data); + } catch (error) { + alertError(error); + } + }, + [alertError, createPrompt], + ); + + const handleUpdatePrompt = useCallback( + async (data: { + path: string; + name: string; + description?: string | null; + argumentHint?: string | null; + content: string; + }) => { + try { + await updatePrompt(data); + } catch (error) { + alertError(error); + } + }, + [alertError, updatePrompt], + ); + + const handleDeletePrompt = useCallback( + async (path: string) => { + try { + await deletePrompt(path); + } catch (error) { + alertError(error); + } + }, + [alertError, deletePrompt], + ); + + const handleMovePrompt = useCallback( + async (data: { path: string; scope: "workspace" | "global" }) => { + try { + await movePrompt(data); + } catch (error) { + alertError(error); + } + }, + [alertError, movePrompt], + ); + + const handleRevealWorkspacePrompts = useCallback(async () => { + try { + const path = await getWorkspacePromptsDir(); + await revealItemInDir(path); + } catch (error) { + alertError(error); + } + }, [alertError, getWorkspacePromptsDir]); + + const handleRevealGeneralPrompts = useCallback(async () => { + try { + const path = await getGlobalPromptsDir(); + await revealItemInDir(path); + } catch (error) { + alertError(error); + } + }, [alertError, getGlobalPromptsDir]); const isWorktreeWorkspace = activeWorkspace?.kind === "worktree"; const activeParentWorkspace = isWorktreeWorkspace ? workspaces.find((entry) => entry.id === activeWorkspace?.parentId) ?? null @@ -1092,9 +1214,7 @@ function MainApp() { ) : null, filePanelMode, - onToggleFilePanel: () => { - setFilePanelMode((prev) => (prev === "git" ? "files" : "git")); - }, + onFilePanelModeChange: setFilePanelMode, fileTreeLoading: isFilesLoading, centerMode, onExitDiff: () => { @@ -1156,6 +1276,14 @@ function MainApp() { gitDiffLoading: activeDiffLoading, gitDiffError: activeDiffError, onDiffActivePathChange: handleActiveDiffPath, + onSendPrompt: handleSendPrompt, + onSendPromptToNewAgent: handleSendPromptToNewAgent, + onCreatePrompt: handleCreatePrompt, + onUpdatePrompt: handleUpdatePrompt, + onDeletePrompt: handleDeletePrompt, + onMovePrompt: handleMovePrompt, + onRevealWorkspacePrompts: handleRevealWorkspacePrompts, + onRevealGeneralPrompts: handleRevealGeneralPrompts, onSend: handleSend, onQueue: queueMessage, onStop: interruptTurn, diff --git a/src/features/files/components/FileTreePanel.tsx b/src/features/files/components/FileTreePanel.tsx index 75d2f332e..5da06985b 100644 --- a/src/features/files/components/FileTreePanel.tsx +++ b/src/features/files/components/FileTreePanel.tsx @@ -5,7 +5,6 @@ import { LogicalPosition } from "@tauri-apps/api/dpi"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { - ArrowLeftRight, ChevronsUpDown, File, FileArchive, @@ -19,6 +18,7 @@ import { Folder, Search, } from "lucide-react"; +import { PanelTabs, type PanelTabId } from "../../layout/components/PanelTabs"; type FileTreeNode = { name: string; @@ -31,7 +31,8 @@ type FileTreePanelProps = { workspacePath: string; files: string[]; isLoading: boolean; - onToggleFilePanel: () => void; + filePanelMode: PanelTabId; + onFilePanelModeChange: (mode: PanelTabId) => void; }; type FileTreeBuildNode = { @@ -177,7 +178,8 @@ export function FileTreePanel({ workspacePath, files, isLoading, - onToggleFilePanel, + filePanelMode, + onFilePanelModeChange, }: FileTreePanelProps) { const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [query, setQuery] = useState(""); @@ -326,17 +328,7 @@ export function FileTreePanel({ return (