feat: add remote workspace creation flow (#185)

This commit is contained in:
ben
2026-01-21 21:03:54 -08:00
committed by GitHub
parent fbb551f649
commit 0098cdfd13
14 changed files with 1246 additions and 444 deletions

View File

@@ -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() {
<WorkspacePicker
open={workspaceStore.workspacePickerOpen()}
workspaces={workspaceStore.filteredWorkspaces()}
workspaces={workspaceStore.workspaces()}
activeWorkspaceId={workspaceStore.activeWorkspaceId()}
search={workspaceStore.workspaceSearch()}
onSearch={workspaceStore.setWorkspaceSearch}
onClose={() => workspaceStore.setWorkspacePickerOpen(false)}
onSelect={workspaceStore.activateWorkspace}
onCreateNew={() => workspaceStore.setCreateWorkspaceOpen(true)}
onCreateLocal={() => workspaceStore.setCreateWorkspaceOpen(true)}
onCreateRemote={() => workspaceStore.setCreateRemoteWorkspaceOpen(true)}
onForget={workspaceStore.forgetWorkspace}
connectingWorkspaceId={workspaceStore.connectingWorkspaceId()}
/>
<CreateWorkspaceModal
@@ -1876,7 +1881,17 @@ export default function App() {
onConfirm={(preset, folder) =>
workspaceStore.createWorkspaceFlow(preset, folder)
}
submitting={busy() && busyLabel() === "Creating workspace"}
submitting={busy() && busyLabel() === "status.creating_workspace"}
/>
<CreateRemoteWorkspaceModal
open={workspaceStore.createRemoteWorkspaceOpen()}
onClose={() => workspaceStore.setCreateRemoteWorkspaceOpen(false)}
onConfirm={(input) => workspaceStore.createRemoteWorkspaceFlow(input)}
submitting={
busy() &&
(busyLabel() === "status.creating_workspace" || busyLabel() === "status.connecting")
}
/>
</>
);

View File

@@ -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 = (
<div class="bg-gray-2 border border-gray-6 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div class="p-6 border-b border-gray-6 flex justify-between items-center bg-gray-1">
<div>
<h3 class="font-semibold text-gray-12 text-lg">{title()}</h3>
<p class="text-gray-10 text-sm">{subtitle()}</p>
</div>
<Show when={showClose()}>
<button
onClick={props.onClose}
disabled={submitting()}
class={`hover:bg-gray-4 p-1 rounded-full ${submitting() ? "opacity-50 cursor-not-allowed" : ""}`.trim()}
>
<X size={20} class="text-gray-10" />
</button>
</Show>
</div>
<div class="p-6 flex-1 overflow-y-auto space-y-6">
<div class="rounded-2xl border border-gray-6 bg-gray-1/40 p-4 flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gray-3 flex items-center justify-center">
<Globe size={20} class="text-gray-12" />
</div>
<div>
<div class="text-sm font-medium text-gray-12">{translate("dashboard.remote_workspace_title")}</div>
<div class="text-xs text-gray-10">{translate("dashboard.remote_workspace_hint")}</div>
</div>
</div>
<div class="space-y-4">
<TextInput
label={translate("dashboard.remote_base_url_label")}
placeholder={translate("dashboard.remote_base_url_placeholder")}
value={baseUrl()}
onInput={(event) => setBaseUrl(event.currentTarget.value)}
disabled={submitting()}
/>
<TextInput
label={translate("dashboard.remote_directory_label")}
placeholder={translate("dashboard.remote_directory_placeholder")}
value={directory()}
onInput={(event) => setDirectory(event.currentTarget.value)}
hint={translate("dashboard.remote_directory_hint")}
disabled={submitting()}
/>
<TextInput
label={translate("dashboard.remote_display_name_label")}
placeholder={translate("dashboard.remote_display_name_placeholder")}
value={displayName()}
onInput={(event) => setDisplayName(event.currentTarget.value)}
disabled={submitting()}
/>
</div>
</div>
<div class="p-6 border-t border-gray-6 bg-gray-1 flex justify-end gap-3">
<Show when={showClose()}>
<Button variant="ghost" onClick={props.onClose} disabled={submitting()}>
{translate("common.cancel")}
</Button>
</Show>
<Button
onClick={() =>
props.onConfirm({
baseUrl: baseUrl().trim(),
directory: directory().trim() ? directory().trim() : null,
displayName: displayName().trim() ? displayName().trim() : null,
})
}
disabled={!canSubmit()}
title={!baseUrl().trim() ? translate("dashboard.remote_base_url_required") : undefined}
>
{confirmLabel()}
</Button>
</div>
</div>
);
return (
<Show when={props.open || isInline()}>
<div
class={
isInline()
? "w-full"
: "fixed inset-0 z-50 flex items-center justify-center bg-gray-1/60 backdrop-blur-sm p-4 animate-in fade-in duration-200"
}
>
{content}
</div>
</Show>
);
}

View File

@@ -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 (
<button
@@ -22,7 +31,7 @@ export default function WorkspaceChip(props: {
>
<div
class={`p-1 rounded ${
props.workspace.preset === "starter"
props.workspace.workspaceType !== "remote" && props.workspace.preset === "starter"
? "bg-amber-7/10 text-amber-6"
: "bg-indigo-7/10 text-indigo-6"
}`}
@@ -30,14 +39,22 @@ export default function WorkspaceChip(props: {
<Icon size={14} />
</div>
<div class="flex flex-col items-start mr-2 min-w-0">
<span class="text-xs font-medium text-gray-12 leading-none mb-0.5 truncate max-w-[9.5rem]">
{props.workspace.name}
</span>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-12 leading-none truncate max-w-[9.5rem]">
{props.workspace.name}
</span>
{props.workspace.workspaceType === "remote" ? (
<span class="text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded-full bg-gray-4 text-gray-11">
{translate("dashboard.remote")}
</span>
) : null}
</div>
<span class="text-[10px] text-gray-10 font-mono leading-none max-w-[120px] truncate">
{props.workspace.path}
{subtitle()}
</span>
</div>
<ChevronDown size={14} class="text-gray-10 group-hover:text-gray-11" />
{props.connecting ? <Loader2 size={14} class="text-gray-10 animate-spin" /> : null}
</button>
);
}

View File

@@ -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> | 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 (
<Show when={props.open}>
<div
@@ -48,47 +57,94 @@ export default function WorkspacePicker(props: {
<div class="max-h-64 overflow-y-auto p-1">
<div class="px-3 py-2 text-[10px] font-semibold text-gray-10 uppercase tracking-wider">
{translate("dashboard.workspaces")}
{translate("dashboard.workspaces")} ({totalCount()})
</div>
<For each={filtered()}>
{(ws) => (
<button
onClick={() => {
props.onSelect(ws.id);
props.onClose();
}}
<div
class={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
props.activeWorkspaceId === ws.id
? "bg-gray-4 text-gray-12"
: "text-gray-11 hover:text-gray-12 hover:bg-gray-4/50"
}`}
>
<div class="flex-1 text-left min-w-0">
<div class="font-medium truncate">{ws.name}</div>
<div class="text-[10px] text-gray-7 font-mono truncate max-w-[200px]">
{ws.path}
<button
onClick={() => {
const result = props.onSelect(ws.id);
if (result instanceof Promise) {
result.then((ok) => {
if (ok !== false) props.onClose();
});
return;
}
if (result !== false) props.onClose();
}}
class="flex-1 text-left min-w-0"
>
<div class="flex items-center gap-2">
<div class="font-medium truncate">{ws.name}</div>
<Show when={ws.workspaceType === "remote"}>
<span class="inline-flex items-center gap-1 text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded-full bg-gray-3 text-gray-11">
<Globe size={10} />
{translate("dashboard.remote")}
</span>
</Show>
</div>
</div>
<div class="text-[10px] text-gray-7 font-mono truncate max-w-[200px]">
{ws.workspaceType === "remote" ? ws.baseUrl ?? ws.path : ws.path}
</div>
<Show when={ws.workspaceType === "remote" && ws.directory}>
<div class="text-[10px] text-gray-8 truncate max-w-[200px]">
{ws.directory}
</div>
</Show>
</button>
<Show when={props.activeWorkspaceId === ws.id}>
<Check size={14} class="text-indigo-11" />
</Show>
</button>
<Show when={props.connectingWorkspaceId === ws.id}>
<Loader2 size={14} class="text-gray-10 animate-spin" />
</Show>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
props.onForget(ws.id);
}}
class="p-1 rounded-md text-gray-9 hover:text-gray-12 hover:bg-gray-3 transition-colors"
title={translate("dashboard.forget_workspace")}
>
<Trash2 size={14} />
</button>
</div>
)}
</For>
</div>
<div class="p-2 border-t border-gray-6 bg-gray-2">
<button
onClick={() => {
props.onCreateNew();
props.onClose();
}}
class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-gray-11 hover:bg-gray-4 hover:text-gray-12 transition-colors"
>
<Plus size={16} />
{translate("dashboard.new_workspace")}
</button>
<div class="grid gap-2">
<button
onClick={() => {
props.onCreateLocal();
props.onClose();
}}
class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-gray-11 hover:bg-gray-4 hover:text-gray-12 transition-colors"
>
<Plus size={16} />
{translate("dashboard.new_workspace")}
</button>
<button
onClick={() => {
props.onCreateRemote();
props.onClose();
}}
class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-gray-11 hover:bg-gray-4 hover:text-gray-12 transition-colors"
>
<Globe size={16} />
{translate("dashboard.new_remote_workspace")}
</button>
</div>
</div>
</div>
</div>

View File

@@ -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<string | null>(null);
const activeWorkspaceInfo = createMemo(() => workspaces().find((w) => w.id === activeWorkspaceId()) ?? null);
const activeWorkspaceDisplay = createMemo<WorkspaceDisplay>(() => {
@@ -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);
setWorkspaceConfigLoaded(true);
const roots = Array.isArray(cfg.authorizedRoots) ? cfg.authorizedRoots : [];
if (roots.length) {
setAuthorizedDirs(roots);
} else {
setAuthorizedDirs([next.path]);
}
} catch {
if (isRemote) {
setWorkspaceConfig(null);
setWorkspaceConfigLoaded(true);
setAuthorizedDirs([next.path]);
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]);
}
}
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,

View File

@@ -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<WorkspaceList> {
return invoke<WorkspaceList>("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<WorkspaceList> {
return invoke<WorkspaceList>("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<WorkspaceList> {
return invoke<WorkspaceList>("workspace_forget", { workspaceId });
}
export async function workspaceAddAuthorizedRoot(input: {
workspacePath: string;
folderPath: string;

View File

@@ -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> | boolean;
createWorkspaceOpen: boolean;
setCreateWorkspaceOpen: (open: boolean) => void;
createWorkspaceFlow: (
@@ -403,6 +404,7 @@ export default function DashboardView(props: DashboardViewProps) {
<div class="flex items-center gap-3">
<WorkspaceChip
workspace={props.activeWorkspaceDisplay}
connecting={props.connectingWorkspaceId === props.activeWorkspaceDisplay.id}
onClick={() => {
props.setWorkspaceSearch("");
props.setWorkspacePickerOpen(true);

View File

@@ -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) {
<div class="max-w-md w-full z-10 space-y-8">
<div class="text-center space-y-2">
<div class="w-12 h-12 bg-gray-2 rounded-2xl mx-auto flex items-center justify-center border border-gray-6 mb-6">
<ArrowLeftRight size={20} class="text-gray-11" />
<Globe size={20} class="text-gray-11" />
</div>
<h2 class="text-2xl font-bold tracking-tight">{translate("onboarding.connect_host")}</h2>
<h2 class="text-2xl font-bold tracking-tight">{translate("onboarding.remote_workspace_title")}</h2>
<p class="text-gray-11 text-sm leading-relaxed">
{translate("onboarding.connect_description")}
{translate("onboarding.remote_workspace_description")}
</p>
</div>
<div class="space-y-4">
<TextInput
label={translate("onboarding.server_url")}
placeholder={translate("onboarding.server_url_placeholder")}
label={translate("dashboard.remote_base_url_label")}
placeholder={translate("dashboard.remote_base_url_placeholder")}
value={props.baseUrl}
onInput={(e) => props.onBaseUrlChange(e.currentTarget.value)}
/>
<TextInput
label={translate("onboarding.directory")}
placeholder={translate("onboarding.directory_placeholder")}
label={translate("dashboard.remote_directory_label")}
placeholder={translate("dashboard.remote_directory_placeholder")}
value={props.clientDirectory}
onInput={(e) => props.onClientDirectoryChange(e.currentTarget.value)}
hint={translate("onboarding.directory_hint")}
hint={translate("dashboard.remote_directory_hint")}
/>
<Button onClick={props.onConnectClient} disabled={props.busy || !props.baseUrl.trim()} class="w-full py-3 text-base">
{translate("onboarding.connect")}
{translate("onboarding.remote_workspace_action")}
</Button>
<Button variant="ghost" onClick={props.onBackToMode} disabled={props.busy} class="w-full">
@@ -499,6 +499,23 @@ export default function OnboardingView(props: OnboardingViewProps) {
</div>
</Show>
<button
onClick={() => props.onModeSelect("client")}
class="group w-full relative bg-gray-2 hover:bg-gray-4 border border-gray-6 hover:border-gray-7 p-6 md:p-8 rounded-3xl text-left transition-all duration-300 hover:shadow-2xl hover:shadow-gray-12/10 hover:-translate-y-0.5 flex items-start gap-6"
>
<div class="shrink-0 w-14 h-14 rounded-2xl bg-gradient-to-br from-gray-7/20 to-gray-5/10 flex items-center justify-center border border-gray-6 group-hover:border-gray-7 transition-colors">
<Globe size={18} class="text-gray-11" />
</div>
<div>
<h3 class="text-xl font-medium text-gray-12 mb-2">
{translate("onboarding.remote_workspace_card_title")}
</h3>
<p class="text-gray-10 text-sm leading-relaxed mb-4">
{translate("onboarding.remote_workspace_card_description")}
</p>
</div>
</button>
<div class="flex items-center gap-2 px-2 py-1">
<button
onClick={props.onRememberModeToggle}
@@ -519,15 +536,6 @@ export default function OnboardingView(props: OnboardingViewProps) {
</button>
</div>
<div class="pt-6 border-t border-gray-6 flex justify-center">
<button
onClick={() => props.onModeSelect("client")}
class="text-gray-7 hover:text-gray-11 text-sm font-medium transition-colors flex items-center gap-2 px-4 py-2 rounded-lg hover:bg-gray-2/50"
>
{translate("onboarding.client_mode")}
</button>
</div>
<Show when={props.error}>
<div class="rounded-2xl bg-red-1/40 px-5 py-4 text-sm text-red-12 border border-red-7/20">
{props.error}

View File

@@ -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.",

View File

@@ -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": "模板标题和提示词是必需的。",

View File

@@ -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<WorkspaceList, String> {
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<WorkspaceList, String> {
let mut state = load_workspace_state(&app)?;
let id = workspace_id.trim();
pub fn workspace_forget(
app: tauri::AppHandle,
workspace_id: String,
) -> Result<WorkspaceList, String> {
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());
}
state.active_id = id.to_string();
save_workspace_state(&app, &state)?;
if state.active_id == id {
state.active_id = state
.workspaces
.first()
.map(|entry| entry.id.clone())
.unwrap_or_else(|| "".to_string());
}
Ok(WorkspaceList {
active_id: state.active_id,
workspaces: state.workspaces,
})
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);
}
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<WorkspaceList, String> {
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<WorkspaceList, String> {
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}"))?;
fs::create_dir_all(&folder).map_err(|e| format!("Failed to create workspace folder: {e}"))?;
let id = stable_workspace_id(&folder);
let id = stable_workspace_id(&folder);
ensure_workspace_files(&folder, &preset)?;
ensure_workspace_files(&folder, &preset)?;
let mut state = load_workspace_state(&app)?;
let mut state = load_workspace_state(&app)?;
state.workspaces.retain(|w| w.id != id);
state.workspaces.push(crate::types::WorkspaceInfo {
id: id.clone(),
name: workspace_name,
path: folder,
preset,
});
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;
save_workspace_state(&app, &state)?;
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,
})
Ok(WorkspaceList {
active_id: state.active_id,
workspaces: state.workspaces,
})
}
#[tauri::command]
pub fn workspace_create_remote(
app: tauri::AppHandle,
base_url: String,
directory: Option<String>,
display_name: Option<String>,
) -> Result<WorkspaceList, String> {
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 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,
})
}
#[tauri::command]
pub fn workspace_update_remote(
app: tauri::AppHandle,
workspace_id: String,
base_url: Option<String>,
directory: Option<String>,
display_name: Option<String>,
) -> Result<WorkspaceList, String> {
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 entry = state.workspaces.iter_mut().find(|w| w.id == id);
let Some(entry) = entry else {
return Err("Unknown workspaceId".to_string());
};
if entry.workspace_type != WorkspaceType::Remote {
return Err("workspaceId is not remote".to_string());
}
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);
}
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<ExecResult, String> {
let workspace_path = workspace_path.trim().to_string();
let folder_path = folder_path.trim().to_string();
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());
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);
}
let openwork_path = PathBuf::from(&workspace_path)
.join(".opencode")
.join("openwork.json");
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()))?;
if let Some(parent) = openwork_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
}
Ok(ExecResult {
ok: true,
status: 0,
stdout: "Updated authorizedRoots".to_string(),
stderr: String::new(),
})
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<ExecResult, String> {
let workspace_path = workspace_path.trim().to_string();
if workspace_path.is_empty() {
return Err("workspacePath is required".to_string());
}
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)?;
let file_path = write_template(&workspace_path, template)?;
Ok(ExecResult {
ok: true,
status: 0,
stdout: format!("Wrote {}", file_path.display()),
stderr: String::new(),
})
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<WorkspaceOpenworkConfig, String> {
let workspace_path = workspace_path.trim().to_string();
if workspace_path.is_empty() {
return Err("workspacePath is required".to_string());
}
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");
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);
}
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()))?;
let raw = fs::read_to_string(&openwork_path)
.map_err(|e| format!("Failed to read {}: {e}", openwork_path.display()))?;
serde_json::from_str::<WorkspaceOpenworkConfig>(&raw).map_err(|e| {
format!("Failed to parse {}: {e}", openwork_path.display())
})
serde_json::from_str::<WorkspaceOpenworkConfig>(&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<ExecResult, String> {
let workspace_path = workspace_path.trim().to_string();
if workspace_path.is_empty() {
return Err("workspacePath is required".to_string());
}
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");
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()))?;
}
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()))?;
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(),
})
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<ExecResult, String> {
let workspace_path = workspace_path.trim().to_string();
if workspace_path.is_empty() {
return Err("workspacePath is required".to_string());
}
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)?;
let file_path = delete_template(&workspace_path, &template_id)?;
Ok(ExecResult {
ok: true,
status: 0,
stdout: format!("Deleted {}", file_path.display()),
stderr: String::new(),
})
Ok(ExecResult {
ok: true,
status: 0,
stdout: format!("Deleted {}", file_path.display()),
stderr: String::new(),
})
}

View File

@@ -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");
}

View File

@@ -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<WorkspaceOpenworkWorkspace>,
#[serde(default, alias = "authorizedRoots")]
pub authorized_roots: Vec<String>,
pub version: u32,
pub workspace: Option<WorkspaceOpenworkWorkspace>,
#[serde(default, alias = "authorizedRoots")]
pub authorized_roots: Vec<String>,
}
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<String>,
#[serde(default, alias = "createdAt")]
pub created_at: Option<u64>,
#[serde(default, alias = "preset")]
pub preset: Option<String>,
pub name: Option<String>,
#[serde(default, alias = "createdAt")]
pub created_at: Option<u64>,
#[serde(default, alias = "preset")]
pub preset: Option<String>,
}
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();
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()],
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<String>,
pub project_dir: Option<String>,
pub hostname: Option<String>,
pub port: Option<u16>,
pub pid: Option<u32>,
pub last_stdout: Option<String>,
pub last_stderr: Option<String>,
pub running: bool,
pub base_url: Option<String>,
pub project_dir: Option<String>,
pub hostname: Option<String>,
pub port: Option<u16>,
pub pid: Option<u32>,
pub last_stdout: Option<String>,
pub last_stderr: Option<String>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct EngineDoctorResult {
pub found: bool,
pub in_path: bool,
pub resolved_path: Option<String>,
pub version: Option<String>,
pub supports_serve: bool,
pub notes: Vec<String>,
pub serve_help_status: Option<i32>,
pub serve_help_stdout: Option<String>,
pub serve_help_stderr: Option<String>,
pub found: bool,
pub in_path: bool,
pub resolved_path: Option<String>,
pub version: Option<String>,
pub supports_serve: bool,
pub notes: Vec<String>,
pub serve_help_status: Option<i32>,
pub serve_help_stdout: Option<String>,
pub serve_help_stderr: Option<String>,
}
#[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<String>,
pub path: String,
pub exists: bool,
pub content: Option<String>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct UpdaterEnvironment {
pub supported: bool,
pub reason: Option<String>,
pub executable_path: Option<String>,
pub app_bundle_path: Option<String>,
pub supported: bool,
pub reason: Option<String>,
pub executable_path: Option<String>,
pub app_bundle_path: Option<String>,
}
#[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<String>,
#[serde(default)]
pub directory: Option<String>,
#[serde(default)]
pub display_name: Option<String>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceList {
pub active_id: String,
pub workspaces: Vec<WorkspaceInfo>,
pub active_id: String,
pub workspaces: Vec<WorkspaceInfo>,
}
#[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<WorkspaceInfo>,
pub struct WorkspaceState {
#[serde(default = "default_workspace_state_version")]
pub version: u8,
pub active_id: String,
pub workspaces: Vec<WorkspaceInfo>,
}
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;

View File

@@ -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<WorkspaceStateV1, String> {
let (_, path) = openwork_state_paths(app)?;
if !path.exists() {
return Ok(WorkspaceStateV1::default());
}
pub fn load_workspace_state(app: &tauri::AppHandle) -> Result<WorkspaceState, String> {
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<WorkspaceInfo, 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}"))?;
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(),
})
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()
}
}