diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 3d42665e0..13595010a 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -18,6 +18,7 @@ import ModelPickerModal from "./components/model-picker-modal"; import ResetModal from "./components/reset-modal"; import TemplateModal from "./components/template-modal"; import WorkspacePicker from "./components/workspace-picker"; +import CreateRemoteWorkspaceModal from "./components/create-remote-workspace-modal"; import CreateWorkspaceModal from "./components/create-workspace-modal"; import McpAuthModal from "./components/mcp-auth-modal"; import OnboardingView from "./pages/onboarding"; @@ -1600,6 +1601,7 @@ export default function App() { setWorkspaceSearch: workspaceStore.setWorkspaceSearch, workspacePickerOpen: workspaceStore.workspacePickerOpen(), setWorkspacePickerOpen: workspaceStore.setWorkspacePickerOpen, + connectingWorkspaceId: workspaceStore.connectingWorkspaceId(), workspaces: workspaceStore.workspaces(), filteredWorkspaces: workspaceStore.filteredWorkspaces(), activeWorkspaceId: workspaceStore.activeWorkspaceId(), @@ -1860,13 +1862,16 @@ export default function App() { workspaceStore.setWorkspacePickerOpen(false)} onSelect={workspaceStore.activateWorkspace} - onCreateNew={() => workspaceStore.setCreateWorkspaceOpen(true)} + onCreateLocal={() => workspaceStore.setCreateWorkspaceOpen(true)} + onCreateRemote={() => workspaceStore.setCreateRemoteWorkspaceOpen(true)} + onForget={workspaceStore.forgetWorkspace} + connectingWorkspaceId={workspaceStore.connectingWorkspaceId()} /> workspaceStore.createWorkspaceFlow(preset, folder) } - submitting={busy() && busyLabel() === "Creating workspace"} + submitting={busy() && busyLabel() === "status.creating_workspace"} + /> + + workspaceStore.setCreateRemoteWorkspaceOpen(false)} + onConfirm={(input) => workspaceStore.createRemoteWorkspaceFlow(input)} + submitting={ + busy() && + (busyLabel() === "status.creating_workspace" || busyLabel() === "status.connecting") + } /> ); diff --git a/packages/app/src/app/components/create-remote-workspace-modal.tsx b/packages/app/src/app/components/create-remote-workspace-modal.tsx new file mode 100644 index 000000000..1cae907f0 --- /dev/null +++ b/packages/app/src/app/components/create-remote-workspace-modal.tsx @@ -0,0 +1,133 @@ +import { Show, createEffect, createMemo, createSignal } from "solid-js"; + +import { Globe, X } from "lucide-solid"; +import { t, currentLocale } from "../../i18n"; + +import Button from "./button"; +import TextInput from "./text-input"; + +export default function CreateRemoteWorkspaceModal(props: { + open: boolean; + onClose: () => void; + onConfirm: (input: { baseUrl: string; directory?: string | null; displayName?: string | null }) => void; + submitting?: boolean; + inline?: boolean; + showClose?: boolean; + title?: string; + subtitle?: string; + confirmLabel?: string; +}) { + const translate = (key: string) => t(key, currentLocale()); + + const [baseUrl, setBaseUrl] = createSignal(""); + const [directory, setDirectory] = createSignal(""); + const [displayName, setDisplayName] = createSignal(""); + + const showClose = () => props.showClose ?? true; + const title = () => props.title ?? translate("dashboard.create_remote_workspace_title"); + const subtitle = () => props.subtitle ?? translate("dashboard.create_remote_workspace_subtitle"); + const confirmLabel = () => props.confirmLabel ?? translate("dashboard.create_remote_workspace_confirm"); + const isInline = () => props.inline ?? false; + const submitting = () => props.submitting ?? false; + + const canSubmit = createMemo(() => baseUrl().trim().length > 0 && !submitting()); + + createEffect(() => { + if (!props.open) return; + setBaseUrl(""); + setDirectory(""); + setDisplayName(""); + }); + + const content = ( +
+
+
+

{title()}

+

{subtitle()}

+
+ + + +
+ +
+
+
+ +
+
+
{translate("dashboard.remote_workspace_title")}
+
{translate("dashboard.remote_workspace_hint")}
+
+
+ +
+ setBaseUrl(event.currentTarget.value)} + disabled={submitting()} + /> + setDirectory(event.currentTarget.value)} + hint={translate("dashboard.remote_directory_hint")} + disabled={submitting()} + /> + setDisplayName(event.currentTarget.value)} + disabled={submitting()} + /> +
+
+ +
+ + + + +
+
+ ); + + return ( + +
+ {content} +
+
+ ); +} diff --git a/packages/app/src/app/components/workspace-chip.tsx b/packages/app/src/app/components/workspace-chip.tsx index 2d6df5af0..6f1603087 100644 --- a/packages/app/src/app/components/workspace-chip.tsx +++ b/packages/app/src/app/components/workspace-chip.tsx @@ -1,8 +1,11 @@ import type { WorkspaceInfo } from "../lib/tauri"; -import { ChevronDown, Folder, Globe, Zap } from "lucide-solid"; +import { t, currentLocale } from "../../i18n"; -function iconForPreset(preset: string) { +import { ChevronDown, Folder, Globe, Loader2, Zap } from "lucide-solid"; + +function iconForWorkspace(preset: string, workspaceType: string) { + if (workspaceType === "remote") return Globe; if (preset === "starter") return Zap; if (preset === "automation") return Folder; if (preset === "minimal") return Globe; @@ -12,8 +15,14 @@ function iconForPreset(preset: string) { export default function WorkspaceChip(props: { workspace: WorkspaceInfo; onClick: () => void; + connecting?: boolean; }) { - const Icon = iconForPreset(props.workspace.preset); + const Icon = iconForWorkspace(props.workspace.preset, props.workspace.workspaceType); + const subtitle = () => + props.workspace.workspaceType === "remote" + ? props.workspace.baseUrl ?? props.workspace.path + : props.workspace.path; + const translate = (key: string) => t(key, currentLocale()); return ( ); } diff --git a/packages/app/src/app/components/workspace-picker.tsx b/packages/app/src/app/components/workspace-picker.tsx index cea86ec41..2f8c6eab5 100644 --- a/packages/app/src/app/components/workspace-picker.tsx +++ b/packages/app/src/app/components/workspace-picker.tsx @@ -1,6 +1,6 @@ import { For, Show, createMemo } from "solid-js"; -import { Check, Plus, Search } from "lucide-solid"; +import { Check, Globe, Loader2, Plus, Search, Trash2 } from "lucide-solid"; import { t, currentLocale } from "../../i18n"; import type { WorkspaceInfo } from "../lib/tauri"; @@ -12,17 +12,26 @@ export default function WorkspacePicker(props: { search: string; onSearch: (value: string) => void; onClose: () => void; - onSelect: (workspaceId: string) => void; - onCreateNew: () => void; + onSelect: (workspaceId: string) => Promise | boolean | void; + onCreateLocal: () => void; + onCreateRemote: () => void; + onForget: (workspaceId: string) => void; + connectingWorkspaceId?: string | null; }) { const translate = (key: string) => t(key, currentLocale()); const filtered = createMemo(() => { const query = props.search.trim().toLowerCase(); if (!query) return props.workspaces; - return props.workspaces.filter((w) => `${w.name} ${w.path}`.toLowerCase().includes(query)); + return props.workspaces.filter((w) => + `${w.name} ${w.path} ${w.baseUrl ?? ""} ${w.displayName ?? ""} ${w.directory ?? ""}` + .toLowerCase() + .includes(query) + ); }); + const totalCount = createMemo(() => props.workspaces.length); + return (
- {translate("dashboard.workspaces")} + {translate("dashboard.workspaces")} ({totalCount()})
{(ws) => ( -
+ +
+ {ws.directory} +
+
+ - + + + + + )}
- +
+ + +
diff --git a/packages/app/src/app/context/workspace.ts b/packages/app/src/app/context/workspace.ts index d79a2929a..98ed9cf4d 100644 --- a/packages/app/src/app/context/workspace.ts +++ b/packages/app/src/app/context/workspace.ts @@ -20,9 +20,12 @@ import { pickDirectory, workspaceBootstrap, workspaceCreate, + workspaceCreateRemote, + workspaceForget, workspaceOpenworkRead, workspaceOpenworkWrite, workspaceSetActive, + workspaceUpdateRemote, type EngineDoctorResult, type EngineInfo, type WorkspaceInfo, @@ -96,6 +99,8 @@ export function createWorkspaceStore(options: { const [workspaceSearch, setWorkspaceSearch] = createSignal(""); const [workspacePickerOpen, setWorkspacePickerOpen] = createSignal(false); const [createWorkspaceOpen, setCreateWorkspaceOpen] = createSignal(false); + const [createRemoteWorkspaceOpen, setCreateRemoteWorkspaceOpen] = createSignal(false); + const [connectingWorkspaceId, setConnectingWorkspaceId] = createSignal(null); const activeWorkspaceInfo = createMemo(() => workspaces().find((w) => w.id === activeWorkspaceId()) ?? null); const activeWorkspaceDisplay = createMemo(() => { @@ -106,17 +111,29 @@ export function createWorkspaceStore(options: { name: "Workspace", path: "", preset: "starter", + workspaceType: "local", + baseUrl: null, + directory: null, + displayName: null, }; } - return { ...ws, name: ws.name || ws.path || "Workspace" }; + const displayName = ws.displayName?.trim() || ws.name || ws.baseUrl || ws.path || "Workspace"; + return { ...ws, name: displayName }; + }); + const activeWorkspacePath = createMemo(() => { + const ws = activeWorkspaceInfo(); + if (!ws) return ""; + if (ws.workspaceType === "remote") return ws.directory?.trim() ?? ""; + return ws.path ?? ""; }); - const activeWorkspacePath = createMemo(() => activeWorkspaceInfo()?.path ?? ""); const activeWorkspaceRoot = createMemo(() => activeWorkspacePath().trim()); const filteredWorkspaces = createMemo(() => { const query = workspaceSearch().trim().toLowerCase(); if (!query) return workspaces(); return workspaces().filter((ws) => { - const haystack = `${ws.name ?? ""} ${ws.path ?? ""}`.toLowerCase(); + const haystack = `${ws.name ?? ""} ${ws.path ?? ""} ${ws.baseUrl ?? ""} ${ + ws.displayName ?? "" + } ${ws.directory ?? ""}`.toLowerCase(); return haystack.includes(query); }); }); @@ -155,35 +172,83 @@ export function createWorkspaceStore(options: { async function activateWorkspace(workspaceId: string) { const id = workspaceId.trim(); - if (!id) return; + if (!id) return false; const next = workspaces().find((w) => w.id === id) ?? null; - if (!next) return; + if (!next) return false; + const isRemote = next.workspaceType === "remote"; + console.log("[workspace] activate", { id: next.id, type: next.workspaceType }); + + if (isRemote) { + const baseUrl = next.baseUrl?.trim() ?? ""; + if (!baseUrl) { + options.setError(t("app.error.remote_base_url_required", currentLocale())); + return false; + } + + setConnectingWorkspaceId(id); + options.setMode("client"); + + const ok = await connectToServer(baseUrl, next.directory?.trim() || undefined, { + workspaceId: next.id, + workspaceType: next.workspaceType, + targetRoot: next.directory?.trim() ?? "", + }); + + if (!ok) { + setConnectingWorkspaceId(null); + return false; + } + + syncActiveWorkspaceId(id); + setProjectDir(next.directory?.trim() ?? ""); + setWorkspaceConfig(null); + setWorkspaceConfigLoaded(true); + setAuthorizedDirs([]); + + if (isTauriRuntime()) { + try { + await workspaceSetActive(id); + } catch { + // ignore + } + } + + setConnectingWorkspaceId(null); + return true; + } const wasHostMode = options.mode() === "host" && options.client(); + const nextRoot = isRemote ? next.directory?.trim() ?? "" : next.path; const oldWorkspacePath = projectDir(); - const workspaceChanged = oldWorkspacePath !== next.path; + const workspaceChanged = oldWorkspacePath !== nextRoot; syncActiveWorkspaceId(id); - setProjectDir(next.path); + setProjectDir(nextRoot); if (isTauriRuntime()) { - setWorkspaceConfigLoaded(false); - try { - const cfg = await workspaceOpenworkRead({ workspacePath: next.path }); - setWorkspaceConfig(cfg); + if (isRemote) { + setWorkspaceConfig(null); setWorkspaceConfigLoaded(true); - - const roots = Array.isArray(cfg.authorizedRoots) ? cfg.authorizedRoots : []; - if (roots.length) { - setAuthorizedDirs(roots); - } else { + setAuthorizedDirs([]); + } else { + setWorkspaceConfigLoaded(false); + try { + const cfg = await workspaceOpenworkRead({ workspacePath: next.path }); + setWorkspaceConfig(cfg); + setWorkspaceConfigLoaded(true); + + const roots = Array.isArray(cfg.authorizedRoots) ? cfg.authorizedRoots : []; + if (roots.length) { + setAuthorizedDirs(roots); + } else { + setAuthorizedDirs([next.path]); + } + } catch { + setWorkspaceConfig(null); + setWorkspaceConfigLoaded(true); setAuthorizedDirs([next.path]); } - } catch { - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([next.path]); } try { @@ -191,17 +256,21 @@ export function createWorkspaceStore(options: { } catch { // ignore } - } else { + } else if (!isRemote) { if (!authorizedDirs().includes(next.path)) { const merged = authorizedDirs().length ? authorizedDirs().slice() : []; if (!merged.includes(next.path)) merged.push(next.path); setAuthorizedDirs(merged); } + } else { + setAuthorizedDirs([]); } - await options.loadWorkspaceTemplates({ workspaceRoot: next.path }).catch(() => undefined); + if (!isRemote) { + await options.loadWorkspaceTemplates({ workspaceRoot: next.path }).catch(() => undefined); + } - if (workspaceChanged && options.client() && !wasHostMode) { + if (!isRemote && workspaceChanged && options.client() && !wasHostMode) { options.setSelectedSessionId(null); options.setMessages([]); options.setTodos([]); @@ -211,7 +280,7 @@ export function createWorkspaceStore(options: { } // In Host mode, restart the engine when workspace changes - if (wasHostMode && workspaceChanged) { + if (!isRemote && wasHostMode && workspaceChanged) { options.setError(null); options.setBusy(true); options.setBusyLabel("status.restarting_engine"); @@ -242,9 +311,24 @@ export function createWorkspaceStore(options: { options.setBusyStartedAt(null); } } + + return true; } - async function connectToServer(nextBaseUrl: string, directory?: string) { + async function connectToServer( + nextBaseUrl: string, + directory?: string, + context?: { + workspaceId?: string; + workspaceType?: WorkspaceInfo["workspaceType"]; + targetRoot?: string; + } + ) { + console.log("[workspace] connect", { + baseUrl: nextBaseUrl, + directory: directory ?? null, + workspaceType: context?.workspaceType ?? null, + }); options.setError(null); options.setBusy(true); options.setBusyLabel("status.connecting"); @@ -252,14 +336,40 @@ export function createWorkspaceStore(options: { options.setSseConnected(false); try { - const nextClient = createClient(nextBaseUrl, directory); + let resolvedDirectory = directory?.trim() ?? ""; + let nextClient = createClient(nextBaseUrl, resolvedDirectory || undefined); const health = await waitForHealthy(nextClient, { timeoutMs: 12_000 }); + if (context?.workspaceType === "remote" && !resolvedDirectory) { + try { + const pathInfo = unwrap(await nextClient.path.get()); + const discovered = pathInfo.directory?.trim() ?? ""; + if (discovered) { + resolvedDirectory = discovered; + console.log("[workspace] remote directory resolved", resolvedDirectory); + if (isTauriRuntime() && context.workspaceId) { + const updated = await workspaceUpdateRemote({ + workspaceId: context.workspaceId, + directory: resolvedDirectory, + }); + setWorkspaces(updated.workspaces); + syncActiveWorkspaceId(updated.activeId); + } + setProjectDir(resolvedDirectory); + nextClient = createClient(nextBaseUrl, resolvedDirectory); + } + } catch (error) { + console.log("[workspace] remote directory lookup failed", error); + } + } + options.setClient(nextClient); options.setConnectedVersion(health.version); options.setBaseUrl(nextBaseUrl); + options.setClientDirectory(resolvedDirectory); - await options.loadSessions(activeWorkspaceRoot().trim()); + const targetRoot = context?.targetRoot ?? (resolvedDirectory || activeWorkspaceRoot().trim()); + await options.loadSessions(targetRoot); await options.refreshPendingPermissions(); try { @@ -286,6 +396,12 @@ export function createWorkspaceStore(options: { options.setPendingPermissions([]); options.setSessionStatusById({}); + if (context?.workspaceType === "remote" && targetRoot) { + await options + .loadWorkspaceTemplates({ workspaceRoot: targetRoot, quiet: true }) + .catch(() => undefined); + } + options.refreshSkills({ force: true }).catch(() => undefined); if (!options.selectedSessionId()) { options.setView("dashboard"); @@ -359,6 +475,104 @@ export function createWorkspaceStore(options: { } } + async function createRemoteWorkspaceFlow(input: { + baseUrl: string; + directory?: string | null; + displayName?: string | null; + }) { + if (!isTauriRuntime()) { + options.setError(t("app.error.tauri_required", currentLocale())); + return false; + } + + const baseUrl = input.baseUrl.trim(); + if (!baseUrl) { + options.setError(t("app.error.remote_base_url_required", currentLocale())); + return false; + } + + options.setError(null); + console.log("[workspace] create remote", { + baseUrl, + directory: input.directory ?? null, + displayName: input.displayName ?? null, + }); + + options.setMode("client"); + const ok = await connectToServer(baseUrl, input.directory?.trim() || undefined, { + workspaceType: "remote", + targetRoot: input.directory?.trim() ?? "", + }); + + if (!ok) { + return false; + } + + const resolvedDirectory = options.clientDirectory().trim() || input.directory?.trim() || ""; + + options.setBusy(true); + options.setBusyLabel("status.creating_workspace"); + options.setBusyStartedAt(Date.now()); + + try { + const ws = await workspaceCreateRemote({ + baseUrl, + directory: resolvedDirectory ? resolvedDirectory : null, + displayName: input.displayName?.trim() ? input.displayName.trim() : null, + }); + setWorkspaces(ws.workspaces); + syncActiveWorkspaceId(ws.activeId); + + setProjectDir(resolvedDirectory); + setWorkspaceConfig(null); + setWorkspaceConfigLoaded(true); + setAuthorizedDirs([]); + + setWorkspacePickerOpen(false); + setCreateRemoteWorkspaceOpen(false); + return true; + } catch (e) { + const message = e instanceof Error ? e.message : safeStringify(e); + options.setError(addOpencodeCacheHint(message)); + return false; + } finally { + options.setBusy(false); + options.setBusyLabel(null); + options.setBusyStartedAt(null); + } + } + + async function forgetWorkspace(workspaceId: string) { + if (!isTauriRuntime()) { + options.setError(t("app.error.tauri_required", currentLocale())); + return; + } + + const id = workspaceId.trim(); + if (!id) return; + + console.log("[workspace] forget", { id }); + + try { + const previousActive = activeWorkspaceId(); + const ws = await workspaceForget(id); + setWorkspaces(ws.workspaces); + syncActiveWorkspaceId(ws.activeId); + + const active = ws.workspaces.find((w) => w.id === ws.activeId) ?? null; + if (active) { + setProjectDir(active.workspaceType === "remote" ? active.directory?.trim() ?? "" : active.path); + } + + if (ws.activeId && ws.activeId !== previousActive) { + await activateWorkspace(ws.activeId); + } + } catch (e) { + const message = e instanceof Error ? e.message : safeStringify(e); + options.setError(addOpencodeCacheHint(message)); + } + } + async function pickWorkspaceFolder() { if (!isTauriRuntime()) { options.setError(t("app.error.tauri_required", currentLocale())); @@ -384,6 +598,11 @@ export function createWorkspaceStore(options: { return false; } + if (activeWorkspaceInfo()?.workspaceType === "remote") { + options.setError(t("app.error.host_requires_local", currentLocale())); + return false; + } + const dir = (optionsOverride?.workspacePath ?? activeWorkspacePath() ?? projectDir()).trim(); if (!dir) { options.setError(t("app.error.pick_workspace_folder", currentLocale())); @@ -572,6 +791,7 @@ export function createWorkspaceStore(options: { async function persistAuthorizedRoots(nextRoots: string[]) { if (!isTauriRuntime()) return; + if (activeWorkspaceInfo()?.workspaceType === "remote") return; const root = activeWorkspacePath().trim(); if (!root) return; @@ -587,6 +807,7 @@ export function createWorkspaceStore(options: { } async function addAuthorizedDir() { + if (activeWorkspaceInfo()?.workspaceType === "remote") return; const next = newAuthorizedDir().trim(); if (!next) return; @@ -604,6 +825,7 @@ export function createWorkspaceStore(options: { async function addAuthorizedDirFromPicker(optionsOverride?: { persistToWorkspace?: boolean }) { if (!isTauriRuntime()) return; + if (activeWorkspaceInfo()?.workspaceType === "remote") return; try { const selection = await pickDirectory({ title: t("onboarding.authorize_folder", currentLocale()) }); @@ -624,6 +846,7 @@ export function createWorkspaceStore(options: { } async function removeAuthorizedDir(dir: string) { + if (activeWorkspaceInfo()?.workspaceType === "remote") return; const roots = normalizeRoots(authorizedDirs().filter((root) => root !== dir)); setAuthorizedDirs(roots); @@ -677,20 +900,32 @@ export function createWorkspaceStore(options: { syncActiveWorkspaceId(ws.activeId); const active = ws.workspaces.find((w) => w.id === ws.activeId) ?? null; if (active) { - setProjectDir(active.path); - try { - const cfg = await workspaceOpenworkRead({ workspacePath: active.path }); - setWorkspaceConfig(cfg); - setWorkspaceConfigLoaded(true); - const roots = Array.isArray(cfg.authorizedRoots) ? cfg.authorizedRoots : []; - setAuthorizedDirs(roots.length ? roots : [active.path]); - } catch { + if (active.workspaceType === "remote") { + setProjectDir(active.directory?.trim() ?? ""); setWorkspaceConfig(null); setWorkspaceConfigLoaded(true); - setAuthorizedDirs([active.path]); + setAuthorizedDirs([]); + if (active.baseUrl) { + options.setBaseUrl(active.baseUrl); + } + } else { + setProjectDir(active.path); + try { + const cfg = await workspaceOpenworkRead({ workspacePath: active.path }); + setWorkspaceConfig(cfg); + setWorkspaceConfigLoaded(true); + const roots = Array.isArray(cfg.authorizedRoots) ? cfg.authorizedRoots : []; + setAuthorizedDirs(roots.length ? roots : [active.path]); + } catch { + setWorkspaceConfig(null); + setWorkspaceConfigLoaded(true); + setAuthorizedDirs([active.path]); + } + + await options + .loadWorkspaceTemplates({ workspaceRoot: active.path, quiet: true }) + .catch(() => undefined); } - - await options.loadWorkspaceTemplates({ workspaceRoot: active.path, quiet: true }).catch(() => undefined); } } catch { // ignore @@ -702,6 +937,26 @@ export function createWorkspaceStore(options: { options.setBaseUrl(info.baseUrl); } + const activeWorkspace = activeWorkspaceInfo(); + if (activeWorkspace?.workspaceType === "remote") { + options.setMode("client"); + const baseUrl = activeWorkspace.baseUrl?.trim() ?? ""; + if (!baseUrl) { + options.setOnboardingStep("client"); + return; + } + + options.setOnboardingStep("connecting"); + const ok = await connectToServer(baseUrl, activeWorkspace.directory?.trim() || undefined, { + workspaceId: activeWorkspace.id, + workspaceType: activeWorkspace.workspaceType, + }); + if (!ok) { + options.setOnboardingStep("client"); + } + return; + } + if (!modePref && onboardingComplete && activeWorkspacePath().trim()) { options.setMode("host"); @@ -810,10 +1065,11 @@ export function createWorkspaceStore(options: { async function onConnectClient() { options.setMode("client"); options.setOnboardingStep("connecting"); - const ok = await connectToServer( - options.baseUrl().trim(), - options.clientDirectory().trim() ? options.clientDirectory().trim() : undefined, - ); + const ok = await createRemoteWorkspaceFlow({ + baseUrl: options.baseUrl().trim(), + directory: options.clientDirectory().trim() ? options.clientDirectory().trim() : null, + displayName: null, + }); if (!ok) { options.setOnboardingStep("client"); } @@ -851,6 +1107,8 @@ export function createWorkspaceStore(options: { workspaceSearch, workspacePickerOpen, createWorkspaceOpen, + createRemoteWorkspaceOpen, + connectingWorkspaceId, activeWorkspaceDisplay, activeWorkspacePath, activeWorkspaceRoot, @@ -858,6 +1116,7 @@ export function createWorkspaceStore(options: { setWorkspaceSearch, setWorkspacePickerOpen, setCreateWorkspaceOpen, + setCreateRemoteWorkspaceOpen, setProjectDir, setAuthorizedDirs, setNewAuthorizedDir, @@ -870,6 +1129,8 @@ export function createWorkspaceStore(options: { activateWorkspace, connectToServer, createWorkspaceFlow, + createRemoteWorkspaceFlow, + forgetWorkspace, pickWorkspaceFolder, startHost, stopHost, diff --git a/packages/app/src/app/lib/tauri.ts b/packages/app/src/app/lib/tauri.ts index 9f0daeaec..e84b94273 100644 --- a/packages/app/src/app/lib/tauri.ts +++ b/packages/app/src/app/lib/tauri.ts @@ -29,6 +29,10 @@ export type WorkspaceInfo = { name: string; path: string; preset: string; + workspaceType: "local" | "remote"; + baseUrl?: string | null; + directory?: string | null; + displayName?: string | null; }; export type WorkspaceList = { @@ -66,6 +70,36 @@ export async function workspaceCreate(input: { }); } +export async function workspaceCreateRemote(input: { + baseUrl: string; + directory?: string | null; + displayName?: string | null; +}): Promise { + return invoke("workspace_create_remote", { + baseUrl: input.baseUrl, + directory: input.directory ?? null, + displayName: input.displayName ?? null, + }); +} + +export async function workspaceUpdateRemote(input: { + workspaceId: string; + baseUrl?: string | null; + directory?: string | null; + displayName?: string | null; +}): Promise { + return invoke("workspace_update_remote", { + workspaceId: input.workspaceId, + baseUrl: input.baseUrl ?? null, + directory: input.directory ?? null, + displayName: input.displayName ?? null, + }); +} + +export async function workspaceForget(workspaceId: string): Promise { + return invoke("workspace_forget", { workspaceId }); +} + export async function workspaceAddAuthorizedRoot(input: { workspacePath: string; folderPath: string; diff --git a/packages/app/src/app/pages/dashboard.tsx b/packages/app/src/app/pages/dashboard.tsx index 3d7c18ac2..557cf4a1b 100644 --- a/packages/app/src/app/pages/dashboard.tsx +++ b/packages/app/src/app/pages/dashboard.tsx @@ -49,10 +49,11 @@ export type DashboardViewProps = { setWorkspaceSearch: (value: string) => void; workspacePickerOpen: boolean; setWorkspacePickerOpen: (open: boolean) => void; + connectingWorkspaceId: string | null; workspaces: WorkspaceInfo[]; filteredWorkspaces: WorkspaceInfo[]; activeWorkspaceId: string; - activateWorkspace: (id: string) => void; + activateWorkspace: (id: string) => Promise | boolean; createWorkspaceOpen: boolean; setCreateWorkspaceOpen: (open: boolean) => void; createWorkspaceFlow: ( @@ -403,6 +404,7 @@ export default function DashboardView(props: DashboardViewProps) {
{ props.setWorkspaceSearch(""); props.setWorkspacePickerOpen(true); diff --git a/packages/app/src/app/pages/onboarding.tsx b/packages/app/src/app/pages/onboarding.tsx index eb8633452..3ce4c1bb8 100644 --- a/packages/app/src/app/pages/onboarding.tsx +++ b/packages/app/src/app/pages/onboarding.tsx @@ -1,7 +1,7 @@ import { For, Match, Show, Switch, createSignal } from "solid-js"; import type { Mode, OnboardingStep } from "../types"; import type { WorkspaceInfo } from "../lib/tauri"; -import { ArrowLeftRight, CheckCircle2, Circle, ChevronDown } from "lucide-solid"; +import { CheckCircle2, ChevronDown, Circle, Globe } from "lucide-solid"; import Button from "../components/button"; import OnboardingWorkspaceSelector from "../components/onboarding-workspace-selector"; @@ -404,31 +404,31 @@ export default function OnboardingView(props: OnboardingViewProps) {
- +
-

{translate("onboarding.connect_host")}

+

{translate("onboarding.remote_workspace_title")}

- {translate("onboarding.connect_description")} + {translate("onboarding.remote_workspace_description")}

props.onBaseUrlChange(e.currentTarget.value)} /> props.onClientDirectoryChange(e.currentTarget.value)} - hint={translate("onboarding.directory_hint")} + hint={translate("dashboard.remote_directory_hint")} />
+ +
-
- -
-
{props.error} diff --git a/packages/app/src/i18n/locales/en.ts b/packages/app/src/i18n/locales/en.ts index 5c5e4d14f..14a8e580f 100644 --- a/packages/app/src/i18n/locales/en.ts +++ b/packages/app/src/i18n/locales/en.ts @@ -17,6 +17,9 @@ export default { "dashboard.find_workspace": "Find workspace...", "dashboard.workspaces": "Workspaces", "dashboard.new_workspace": "New Workspace...", + "dashboard.new_remote_workspace": "Add Remote Workspace...", + "dashboard.forget_workspace": "Forget workspace", + "dashboard.remote": "Remote", "dashboard.connection": "Connection", "dashboard.local_engine": "Local Engine", "dashboard.client_mode": "Client Mode", @@ -47,6 +50,19 @@ export default { "dashboard.create_workspace_title": "Create Workspace", "dashboard.create_workspace_subtitle": "Initialize a new folder-based workspace.", "dashboard.create_workspace_confirm": "Create Workspace", + "dashboard.create_remote_workspace_title": "Add Remote Workspace", + "dashboard.create_remote_workspace_subtitle": "Save a remote OpenCode server as a workspace.", + "dashboard.create_remote_workspace_confirm": "Add Workspace", + "dashboard.remote_workspace_title": "Remote workspace", + "dashboard.remote_workspace_hint": "Track an OpenCode server and reconnect anytime.", + "dashboard.remote_base_url_label": "Server URL", + "dashboard.remote_base_url_placeholder": "https://opencode.example.com:4096", + "dashboard.remote_base_url_required": "Add a server URL to continue.", + "dashboard.remote_directory_label": "Workspace directory (optional)", + "dashboard.remote_directory_placeholder": "/home/team/project", + "dashboard.remote_directory_hint": "Leave blank to use the server default.", + "dashboard.remote_display_name_label": "Display name (optional)", + "dashboard.remote_display_name_placeholder": "Design team workspace", "dashboard.select_folder": "Select Folder", "dashboard.choose_folder": "Choose a folder", "dashboard.choose_folder_next": "You will choose a directory next.", @@ -579,6 +595,11 @@ export default { "onboarding.directory": "Directory (optional)", "onboarding.directory_hint": "Use if your host runs multiple workspaces.", "onboarding.connect": "Connect", + "onboarding.remote_workspace_title": "Connect a remote workspace", + "onboarding.remote_workspace_description": "Add an OpenCode server as a workspace so sessions and templates stay in sync.", + "onboarding.remote_workspace_action": "Add workspace", + "onboarding.remote_workspace_card_title": "Use a remote workspace", + "onboarding.remote_workspace_card_description": "Connect to an existing OpenCode server and track it like any other workspace.", "onboarding.welcome_title": "How would you like to run OpenWork today?", "onboarding.run_local": "Run on this computer", "onboarding.run_local_description": "OpenWork runs OpenCode locally and keeps your work private.", @@ -634,6 +655,8 @@ export default { "app.error.tauri_required": "This action requires the Tauri app runtime.", "app.error.choose_folder": "Choose a folder to continue.", "app.error.pick_workspace_folder": "Pick a workspace folder first.", + "app.error.remote_base_url_required": "Add a server URL to continue.", + "app.error.host_requires_local": "Select a local workspace to start the engine.", "app.error.sidecar_unsupported_windows": "Sidecar OpenCode is not supported on Windows yet. Using PATH instead.", "app.error.install_failed": "OpenCode install failed. See logs above.", "app.error.title_prompt_required": "Template title and prompt are required.", diff --git a/packages/app/src/i18n/locales/zh.ts b/packages/app/src/i18n/locales/zh.ts index 6331b9d96..852bf23a1 100644 --- a/packages/app/src/i18n/locales/zh.ts +++ b/packages/app/src/i18n/locales/zh.ts @@ -17,6 +17,9 @@ export default { "dashboard.find_workspace": "查找工作区...", "dashboard.workspaces": "工作区", "dashboard.new_workspace": "新建工作区...", + "dashboard.new_remote_workspace": "添加远程工作区...", + "dashboard.forget_workspace": "忘记工作区", + "dashboard.remote": "远程", "dashboard.connection": "连接", "dashboard.local_engine": "本地引擎", "dashboard.client_mode": "客户端模式", @@ -47,6 +50,19 @@ export default { "dashboard.create_workspace_title": "创建工作区", "dashboard.create_workspace_subtitle": "初始化新的基于文件夹的工作区。", "dashboard.create_workspace_confirm": "创建工作区", + "dashboard.create_remote_workspace_title": "添加远程工作区", + "dashboard.create_remote_workspace_subtitle": "将远程 OpenCode 服务器保存为工作区。", + "dashboard.create_remote_workspace_confirm": "添加工作区", + "dashboard.remote_workspace_title": "远程工作区", + "dashboard.remote_workspace_hint": "跟踪 OpenCode 服务器,随时重新连接。", + "dashboard.remote_base_url_label": "服务器地址", + "dashboard.remote_base_url_placeholder": "https://opencode.example.com:4096", + "dashboard.remote_base_url_required": "请先填写服务器地址。", + "dashboard.remote_directory_label": "工作区目录(可选)", + "dashboard.remote_directory_placeholder": "/home/team/project", + "dashboard.remote_directory_hint": "留空则使用服务器默认目录。", + "dashboard.remote_display_name_label": "显示名称(可选)", + "dashboard.remote_display_name_placeholder": "设计团队工作区", "dashboard.select_folder": "选择文件夹", "dashboard.choose_folder": "选择文件夹", "dashboard.choose_folder_next": "接下来您将选择一个目录。", @@ -586,6 +602,11 @@ export default { "onboarding.directory": "目录(可选)", "onboarding.directory_hint": "如果主机运行多个工作区,请使用此选项。", "onboarding.connect": "连接", + "onboarding.remote_workspace_title": "连接远程工作区", + "onboarding.remote_workspace_description": "将 OpenCode 服务器添加为工作区,以保持会话和模板同步。", + "onboarding.remote_workspace_action": "添加工作区", + "onboarding.remote_workspace_card_title": "使用远程工作区", + "onboarding.remote_workspace_card_description": "连接现有 OpenCode 服务器,并像其他工作区一样跟踪。", "onboarding.welcome_title": "今天想如何运行 OpenWork?", "onboarding.run_local": "在此计算机上运行", "onboarding.run_local_description": "OpenWork 在本地运行 OpenCode 并保持您的工作私密。", @@ -641,6 +662,8 @@ export default { "app.error.tauri_required": "此操作需要 Tauri 应用运行时。", "app.error.choose_folder": "选择一个文件夹以继续。", "app.error.pick_workspace_folder": "请先选择一个工作区文件夹。", + "app.error.remote_base_url_required": "请先填写服务器地址。", + "app.error.host_requires_local": "请先选择本地工作区以启动引擎。", "app.error.sidecar_unsupported_windows": "Windows 尚不支持 Sidecar OpenCode。将改用 PATH。", "app.error.install_failed": "OpenCode 安装失败。请查看上方日志。", "app.error.title_prompt_required": "模板标题和提示词是必需的。", diff --git a/packages/desktop/src-tauri/src/commands/workspace.rs b/packages/desktop/src-tauri/src/commands/workspace.rs index 9aa9128a1..721bb07e1 100644 --- a/packages/desktop/src-tauri/src/commands/workspace.rs +++ b/packages/desktop/src-tauri/src/commands/workspace.rs @@ -1,261 +1,433 @@ use std::fs; use std::path::PathBuf; -use crate::types::{ExecResult, WorkspaceList, WorkspaceOpenworkConfig, WorkspaceTemplate}; +use crate::types::{ + ExecResult, WorkspaceInfo, WorkspaceList, WorkspaceOpenworkConfig, WorkspaceTemplate, + WorkspaceType, +}; use crate::workspace::files::ensure_workspace_files; -use crate::workspace::state::{ensure_starter_workspace, load_workspace_state, save_workspace_state, stable_workspace_id}; +use crate::workspace::state::{ + ensure_starter_workspace, load_workspace_state, save_workspace_state, stable_workspace_id, + stable_workspace_id_for_remote, +}; use crate::workspace::templates::{delete_template, write_template}; #[tauri::command] pub fn workspace_bootstrap(app: tauri::AppHandle) -> Result { - let mut state = load_workspace_state(&app)?; + println!("[workspace] bootstrap"); + let mut state = load_workspace_state(&app)?; - let starter = ensure_starter_workspace(&app)?; - ensure_workspace_files(&starter.path, &starter.preset)?; + let starter = ensure_starter_workspace(&app)?; + ensure_workspace_files(&starter.path, &starter.preset)?; - if !state.workspaces.iter().any(|w| w.id == starter.id) { - state.workspaces.push(starter.clone()); - } + if !state.workspaces.iter().any(|w| w.id == starter.id) { + state.workspaces.push(starter.clone()); + } - if state.active_id.trim().is_empty() { - state.active_id = starter.id.clone(); - } + if state.active_id.trim().is_empty() { + state.active_id = starter.id.clone(); + } - if !state.workspaces.iter().any(|w| w.id == state.active_id) { - state.active_id = starter.id.clone(); - } + if !state.workspaces.iter().any(|w| w.id == state.active_id) { + state.active_id = starter.id.clone(); + } - save_workspace_state(&app, &state)?; + save_workspace_state(&app, &state)?; - Ok(WorkspaceList { - active_id: state.active_id, - workspaces: state.workspaces, - }) + Ok(WorkspaceList { + active_id: state.active_id, + workspaces: state.workspaces, + }) } #[tauri::command] -pub fn workspace_set_active(app: tauri::AppHandle, workspace_id: String) -> Result { - let mut state = load_workspace_state(&app)?; - let id = workspace_id.trim(); +pub fn workspace_forget( + app: tauri::AppHandle, + workspace_id: String, +) -> Result { + println!("[workspace] forget request: {workspace_id}"); + let mut state = load_workspace_state(&app)?; + let id = workspace_id.trim(); - if id.is_empty() { - return Err("workspaceId is required".to_string()); - } + if id.is_empty() { + return Err("workspaceId is required".to_string()); + } - if !state.workspaces.iter().any(|w| w.id == id) { - return Err("Unknown workspaceId".to_string()); - } + let before = state.workspaces.len(); + state.workspaces.retain(|w| w.id != id); + if before == state.workspaces.len() { + return Err("Unknown workspaceId".to_string()); + } + + if state.active_id == id { + state.active_id = state + .workspaces + .first() + .map(|entry| entry.id.clone()) + .unwrap_or_else(|| "".to_string()); + } - state.active_id = id.to_string(); - save_workspace_state(&app, &state)?; + if state.workspaces.is_empty() { + let starter = ensure_starter_workspace(&app)?; + ensure_workspace_files(&starter.path, &starter.preset)?; + state.active_id = starter.id.clone(); + state.workspaces.push(starter); + } - Ok(WorkspaceList { - active_id: state.active_id, - workspaces: state.workspaces, - }) + save_workspace_state(&app, &state)?; + println!("[workspace] forget complete"); + + Ok(WorkspaceList { + active_id: state.active_id, + workspaces: state.workspaces, + }) +} + +#[tauri::command] +pub fn workspace_set_active( + app: tauri::AppHandle, + workspace_id: String, +) -> Result { + println!("[workspace] set_active request: {workspace_id}"); + let mut state = load_workspace_state(&app)?; + let id = workspace_id.trim(); + + if id.is_empty() { + return Err("workspaceId is required".to_string()); + } + + if !state.workspaces.iter().any(|w| w.id == id) { + return Err("Unknown workspaceId".to_string()); + } + + state.active_id = id.to_string(); + save_workspace_state(&app, &state)?; + println!("[workspace] set_active complete: {id}"); + + Ok(WorkspaceList { + active_id: state.active_id, + workspaces: state.workspaces, + }) } #[tauri::command] pub fn workspace_create( - app: tauri::AppHandle, - folder_path: String, - name: String, - preset: String, + app: tauri::AppHandle, + folder_path: String, + name: String, + preset: String, ) -> Result { - let folder = folder_path.trim().to_string(); - if folder.is_empty() { - return Err("folderPath is required".to_string()); - } + println!("[workspace] create local request"); + let folder = folder_path.trim().to_string(); + if folder.is_empty() { + return Err("folderPath is required".to_string()); + } - let workspace_name = name.trim().to_string(); - if workspace_name.is_empty() { - return Err("name is required".to_string()); - } + let workspace_name = name.trim().to_string(); + if workspace_name.is_empty() { + return Err("name is required".to_string()); + } - let preset = preset.trim().to_string(); - let preset = if preset.is_empty() { "starter".to_string() } else { preset }; + let preset = preset.trim().to_string(); + let preset = if preset.is_empty() { + "starter".to_string() + } else { + preset + }; + + fs::create_dir_all(&folder).map_err(|e| format!("Failed to create workspace folder: {e}"))?; + + let id = stable_workspace_id(&folder); + + ensure_workspace_files(&folder, &preset)?; + + let mut state = load_workspace_state(&app)?; + + state.workspaces.retain(|w| w.id != id); + state.workspaces.push(WorkspaceInfo { + id: id.clone(), + name: workspace_name, + path: folder, + preset, + workspace_type: WorkspaceType::Local, + base_url: None, + directory: None, + display_name: None, + }); + + state.active_id = id.clone(); + save_workspace_state(&app, &state)?; + println!("[workspace] create local complete: {id}"); + + Ok(WorkspaceList { + active_id: state.active_id, + workspaces: state.workspaces, + }) +} - fs::create_dir_all(&folder) - .map_err(|e| format!("Failed to create workspace folder: {e}"))?; +#[tauri::command] +pub fn workspace_create_remote( + app: tauri::AppHandle, + base_url: String, + directory: Option, + display_name: Option, +) -> Result { + println!("[workspace] create remote request"); + let base_url = base_url.trim().to_string(); + if base_url.is_empty() { + return Err("baseUrl is required".to_string()); + } + if !base_url.starts_with("http://") && !base_url.starts_with("https://") { + return Err("baseUrl must start with http:// or https://".to_string()); + } - let id = stable_workspace_id(&folder); + let directory = directory + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let display_name = display_name + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + let id = stable_workspace_id_for_remote(&base_url, directory.as_deref()); + let name = display_name.clone().unwrap_or_else(|| base_url.clone()); + let path = directory.clone().unwrap_or_default(); + + let mut state = load_workspace_state(&app)?; + state.workspaces.retain(|w| w.id != id); + state.workspaces.push(WorkspaceInfo { + id: id.clone(), + name, + path, + preset: "remote".to_string(), + workspace_type: WorkspaceType::Remote, + base_url: Some(base_url), + directory, + display_name, + }); + state.active_id = id.clone(); + save_workspace_state(&app, &state)?; + println!("[workspace] create remote complete: {id}"); + + Ok(WorkspaceList { + active_id: state.active_id, + workspaces: state.workspaces, + }) +} - ensure_workspace_files(&folder, &preset)?; +#[tauri::command] +pub fn workspace_update_remote( + app: tauri::AppHandle, + workspace_id: String, + base_url: Option, + directory: Option, + display_name: Option, +) -> Result { + println!("[workspace] update remote request: {workspace_id}"); + let mut state = load_workspace_state(&app)?; + let id = workspace_id.trim(); + if id.is_empty() { + return Err("workspaceId is required".to_string()); + } - let mut state = load_workspace_state(&app)?; + let entry = state.workspaces.iter_mut().find(|w| w.id == id); + let Some(entry) = entry else { + return Err("Unknown workspaceId".to_string()); + }; - state.workspaces.retain(|w| w.id != id); - state.workspaces.push(crate::types::WorkspaceInfo { - id: id.clone(), - name: workspace_name, - path: folder, - preset, - }); + if entry.workspace_type != WorkspaceType::Remote { + return Err("workspaceId is not remote".to_string()); + } - state.active_id = id; - save_workspace_state(&app, &state)?; + if let Some(next_base_url) = base_url + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + if !next_base_url.starts_with("http://") && !next_base_url.starts_with("https://") { + return Err("baseUrl must start with http:// or https://".to_string()); + } + entry.base_url = Some(next_base_url); + } - Ok(WorkspaceList { - active_id: state.active_id, - workspaces: state.workspaces, - }) + let next_directory = directory + .as_ref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + if directory.is_some() { + entry.directory = next_directory.clone(); + entry.path = next_directory.unwrap_or_default(); + } + + if let Some(next_name) = display_name + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + entry.display_name = Some(next_name.clone()); + entry.name = next_name; + } + + save_workspace_state(&app, &state)?; + println!("[workspace] update remote complete: {id}"); + + Ok(WorkspaceList { + active_id: state.active_id, + workspaces: state.workspaces, + }) } #[tauri::command] pub fn workspace_add_authorized_root( - _app: tauri::AppHandle, - workspace_path: String, - folder_path: String, + _app: tauri::AppHandle, + workspace_path: String, + folder_path: String, ) -> Result { - let workspace_path = workspace_path.trim().to_string(); - let folder_path = folder_path.trim().to_string(); - - if workspace_path.is_empty() { - return Err("workspacePath is required".to_string()); - } - if folder_path.is_empty() { - return Err("folderPath is required".to_string()); - } - - let openwork_path = PathBuf::from(&workspace_path) - .join(".opencode") - .join("openwork.json"); - - if let Some(parent) = openwork_path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?; - } - - let mut config: WorkspaceOpenworkConfig = if openwork_path.exists() { - let raw = fs::read_to_string(&openwork_path) - .map_err(|e| format!("Failed to read {}: {e}", openwork_path.display()))?; - serde_json::from_str(&raw).unwrap_or_default() - } else { - let mut cfg = WorkspaceOpenworkConfig::default(); - if !cfg.authorized_roots.iter().any(|p| p == &workspace_path) { - cfg.authorized_roots.push(workspace_path.clone()); + let workspace_path = workspace_path.trim().to_string(); + let folder_path = folder_path.trim().to_string(); + + if workspace_path.is_empty() { + return Err("workspacePath is required".to_string()); + } + if folder_path.is_empty() { + return Err("folderPath is required".to_string()); } - cfg - }; - - if !config.authorized_roots.iter().any(|p| p == &folder_path) { - config.authorized_roots.push(folder_path); - } - - fs::write( - &openwork_path, - serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?, - ) - .map_err(|e| format!("Failed to write {}: {e}", openwork_path.display()))?; - - Ok(ExecResult { - ok: true, - status: 0, - stdout: "Updated authorizedRoots".to_string(), - stderr: String::new(), - }) + + let openwork_path = PathBuf::from(&workspace_path) + .join(".opencode") + .join("openwork.json"); + + if let Some(parent) = openwork_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?; + } + + let mut config: WorkspaceOpenworkConfig = if openwork_path.exists() { + let raw = fs::read_to_string(&openwork_path) + .map_err(|e| format!("Failed to read {}: {e}", openwork_path.display()))?; + serde_json::from_str(&raw).unwrap_or_default() + } else { + let mut cfg = WorkspaceOpenworkConfig::default(); + if !cfg.authorized_roots.iter().any(|p| p == &workspace_path) { + cfg.authorized_roots.push(workspace_path.clone()); + } + cfg + }; + + if !config.authorized_roots.iter().any(|p| p == &folder_path) { + config.authorized_roots.push(folder_path); + } + + fs::write( + &openwork_path, + serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?, + ) + .map_err(|e| format!("Failed to write {}: {e}", openwork_path.display()))?; + + Ok(ExecResult { + ok: true, + status: 0, + stdout: "Updated authorizedRoots".to_string(), + stderr: String::new(), + }) } #[tauri::command] pub fn workspace_template_write( - _app: tauri::AppHandle, - workspace_path: String, - template: WorkspaceTemplate, + _app: tauri::AppHandle, + workspace_path: String, + template: WorkspaceTemplate, ) -> Result { - let workspace_path = workspace_path.trim().to_string(); - if workspace_path.is_empty() { - return Err("workspacePath is required".to_string()); - } - - let file_path = write_template(&workspace_path, template)?; - - Ok(ExecResult { - ok: true, - status: 0, - stdout: format!("Wrote {}", file_path.display()), - stderr: String::new(), - }) + let workspace_path = workspace_path.trim().to_string(); + if workspace_path.is_empty() { + return Err("workspacePath is required".to_string()); + } + + let file_path = write_template(&workspace_path, template)?; + + Ok(ExecResult { + ok: true, + status: 0, + stdout: format!("Wrote {}", file_path.display()), + stderr: String::new(), + }) } #[tauri::command] pub fn workspace_openwork_read( - _app: tauri::AppHandle, - workspace_path: String, + _app: tauri::AppHandle, + workspace_path: String, ) -> Result { - let workspace_path = workspace_path.trim().to_string(); - if workspace_path.is_empty() { - return Err("workspacePath is required".to_string()); - } - - let openwork_path = PathBuf::from(&workspace_path) - .join(".opencode") - .join("openwork.json"); - - if !openwork_path.exists() { - let mut cfg = WorkspaceOpenworkConfig::default(); - cfg.authorized_roots.push(workspace_path); - return Ok(cfg); - } - - let raw = fs::read_to_string(&openwork_path) - .map_err(|e| format!("Failed to read {}: {e}", openwork_path.display()))?; - - serde_json::from_str::(&raw).map_err(|e| { - format!("Failed to parse {}: {e}", openwork_path.display()) - }) + let workspace_path = workspace_path.trim().to_string(); + if workspace_path.is_empty() { + return Err("workspacePath is required".to_string()); + } + + let openwork_path = PathBuf::from(&workspace_path) + .join(".opencode") + .join("openwork.json"); + + if !openwork_path.exists() { + let mut cfg = WorkspaceOpenworkConfig::default(); + cfg.authorized_roots.push(workspace_path); + return Ok(cfg); + } + + let raw = fs::read_to_string(&openwork_path) + .map_err(|e| format!("Failed to read {}: {e}", openwork_path.display()))?; + + serde_json::from_str::(&raw) + .map_err(|e| format!("Failed to parse {}: {e}", openwork_path.display())) } #[tauri::command] pub fn workspace_openwork_write( - _app: tauri::AppHandle, - workspace_path: String, - config: WorkspaceOpenworkConfig, + _app: tauri::AppHandle, + workspace_path: String, + config: WorkspaceOpenworkConfig, ) -> Result { - let workspace_path = workspace_path.trim().to_string(); - if workspace_path.is_empty() { - return Err("workspacePath is required".to_string()); - } - - let openwork_path = PathBuf::from(&workspace_path) - .join(".opencode") - .join("openwork.json"); - - if let Some(parent) = openwork_path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?; - } - - fs::write( - &openwork_path, - serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?, - ) - .map_err(|e| format!("Failed to write {}: {e}", openwork_path.display()))?; - - Ok(ExecResult { - ok: true, - status: 0, - stdout: format!("Wrote {}", openwork_path.display()), - stderr: String::new(), - }) + let workspace_path = workspace_path.trim().to_string(); + if workspace_path.is_empty() { + return Err("workspacePath is required".to_string()); + } + + let openwork_path = PathBuf::from(&workspace_path) + .join(".opencode") + .join("openwork.json"); + + if let Some(parent) = openwork_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?; + } + + fs::write( + &openwork_path, + serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?, + ) + .map_err(|e| format!("Failed to write {}: {e}", openwork_path.display()))?; + + Ok(ExecResult { + ok: true, + status: 0, + stdout: format!("Wrote {}", openwork_path.display()), + stderr: String::new(), + }) } #[tauri::command] pub fn workspace_template_delete( - _app: tauri::AppHandle, - workspace_path: String, - template_id: String, + _app: tauri::AppHandle, + workspace_path: String, + template_id: String, ) -> Result { - let workspace_path = workspace_path.trim().to_string(); - if workspace_path.is_empty() { - return Err("workspacePath is required".to_string()); - } - - let file_path = delete_template(&workspace_path, &template_id)?; - - Ok(ExecResult { - ok: true, - status: 0, - stdout: format!("Deleted {}", file_path.display()), - stderr: String::new(), - }) + let workspace_path = workspace_path.trim().to_string(); + if workspace_path.is_empty() { + return Err("workspacePath is required".to_string()); + } + + let file_path = delete_template(&workspace_path, &template_id)?; + + Ok(ExecResult { + ok: true, + status: 0, + stdout: format!("Deleted {}", file_path.display()), + stderr: String::new(), + }) } diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 3b854e06c..8beb8b6cc 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -19,55 +19,53 @@ use commands::opkg::{import_skill, opkg_install}; use commands::skills::{install_skill_template, list_local_skills, uninstall_skill}; use commands::updater::updater_environment; use commands::workspace::{ - workspace_add_authorized_root, - workspace_bootstrap, - workspace_create, - workspace_openwork_read, - workspace_openwork_write, - workspace_set_active, - workspace_template_delete, - workspace_template_write, + workspace_add_authorized_root, workspace_bootstrap, workspace_create, workspace_create_remote, + workspace_forget, workspace_openwork_read, workspace_openwork_write, workspace_set_active, + workspace_template_delete, workspace_template_write, workspace_update_remote, }; use engine::manager::EngineManager; pub fn run() { - let builder = tauri::Builder::default() - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_opener::init()); + let builder = tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_opener::init()); - #[cfg(desktop)] - let builder = builder - .plugin(tauri_plugin_process::init()) - .plugin(tauri_plugin_updater::Builder::new().build()); + #[cfg(desktop)] + let builder = builder + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_updater::Builder::new().build()); - builder - .manage(EngineManager::default()) - .invoke_handler(tauri::generate_handler![ - engine_start, - engine_stop, - engine_info, - engine_doctor, - engine_install, - workspace_bootstrap, - workspace_set_active, - workspace_create, - workspace_add_authorized_root, - workspace_template_write, - workspace_template_delete, - workspace_openwork_read, - workspace_openwork_write, - opkg_install, - import_skill, - install_skill_template, - list_local_skills, - uninstall_skill, - read_opencode_config, - write_opencode_config, - updater_environment, - reset_openwork_state, - reset_opencode_cache, - opencode_mcp_auth - ]) - .run(tauri::generate_context!()) - .expect("error while running OpenWork"); + builder + .manage(EngineManager::default()) + .invoke_handler(tauri::generate_handler![ + engine_start, + engine_stop, + engine_info, + engine_doctor, + engine_install, + workspace_bootstrap, + workspace_set_active, + workspace_create, + workspace_create_remote, + workspace_update_remote, + workspace_forget, + workspace_add_authorized_root, + workspace_template_write, + workspace_template_delete, + workspace_openwork_read, + workspace_openwork_write, + opkg_install, + import_skill, + install_skill_template, + list_local_skills, + uninstall_skill, + read_opencode_config, + write_opencode_config, + updater_environment, + reset_openwork_state, + reset_opencode_cache, + opencode_mcp_auth + ]) + .run(tauri::generate_context!()) + .expect("error while running OpenWork"); } diff --git a/packages/desktop/src-tauri/src/types.rs b/packages/desktop/src-tauri/src/types.rs index e4209273c..159f8165c 100644 --- a/packages/desktop/src-tauri/src/types.rs +++ b/packages/desktop/src-tauri/src/types.rs @@ -3,145 +3,175 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct WorkspaceOpenworkConfig { - pub version: u32, - pub workspace: Option, - #[serde(default, alias = "authorizedRoots")] - pub authorized_roots: Vec, + pub version: u32, + pub workspace: Option, + #[serde(default, alias = "authorizedRoots")] + pub authorized_roots: Vec, } impl Default for WorkspaceOpenworkConfig { - fn default() -> Self { - Self { - version: 1, - workspace: None, - authorized_roots: Vec::new(), + fn default() -> Self { + Self { + version: 1, + workspace: None, + authorized_roots: Vec::new(), + } } - } } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct WorkspaceOpenworkWorkspace { - pub name: Option, - #[serde(default, alias = "createdAt")] - pub created_at: Option, - #[serde(default, alias = "preset")] - pub preset: Option, + pub name: Option, + #[serde(default, alias = "createdAt")] + pub created_at: Option, + #[serde(default, alias = "preset")] + pub preset: Option, } impl WorkspaceOpenworkConfig { - pub fn new(workspace_path: &str, preset: &str, now_ms: u64) -> Self { - let root = std::path::PathBuf::from(workspace_path); - let inferred_name = root - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("Workspace") - .to_string(); - - Self { - version: 1, - workspace: Some(WorkspaceOpenworkWorkspace { - name: Some(inferred_name), - created_at: Some(now_ms), - preset: Some(preset.to_string()), - }), - authorized_roots: vec![workspace_path.to_string()], + pub fn new(workspace_path: &str, preset: &str, now_ms: u64) -> Self { + let root = std::path::PathBuf::from(workspace_path); + let inferred_name = root + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("Workspace") + .to_string(); + + Self { + version: 1, + workspace: Some(WorkspaceOpenworkWorkspace { + name: Some(inferred_name), + created_at: Some(now_ms), + preset: Some(preset.to_string()), + }), + authorized_roots: vec![workspace_path.to_string()], + } } - } } #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct EngineInfo { - pub running: bool, - pub base_url: Option, - pub project_dir: Option, - pub hostname: Option, - pub port: Option, - pub pid: Option, - pub last_stdout: Option, - pub last_stderr: Option, + pub running: bool, + pub base_url: Option, + pub project_dir: Option, + pub hostname: Option, + pub port: Option, + pub pid: Option, + pub last_stdout: Option, + pub last_stderr: Option, } #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct EngineDoctorResult { - pub found: bool, - pub in_path: bool, - pub resolved_path: Option, - pub version: Option, - pub supports_serve: bool, - pub notes: Vec, - pub serve_help_status: Option, - pub serve_help_stdout: Option, - pub serve_help_stderr: Option, + pub found: bool, + pub in_path: bool, + pub resolved_path: Option, + pub version: Option, + pub supports_serve: bool, + pub notes: Vec, + pub serve_help_status: Option, + pub serve_help_stdout: Option, + pub serve_help_stderr: Option, } #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct ExecResult { - pub ok: bool, - pub status: i32, - pub stdout: String, - pub stderr: String, + pub ok: bool, + pub status: i32, + pub stdout: String, + pub stderr: String, } #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct OpencodeConfigFile { - pub path: String, - pub exists: bool, - pub content: Option, + pub path: String, + pub exists: bool, + pub content: Option, } #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct UpdaterEnvironment { - pub supported: bool, - pub reason: Option, - pub executable_path: Option, - pub app_bundle_path: Option, + pub supported: bool, + pub reason: Option, + pub executable_path: Option, + pub app_bundle_path: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum WorkspaceType { + Local, + Remote, +} + +impl Default for WorkspaceType { + fn default() -> Self { + WorkspaceType::Local + } } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct WorkspaceInfo { - pub id: String, - pub name: String, - pub path: String, - pub preset: String, + pub id: String, + pub name: String, + pub path: String, + pub preset: String, + #[serde(default)] + pub workspace_type: WorkspaceType, + #[serde(default)] + pub base_url: Option, + #[serde(default)] + pub directory: Option, + #[serde(default)] + pub display_name: Option, } #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct WorkspaceList { - pub active_id: String, - pub workspaces: Vec, + pub active_id: String, + pub workspaces: Vec, } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct WorkspaceTemplate { - pub id: String, - pub title: String, - pub description: String, - pub prompt: String, - #[serde(default)] - pub created_at: u64, + pub id: String, + pub title: String, + pub description: String, + pub prompt: String, + #[serde(default)] + pub created_at: u64, +} + +fn default_workspace_state_version() -> u8 { + 1 } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] -pub struct WorkspaceStateV1 { - pub active_id: String, - pub workspaces: Vec, +pub struct WorkspaceState { + #[serde(default = "default_workspace_state_version")] + pub version: u8, + pub active_id: String, + pub workspaces: Vec, } -impl Default for WorkspaceStateV1 { - fn default() -> Self { - Self { - active_id: "starter".to_string(), - workspaces: Vec::new(), +impl Default for WorkspaceState { + fn default() -> Self { + Self { + version: WORKSPACE_STATE_VERSION, + active_id: "starter".to_string(), + workspaces: Vec::new(), + } } - } } + +pub const WORKSPACE_STATE_VERSION: u8 = 2; diff --git a/packages/desktop/src-tauri/src/workspace/state.rs b/packages/desktop/src-tauri/src/workspace/state.rs index 2d7a06df5..35443455e 100644 --- a/packages/desktop/src-tauri/src/workspace/state.rs +++ b/packages/desktop/src-tauri/src/workspace/state.rs @@ -4,59 +4,89 @@ use std::path::PathBuf; use tauri::Manager; -use crate::types::{WorkspaceInfo, WorkspaceStateV1}; +use crate::types::{WorkspaceInfo, WorkspaceState, WorkspaceType, WORKSPACE_STATE_VERSION}; use crate::utils::now_ms; pub fn stable_workspace_id(path: &str) -> String { - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - path.hash(&mut hasher); - format!("ws-{:x}", hasher.finish()) + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + path.hash(&mut hasher); + format!("ws-{:x}", hasher.finish()) } pub fn openwork_state_paths(app: &tauri::AppHandle) -> Result<(PathBuf, PathBuf), String> { - let data_dir = app - .path() - .app_data_dir() - .map_err(|e| format!("Failed to resolve app data dir: {e}"))?; - let file_path = data_dir.join("openwork-workspaces.json"); - Ok((data_dir, file_path)) + let data_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Failed to resolve app data dir: {e}"))?; + let file_path = data_dir.join("openwork-workspaces.json"); + Ok((data_dir, file_path)) } -pub fn load_workspace_state(app: &tauri::AppHandle) -> Result { - let (_, path) = openwork_state_paths(app)?; - if !path.exists() { - return Ok(WorkspaceStateV1::default()); - } +pub fn load_workspace_state(app: &tauri::AppHandle) -> Result { + let (_, path) = openwork_state_paths(app)?; + if !path.exists() { + return Ok(WorkspaceState::default()); + } - let raw = fs::read_to_string(&path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?; - serde_json::from_str(&raw).map_err(|e| format!("Failed to parse {}: {e}", path.display())) + let raw = + fs::read_to_string(&path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?; + let mut state: WorkspaceState = serde_json::from_str(&raw) + .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?; + + if state.version < WORKSPACE_STATE_VERSION { + state.version = WORKSPACE_STATE_VERSION; + } + + Ok(state) } -pub fn save_workspace_state(app: &tauri::AppHandle, state: &WorkspaceStateV1) -> Result<(), String> { - let (dir, path) = openwork_state_paths(app)?; - fs::create_dir_all(&dir).map_err(|e| format!("Failed to create {}: {e}", dir.display()))?; - fs::write(&path, serde_json::to_string_pretty(state).map_err(|e| e.to_string())?) +pub fn save_workspace_state(app: &tauri::AppHandle, state: &WorkspaceState) -> Result<(), String> { + let (dir, path) = openwork_state_paths(app)?; + fs::create_dir_all(&dir).map_err(|e| format!("Failed to create {}: {e}", dir.display()))?; + fs::write( + &path, + serde_json::to_string_pretty(state).map_err(|e| e.to_string())?, + ) .map_err(|e| format!("Failed to write {}: {e}", path.display()))?; - Ok(()) + Ok(()) } pub fn ensure_starter_workspace(app: &tauri::AppHandle) -> Result { - let data_dir = app - .path() - .app_data_dir() - .map_err(|e| format!("Failed to resolve app data dir: {e}"))?; - let starter_dir = data_dir.join("workspaces").join("starter"); - fs::create_dir_all(&starter_dir) - .map_err(|e| format!("Failed to create starter workspace: {e}"))?; - - Ok(WorkspaceInfo { - id: stable_workspace_id(starter_dir.to_string_lossy().as_ref()), - name: "Starter".to_string(), - path: starter_dir.to_string_lossy().to_string(), - preset: "starter".to_string(), - }) + let data_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Failed to resolve app data dir: {e}"))?; + let starter_dir = data_dir.join("workspaces").join("starter"); + fs::create_dir_all(&starter_dir) + .map_err(|e| format!("Failed to create starter workspace: {e}"))?; + + Ok(WorkspaceInfo { + id: stable_workspace_id(starter_dir.to_string_lossy().as_ref()), + name: "Starter".to_string(), + path: starter_dir.to_string_lossy().to_string(), + preset: "starter".to_string(), + workspace_type: WorkspaceType::Local, + base_url: None, + directory: None, + display_name: None, + }) +} + +pub fn stable_workspace_id_for_remote(base_url: &str, directory: Option<&str>) -> String { + let mut key = format!("remote::{base_url}"); + if let Some(dir) = directory { + if !dir.trim().is_empty() { + key.push_str("::"); + key.push_str(dir.trim()); + } + } + stable_workspace_id(&key) } pub fn default_template_created_at(input: u64) -> u64 { - if input > 0 { input } else { now_ms() } + if input > 0 { + input + } else { + now_ms() + } }