Skip to content

Commit 25a786e

Browse files
authored
fix: support tilde paths and persistent recent dirs in mobile add-workspaces (Dimillian#512)
1 parent b0ec2e8 commit 25a786e

13 files changed

Lines changed: 629 additions & 39 deletions

File tree

107 KB
Loading

src-tauri/src/codex/home.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ fn join_env_path(prefix: &str, remainder: &str) -> PathBuf {
137137
}
138138
}
139139

140-
fn resolve_home_dir() -> Option<PathBuf> {
140+
pub(crate) fn resolve_home_dir() -> Option<PathBuf> {
141141
if let Ok(value) = env::var("HOME") {
142142
if !value.trim().is_empty() {
143143
return Some(PathBuf::from(value));
@@ -148,6 +148,24 @@ fn resolve_home_dir() -> Option<PathBuf> {
148148
return Some(PathBuf::from(value));
149149
}
150150
}
151+
#[cfg(unix)]
152+
{
153+
// Fallback for daemon environments that do not expose HOME.
154+
unsafe {
155+
let uid = libc::geteuid();
156+
let pwd = libc::getpwuid(uid);
157+
if !pwd.is_null() {
158+
let dir_ptr = (*pwd).pw_dir;
159+
if !dir_ptr.is_null() {
160+
if let Ok(dir) = std::ffi::CStr::from_ptr(dir_ptr).to_str() {
161+
if !dir.trim().is_empty() {
162+
return Some(PathBuf::from(dir));
163+
}
164+
}
165+
}
166+
}
167+
}
168+
}
151169
None
152170
}
153171

src-tauri/src/shared/workspaces_core/crud_persistence.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use crate::storage::write_workspaces;
1515
use crate::types::{AppSettings, WorkspaceEntry, WorkspaceInfo, WorkspaceKind, WorkspaceSettings};
1616

1717
use super::connect::{kill_session_by_id, take_live_shared_session, workspace_session_spawn_lock};
18-
use super::helpers::normalize_setup_script;
18+
use super::helpers::{normalize_setup_script, normalize_workspace_path_input};
1919

2020
pub(crate) async fn add_workspace_core<F, Fut>(
2121
path: String,
@@ -29,9 +29,11 @@ where
2929
F: Fn(WorkspaceEntry, Option<String>, Option<String>, Option<PathBuf>) -> Fut,
3030
Fut: Future<Output = Result<Arc<WorkspaceSession>, String>>,
3131
{
32-
if !PathBuf::from(&path).is_dir() {
32+
let normalized_path = normalize_workspace_path_input(&path);
33+
if !normalized_path.is_dir() {
3334
return Err("Workspace path must be a folder.".to_string());
3435
}
36+
let path = normalized_path.to_string_lossy().to_string();
3537

3638
let name = PathBuf::from(&path)
3739
.file_name()

src-tauri/src/shared/workspaces_core/helpers.rs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,22 @@ pub(crate) fn worktree_setup_marker_path(data_dir: &PathBuf, workspace_id: &str)
6363
}
6464

6565
pub(crate) fn is_workspace_path_dir_core(path: &str) -> bool {
66-
PathBuf::from(path).is_dir()
66+
normalize_workspace_path_input(path).is_dir()
67+
}
68+
69+
pub(crate) fn normalize_workspace_path_input(path: &str) -> PathBuf {
70+
let trimmed = path.trim();
71+
if let Some(rest) = trimmed.strip_prefix("~/") {
72+
if let Some(home) = crate::codex::home::resolve_home_dir() {
73+
return home.join(rest);
74+
}
75+
}
76+
if trimmed == "~" {
77+
if let Some(home) = crate::codex::home::resolve_home_dir() {
78+
return home;
79+
}
80+
}
81+
PathBuf::from(trimmed)
6782
}
6883

6984
pub(crate) async fn list_workspaces_core(
@@ -131,9 +146,15 @@ pub(super) fn sort_workspaces(workspaces: &mut [WorkspaceInfo]) {
131146

132147
#[cfg(test)]
133148
mod tests {
134-
use super::{copy_agents_md_from_parent_to_worktree, AGENTS_MD_FILE_NAME};
149+
use super::{
150+
copy_agents_md_from_parent_to_worktree, normalize_workspace_path_input, AGENTS_MD_FILE_NAME,
151+
};
152+
use std::path::PathBuf;
153+
use std::sync::Mutex;
135154
use uuid::Uuid;
136155

156+
static ENV_LOCK: Mutex<()> = Mutex::new(());
157+
137158
fn make_temp_dir() -> std::path::PathBuf {
138159
let dir = std::env::temp_dir().join(format!("codex-monitor-{}", Uuid::new_v4()));
139160
std::fs::create_dir_all(&dir).expect("failed to create temp dir");
@@ -179,4 +200,21 @@ mod tests {
179200
let _ = std::fs::remove_dir_all(parent);
180201
let _ = std::fs::remove_dir_all(worktree);
181202
}
203+
204+
#[test]
205+
fn normalize_workspace_path_input_expands_home_prefix() {
206+
let _guard = ENV_LOCK.lock().expect("lock env");
207+
let previous_home = std::env::var("HOME").ok();
208+
std::env::set_var("HOME", "/tmp/cm-home");
209+
210+
assert_eq!(
211+
normalize_workspace_path_input("~/dev/repo"),
212+
PathBuf::from("/tmp/cm-home/dev/repo")
213+
);
214+
215+
match previous_home {
216+
Some(value) => std::env::set_var("HOME", value),
217+
None => std::env::remove_var("HOME"),
218+
}
219+
}
182220
}

src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ function MainApp() {
236236
addWorkspacesFromPaths,
237237
mobileRemoteWorkspacePathPrompt,
238238
updateMobileRemoteWorkspacePathInput,
239+
appendMobileRemoteWorkspacePathFromRecent,
239240
cancelMobileRemoteWorkspacePathPrompt,
240241
submitMobileRemoteWorkspacePathPrompt,
241242
addCloneAgent,
@@ -2710,6 +2711,9 @@ function MainApp() {
27102711
onWorkspaceFromUrlPromptConfirm={submitWorkspaceFromUrlPrompt}
27112712
mobileRemoteWorkspacePathPrompt={mobileRemoteWorkspacePathPrompt}
27122713
onMobileRemoteWorkspacePathPromptChange={updateMobileRemoteWorkspacePathInput}
2714+
onMobileRemoteWorkspacePathPromptRecentPathSelect={
2715+
appendMobileRemoteWorkspacePathFromRecent
2716+
}
27132717
onMobileRemoteWorkspacePathPromptCancel={cancelMobileRemoteWorkspacePathPrompt}
27142718
onMobileRemoteWorkspacePathPromptConfirm={submitMobileRemoteWorkspacePathPrompt}
27152719
branchSwitcher={branchSwitcher}

src/features/app/components/AppModals.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ type WorkspaceFromUrlPromptState = ReturnType<
5656
type MobileRemoteWorkspacePathPromptState = {
5757
value: string;
5858
error: string | null;
59+
recentPaths: string[];
5960
} | null;
6061

6162
type AppModalsProps = {
@@ -102,6 +103,7 @@ type AppModalsProps = {
102103
onWorkspaceFromUrlPromptConfirm: () => void;
103104
mobileRemoteWorkspacePathPrompt: MobileRemoteWorkspacePathPromptState;
104105
onMobileRemoteWorkspacePathPromptChange: (value: string) => void;
106+
onMobileRemoteWorkspacePathPromptRecentPathSelect: (path: string) => void;
105107
onMobileRemoteWorkspacePathPromptCancel: () => void;
106108
onMobileRemoteWorkspacePathPromptConfirm: () => void;
107109
branchSwitcher: BranchSwitcherState;
@@ -155,6 +157,7 @@ export const AppModals = memo(function AppModals({
155157
onWorkspaceFromUrlPromptConfirm,
156158
mobileRemoteWorkspacePathPrompt,
157159
onMobileRemoteWorkspacePathPromptChange,
160+
onMobileRemoteWorkspacePathPromptRecentPathSelect,
158161
onMobileRemoteWorkspacePathPromptCancel,
159162
onMobileRemoteWorkspacePathPromptConfirm,
160163
branchSwitcher,
@@ -270,7 +273,9 @@ export const AppModals = memo(function AppModals({
270273
<MobileRemoteWorkspacePrompt
271274
value={mobileRemoteWorkspacePathPrompt.value}
272275
error={mobileRemoteWorkspacePathPrompt.error}
276+
recentPaths={mobileRemoteWorkspacePathPrompt.recentPaths}
273277
onChange={onMobileRemoteWorkspacePathPromptChange}
278+
onRecentPathSelect={onMobileRemoteWorkspacePathPromptRecentPathSelect}
274279
onCancel={onMobileRemoteWorkspacePathPromptCancel}
275280
onConfirm={onMobileRemoteWorkspacePathPromptConfirm}
276281
/>

src/features/app/hooks/useWorkspaceController.test.tsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ describe("useWorkspaceController dialogs", () => {
7070
beforeEach(() => {
7171
vi.clearAllMocks();
7272
vi.mocked(isMobilePlatform).mockReturnValue(false);
73+
window.localStorage.clear();
7374
});
7475

7576
it("shows add-workspaces summary in controller layer", async () => {
@@ -176,5 +177,99 @@ describe("useWorkspaceController dialogs", () => {
176177
expect(added).toMatchObject({ id: workspaceOne.id });
177178
expect(isWorkspacePathDir).toHaveBeenCalledWith("/srv/codex-monitor");
178179
expect(result.current.mobileRemoteWorkspacePathPrompt).toBeNull();
180+
expect(window.localStorage.getItem("mobile-remote-workspace-recent-paths")).toBe(
181+
JSON.stringify(["/tmp/ws-1"]),
182+
);
183+
});
184+
185+
it("appends selected recent path only when missing", async () => {
186+
vi.mocked(isMobilePlatform).mockReturnValue(true);
187+
window.localStorage.setItem(
188+
"mobile-remote-workspace-recent-paths",
189+
JSON.stringify(["/srv/one", "/srv/two"]),
190+
);
191+
vi.mocked(listWorkspaces).mockResolvedValue([]);
192+
193+
const { result } = renderHook(() =>
194+
useWorkspaceController({
195+
appSettings: {
196+
...baseAppSettings,
197+
backendMode: "remote",
198+
},
199+
addDebugEntry: vi.fn(),
200+
queueSaveSettings: vi.fn(async (next) => next),
201+
}),
202+
);
203+
204+
await act(async () => {
205+
await Promise.resolve();
206+
});
207+
208+
await act(async () => {
209+
void result.current.addWorkspace();
210+
});
211+
212+
expect(result.current.mobileRemoteWorkspacePathPrompt?.recentPaths).toEqual([
213+
"/srv/one",
214+
"/srv/two",
215+
]);
216+
217+
await act(async () => {
218+
result.current.appendMobileRemoteWorkspacePathFromRecent("/srv/one");
219+
});
220+
expect(result.current.mobileRemoteWorkspacePathPrompt?.value).toBe("/srv/one");
221+
222+
await act(async () => {
223+
result.current.appendMobileRemoteWorkspacePathFromRecent("/srv/one");
224+
});
225+
expect(result.current.mobileRemoteWorkspacePathPrompt?.value).toBe("/srv/one");
226+
227+
await act(async () => {
228+
result.current.appendMobileRemoteWorkspacePathFromRecent("/srv/two");
229+
});
230+
expect(result.current.mobileRemoteWorkspacePathPrompt?.value).toBe(
231+
"/srv/one\n/srv/two",
232+
);
233+
});
234+
235+
it("accepts quoted mobile remote paths", async () => {
236+
vi.mocked(isMobilePlatform).mockReturnValue(true);
237+
vi.mocked(listWorkspaces).mockResolvedValue([]);
238+
vi.mocked(isWorkspacePathDir).mockResolvedValue(true);
239+
vi.mocked(addWorkspace).mockResolvedValue(workspaceOne);
240+
241+
const { result } = renderHook(() =>
242+
useWorkspaceController({
243+
appSettings: {
244+
...baseAppSettings,
245+
backendMode: "remote",
246+
},
247+
addDebugEntry: vi.fn(),
248+
queueSaveSettings: vi.fn(async (next) => next),
249+
}),
250+
);
251+
252+
await act(async () => {
253+
await Promise.resolve();
254+
});
255+
256+
let addPromise: Promise<WorkspaceInfo | null> = Promise.resolve(null);
257+
await act(async () => {
258+
addPromise = result.current.addWorkspace();
259+
});
260+
261+
await act(async () => {
262+
result.current.updateMobileRemoteWorkspacePathInput("'~/dev/personal'");
263+
});
264+
265+
await act(async () => {
266+
result.current.submitMobileRemoteWorkspacePathPrompt();
267+
});
268+
269+
await act(async () => {
270+
await addPromise;
271+
});
272+
273+
expect(isWorkspacePathDir).toHaveBeenCalledWith("~/dev/personal");
179274
});
180275
});

src/features/app/hooks/useWorkspaceController.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useWorkspaces } from "../../workspaces/hooks/useWorkspaces";
33
import type { AppSettings, WorkspaceInfo } from "../../../types";
44
import type { DebugEntry } from "../../../types";
55
import { useWorkspaceDialogs } from "./useWorkspaceDialogs";
6+
import { isMobilePlatform } from "../../../utils/platformPaths";
67

78
type WorkspaceControllerOptions = {
89
appSettings: AppSettings;
@@ -34,29 +35,52 @@ export function useWorkspaceController({
3435
updateMobileRemoteWorkspacePathInput,
3536
cancelMobileRemoteWorkspacePathPrompt,
3637
submitMobileRemoteWorkspacePathPrompt,
38+
appendMobileRemoteWorkspacePathFromRecent,
39+
rememberRecentMobileRemoteWorkspacePaths,
3740
showAddWorkspacesResult,
3841
confirmWorkspaceRemoval,
3942
confirmWorktreeRemoval,
4043
showWorkspaceRemovalError,
4144
showWorktreeRemovalError,
4245
} = useWorkspaceDialogs();
4346

44-
const addWorkspacesFromPaths = useCallback(
45-
async (paths: string[]): Promise<WorkspaceInfo | null> => {
47+
const runAddWorkspacesFromPaths = useCallback(
48+
async (
49+
paths: string[],
50+
options?: { rememberMobileRemoteRecents?: boolean },
51+
) => {
4652
const result = await addWorkspacesFromPathsCore(paths);
4753
await showAddWorkspacesResult(result);
54+
if (options?.rememberMobileRemoteRecents && result.added.length > 0) {
55+
rememberRecentMobileRemoteWorkspacePaths(result.added.map((entry) => entry.path));
56+
}
57+
return result;
58+
},
59+
[
60+
addWorkspacesFromPathsCore,
61+
rememberRecentMobileRemoteWorkspacePaths,
62+
showAddWorkspacesResult,
63+
],
64+
);
65+
66+
const addWorkspacesFromPaths = useCallback(
67+
async (paths: string[]): Promise<WorkspaceInfo | null> => {
68+
const result = await runAddWorkspacesFromPaths(paths);
4869
return result.firstAdded;
4970
},
50-
[addWorkspacesFromPathsCore, showAddWorkspacesResult],
71+
[runAddWorkspacesFromPaths],
5172
);
5273

5374
const addWorkspace = useCallback(async (): Promise<WorkspaceInfo | null> => {
5475
const paths = await requestWorkspacePaths(appSettings.backendMode);
5576
if (paths.length === 0) {
5677
return null;
5778
}
58-
return addWorkspacesFromPaths(paths);
59-
}, [addWorkspacesFromPaths, appSettings.backendMode, requestWorkspacePaths]);
79+
const result = await runAddWorkspacesFromPaths(paths, {
80+
rememberMobileRemoteRecents: isMobilePlatform() && appSettings.backendMode === "remote",
81+
});
82+
return result.firstAdded;
83+
}, [appSettings.backendMode, requestWorkspacePaths, runAddWorkspacesFromPaths]);
6084

6185
const removeWorkspace = useCallback(
6286
async (workspaceId: string) => {
@@ -96,6 +120,7 @@ export function useWorkspaceController({
96120
updateMobileRemoteWorkspacePathInput,
97121
cancelMobileRemoteWorkspacePathPrompt,
98122
submitMobileRemoteWorkspacePathPrompt,
123+
appendMobileRemoteWorkspacePathFromRecent,
99124
removeWorkspace,
100125
removeWorktree,
101126
};

0 commit comments

Comments
 (0)