mirror of
https://github.com/different-ai/openwork
synced 2026-05-11 01:32:04 +02:00
feat: add remote workspace creation flow (#185)
This commit is contained in:
@@ -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")
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "模板标题和提示词是必需的。",
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user