import { useEffect, useRef, useState } from "react"; import { create } from "zustand"; import { call, commands, isCancelled, nextRequestId, type ProjectDiscovery, type RequestId } from "@/platform/ipc"; import { invokeLocal } from "@/platform/tauriCore"; import { confirmProjectTrust } from "@/platform/projectTrust"; import { basename, dirname, useProject } from "@/platform/project"; import { isRemoteWindow } from "@/platform/remote"; import { tx } from "@/platform/i18n"; import { Button, Checkbox, Dialog, Icon, IconButton, Input, T } from "@/ui"; import { openProjectWithPolicy } from "./windows"; import styles from "./WorkspaceDialog.module.css"; const useWorkspaceDialog = create<{ mode: "create" | "manage" | null }>(() => ({ mode: null })); export const showWorkspaceDialog = (mode: "create" | "manage") => useWorkspaceDialog.setState({ mode }); export function WorkspaceDialogHost() { const mode = useWorkspaceDialog((s) => s.mode); return mode ? useWorkspaceDialog.setState({ mode: null })} /> : null; } function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose: () => void }) { const project = useProject.getState().project; const workspace = mode === "manage" ? project?.workspace : null; const [name, setName] = useState(workspace?.name ?? "Workspace"); const [path, setPath] = useState( workspace ? project!.root : project && !isRemoteWindow() ? `${dirname(project.root)}/workspace` : "", ); const [projects, setProjects] = useState( workspace?.projects ?? (project && !isRemoteWindow() && !project.workspace ? [project.root] : []), ); const [adding, setAdding] = useState(""); const [busy, setBusy] = useState(false); const [scanning, setScanning] = useState(false); const [discovery, setDiscovery] = useState(null); const [selected, setSelected] = useState>(new Set()); const [truncated, setTruncated] = useState(false); const [error, setError] = useState(""); const request = useRef(null); const mounted = useRef(true); useEffect(() => { mounted.current = true; return () => { mounted.current = false; if (request.current !== null) { // Discovery and its native folder picker stay local in remote windows too. void invokeLocal("cancel_request", { id: request.current }).catch(() => {}); request.current = null; } }; }, []); const disabled = busy || scanning; const add = (value: string) => { const path = value.trim(); if (path && !projects.includes(path)) setProjects([...projects, path]); setAdding(""); }; const browse = async (target: "workspace" | "project") => { const { open } = await import("@tauri-apps/plugin-dialog"); const result = await open({ directory: true, multiple: target === "project", title: tx(target === "project" ? "Add Projects" : "Workspace Location"), }); const paths = result === null ? [] : Array.isArray(result) ? result : [result]; if (!mounted.current || !paths.length) return; if (target === "workspace") { setPath(paths[0]); return; } const id = nextRequestId(); request.current = id; setScanning(true); setError(""); setTruncated(false); try { const found = await call(commands.workspaceDiscoverProjects(id, paths)); if (request.current !== id) return; setProjects((current) => [...new Set([...current, ...found.direct])]); setTruncated(found.truncated); if (found.projects.length) { setDiscovery(found); setSelected( new Set(found.projects.map((p) => p.path).filter((p) => !projects.includes(p) && !found.direct.includes(p))), ); } } catch (e) { if (request.current === id && !isCancelled(e)) setError(e instanceof Error ? e.message : String(e)); } finally { if (request.current === id) { request.current = null; setScanning(false); } } }; const save = async () => { if (disabled || discovery) return; setBusy(true); setError(""); try { const included = adding.trim() ? [...new Set([...projects, adding.trim()])] : projects; if (mode === "create") { const root = await call(commands.workspaceCreate(path.trim(), name.trim(), included)); await openProjectWithPolicy(root); } else { for (const path of included.filter((p) => !workspace?.projects.includes(p))) { await confirmProjectTrust(path); if (!(await call(commands.projectTrustStatus(path))).trusted) { setError( tx`Cannot attach ${path} because it is restricted. Open it separately and choose Trust Project, then attach it again.`, ); return; } } const updated = await call(commands.workspaceUpdate(name.trim(), included)); useProject.setState({ project: updated, settings: updated.settings }); } onClose(); } catch (e) { if (!isCancelled(e)) setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } }; if (discovery) return ( setDiscovery(null) }, { label: "Add Selected", role: "default", disabled: !selected.size, onClick: () => { setProjects((current) => [...new Set([...current, ...selected])]); setDiscovery(null); }, }, ]} >
Projects found

Choose the projects to link to this workspace.

{discovery.projects.map((candidate) => { const linked = projects.includes(candidate.path); return (
{candidate.relative_path} {linked ? Already linked : basename(candidate.root)} } onChange={(checked) => setSelected((current) => { const next = new Set(current); if (checked) next.add(candidate.path); else next.delete(candidate.path); return next; }) } />
); })}
{truncated && (

Search stopped at the entry limit. More projects may exist; choose a smaller folder to search again.

)}
); return ( { if (!busy) onClose(); }} title={mode === "create" ? "New Workspace" : "Manage Workspace"} width={640} buttons={[ { label: "Cancel", role: "cancel", disabled: busy }, { label: mode === "create" ? "Create" : "Apply", role: "default", disabled: disabled || !name.trim() || !path.trim(), onClick: () => void save(), }, ]} >
Linked projects
{projects.map((root) => (
{basename(root)} {root}
setProjects(projects.filter((p) => p !== root))} />
))} {!projects.length && (

Add the projects you want to work on together.

)}
setAdding(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); add(adding); } }} />

Project folders stay in place. Removing a project here keeps its files.

{scanning && (

Searching for projects…

)} {truncated && (

Search stopped at the entry limit. More projects may exist; choose a smaller folder to search again.

)} {error && (
{error}
)}
); }