diff --git a/docs/screenshots/SCR-20260225-jjrk.png b/docs/screenshots/SCR-20260225-jjrk.png new file mode 100644 index 000000000..e2463ef41 Binary files /dev/null and b/docs/screenshots/SCR-20260225-jjrk.png differ diff --git a/src-tauri/src/codex/home.rs b/src-tauri/src/codex/home.rs index 7cb0b10cf..cd3e29e25 100644 --- a/src-tauri/src/codex/home.rs +++ b/src-tauri/src/codex/home.rs @@ -137,7 +137,7 @@ fn join_env_path(prefix: &str, remainder: &str) -> PathBuf { } } -fn resolve_home_dir() -> Option { +pub(crate) fn resolve_home_dir() -> Option { if let Ok(value) = env::var("HOME") { if !value.trim().is_empty() { return Some(PathBuf::from(value)); @@ -148,6 +148,24 @@ fn resolve_home_dir() -> Option { return Some(PathBuf::from(value)); } } + #[cfg(unix)] + { + // Fallback for daemon environments that do not expose HOME. + unsafe { + let uid = libc::geteuid(); + let pwd = libc::getpwuid(uid); + if !pwd.is_null() { + let dir_ptr = (*pwd).pw_dir; + if !dir_ptr.is_null() { + if let Ok(dir) = std::ffi::CStr::from_ptr(dir_ptr).to_str() { + if !dir.trim().is_empty() { + return Some(PathBuf::from(dir)); + } + } + } + } + } + } None } diff --git a/src-tauri/src/shared/workspaces_core/crud_persistence.rs b/src-tauri/src/shared/workspaces_core/crud_persistence.rs index f771faf39..7df26ce98 100644 --- a/src-tauri/src/shared/workspaces_core/crud_persistence.rs +++ b/src-tauri/src/shared/workspaces_core/crud_persistence.rs @@ -15,7 +15,7 @@ use crate::storage::write_workspaces; use crate::types::{AppSettings, WorkspaceEntry, WorkspaceInfo, WorkspaceKind, WorkspaceSettings}; use super::connect::{kill_session_by_id, take_live_shared_session, workspace_session_spawn_lock}; -use super::helpers::normalize_setup_script; +use super::helpers::{normalize_setup_script, normalize_workspace_path_input}; pub(crate) async fn add_workspace_core( path: String, @@ -29,9 +29,11 @@ where F: Fn(WorkspaceEntry, Option, Option, Option) -> Fut, Fut: Future, String>>, { - if !PathBuf::from(&path).is_dir() { + let normalized_path = normalize_workspace_path_input(&path); + if !normalized_path.is_dir() { return Err("Workspace path must be a folder.".to_string()); } + let path = normalized_path.to_string_lossy().to_string(); let name = PathBuf::from(&path) .file_name() diff --git a/src-tauri/src/shared/workspaces_core/helpers.rs b/src-tauri/src/shared/workspaces_core/helpers.rs index 41938f698..f4f67ddc0 100644 --- a/src-tauri/src/shared/workspaces_core/helpers.rs +++ b/src-tauri/src/shared/workspaces_core/helpers.rs @@ -63,7 +63,22 @@ pub(crate) fn worktree_setup_marker_path(data_dir: &PathBuf, workspace_id: &str) } pub(crate) fn is_workspace_path_dir_core(path: &str) -> bool { - PathBuf::from(path).is_dir() + normalize_workspace_path_input(path).is_dir() +} + +pub(crate) fn normalize_workspace_path_input(path: &str) -> PathBuf { + let trimmed = path.trim(); + if let Some(rest) = trimmed.strip_prefix("~/") { + if let Some(home) = crate::codex::home::resolve_home_dir() { + return home.join(rest); + } + } + if trimmed == "~" { + if let Some(home) = crate::codex::home::resolve_home_dir() { + return home; + } + } + PathBuf::from(trimmed) } pub(crate) async fn list_workspaces_core( @@ -131,9 +146,15 @@ pub(super) fn sort_workspaces(workspaces: &mut [WorkspaceInfo]) { #[cfg(test)] mod tests { - use super::{copy_agents_md_from_parent_to_worktree, AGENTS_MD_FILE_NAME}; + use super::{ + copy_agents_md_from_parent_to_worktree, normalize_workspace_path_input, AGENTS_MD_FILE_NAME, + }; + use std::path::PathBuf; + use std::sync::Mutex; use uuid::Uuid; + static ENV_LOCK: Mutex<()> = Mutex::new(()); + fn make_temp_dir() -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("codex-monitor-{}", Uuid::new_v4())); std::fs::create_dir_all(&dir).expect("failed to create temp dir"); @@ -179,4 +200,21 @@ mod tests { let _ = std::fs::remove_dir_all(parent); let _ = std::fs::remove_dir_all(worktree); } + + #[test] + fn normalize_workspace_path_input_expands_home_prefix() { + let _guard = ENV_LOCK.lock().expect("lock env"); + let previous_home = std::env::var("HOME").ok(); + std::env::set_var("HOME", "/tmp/cm-home"); + + assert_eq!( + normalize_workspace_path_input("~/dev/repo"), + PathBuf::from("/tmp/cm-home/dev/repo") + ); + + match previous_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + } } diff --git a/src/App.tsx b/src/App.tsx index e9ac6d8b5..7c02211bb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -236,6 +236,7 @@ function MainApp() { addWorkspacesFromPaths, mobileRemoteWorkspacePathPrompt, updateMobileRemoteWorkspacePathInput, + appendMobileRemoteWorkspacePathFromRecent, cancelMobileRemoteWorkspacePathPrompt, submitMobileRemoteWorkspacePathPrompt, addCloneAgent, @@ -2708,6 +2709,9 @@ function MainApp() { onWorkspaceFromUrlPromptConfirm={submitWorkspaceFromUrlPrompt} mobileRemoteWorkspacePathPrompt={mobileRemoteWorkspacePathPrompt} onMobileRemoteWorkspacePathPromptChange={updateMobileRemoteWorkspacePathInput} + onMobileRemoteWorkspacePathPromptRecentPathSelect={ + appendMobileRemoteWorkspacePathFromRecent + } onMobileRemoteWorkspacePathPromptCancel={cancelMobileRemoteWorkspacePathPrompt} onMobileRemoteWorkspacePathPromptConfirm={submitMobileRemoteWorkspacePathPrompt} branchSwitcher={branchSwitcher} diff --git a/src/features/app/components/AppModals.tsx b/src/features/app/components/AppModals.tsx index a8cc9b66a..eeef062e7 100644 --- a/src/features/app/components/AppModals.tsx +++ b/src/features/app/components/AppModals.tsx @@ -56,6 +56,7 @@ type WorkspaceFromUrlPromptState = ReturnType< type MobileRemoteWorkspacePathPromptState = { value: string; error: string | null; + recentPaths: string[]; } | null; type AppModalsProps = { @@ -102,6 +103,7 @@ type AppModalsProps = { onWorkspaceFromUrlPromptConfirm: () => void; mobileRemoteWorkspacePathPrompt: MobileRemoteWorkspacePathPromptState; onMobileRemoteWorkspacePathPromptChange: (value: string) => void; + onMobileRemoteWorkspacePathPromptRecentPathSelect: (path: string) => void; onMobileRemoteWorkspacePathPromptCancel: () => void; onMobileRemoteWorkspacePathPromptConfirm: () => void; branchSwitcher: BranchSwitcherState; @@ -155,6 +157,7 @@ export const AppModals = memo(function AppModals({ onWorkspaceFromUrlPromptConfirm, mobileRemoteWorkspacePathPrompt, onMobileRemoteWorkspacePathPromptChange, + onMobileRemoteWorkspacePathPromptRecentPathSelect, onMobileRemoteWorkspacePathPromptCancel, onMobileRemoteWorkspacePathPromptConfirm, branchSwitcher, @@ -270,7 +273,9 @@ export const AppModals = memo(function AppModals({ diff --git a/src/features/app/hooks/useWorkspaceController.test.tsx b/src/features/app/hooks/useWorkspaceController.test.tsx index 558e1f331..80c474106 100644 --- a/src/features/app/hooks/useWorkspaceController.test.tsx +++ b/src/features/app/hooks/useWorkspaceController.test.tsx @@ -70,6 +70,7 @@ describe("useWorkspaceController dialogs", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(isMobilePlatform).mockReturnValue(false); + window.localStorage.clear(); }); it("shows add-workspaces summary in controller layer", async () => { @@ -176,5 +177,99 @@ describe("useWorkspaceController dialogs", () => { expect(added).toMatchObject({ id: workspaceOne.id }); expect(isWorkspacePathDir).toHaveBeenCalledWith("/srv/codex-monitor"); expect(result.current.mobileRemoteWorkspacePathPrompt).toBeNull(); + expect(window.localStorage.getItem("mobile-remote-workspace-recent-paths")).toBe( + JSON.stringify(["/tmp/ws-1"]), + ); + }); + + it("appends selected recent path only when missing", async () => { + vi.mocked(isMobilePlatform).mockReturnValue(true); + window.localStorage.setItem( + "mobile-remote-workspace-recent-paths", + JSON.stringify(["/srv/one", "/srv/two"]), + ); + vi.mocked(listWorkspaces).mockResolvedValue([]); + + const { result } = renderHook(() => + useWorkspaceController({ + appSettings: { + ...baseAppSettings, + backendMode: "remote", + }, + addDebugEntry: vi.fn(), + queueSaveSettings: vi.fn(async (next) => next), + }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + await act(async () => { + void result.current.addWorkspace(); + }); + + expect(result.current.mobileRemoteWorkspacePathPrompt?.recentPaths).toEqual([ + "/srv/one", + "/srv/two", + ]); + + await act(async () => { + result.current.appendMobileRemoteWorkspacePathFromRecent("/srv/one"); + }); + expect(result.current.mobileRemoteWorkspacePathPrompt?.value).toBe("/srv/one"); + + await act(async () => { + result.current.appendMobileRemoteWorkspacePathFromRecent("/srv/one"); + }); + expect(result.current.mobileRemoteWorkspacePathPrompt?.value).toBe("/srv/one"); + + await act(async () => { + result.current.appendMobileRemoteWorkspacePathFromRecent("/srv/two"); + }); + expect(result.current.mobileRemoteWorkspacePathPrompt?.value).toBe( + "/srv/one\n/srv/two", + ); + }); + + it("accepts quoted mobile remote paths", async () => { + vi.mocked(isMobilePlatform).mockReturnValue(true); + vi.mocked(listWorkspaces).mockResolvedValue([]); + vi.mocked(isWorkspacePathDir).mockResolvedValue(true); + vi.mocked(addWorkspace).mockResolvedValue(workspaceOne); + + const { result } = renderHook(() => + useWorkspaceController({ + appSettings: { + ...baseAppSettings, + backendMode: "remote", + }, + addDebugEntry: vi.fn(), + queueSaveSettings: vi.fn(async (next) => next), + }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + let addPromise: Promise = Promise.resolve(null); + await act(async () => { + addPromise = result.current.addWorkspace(); + }); + + await act(async () => { + result.current.updateMobileRemoteWorkspacePathInput("'~/dev/personal'"); + }); + + await act(async () => { + result.current.submitMobileRemoteWorkspacePathPrompt(); + }); + + await act(async () => { + await addPromise; + }); + + expect(isWorkspacePathDir).toHaveBeenCalledWith("~/dev/personal"); }); }); diff --git a/src/features/app/hooks/useWorkspaceController.ts b/src/features/app/hooks/useWorkspaceController.ts index 45b4251a0..981726395 100644 --- a/src/features/app/hooks/useWorkspaceController.ts +++ b/src/features/app/hooks/useWorkspaceController.ts @@ -3,6 +3,7 @@ import { useWorkspaces } from "../../workspaces/hooks/useWorkspaces"; import type { AppSettings, WorkspaceInfo } from "../../../types"; import type { DebugEntry } from "../../../types"; import { useWorkspaceDialogs } from "./useWorkspaceDialogs"; +import { isMobilePlatform } from "../../../utils/platformPaths"; type WorkspaceControllerOptions = { appSettings: AppSettings; @@ -34,6 +35,8 @@ export function useWorkspaceController({ updateMobileRemoteWorkspacePathInput, cancelMobileRemoteWorkspacePathPrompt, submitMobileRemoteWorkspacePathPrompt, + appendMobileRemoteWorkspacePathFromRecent, + rememberRecentMobileRemoteWorkspacePaths, showAddWorkspacesResult, confirmWorkspaceRemoval, confirmWorktreeRemoval, @@ -41,13 +44,31 @@ export function useWorkspaceController({ showWorktreeRemovalError, } = useWorkspaceDialogs(); - const addWorkspacesFromPaths = useCallback( - async (paths: string[]): Promise => { + const runAddWorkspacesFromPaths = useCallback( + async ( + paths: string[], + options?: { rememberMobileRemoteRecents?: boolean }, + ) => { const result = await addWorkspacesFromPathsCore(paths); await showAddWorkspacesResult(result); + if (options?.rememberMobileRemoteRecents && result.added.length > 0) { + rememberRecentMobileRemoteWorkspacePaths(result.added.map((entry) => entry.path)); + } + return result; + }, + [ + addWorkspacesFromPathsCore, + rememberRecentMobileRemoteWorkspacePaths, + showAddWorkspacesResult, + ], + ); + + const addWorkspacesFromPaths = useCallback( + async (paths: string[]): Promise => { + const result = await runAddWorkspacesFromPaths(paths); return result.firstAdded; }, - [addWorkspacesFromPathsCore, showAddWorkspacesResult], + [runAddWorkspacesFromPaths], ); const addWorkspace = useCallback(async (): Promise => { @@ -55,8 +76,11 @@ export function useWorkspaceController({ if (paths.length === 0) { return null; } - return addWorkspacesFromPaths(paths); - }, [addWorkspacesFromPaths, appSettings.backendMode, requestWorkspacePaths]); + const result = await runAddWorkspacesFromPaths(paths, { + rememberMobileRemoteRecents: isMobilePlatform() && appSettings.backendMode === "remote", + }); + return result.firstAdded; + }, [appSettings.backendMode, requestWorkspacePaths, runAddWorkspacesFromPaths]); const removeWorkspace = useCallback( async (workspaceId: string) => { @@ -96,6 +120,7 @@ export function useWorkspaceController({ updateMobileRemoteWorkspacePathInput, cancelMobileRemoteWorkspacePathPrompt, submitMobileRemoteWorkspacePathPrompt, + appendMobileRemoteWorkspacePathFromRecent, removeWorkspace, removeWorktree, }; diff --git a/src/features/app/hooks/useWorkspaceDialogs.ts b/src/features/app/hooks/useWorkspaceDialogs.ts index cedc4eb32..3731eb463 100644 --- a/src/features/app/hooks/useWorkspaceDialogs.ts +++ b/src/features/app/hooks/useWorkspaceDialogs.ts @@ -5,19 +5,100 @@ import { isMobilePlatform } from "../../../utils/platformPaths"; import { pickWorkspacePaths } from "../../../services/tauri"; import type { AddWorkspacesFromPathsResult } from "../../workspaces/hooks/useWorkspaceCrud"; +const RECENT_REMOTE_WORKSPACE_PATHS_STORAGE_KEY = "mobile-remote-workspace-recent-paths"; +const RECENT_REMOTE_WORKSPACE_PATHS_LIMIT = 5; + function parseWorkspacePathInput(value: string) { + const stripWrappingQuotes = (entry: string) => { + const trimmed = entry.trim(); + if (trimmed.length < 2) { + return trimmed; + } + const first = trimmed[0]; + const last = trimmed[trimmed.length - 1]; + if ((first === "'" || first === '"') && first === last) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; + }; + return value .split(/\r?\n|,|;/) - .map((entry) => entry.trim()) + .map((entry) => stripWrappingQuotes(entry)) .filter(Boolean); } +function appendPathIfMissing(value: string, path: string) { + const trimmedPath = path.trim(); + if (!trimmedPath) { + return value; + } + const entries = parseWorkspacePathInput(value); + if (entries.includes(trimmedPath)) { + return value; + } + return [...entries, trimmedPath].join("\n"); +} + +function loadRecentRemoteWorkspacePaths(): string[] { + if (typeof window === "undefined") { + return []; + } + const raw = window.localStorage.getItem(RECENT_REMOTE_WORKSPACE_PATHS_STORAGE_KEY); + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean) + .slice(0, RECENT_REMOTE_WORKSPACE_PATHS_LIMIT); + } catch { + return []; + } +} + +function persistRecentRemoteWorkspacePaths(paths: string[]) { + if (typeof window === "undefined") { + return; + } + window.localStorage.setItem( + RECENT_REMOTE_WORKSPACE_PATHS_STORAGE_KEY, + JSON.stringify(paths), + ); +} + +function mergeRecentRemoteWorkspacePaths(current: string[], nextPaths: string[]): string[] { + const seen = new Set(); + const merged: string[] = []; + const push = (entry: string) => { + const trimmed = entry.trim(); + if (!trimmed || seen.has(trimmed)) { + return; + } + seen.add(trimmed); + merged.push(trimmed); + }; + nextPaths.forEach(push); + current.forEach(push); + return merged.slice(0, RECENT_REMOTE_WORKSPACE_PATHS_LIMIT); +} + type MobileRemoteWorkspacePathPromptState = { value: string; error: string | null; + recentPaths: string[]; } | null; export function useWorkspaceDialogs() { + const [recentMobileRemoteWorkspacePaths, setRecentMobileRemoteWorkspacePaths] = useState< + string[] + >(() => loadRecentRemoteWorkspacePaths()); const [mobileRemoteWorkspacePathPrompt, setMobileRemoteWorkspacePathPrompt] = useState(null); const mobileRemoteWorkspacePathResolveRef = useRef<((paths: string[]) => void) | null>( @@ -40,12 +121,13 @@ export function useWorkspaceDialogs() { setMobileRemoteWorkspacePathPrompt({ value: "", error: null, + recentPaths: recentMobileRemoteWorkspacePaths, }); return new Promise((resolve) => { mobileRemoteWorkspacePathResolveRef.current = resolve; }); - }, [resolveMobileRemoteWorkspacePathRequest]); + }, [recentMobileRemoteWorkspacePaths, resolveMobileRemoteWorkspacePathRequest]); const updateMobileRemoteWorkspacePathInput = useCallback((value: string) => { setMobileRemoteWorkspacePathPrompt((prev) => @@ -64,6 +146,34 @@ export function useWorkspaceDialogs() { resolveMobileRemoteWorkspacePathRequest([]); }, [resolveMobileRemoteWorkspacePathRequest]); + const appendMobileRemoteWorkspacePathFromRecent = useCallback((path: string) => { + setMobileRemoteWorkspacePathPrompt((prev) => + prev + ? { + ...prev, + value: appendPathIfMissing(prev.value, path), + error: null, + } + : prev, + ); + }, []); + + const rememberRecentMobileRemoteWorkspacePaths = useCallback((paths: string[]) => { + setRecentMobileRemoteWorkspacePaths((prev) => { + const next = mergeRecentRemoteWorkspacePaths(prev, paths); + persistRecentRemoteWorkspacePaths(next); + return next; + }); + setMobileRemoteWorkspacePathPrompt((prev) => + prev + ? { + ...prev, + recentPaths: mergeRecentRemoteWorkspacePaths(prev.recentPaths, paths), + } + : prev, + ); + }, []); + const submitMobileRemoteWorkspacePathPrompt = useCallback(() => { if (!mobileRemoteWorkspacePathPrompt) { return; @@ -220,6 +330,8 @@ export function useWorkspaceDialogs() { updateMobileRemoteWorkspacePathInput, cancelMobileRemoteWorkspacePathPrompt, submitMobileRemoteWorkspacePathPrompt, + appendMobileRemoteWorkspacePathFromRecent, + rememberRecentMobileRemoteWorkspacePaths, showAddWorkspacesResult, confirmWorkspaceRemoval, confirmWorktreeRemoval, diff --git a/src/features/workspaces/components/MobileRemoteWorkspacePrompt.tsx b/src/features/workspaces/components/MobileRemoteWorkspacePrompt.tsx index 472f53cfc..5bcc0bcb1 100644 --- a/src/features/workspaces/components/MobileRemoteWorkspacePrompt.tsx +++ b/src/features/workspaces/components/MobileRemoteWorkspacePrompt.tsx @@ -4,7 +4,9 @@ import { ModalShell } from "../../design-system/components/modal/ModalShell"; type MobileRemoteWorkspacePromptProps = { value: string; error: string | null; + recentPaths: string[]; onChange: (value: string) => void; + onRecentPathSelect: (path: string) => void; onCancel: () => void; onConfirm: () => void; }; @@ -12,7 +14,9 @@ type MobileRemoteWorkspacePromptProps = { export function MobileRemoteWorkspacePrompt({ value, error, + recentPaths, onChange, + onRecentPathSelect, onCancel, onConfirm, }: MobileRemoteWorkspacePromptProps) { @@ -48,8 +52,25 @@ export function MobileRemoteWorkspacePrompt({ wrap="off" />
- One path per line. Comma and semicolon separators also work. + One path per line. Comma and semicolon separators also work. You can use `~/...`.
+ {recentPaths.length > 0 && ( +
+
Recently added
+
+ {recentPaths.map((path) => ( + + ))} +
+
+ )} {error &&
{error}
}