diff --git a/src-tauri/src/commands/remote_workspace.rs b/src-tauri/src/commands/remote_workspace.rs index 6dc039df5c..3e1834d25e 100644 --- a/src-tauri/src/commands/remote_workspace.rs +++ b/src-tauri/src/commands/remote_workspace.rs @@ -5,7 +5,7 @@ use serde::Deserialize; #[cfg(feature = "tauri-runtime")] use std::time::Duration; #[cfg(feature = "tauri-runtime")] -use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; +use tauri::{AppHandle, Emitter, EventTarget, Manager, WebviewUrl, WebviewWindowBuilder}; #[cfg(feature = "tauri-runtime")] use crate::app_error::AppCommandError; @@ -171,35 +171,43 @@ pub async fn reorder_remote_workspace_connections( remote_workspace_connection_service::reorder(&db.conn, ids).await } +/// Tauri event used to hand an already-open remote workspace window a folder +/// to open. MUST match `REMOTE_OPEN_FOLDER_EVENT` in +/// `src/lib/remote-workspace.ts`. #[cfg(feature = "tauri-runtime")] -#[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn open_remote_workspace( - app: AppHandle, - db: tauri::State<'_, AppDatabase>, - id: i32, -) -> Result<(), AppCommandError> { - let connection = remote_workspace_connection_service::get(&db.conn, id) - .await - .map_err(AppCommandError::db)? - .ok_or_else(|| AppCommandError::not_found(format!("Remote connection {id} not found")))?; +const REMOTE_OPEN_FOLDER_EVENT: &str = "remote-open-folder"; - let label = format!("remote-workspace-{id}"); - if let Some(existing) = app.get_webview_window(&label) { - let _ = existing.unminimize(); - existing.set_focus().map_err(|e| { - AppCommandError::window("Failed to focus remote workspace", e.to_string()) - })?; - return Ok(()); - } +/// Query param carrying the same request to a window that is being spawned +/// (an event can't reach a webview that doesn't exist yet). MUST match the +/// param read by `RemoteWorkspaceOpenFolderListener`. +#[cfg(feature = "tauri-runtime")] +const OPEN_FOLDER_PATH_PARAM: &str = "openFolderPath"; - validate_remote_health(&connection.base_url, &connection.token, &connection.headers).await?; +#[cfg(feature = "tauri-runtime")] +#[derive(Clone, serde::Serialize)] +struct RemoteOpenFolderPayload { + path: String, +} +/// Spawn (and register) the window bound to a remote connection. `extra_query` +/// is appended verbatim to the workspace URL and must already be URL-encoded. +#[cfg(feature = "tauri-runtime")] +fn build_remote_workspace_window( + app: &AppHandle, + id: i32, + name: &str, + extra_query: &str, +) -> Result { + let label = format!("remote-workspace-{id}"); let window_instance_id = new_remote_window_instance_id(); let url = WebviewUrl::App( - format!("workspace?remoteConnectionId={id}&remoteWindowId={window_instance_id}").into(), + format!( + "workspace?remoteConnectionId={id}&remoteWindowId={window_instance_id}{extra_query}" + ) + .into(), ); - let builder = WebviewWindowBuilder::new(&app, &label, url) - .title(format!("Codeg - {}", connection.name)) + let builder = WebviewWindowBuilder::new(app, &label, url) + .title(format!("Codeg - {name}")) .inner_size(1260.0, 860.0) .min_inner_size(400.0, 600.0) .center(); @@ -221,5 +229,79 @@ pub async fn open_remote_workspace( .register_window_instance_cleanup(&window, window_instance_id); } crate::commands::windows::post_window_setup(&window); + Ok(window) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn open_remote_workspace( + app: AppHandle, + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result<(), AppCommandError> { + let connection = remote_workspace_connection_service::get(&db.conn, id) + .await + .map_err(AppCommandError::db)? + .ok_or_else(|| AppCommandError::not_found(format!("Remote connection {id} not found")))?; + + let label = format!("remote-workspace-{id}"); + if let Some(existing) = app.get_webview_window(&label) { + let _ = existing.unminimize(); + existing.set_focus().map_err(|e| { + AppCommandError::window("Failed to focus remote workspace", e.to_string()) + })?; + return Ok(()); + } + + validate_remote_health(&connection.base_url, &connection.token, &connection.headers).await?; + + build_remote_workspace_window(&app, id, &connection.name, "")?; + Ok(()) +} + +/// Open a folder that lives on a remote workspace host. +/// +/// Folders belong to the backend that owns their paths, so this never opens the +/// folder here — it raises (or spawns) the window bound to `connection_id` and +/// hands it the path. That window opens the folder through its own transport, +/// which keeps the folder in the workspace that owns it and lets any failure +/// surface where the user is looking. +/// +/// A live window gets a Tauri event; a window we have to spawn gets the path as +/// a URL param, since an event can't reach a webview that doesn't exist yet. +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn open_remote_workspace_folder( + app: AppHandle, + db: tauri::State<'_, AppDatabase>, + id: i32, + path: String, +) -> Result<(), AppCommandError> { + let connection = remote_workspace_connection_service::get(&db.conn, id) + .await + .map_err(AppCommandError::db)? + .ok_or_else(|| AppCommandError::not_found(format!("Remote connection {id} not found")))?; + + let label = format!("remote-workspace-{id}"); + if let Some(existing) = app.get_webview_window(&label) { + let _ = existing.unminimize(); + existing.set_focus().map_err(|e| { + AppCommandError::window("Failed to focus remote workspace", e.to_string()) + })?; + app.emit_to( + EventTarget::webview(&label), + REMOTE_OPEN_FOLDER_EVENT, + RemoteOpenFolderPayload { path }, + ) + .map_err(|e| { + AppCommandError::window("Failed to open the folder in the remote workspace", e.to_string()) + })?; + return Ok(()); + } + + validate_remote_health(&connection.base_url, &connection.token, &connection.headers).await?; + + let query = format!("&{}={}", OPEN_FOLDER_PATH_PARAM, urlencoding::encode(&path)); + build_remote_workspace_window(&app, id, &connection.name, &query)?; Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 95bdcb2327..c55d41e753 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1148,6 +1148,7 @@ mod tauri_app { remote_workspace_commands::get_remote_workspace_connection, remote_workspace_commands::reorder_remote_workspace_connections, remote_workspace_commands::open_remote_workspace, + remote_workspace_commands::open_remote_workspace_folder, remote_proxy_commands::remote_http_call, remote_proxy_commands::remote_upload_attachment, remote_proxy_commands::remote_upload_workspace_paths, diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index 8f45055ac4..9660ebe577 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -75,6 +75,7 @@ import { PetFocusBridge, } from "@/components/workspace/deep-link-bootstrap" import { WorkspaceOpenFolderListener } from "@/components/workspace/workspace-open-folder-listener" +import { RemoteWorkspaceOpenFolderListener } from "@/components/workspace/remote-workspace-open-folder-listener" import { HeavyPluginsWarmup } from "@/components/ai-elements/heavy-plugins-warmup" import { ResizableHandle, @@ -1297,6 +1298,7 @@ function WorkspaceLayoutInner({ children }: { children: React.ReactNode }) { listener calls openConversations() to surface a launcher-opened folder. */} + {children} diff --git a/src/components/layout/workspace-folder-dialog.test.tsx b/src/components/layout/workspace-folder-dialog.test.tsx index edc1c3da3e..d222301867 100644 --- a/src/components/layout/workspace-folder-dialog.test.tsx +++ b/src/components/layout/workspace-folder-dialog.test.tsx @@ -9,6 +9,7 @@ import type { FolderDetail, FolderLinkDetail, FolderLinkPlan, + RemoteWorkspaceConnection, } from "@/lib/types" import { WorkspaceFolderDialog } from "./workspace-folder-dialog" @@ -38,6 +39,21 @@ vi.mock("@/lib/transport", () => ({ getActiveRemoteConnectionId: () => null, })) +// The chooser runs on top of the real `useRemoteWorkspaceConnections` hook, so +// only the module it reads from is faked. +const remote = vi.hoisted(() => ({ + listRemoteWorkspaceConnections: vi.fn<() => Promise>( + async () => [] + ), + getRemoteHomeDirectory: vi.fn<() => Promise>(async () => "/srv"), + listRemoteDirectoryEntries: vi.fn<() => Promise>(async () => []), + openRemoteWorkspaceFolder: vi.fn<() => Promise>(async () => {}), +})) +vi.mock("@/lib/remote-workspace", () => ({ + ...remote, + REMOTE_OPEN_FOLDER_EVENT: "remote-open-folder", +})) + const openFolder = vi.hoisted(() => vi.fn()) vi.mock("@/stores/app-workspace-store", () => ({ useAppWorkspaceStore: (selector: (state: unknown) => unknown) => @@ -81,6 +97,22 @@ const link = (overrides: Partial = {}): FolderLinkDetail => ({ ...overrides, }) +/** + * Mounted closed, like the real dialogs: the connection list loads before the + * first open, which is what lets the chooser be the first thing shown. + */ +function ClosedHarness() { + const [open, setOpen] = useState(false) + return ( + + + + + ) +} + const plan = (overrides: Partial = {}): FolderLinkPlan => ({ targetPath: "/home/me/work/api", baseName: "api", @@ -92,6 +124,20 @@ const plan = (overrides: Partial = {}): FolderLinkPlan => ({ ...overrides, }) +const connection = ( + overrides: Partial = {} +): RemoteWorkspaceConnection => ({ + id: 3, + name: "Beast", + base_url: "http://beast.local:3000", + token: "secret", + headers: [], + sort_order: 0, + created_at: "2026-08-03T00:00:00Z", + updated_at: "2026-08-03T00:00:00Z", + ...overrides, +}) + function Harness({ manage }: { manage?: FolderDetail }) { const [open, setOpen] = useState(true) return ( @@ -114,6 +160,10 @@ beforeEach(() => { api.previewFolderLinks.mockResolvedValue([]) api.createFolderLinks.mockResolvedValue([]) openFolder.mockResolvedValue(folder()) + remote.listRemoteWorkspaceConnections.mockResolvedValue([]) + remote.getRemoteHomeDirectory.mockResolvedValue("/srv") + remote.listRemoteDirectoryEntries.mockResolvedValue([]) + remote.openRemoteWorkspaceFolder.mockResolvedValue(undefined) }) describe("WorkspaceFolderDialog — creation flow", () => { @@ -571,6 +621,172 @@ describe("WorkspaceFolderDialog — native picker", () => { }) }) +describe("WorkspaceFolderDialog — workspace chooser", () => { + // Every case opens the dialog only after the connection list has settled: + // the view shown on open is decided at that moment. + async function openWithConnections(list: RemoteWorkspaceConnection[]) { + platform.desktop = true + remote.listRemoteWorkspaceConnections.mockResolvedValue(list) + render() + await act(async () => {}) + await act(async () => { + fireEvent.click(screen.getByText("show")) + }) + } + + it("asks which workspace to browse when connections exist", async () => { + await openWithConnections([connection()]) + + expect( + screen.getByText("Choose which workspace to open a folder from.") + ).toBeInTheDocument() + expect(screen.getByText("This PC")).toBeInTheDocument() + expect(screen.getByText("Beast")).toBeInTheDocument() + expect(screen.getByText("http://beast.local:3000")).toBeInTheDocument() + }) + + it("goes straight to the local browser when nothing is connected", async () => { + await openWithConnections([]) + + await screen.findByDisplayValue("/home/me") + expect(screen.queryByText("This PC")).toBeNull() + }) + + it("offers the chooser for a connection added while it sat closed", async () => { + // Nothing connected when the dialog mounts... + platform.desktop = true + render() + await act(async () => {}) + // ...then a connection appears from the settings window. + remote.listRemoteWorkspaceConnections.mockResolvedValue([connection()]) + + await act(async () => { + fireEvent.click(screen.getByText("show")) + }) + + // The stale list would have opened straight into this machine's folders; + // the load that runs on open has to correct it. + expect(await screen.findByText("Beast")).toBeInTheDocument() + }) + + it("drops the chooser when the last connection is removed", async () => { + platform.desktop = true + remote.listRemoteWorkspaceConnections.mockResolvedValue([connection()]) + render() + await act(async () => {}) + remote.listRemoteWorkspaceConnections.mockResolvedValue([]) + + await act(async () => { + fireEvent.click(screen.getByText("show")) + }) + + // Nothing left to choose between, so it settles on this machine instead of + // leaving an empty chooser on screen. + await screen.findByDisplayValue("/home/me") + expect(screen.queryByText("This PC")).toBeNull() + }) + + it("filters the workspaces by name", async () => { + await openWithConnections([ + connection(), + connection({ id: 4, name: "Garage", base_url: "http://garage.local" }), + ]) + + const search = await screen.findByPlaceholderText("Search workspaces") + fireEvent.change(search, { target: { value: "gar" } }) + + expect(screen.getByText("Garage")).toBeInTheDocument() + expect(screen.queryByText("Beast")).toBeNull() + // The local machine drops out too — the filter covers the whole list. + expect(screen.queryByText("This PC")).toBeNull() + }) + + it("says so when nothing matches", async () => { + await openWithConnections([connection()]) + + const search = await screen.findByPlaceholderText("Search workspaces") + fireEvent.change(search, { target: { value: "nope" } }) + + expect(screen.getByText("No workspace matches “nope”")).toBeInTheDocument() + }) + + it("browses the chosen remote host, not this machine", async () => { + remote.listRemoteDirectoryEntries.mockResolvedValue([ + dir("projects", "/srv/projects"), + ]) + await openWithConnections([connection()]) + await act(async () => { + fireEvent.click(await screen.findByText("Beast")) + }) + + await screen.findByDisplayValue("/srv") + expect(api.getHomeDirectory).not.toHaveBeenCalled() + expect(remote.getRemoteHomeDirectory).toHaveBeenCalledWith(3) + expect(remote.listRemoteDirectoryEntries).toHaveBeenCalledWith(3, "/srv") + // The system picker opens a dialog on this machine — useless for a remote. + expect(screen.queryByRole("button", { name: "System picker" })).toBeNull() + }) + + it("opens a remote folder in its own workspace window", async () => { + remote.listRemoteDirectoryEntries.mockResolvedValue([ + dir("projects", "/srv/projects"), + ]) + await openWithConnections([connection()]) + await act(async () => { + fireEvent.click(await screen.findByText("Beast")) + }) + await act(async () => { + fireEvent.click(await screen.findByRole("button", { name: /projects/ })) + }) + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Open" })) + }) + + // The folder lives on the other host, so it is handed over rather than + // opened here — and there is no linking step to advance to. + expect(remote.openRemoteWorkspaceFolder).toHaveBeenCalledWith( + 3, + "/srv/projects" + ) + expect(openFolder).not.toHaveBeenCalled() + }) + + it("surfaces a failed handoff with the workspace name", async () => { + remote.openRemoteWorkspaceFolder.mockRejectedValue(new Error("offline")) + await openWithConnections([connection()]) + await act(async () => { + fireEvent.click(await screen.findByText("Beast")) + }) + const box = await screen.findByDisplayValue("/srv") + fireEvent.change(box, { target: { value: "/srv/projects" } }) + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Open" })) + }) + + expect(toast.error).toHaveBeenCalledWith( + "Failed to open the folder on Beast", + expect.anything() + ) + }) + + it("can switch workspace from the browser and back to this PC", async () => { + await openWithConnections([connection()]) + await act(async () => { + fireEvent.click(await screen.findByText("Beast")) + }) + await act(async () => { + fireEvent.click(await screen.findByText("Change")) + }) + await act(async () => { + fireEvent.click(await screen.findByText("This PC")) + }) + + // Back on the local source: this machine's own home directory. + await screen.findByDisplayValue("/home/me") + expect(api.getHomeDirectory).toHaveBeenCalled() + }) +}) + describe("WorkspaceFolderDialog — link rows", () => { it("shows each link's real target path", async () => { api.listFolderLinks.mockResolvedValue([link()]) diff --git a/src/components/layout/workspace-folder-dialog.tsx b/src/components/layout/workspace-folder-dialog.tsx index c274a60b66..b07cd4045e 100644 --- a/src/components/layout/workspace-folder-dialog.tsx +++ b/src/components/layout/workspace-folder-dialog.tsx @@ -6,16 +6,21 @@ import { AlertTriangle, ArrowLeft, Check, + ChevronRight, FolderOpen, Link2, Link2Off, Loader2, + Monitor, MonitorDot, Pencil, Plus, RefreshCw, + Search, + Server, Trash2, X, + type LucideIcon, } from "lucide-react" import { Dialog, @@ -52,10 +57,16 @@ import { repairFolderLink, } from "@/lib/api" import { useImeGuard } from "@/hooks/use-ime-guard" +import { useRemoteWorkspaceConnections } from "@/hooks/use-remote-workspace-connections" import { isDesktop, openFileDialog } from "@/lib/platform" import { parentFsPath } from "@/lib/path-utils" import { getActiveRemoteConnectionId } from "@/lib/transport" import { toErrorMessage } from "@/lib/app-error" +import { + getRemoteHomeDirectory, + listRemoteDirectoryEntries, + openRemoteWorkspaceFolder, +} from "@/lib/remote-workspace" import { basenameOf, linkNameKey, @@ -68,10 +79,20 @@ import type { FolderLinkDetail, FolderLinkPlan, FolderLinkStatus, + RemoteWorkspaceConnection, } from "@/lib/types" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" -type View = "pick-root" | "links" | "add-targets" +type View = "pick-workspace" | "pick-root" | "links" | "add-targets" + +/** + * Where the folder being picked will live. `local` is the machine codeg is + * running on; `remote` is one of the connected codeg-servers, whose folders + * can only be opened in that workspace's own window. + */ +type WorkspaceChoice = + | { kind: "local" } + | { kind: "remote"; connection: RemoteWorkspaceConnection } interface WorkspaceFolderDialogProps { open: boolean @@ -94,6 +115,12 @@ interface WorkspaceFolderDialogProps { * step 2 links any number of other directories in as subdirectories of that * root. Reopened from the folder menu, it starts at step 2 so links can be * renamed, repaired, or removed later. + * + * On a local desktop window with remote workspaces connected, step 0 asks + * *which* workspace to browse first: this machine, or one of the connected + * servers. Picking a remote one browses that host's filesystem and opens the + * folder in its own window — folders belong to the backend that owns their + * paths, so a remote folder can't be opened into this workspace's list. */ export function WorkspaceFolderDialog({ open, @@ -104,9 +131,13 @@ export function WorkspaceFolderDialog({ const t = useTranslations("Folder.workspaceDialog") const tBrowser = useTranslations("DirectoryBrowser") const openFolder = useAppWorkspaceStore((s) => s.openFolder) + const { connections, refresh: refreshConnections } = + useRemoteWorkspaceConnections() const manageMode = !!folder const [view, setView] = useState(manageMode ? "links" : "pick-root") + const [choice, setChoice] = useState(null) + const [workspaceQuery, setWorkspaceQuery] = useState("") const [rootFolder, setRootFolder] = useState( folder ?? null ) @@ -130,8 +161,22 @@ export function WorkspaceFolderDialog({ const [renameValue, setRenameValue] = useState("") const [busyLinkId, setBusyLinkId] = useState(null) - const nativePickerAvailable = - isDesktop() && getActiveRemoteConnectionId() === null + // A remote-desktop window already *is* a workspace; only the local window + // gets to choose between them. The system picker is local-only for the same + // reason: it opens a dialog on this machine. + const isLocalDesktop = isDesktop() && getActiveRemoteConnectionId() === null + const nativePickerAvailable = isLocalDesktop && choice?.kind !== "remote" + + // Stable per chosen host — DirectoryBrowser restarts its session whenever the + // source identity changes, so an inline object would reset it every render. + const remoteFileSystem = useMemo(() => { + if (choice?.kind !== "remote") return undefined + const { id } = choice.connection + return { + home: () => getRemoteHomeDirectory(id), + list: (path: string) => listRemoteDirectoryEntries(id, path), + } + }, [choice]) const linkBrowseStart = useMemo( () => @@ -139,11 +184,38 @@ export function WorkspaceFolderDialog({ [rootFolder] ) + // Re-read on mount and on every open: connections come and go from another + // window (the settings window), so a list captured at mount goes stale. A + // window bound to a remote server has no local list to offer. + useEffect(() => { + if (manageMode || !isLocalDesktop) return + void refreshConnections() + }, [open, manageMode, isLocalDesktop, refreshConnections]) + + // Identity of the connection list, compared by content: every load hands back + // a fresh array, so an id-based key is what tells "the list changed" apart + // from "the same list arrived again". + const connectionKey = connections.map((c) => c.id).join(",") + // Mirrored so the reset below can read the list the view was decided from + // without re-running when it loads. + const connectionKeyRef = useRef(connectionKey) + useEffect(() => { + connectionKeyRef.current = connectionKey + }, [connectionKey]) + const decidedKeyRef = useRef(null) + // Reset to a clean session on every real open, so a dialog reopened after a // cancel never shows the previous run's picks. useEffect(() => { if (!open) return - setView(folder ? "links" : "pick-root") + const known = connectionKeyRef.current + decidedKeyRef.current = known + const canChooseWorkspace = !folder && isLocalDesktop && known !== "" + setView( + folder ? "links" : canChooseWorkspace ? "pick-workspace" : "pick-root" + ) + setChoice(canChooseWorkspace ? null : { kind: "local" }) + setWorkspaceQuery("") setRootFolder(folder ?? null) setRootPath(folder?.path ?? "") setTargetPath("") @@ -156,7 +228,33 @@ export function WorkspaceFolderDialog({ setOpeningRoot(false) setCreating(false) setPreviewing(false) - }, [open, folder]) + }, [open, folder, isLocalDesktop]) + + // The reload kicked off on open lands *after* the view was decided, so the + // decision above is made from whatever the list held before it. Correct the + // one case that matters — the list gained or lost every connection while the + // dialog sat closed — and then leave the user alone: later steps (a picked + // root, the linking view) are never interrupted. + useEffect(() => { + if (!open || manageMode || !isLocalDesktop) return + const previous = decidedKeyRef.current + if (previous === null || previous === connectionKey) return + decidedKeyRef.current = connectionKey + + if (previous === "" && connectionKey !== "" && view === "pick-root") { + // Nothing to choose between when the dialog opened; now there is. + setChoice(null) + setView("pick-workspace") + } else if ( + previous !== "" && + connectionKey === "" && + view === "pick-workspace" + ) { + // The last connection went away while the chooser was up. + setChoice({ kind: "local" }) + setView("pick-root") + } + }, [open, manageMode, isLocalDesktop, connectionKey, view]) const refreshLinks = useCallback(async (folderId: number) => { setLoadingLinks(true) @@ -176,8 +274,35 @@ export function WorkspaceFolderDialog({ // ── Step 1: workspace root ──────────────────────────────────────────────── + /** + * Hand a remote folder to its own workspace window. There is no step 2 here: + * the folder lands in another backend, so linking, renaming and repairing it + * are that window's job (its folder menu reopens this dialog in manage mode, + * against the backend that owns the links). + */ + const commitRemoteRoot = useCallback( + async (connection: RemoteWorkspaceConnection, path: string) => { + setOpeningRoot(true) + try { + await openRemoteWorkspaceFolder(connection.id, path) + onOpenChange(false) + } catch (err) { + toast.error(t("remoteOpenFailed", { name: connection.name }), { + description: toErrorMessage(err), + }) + } finally { + setOpeningRoot(false) + } + }, + [onOpenChange, t] + ) + const commitRoot = useCallback( async (path: string) => { + if (choice?.kind === "remote") { + await commitRemoteRoot(choice.connection, path) + return + } setOpeningRoot(true) try { const detail = await openFolder(path) @@ -190,7 +315,7 @@ export function WorkspaceFolderDialog({ setOpeningRoot(false) } }, - [openFolder, onFolderOpened, t] + [choice, commitRemoteRoot, openFolder, onFolderOpened, t] ) const handleConfirmRoot = useCallback(async () => { @@ -377,6 +502,8 @@ export function WorkspaceFolderDialog({ // ── Render ──────────────────────────────────────────────────────────────── + const remoteChoice = choice?.kind === "remote" ? choice.connection : null + const title = view === "add-targets" ? t("addTargetsTitle") @@ -384,22 +511,113 @@ export function WorkspaceFolderDialog({ ? t("manageTitle") : t("title") + // Same rule for every row: match the name or the host it reads. + const query = workspaceQuery.trim().toLowerCase() + const matchesQuery = (...fields: string[]) => + query === "" || fields.some((f) => f.toLowerCase().includes(query)) + const localMatches = matchesQuery(t("thisPc"), t("thisPcHint")) + const matchingConnections = connections.filter((connection) => + matchesQuery(connection.name, connection.base_url) + ) + return ( {title} - {view === "pick-root" - ? t("pickRootDescription") - : view === "add-targets" - ? t("addTargetsDescription") - : t("linksDescription")} + {view === "pick-workspace" + ? t("pickWorkspaceDescription") + : view === "pick-root" + ? remoteChoice + ? t("pickRootRemoteDescription", { name: remoteChoice.name }) + : t("pickRootDescription") + : view === "add-targets" + ? t("addTargetsDescription") + : t("linksDescription")} + {view === "pick-workspace" ? ( + <> +
+ + setWorkspaceQuery(e.target.value)} + placeholder={t("searchWorkspaces")} + className="h-8 pl-8 text-sm" + autoFocus + /> +
+ +
+ {localMatches ? ( + { + setChoice({ kind: "local" }) + setView("pick-root") + }} + /> + ) : null} + {matchingConnections.map((connection) => ( + { + setChoice({ kind: "remote", connection }) + setRootPath("") + setView("pick-root") + }} + /> + ))} + {!localMatches && matchingConnections.length === 0 ? ( +
+ {t("noWorkspaceMatch", { query: workspaceQuery.trim() })} +
+ ) : null} +
+
+ + + + + ) : null} + {view === "pick-root" ? ( <> + {isLocalDesktop && connections.length > 0 ? ( +
+ {remoteChoice ? ( + + ) : ( + + )} + + {remoteChoice ? remoteChoice.name : t("thisPc")} + + +
+ ) : null} onOpenChange(false)} + onClick={() => + isLocalDesktop && connections.length > 0 + ? setView("pick-workspace") + : onOpenChange(false) + } > - {tBrowser("cancel")} + {isLocalDesktop && connections.length > 0 + ? t("back") + : tBrowser("cancel")} @@ -695,6 +920,41 @@ function NativePickerButton({ ) } +/** + * One row of the workspace chooser: a full-width target with the workspace name + * and, underneath, what machine it actually reads (the local machine, or the + * server URL). Rows commit on click — the chooser is a single decision, so a + * select-then-confirm step would just add a keystroke. + */ +function WorkspaceRow({ + icon: Icon, + name, + hint, + onSelect, +}: { + icon: LucideIcon + name: string + hint: string + onSelect: () => void +}) { + return ( + + ) +} + interface PendingLink { targetPath: string name: string diff --git a/src/components/shared/directory-browser.tsx b/src/components/shared/directory-browser.tsx index 9185512666..f4c58a2ad9 100644 --- a/src/components/shared/directory-browser.tsx +++ b/src/components/shared/directory-browser.tsx @@ -48,6 +48,24 @@ import type { DirectoryEntry } from "@/lib/types" export const normalizeFsPath = (path: string) => path.replace(/[/\\]+$/, "") || path +/** + * Where a browsing session reads its directories from. Defaults to this + * window's own backend; a host browsing another workspace (a remote codeg + * -server, say) swaps in a source that talks to it directly. + */ +export interface DirectoryBrowserFileSystem { + home: () => Promise + list: (path: string) => Promise +} + +// Wrapped rather than passed by reference: a module-scope binding to the mock +// would make every test that imports this panel (minimally mocked `@/lib/api` +// included) need those two exports at import time. +const localFileSystem: DirectoryBrowserFileSystem = { + home: () => getHomeDirectory(), + list: (path: string) => listDirectoryEntries(path), +} + // Synchronous layout effect on the client (so the session/selection guards see // the latest committed values before any pending async work resolves), but a // passive effect during the static-export prerender to avoid the SSR warning. @@ -85,6 +103,12 @@ interface DirectoryBrowserProps { onQuickSelect?: (path: string) => void /** Mirrors the panel's in-flight confirm so hosts can disable their button. */ onBusyChange?: (busy: boolean) => void + /** + * Backend the tree is read from. Changing this while mounted is not + * supported — hosts that switch source (local ↔ remote workspace) unmount + * the panel or drop `active` first so the next session starts clean. + */ + fileSystem?: DirectoryBrowserFileSystem /** Turns rows into checkboxes; `value` still tracks the last row clicked. */ multiple?: boolean selectedPaths?: string[] @@ -115,6 +139,7 @@ export const DirectoryBrowser = forwardRef< onValueChange, onQuickSelect, onBusyChange, + fileSystem, multiple = false, selectedPaths, onToggleSelected, @@ -125,6 +150,9 @@ export const DirectoryBrowser = forwardRef< ) { const t = useTranslations("DirectoryBrowser") const ime = useImeGuard() + // Hosts pass a stable (memoized) source: an inline object would restart the + // session on every render. + const fs = fileSystem ?? localFileSystem const [rootPath, setRootPath] = useState("") const [entries, setEntries] = useState>( @@ -144,12 +172,19 @@ export const DirectoryBrowser = forwardRef< // would otherwise bump again and strand the first init's in-flight writes). const sessionGen = useRef(0) const prevActive = useRef(active) + const prevFileSystem = useRef(fileSystem) useIsomorphicLayoutEffect(() => { - if (prevActive.current !== active) { + // A source swap (local ↔ remote workspace) is a new session for the same + // reason a hide/show is: everything the previous source cached is invalid. + if ( + prevActive.current !== active || + prevFileSystem.current !== fileSystem + ) { prevActive.current = active + prevFileSystem.current = fileSystem sessionGen.current += 1 } - }, [active]) + }, [active, fileSystem]) // Monotonic navigation id. Each navigateTo() bumps it and the open-time init() // captures it, so within a single session a slower earlier navigation (or a // late init) can't overwrite the destination of a newer one — the latest user @@ -179,7 +214,7 @@ export const DirectoryBrowser = forwardRef< setLoading((prev) => new Set(prev).add(path)) setError(null) try { - const result = await listDirectoryEntries(path) + const result = await fs.list(path) // Skip writes if a hide/show happened mid-flight — they belong to a // session that no longer exists and would surface stale data. if (gen === sessionGen.current) { @@ -199,7 +234,7 @@ export const DirectoryBrowser = forwardRef< } } }, - [entries, t] + [entries, fs, t] ) const navigateTo = useCallback( @@ -243,7 +278,7 @@ export const DirectoryBrowser = forwardRef< const init = async () => { try { - const startPath = initialPath || (await getHomeDirectory()) + const startPath = initialPath || (await fs.home()) // Drop these writes if a hide/show superseded this init, or the user // already navigated somewhere else while the start dir was loading. if (gen !== sessionGen.current || seq !== navSeq.current) return @@ -251,7 +286,7 @@ export const DirectoryBrowser = forwardRef< onValueChange(startPath) setLoading(new Set([startPath])) - const result = await listDirectoryEntries(startPath) + const result = await fs.list(startPath) if (gen !== sessionGen.current || seq !== navSeq.current) return setEntries(new Map([[startPath, result]])) setLoading(new Set()) @@ -322,14 +357,14 @@ export const DirectoryBrowser = forwardRef< const handleGoHome = useCallback(async () => { const gen = sessionGen.current try { - const home = await getHomeDirectory() + const home = await fs.home() if (gen !== sessionGen.current) return navigateTo(home) } catch { if (gen !== sessionGen.current) return setError(t("errorLoadingDir")) } - }, [navigateTo, t]) + }, [fs, navigateTo, t]) const handlePathInputKeyDown = useCallback( (e: React.KeyboardEvent) => { diff --git a/src/components/workspace/remote-workspace-open-folder-listener.test.tsx b/src/components/workspace/remote-workspace-open-folder-listener.test.tsx new file mode 100644 index 0000000000..25105c9710 --- /dev/null +++ b/src/components/workspace/remote-workspace-open-folder-listener.test.tsx @@ -0,0 +1,172 @@ +import { act, cleanup, render, waitFor } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import enMessages from "@/i18n/messages/en.json" +import type { FolderDetail } from "@/lib/types" +import { + resetAppWorkspaceStore, + useAppWorkspaceStore, +} from "@/stores/app-workspace-store" + +// Mirrors how the component is really mounted: hydration is flipped by the +// stores, and the tab/route halves come from contexts. +let tabs: { + tabsHydrated: boolean + openNewConversationTab: ReturnType +} +let openConversations: ReturnType +let openFolder: ReturnType +let desktop = true +let remoteWindow = true +let search = "" +let eventHandler: ((event: { payload: unknown }) => void) | null = null +let listenCalls = 0 + +vi.mock("@/contexts/tab-context", () => ({ + useTabStore: (selector: (s: typeof tabs) => unknown) => selector(tabs), + useTabActions: () => tabs, +})) +vi.mock("@/contexts/workbench-route-context", () => ({ + useWorkbenchRoute: () => ({ openConversations }), +})) +vi.mock("@/lib/platform", () => ({ + isDesktop: () => desktop, + isRemoteDesktopWindow: () => desktop && remoteWindow, +})) +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(search), +})) +vi.mock("@tauri-apps/api/event", () => ({ + listen: async (_event: string, cb: (event: { payload: unknown }) => void) => { + eventHandler = cb + listenCalls += 1 + return () => {} + }, +})) + +const toast = vi.hoisted(() => ({ error: vi.fn() })) +vi.mock("sonner", () => ({ toast })) + +import { RemoteWorkspaceOpenFolderListener } from "./remote-workspace-open-folder-listener" + +const folder = (path: string) => + ({ id: 11, path, name: "projects" }) as FolderDetail + +function renderListener() { + return render( + + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() + eventHandler = null + listenCalls = 0 + desktop = true + remoteWindow = true + search = "" + openFolder = vi.fn(async (path: string) => folder(path)) + openConversations = vi.fn() + tabs = { tabsHydrated: true, openNewConversationTab: vi.fn() } + resetAppWorkspaceStore() + useAppWorkspaceStore.setState({ foldersHydrated: true, openFolder }) +}) + +afterEach(() => cleanup()) + +describe("RemoteWorkspaceOpenFolderListener", () => { + it("opens the folder handed over when the window was spawned for it", async () => { + // Put the params in the real URL too: the component strips the handoff + // param from `window.location`, not from the Next router. + window.history.replaceState( + {}, + "", + "/workspace?remoteConnectionId=3&openFolderPath=/srv/projects" + ) + search = "remoteConnectionId=3&openFolderPath=/srv/projects" + renderListener() + + await waitFor(() => + expect(openFolder).toHaveBeenCalledWith("/srv/projects") + ) + expect(openConversations).toHaveBeenCalled() + expect(tabs.openNewConversationTab).toHaveBeenCalledWith( + 11, + "/srv/projects" + ) + // The remote identity has to survive; only the handoff param is dropped. + expect(window.location.search).toBe("?remoteConnectionId=3") + }) + + it("queues a spawn-time request until hydration completes", async () => { + search = "openFolderPath=/srv/projects" + useAppWorkspaceStore.setState({ foldersHydrated: false }) + tabs = { ...tabs, tabsHydrated: false } + const { rerender } = renderListener() + expect(openFolder).not.toHaveBeenCalled() + + tabs = { ...tabs, tabsHydrated: true } + rerender( + + + + ) + act(() => { + useAppWorkspaceStore.setState({ foldersHydrated: true }) + }) + + await waitFor(() => + expect(openFolder).toHaveBeenCalledWith("/srv/projects") + ) + }) + + it("opens a folder handed to an already-open window", async () => { + renderListener() + await waitFor(() => expect(eventHandler).toBeTruthy()) + + eventHandler!({ payload: { path: "/srv/api" } }) + await waitFor(() => expect(openFolder).toHaveBeenCalledWith("/srv/api")) + expect(tabs.openNewConversationTab).toHaveBeenCalledWith(11, "/srv/api") + }) + + it("ignores an event with no path", async () => { + renderListener() + await waitFor(() => expect(eventHandler).toBeTruthy()) + + eventHandler!({ payload: {} }) + eventHandler!({ payload: { path: "" } }) + await Promise.resolve() + expect(openFolder).not.toHaveBeenCalled() + }) + + it("stays idle in a local window, which hands folders out, not in", () => { + remoteWindow = false + renderListener() + expect(listenCalls).toBe(0) + }) + + it("stays idle on the web, where no local window can hand anything over", () => { + desktop = false + renderListener() + expect(listenCalls).toBe(0) + }) + + it("reports a folder the remote host refuses to open", async () => { + openFolder = vi.fn(async () => { + throw new Error("not a directory") + }) + useAppWorkspaceStore.setState({ openFolder }) + search = "openFolderPath=/srv/gone" + renderListener() + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + "Failed to open the folder", + expect.anything() + ) + ) + }) +}) diff --git a/src/components/workspace/remote-workspace-open-folder-listener.tsx b/src/components/workspace/remote-workspace-open-folder-listener.tsx new file mode 100644 index 0000000000..9c9f68ba2e --- /dev/null +++ b/src/components/workspace/remote-workspace-open-folder-listener.tsx @@ -0,0 +1,150 @@ +"use client" + +import { useCallback, useEffect, useRef } from "react" +import { useSearchParams } from "next/navigation" +import { listen, type UnlistenFn } from "@tauri-apps/api/event" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +import { useAppWorkspaceStore } from "@/stores/app-workspace-store" +import { useTabActions, useTabStore } from "@/contexts/tab-context" +import { useWorkbenchRoute } from "@/contexts/workbench-route-context" +import { toErrorMessage } from "@/lib/app-error" +import { isRemoteDesktopWindow } from "@/lib/platform" +import { REMOTE_OPEN_FOLDER_EVENT } from "@/lib/remote-workspace" + +/** + * URL param carrying a folder to open, set when this window was spawned by + * `open_remote_workspace_folder`. MUST match `OPEN_FOLDER_PATH_PARAM` in + * `src-tauri/src/commands/remote_workspace.rs`. + */ +export const OPEN_FOLDER_PATH_PARAM = "openFolderPath" + +/** + * Opens the folder a local window asked this remote workspace window to open. + * + * A folder belongs to the backend that owns its path, so the "Open Folder" + * picker on the local machine can't open a remote folder itself — it raises + * this window and hands the path over. Two delivery routes, one handler: + * + * - A URL param, when the window had to be spawned (an event can't reach a + * webview that doesn't exist yet). + * - A `remote-open-folder` Tauri event, when the window was already open. + * + * Both are replayed after hydration: a spawn-time param is read before folders + * or tabs exist, and an event that lands mid-startup would otherwise have + * nowhere to put the folder. + */ +export function RemoteWorkspaceOpenFolderListener() { + const t = useTranslations("Folder.workspaceDialog") + const { openNewConversationTab } = useTabActions() + const { openConversations } = useWorkbenchRoute() + const searchParams = useSearchParams() + const tabsHydrated = useTabStore((s) => s.tabsHydrated) + // Only here to re-run the replay effect: `attempt` reads the store directly. + const foldersHydrated = useAppWorkspaceStore((s) => s.foldersHydrated) + + const pendingRef = useRef(null) + + // Read at run time via a ref so the subscription below never has to be torn + // down and re-established when these change. + const stateRef = useRef({ + tabsHydrated, + openConversations, + openNewConversationTab, + }) + useEffect(() => { + stateRef.current = { + tabsHydrated, + openConversations, + openNewConversationTab, + } + }, [tabsHydrated, openConversations, openNewConversationTab]) + + const attempt = useCallback(() => { + const path = pendingRef.current + if (!path) return + const store = useAppWorkspaceStore.getState() + if (!store.foldersHydrated || !stateRef.current.tabsHydrated) return + // One-shot: clear before the async work so a later hydration change can't + // open the same folder twice. + pendingRef.current = null + void (async () => { + try { + const detail = await store.openFolder(path) + // Return to the conversation workspace if a route (e.g. Automations) + // was covering the content region, else the new tab opens unseen. + stateRef.current.openConversations() + stateRef.current.openNewConversationTab(detail.id, detail.path) + } catch (err) { + toast.error(t("openFailed"), { description: toErrorMessage(err) }) + } + })() + }, [t]) + + // Spawn-time request: queue it (folders aren't loaded yet) and strip the + // param so a reload doesn't reopen the folder. Other params — notably the + // remote identity — have to survive. + useEffect(() => { + const requested = searchParams.get(OPEN_FOLDER_PATH_PARAM) + if (!requested) return + pendingRef.current = requested + try { + const next = new URLSearchParams(window.location.search) + next.delete(OPEN_FOLDER_PATH_PARAM) + const query = next.toString() + window.history.replaceState( + {}, + "", + query ? `/workspace?${query}` : "/workspace" + ) + } catch { + /* ignore */ + } + attempt() + // `searchParams` is read once on mount by design — this is a spawn-time + // handoff, not a live route. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Both halves hydrate independently, so either can be the last to arrive. + useEffect(() => { + attempt() + }, [foldersHydrated, tabsHydrated, attempt]) + + // Live request: this window is already up, so the local window emits an + // event instead of respawning it. Only a remote window is ever the target — + // the local window hands folders *out*, it never receives them. + useEffect(() => { + if (!isRemoteDesktopWindow()) return + let cancelled = false + let unlisten: UnlistenFn | undefined + + void (async () => { + try { + const off = await listen<{ path?: string }>( + REMOTE_OPEN_FOLDER_EVENT, + (event) => { + const path = event.payload?.path + if (!path) return + pendingRef.current = path + attempt() + } + ) + if (cancelled) off() + else unlisten = off + } catch (err) { + console.warn( + "[RemoteWorkspaceOpenFolderListener] subscription failed:", + err + ) + } + })() + + return () => { + cancelled = true + unlisten?.() + } + }, [attempt]) + + return null +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 7dc40b88d7..9575d76113 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2039,6 +2039,15 @@ "manageTitle": "المجلدات المرتبطة", "addTargetsTitle": "إضافة مجلدات للربط", "pickRootDescription": "اختر المجلد الرئيسي لمساحة العمل هذه.", + "pickWorkspaceDescription": "اختر مساحة العمل التي تريد فتح مجلد منها.", + "searchWorkspaces": "البحث في مساحات العمل", + "thisPc": "هذا الجهاز", + "thisPcHint": "المجلدات على هذا الجهاز", + "noWorkspaceMatch": "لا توجد مساحة عمل تطابق “{query}”", + "changeWorkspace": "تغيير", + "pickRootRemoteDescription": "اختر مجلدًا على {name}. سيُفتح في نافذة مساحة العمل هذه.", + "openInWorkspace": "فتح", + "remoteOpenFailed": "تعذّر فتح المجلد على {name}", "linksDescription": "اربط مجلدات أخرى كمجلدات فرعية ليتمكن الوكلاء من العمل عليها جميعًا من مساحة عمل واحدة.", "addTargetsDescription": "اختر مجلدًا واحدًا أو أكثر. سيصبح كل منها مجلدًا فرعيًا في مساحة العمل.", "useSystemPicker": "منتقي النظام", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index a92f5da47f..2617dd6112 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2039,6 +2039,15 @@ "manageTitle": "Verknüpfte Ordner", "addTargetsTitle": "Ordner zum Verknüpfen hinzufügen", "pickRootDescription": "Wähle den Hauptordner für diesen Arbeitsbereich.", + "pickWorkspaceDescription": "Wähle den Arbeitsbereich aus, aus dem ein Ordner geöffnet werden soll.", + "searchWorkspaces": "Arbeitsbereiche suchen", + "thisPc": "Dieser PC", + "thisPcHint": "Ordner auf diesem Rechner", + "noWorkspaceMatch": "Kein Arbeitsbereich passt zu „{query}“", + "changeWorkspace": "Ändern", + "pickRootRemoteDescription": "Wähle einen Ordner auf {name}. Er wird im Fenster dieses Arbeitsbereichs geöffnet.", + "openInWorkspace": "Öffnen", + "remoteOpenFailed": "Ordner auf {name} konnte nicht geöffnet werden", "linksDescription": "Verknüpfe weitere Ordner als Unterverzeichnisse, damit Agenten aus einem Arbeitsbereich heraus über alle hinweg arbeiten können.", "addTargetsDescription": "Wähle einen oder mehrere Ordner. Jeder wird zu einem Unterverzeichnis des Arbeitsbereichs.", "useSystemPicker": "Systemauswahl", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 7d2996c29f..a81befa2fc 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2039,6 +2039,15 @@ "manageTitle": "Linked Folders", "addTargetsTitle": "Add Folders to Link", "pickRootDescription": "Pick the main folder for this workspace.", + "pickWorkspaceDescription": "Choose which workspace to open a folder from.", + "searchWorkspaces": "Search workspaces", + "thisPc": "This PC", + "thisPcHint": "Folders on this machine", + "noWorkspaceMatch": "No workspace matches “{query}”", + "changeWorkspace": "Change", + "pickRootRemoteDescription": "Pick a folder on {name}. It opens in that workspace window.", + "openInWorkspace": "Open", + "remoteOpenFailed": "Failed to open the folder on {name}", "linksDescription": "Link other folders in as subdirectories so agents can work across all of them from this one workspace.", "addTargetsDescription": "Select one or more folders. Each becomes a subdirectory of the workspace.", "useSystemPicker": "System picker", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 9e617ee625..286436d055 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2039,6 +2039,15 @@ "manageTitle": "Carpetas vinculadas", "addTargetsTitle": "Añadir carpetas para vincular", "pickRootDescription": "Elige la carpeta principal de este espacio de trabajo.", + "pickWorkspaceDescription": "Elige desde qué espacio de trabajo abrir una carpeta.", + "searchWorkspaces": "Buscar espacios de trabajo", + "thisPc": "Este equipo", + "thisPcHint": "Carpetas en esta máquina", + "noWorkspaceMatch": "Ningún espacio de trabajo coincide con «{query}»", + "changeWorkspace": "Cambiar", + "pickRootRemoteDescription": "Elige una carpeta en {name}. Se abrirá en la ventana de ese espacio de trabajo.", + "openInWorkspace": "Abrir", + "remoteOpenFailed": "No se pudo abrir la carpeta en {name}", "linksDescription": "Vincula otras carpetas como subdirectorios para que los agentes trabajen en todas ellas desde un solo espacio de trabajo.", "addTargetsDescription": "Selecciona una o más carpetas. Cada una será un subdirectorio del espacio de trabajo.", "useSystemPicker": "Selector del sistema", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 462b4166b8..8bfabcf740 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2039,6 +2039,15 @@ "manageTitle": "Dossiers liés", "addTargetsTitle": "Ajouter des dossiers à lier", "pickRootDescription": "Choisissez le dossier principal de cet espace de travail.", + "pickWorkspaceDescription": "Choisissez l’espace de travail depuis lequel ouvrir un dossier.", + "searchWorkspaces": "Rechercher des espaces de travail", + "thisPc": "Ce PC", + "thisPcHint": "Dossiers sur cette machine", + "noWorkspaceMatch": "Aucun espace de travail ne correspond à « {query} »", + "changeWorkspace": "Changer", + "pickRootRemoteDescription": "Choisissez un dossier sur {name}. Il s’ouvrira dans la fenêtre de cet espace de travail.", + "openInWorkspace": "Ouvrir", + "remoteOpenFailed": "Impossible d’ouvrir le dossier sur {name}", "linksDescription": "Liez d'autres dossiers en sous-répertoires pour que les agents travaillent sur tous depuis un seul espace de travail.", "addTargetsDescription": "Sélectionnez un ou plusieurs dossiers. Chacun devient un sous-répertoire de l'espace de travail.", "useSystemPicker": "Sélecteur système", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 325573f2d5..b6636d9042 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2039,6 +2039,15 @@ "manageTitle": "リンク済みフォルダー", "addTargetsTitle": "リンクするフォルダーを追加", "pickRootDescription": "このワークスペースのメインフォルダーを選択します。", + "pickWorkspaceDescription": "フォルダーを開くワークスペースを選びます。", + "searchWorkspaces": "ワークスペースを検索", + "thisPc": "この PC", + "thisPcHint": "このマシンのフォルダー", + "noWorkspaceMatch": "「{query}」に一致するワークスペースがありません", + "changeWorkspace": "変更", + "pickRootRemoteDescription": "{name} 上のフォルダーを選びます。そのワークスペースのウィンドウで開かれます。", + "openInWorkspace": "開く", + "remoteOpenFailed": "{name} でフォルダーを開けませんでした", "linksDescription": "他のフォルダーをサブディレクトリとしてリンクすると、エージェントは 1 つのワークスペースから横断的に作業できます。", "addTargetsDescription": "フォルダーを 1 つ以上選択してください。それぞれがワークスペースのサブディレクトリになります。", "useSystemPicker": "システムの選択画面", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 2669e70132..d655572408 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2039,6 +2039,15 @@ "manageTitle": "연결된 폴더", "addTargetsTitle": "연결할 폴더 추가", "pickRootDescription": "이 작업 공간의 기본 폴더를 선택하세요.", + "pickWorkspaceDescription": "폴더를 열 워크스페이스를 선택하세요.", + "searchWorkspaces": "워크스페이스 검색", + "thisPc": "이 PC", + "thisPcHint": "이 컴퓨터의 폴더", + "noWorkspaceMatch": "“{query}”과(와) 일치하는 워크스페이스가 없습니다", + "changeWorkspace": "변경", + "pickRootRemoteDescription": "{name}에서 폴더를 선택하세요. 해당 워크스페이스 창에서 열립니다.", + "openInWorkspace": "열기", + "remoteOpenFailed": "{name}에서 폴더를 열지 못했습니다", "linksDescription": "다른 폴더를 하위 디렉터리로 연결하면 에이전트가 하나의 작업 공간에서 여러 프로젝트를 함께 다룰 수 있습니다.", "addTargetsDescription": "폴더를 하나 이상 선택하세요. 각각 작업 공간의 하위 디렉터리가 됩니다.", "useSystemPicker": "시스템 선택기", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index bef40c2069..e3d4a7e0da 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2039,6 +2039,15 @@ "manageTitle": "Pastas vinculadas", "addTargetsTitle": "Adicionar pastas para vincular", "pickRootDescription": "Escolha a pasta principal deste espaço de trabalho.", + "pickWorkspaceDescription": "Escolha de qual espaço de trabalho abrir uma pasta.", + "searchWorkspaces": "Pesquisar espaços de trabalho", + "thisPc": "Este computador", + "thisPcHint": "Pastas nesta máquina", + "noWorkspaceMatch": "Nenhum espaço de trabalho corresponde a “{query}”", + "changeWorkspace": "Alterar", + "pickRootRemoteDescription": "Escolha uma pasta em {name}. Ela será aberta na janela desse espaço de trabalho.", + "openInWorkspace": "Abrir", + "remoteOpenFailed": "Falha ao abrir a pasta em {name}", "linksDescription": "Vincule outras pastas como subdiretórios para que os agentes trabalhem em todas elas a partir de um único espaço de trabalho.", "addTargetsDescription": "Selecione uma ou mais pastas. Cada uma vira um subdiretório do espaço de trabalho.", "useSystemPicker": "Seletor do sistema", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 8fcec81768..1abcd63253 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2039,6 +2039,15 @@ "manageTitle": "关联的文件夹", "addTargetsTitle": "添加要关联的文件夹", "pickRootDescription": "选择该工作空间的主文件夹。", + "pickWorkspaceDescription": "选择要从哪个工作区打开文件夹。", + "searchWorkspaces": "搜索工作区", + "thisPc": "本机", + "thisPcHint": "此计算机上的文件夹", + "noWorkspaceMatch": "没有匹配“{query}”的工作区", + "changeWorkspace": "更改", + "pickRootRemoteDescription": "在 {name} 上选择文件夹。它会在该工作区的窗口中打开。", + "openInWorkspace": "打开", + "remoteOpenFailed": "无法在 {name} 上打开文件夹", "linksDescription": "把其它文件夹以子目录的形式关联进来,智能体就能在同一个工作空间里跨项目工作。", "addTargetsDescription": "选择一个或多个文件夹,每个都会成为工作空间的一个子目录。", "useSystemPicker": "系统选择器", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index e3a490d99d..6b3174c5fd 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2039,6 +2039,15 @@ "manageTitle": "已連結的資料夾", "addTargetsTitle": "新增要連結的資料夾", "pickRootDescription": "選擇這個工作區的主資料夾。", + "pickWorkspaceDescription": "選擇要從哪個工作區開啟資料夾。", + "searchWorkspaces": "搜尋工作區", + "thisPc": "本機", + "thisPcHint": "這台電腦上的資料夾", + "noWorkspaceMatch": "沒有符合「{query}」的工作區", + "changeWorkspace": "變更", + "pickRootRemoteDescription": "在 {name} 上選擇資料夾。它會在該工作區的視窗中開啟。", + "openInWorkspace": "開啟", + "remoteOpenFailed": "無法在 {name} 上開啟資料夾", "linksDescription": "把其他資料夾以子目錄的形式連結進來,代理就能在同一個工作區跨專案工作。", "addTargetsDescription": "選擇一個或多個資料夾,每個都會成為工作區的一個子目錄。", "useSystemPicker": "系統選擇器", diff --git a/src/lib/remote-workspace.ts b/src/lib/remote-workspace.ts index 4f4d8d5580..c8bb495445 100644 --- a/src/lib/remote-workspace.ts +++ b/src/lib/remote-workspace.ts @@ -1,5 +1,6 @@ import { getShellTransport } from "@/lib/transport" import type { + DirectoryEntry, RemoteWorkspaceConnection, RemoteWorkspaceConnectionInput, } from "@/lib/types" @@ -57,3 +58,64 @@ export async function reorderRemoteWorkspaceConnections( export async function openRemoteWorkspace(id: number): Promise { return getShellTransport().call("open_remote_workspace", { id }) } + +/** + * Tauri event the local window uses to hand an already-open remote workspace + * window a folder to open. MUST match `REMOTE_OPEN_FOLDER_EVENT` in + * `src-tauri/src/commands/remote_workspace.rs`. + */ +export const REMOTE_OPEN_FOLDER_EVENT = "remote-open-folder" + +/** + * Run one command on a specific remote workspace, bypassing this window's own + * transport. + * + * A window's transport is bound to exactly one backend — the local machine, + * or the remote server this window was opened against. Browsing *another* + * workspace (to pick a folder on it before deciding to switch) has to talk to + * that server directly, so these calls go through the local `remote_http_call` + * proxy instead of `getTransport()`. + * + * Desktop-only: the proxy is a Tauri command, so a web client (already talking + * to one server of its own) has nothing to proxy through. + */ +function remoteCall( + connectionId: number, + command: string, + args?: Record +): Promise { + return getShellTransport().call("remote_http_call", { + connectionId, + command, + args: args ?? {}, + }) +} + +/** List a directory on a remote workspace host. */ +export async function listRemoteDirectoryEntries( + connectionId: number, + path: string +): Promise { + return remoteCall(connectionId, "list_directory_entries", { + path, + }) +} + +/** Home directory of the user running a remote codeg-server. */ +export async function getRemoteHomeDirectory( + connectionId: number +): Promise { + return remoteCall(connectionId, "get_home_directory") +} + +/** + * Open `path` in its own workspace window: focuses the window bound to this + * connection (spawning it if needed) and hands it the path to open. The folder + * is opened *there*, by that window, so it lands in the backend that owns it. + */ +export async function openRemoteWorkspaceFolder( + id: number, + path: string +): Promise { + return getShellTransport().call("open_remote_workspace_folder", { id, path }) +}