fix(startup): move boot orchestration to TS and cut desktop sync stalls (#1366)

Shift startup/loading control to TS with explicit boot phases, cached->live sidebar transitions, and startup trace markers while reducing Rust-side blocking in workspace and orchestrator status paths to improve macOS responsiveness during boot and workspace switching.

Made-with: Cursor
This commit is contained in:
ben
2026-04-06 07:44:34 -07:00
committed by GitHub
parent 758513a936
commit dfb7f1b2dc
15 changed files with 656 additions and 148 deletions

View File

@@ -125,6 +125,13 @@ import {
stripBundleQuery,
} from "./bundles";
import { createBundlesStore } from "./bundles/store";
import {
classifyStartupBranch,
pushStartupTraceEvent,
type BootPhase,
type StartupBranch,
type StartupTraceEvent,
} from "./lib/startup-boot";
type SettingsReturnTarget = {
view: View;
@@ -138,6 +145,27 @@ type PendingInitialSessionSelection = {
readyAt: number;
};
const STARTUP_SESSION_SNAPSHOT_KEY = "openwork.startupSessionSnapshot.v1";
const STARTUP_SESSION_SNAPSHOT_VERSION = 1;
const STARTUP_SESSION_SNAPSHOT_MAX_PER_WORKSPACE = 12;
type StartupSessionSnapshotEntry = {
id: string;
title: string;
parentID?: string | null;
directory?: string | null;
time?: {
updated?: number | null;
created?: number | null;
};
};
type StartupSessionSnapshot = {
version: number;
updatedAt: number;
sessionsByWorkspaceId: Record<string, StartupSessionSnapshotEntry[]>;
};
export default function App() {
const { resetSessionDisplayPreferences } = useSessionDisplayPreferences();
const envOpenworkWorkspaceId =
@@ -321,6 +349,8 @@ export default function App() {
if (!isTauriRuntime()) return;
if (!developerMode()) return;
if (!documentVisible()) return;
if (booting()) return;
if (workspaceStore?.connectingWorkspaceId?.()) return;
let busy = false;
@@ -353,10 +383,40 @@ export default function App() {
const [error, setError] = createSignal<string | null>(null);
const [opencodeConnectStatus, setOpencodeConnectStatus] = createSignal<OpencodeConnectStatus | null>(null);
const [booting, setBooting] = createSignal(true);
const [bootPhase, setBootPhase] = createSignal<BootPhase>("nativeInit");
const [startupBranch, setStartupBranch] = createSignal<StartupBranch>("unknown");
const [startupTrace, setStartupTrace] = createSignal<StartupTraceEvent[]>([]);
const [firstSidebarVisibleAt, setFirstSidebarVisibleAt] = createSignal<number | null>(null);
const [firstSessionPaintAt, setFirstSessionPaintAt] = createSignal<number | null>(null);
const [, setLastKnownConfigSnapshot] = createSignal("");
const [developerMode, setDeveloperMode] = createSignal(false);
const [documentVisible, setDocumentVisible] = createSignal(true);
const markStartupTrace = (phase: BootPhase, event: string, detail?: Record<string, unknown>) => {
setStartupTrace((current) =>
pushStartupTraceEvent(current, {
at: Date.now(),
phase,
event,
...(detail ? { detail } : {}),
}),
);
};
createEffect(() => {
const phase = bootPhase();
const isBooting = phase !== "ready" && phase !== "error";
setBooting(isBooting);
});
createEffect(() => {
if (bootPhase() === "ready" || bootPhase() === "error") return;
const message = error();
if (!message) return;
setBootPhase("error");
markStartupTrace("error", "startup-error", { message });
});
createEffect(() => {
if (developerMode()) return;
clearDevLogs();
@@ -549,6 +609,9 @@ export default function App() {
const activeSessionStatusById = createMemo(() => sessionStatusById());
const activeTodos = createMemo(() => todos());
const activeWorkingFiles = createMemo(() => workingFiles());
const [startupSessionSnapshotByWorkspaceId, setStartupSessionSnapshotByWorkspaceId] = createSignal<
Record<string, StartupSessionSnapshotEntry[]>
>({});
const [sessionsLoaded, setSessionsLoaded] = createSignal(false);
const loadSessionsWithReady = async (scopeRoot?: string) => {
@@ -556,6 +619,20 @@ export default function App() {
setSessionsLoaded(true);
};
createEffect(() => {
if (typeof window === "undefined") return;
try {
const raw = window.localStorage.getItem(STARTUP_SESSION_SNAPSHOT_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as StartupSessionSnapshot;
if (!parsed || parsed.version !== STARTUP_SESSION_SNAPSHOT_VERSION) return;
if (!parsed.sessionsByWorkspaceId || typeof parsed.sessionsByWorkspaceId !== "object") return;
setStartupSessionSnapshotByWorkspaceId(parsed.sessionsByWorkspaceId);
} catch {
// ignore malformed snapshots
}
});
createEffect(() => {
if (!client()) {
setSessionsLoaded(false);
@@ -764,12 +841,55 @@ export default function App() {
openworkServer: openworkServerStore,
openworkEnvWorkspaceId: envOpenworkWorkspaceId,
onEngineStable: () => {},
onBootPhaseChange: (phase, detail) => {
setBootPhase(phase);
markStartupTrace(phase, "phase-change", detail);
},
onStartupBranch: (branch, detail) => {
setStartupBranch(branch);
markStartupTrace(bootPhase(), "branch", { branch, ...(detail ?? {}) });
},
onStartupTrace: (event, detail) => {
markStartupTrace(bootPhase(), event, detail);
},
engineRuntime,
developerMode,
pendingInitialSessionSelection,
setPendingInitialSessionSelection,
});
createEffect(() => {
if (startupBranch() !== "unknown") return;
const active = workspaceStore.selectedWorkspaceInfo?.() ?? null;
const derived = classifyStartupBranch({
workspaceCount: workspaceStore.workspaces().length,
activeWorkspaceType: active?.workspaceType ?? null,
startupPreference: startupPreference(),
engineHasBaseUrl: Boolean(workspaceStore.engine()?.baseUrl),
selectedWorkspacePath: workspaceStore.selectedWorkspacePath?.() ?? "",
});
if (derived !== "unknown") {
setStartupBranch(derived);
markStartupTrace(bootPhase(), "branch-derived", { branch: derived });
}
});
createEffect(() => {
if (typeof window === "undefined") return;
if (!developerMode()) return;
const payload = {
phase: bootPhase(),
branch: startupBranch(),
events: startupTrace(),
};
try {
(window as { __openworkStartupTrace?: typeof payload }).__openworkStartupTrace = payload;
console.log("[startup-trace]", payload);
} catch {
// ignore trace publishing failures
}
});
const {
providerAuthModalOpen,
providerAuthBusy,
@@ -974,6 +1094,98 @@ export default function App() {
});
});
const hydratedSidebarWorkspaceGroups = createMemo<WorkspaceSessionGroup[]>(() => {
const liveGroups = sidebarWorkspaceGroups();
if (liveGroups.some((group) => group.sessions.length > 0)) {
return liveGroups;
}
const snapshotByWorkspaceId = startupSessionSnapshotByWorkspaceId();
if (!snapshotByWorkspaceId || Object.keys(snapshotByWorkspaceId).length === 0) {
return liveGroups;
}
return liveGroups.map((group) => {
if (group.sessions.length > 0) return group;
const cachedSessions = snapshotByWorkspaceId[group.workspace.id] ?? [];
if (!cachedSessions.length) return group;
return {
...group,
sessions: cachedSessions,
};
});
});
const sidebarHydratedFromCache = createMemo(() => {
const liveGroups = sidebarWorkspaceGroups();
const hydratedGroups = hydratedSidebarWorkspaceGroups();
if (!hydratedGroups.length) return false;
if (liveGroups.length !== hydratedGroups.length) return false;
return hydratedGroups.some((group, index) => {
const liveGroup = liveGroups[index];
if (!liveGroup) return false;
return liveGroup.sessions.length === 0 && group.sessions.length > 0;
});
});
createEffect(() => {
if (firstSidebarVisibleAt()) return;
const anyRowsVisible = hydratedSidebarWorkspaceGroups().some((group) => group.sessions.length > 0);
if (!anyRowsVisible) return;
const at = Date.now();
setFirstSidebarVisibleAt(at);
markStartupTrace(bootPhase(), "first-sidebar-visible", {
at,
source: sidebarHydratedFromCache() ? "cache" : "live",
});
});
createEffect(() => {
if (firstSessionPaintAt()) return;
if (currentView() !== "session") return;
const selected = activeSessionId();
if (!selected) return;
const hasVisibleSessionSurface = visibleMessages().length > 0 || sessionsLoaded();
if (!hasVisibleSessionSurface) return;
const at = Date.now();
setFirstSessionPaintAt(at);
markStartupTrace(bootPhase(), "first-session-paint", { at, sessionId: selected });
});
createEffect(() => {
if (typeof window === "undefined") return;
if (!sessionsLoaded()) return;
const groups = sidebarWorkspaceGroups();
const sessionsByWorkspaceId: Record<string, StartupSessionSnapshotEntry[]> = {};
for (const group of groups) {
if (!group.sessions.length) continue;
sessionsByWorkspaceId[group.workspace.id] = group.sessions
.slice(0, STARTUP_SESSION_SNAPSHOT_MAX_PER_WORKSPACE)
.map((session) => ({
id: session.id,
title: session.title,
parentID: session.parentID ?? null,
directory: session.directory ?? null,
time: session.time,
}));
}
if (Object.keys(sessionsByWorkspaceId).length === 0) return;
const payload: StartupSessionSnapshot = {
version: STARTUP_SESSION_SNAPSHOT_VERSION,
updatedAt: Date.now(),
sessionsByWorkspaceId,
};
try {
window.localStorage.setItem(STARTUP_SESSION_SNAPSHOT_KEY, JSON.stringify(payload));
setStartupSessionSnapshotByWorkspaceId(sessionsByWorkspaceId);
} catch {
// ignore storage write failures
}
});
createEffect(() => {
if (typeof window === "undefined") return;
const workspaceId = workspaceStore.selectedWorkspaceId();
@@ -1556,7 +1768,7 @@ export default function App() {
});
}
void workspaceStore.bootstrapOnboarding().finally(() => setBooting(false));
void workspaceStore.bootstrapOnboarding();
});
createEffect(() => {
@@ -1895,7 +2107,7 @@ export default function App() {
setCreateWorkspaceOpen: workspaceStore.setCreateWorkspaceOpen,
createWorkspaceFlow: workspaceStore.createWorkspaceFlow,
pickWorkspaceFolder: workspaceStore.pickWorkspaceFolder,
workspaceSessionGroups: sidebarWorkspaceGroups(),
workspaceSessionGroups: hydratedSidebarWorkspaceGroups(),
selectedSessionId: activeSessionId(),
openRenameWorkspace: workspaceStore.openRenameWorkspace,
editWorkspaceConnection: workspaceStore.openWorkspaceConnectionSettings,
@@ -2012,6 +2224,10 @@ export default function App() {
orchestratorStatus: orchestratorStatusState(),
opencodeRouterInfo: opencodeRouterInfoState(),
appVersion: appVersion(),
booting: booting(),
startupPhase: bootPhase(),
startupBranch: startupBranch(),
startupTrace: startupTrace(),
headerStatus: headerStatus(),
busyHint: busyHint(),
updateStatus: updateStatus(),
@@ -2019,7 +2235,8 @@ export default function App() {
installUpdateAndRestart,
skills: skills(),
newTaskDisabled: newTaskDisabled(),
workspaceSessionGroups: sidebarWorkspaceGroups(),
sidebarHydratedFromCache: sidebarHydratedFromCache(),
workspaceSessionGroups: hydratedSidebarWorkspaceGroups(),
openRenameWorkspace: workspaceStore.openRenameWorkspace,
messages: visibleMessages(),
getSessionById: sessionById,

View File

@@ -29,6 +29,7 @@ import { t } from "../../../i18n";
type Props = {
workspaceSessionGroups: WorkspaceSessionGroup[];
showInitialLoading?: boolean;
selectedWorkspaceId: string;
developerMode: boolean;
selectedSessionId: string | null;
@@ -737,95 +738,113 @@ export default function WorkspaceSessionList(props: Props) {
<div class="mt-3 px-1 pb-1">
<div class="relative flex flex-col gap-1 pl-2.5 before:absolute before:bottom-2 before:left-0 before:top-2 before:w-[2px] before:bg-gray-3 before:content-['']">
<Show
when={
group.status === "loading" &&
group.sessions.length === 0
}
when={props.showInitialLoading}
fallback={
<Show
when={group.sessions.length > 0}
when={
group.status === "loading" &&
group.sessions.length === 0
}
fallback={
<Show when={group.status === "error"}>
<div
class={`w-full rounded-[15px] border px-3 py-2.5 text-left text-[11px] ${
taskLoadError().tone === "offline"
? "border-amber-7/35 bg-amber-2/50 text-amber-11"
: "border-red-7/35 bg-red-1/40 text-red-11"
}`}
title={taskLoadError().title}
<Show
when={group.sessions.length > 0}
fallback={
<Show when={group.status === "error"}>
<div
class={`w-full rounded-[15px] border px-3 py-2.5 text-left text-[11px] ${
taskLoadError().tone === "offline"
? "border-amber-7/35 bg-amber-2/50 text-amber-11"
: "border-red-7/35 bg-red-1/40 text-red-11"
}`}
title={taskLoadError().title}
>
{taskLoadError().message}
</div>
</Show>
}
>
<For
each={previewSessions(
workspace().id,
group.sessions,
tree,
forcedExpandedSessionIds,
)}
>
{taskLoadError().message}
</div>
{(row) =>
renderSessionRow(
workspace().id,
row,
tree,
forcedExpandedSessionIds,
)}
</For>
<Show
when={
group.sessions.length === 0 &&
group.status === "ready"
}
>
<button
type="button"
class="group/empty w-full rounded-[15px] border border-transparent px-3 py-2.5 text-left text-[11px] text-gray-10 transition-colors hover:bg-gray-2/60 hover:text-gray-11"
onClick={() =>
props.onCreateTaskInWorkspace(workspace().id)
}
disabled={props.newTaskDisabled}
>
<span class="group-hover/empty:hidden">
{t("workspace.no_tasks")}
</span>
<span class="hidden group-hover/empty:inline font-medium">
{t("workspace.new_task_inline")}
</span>
</button>
</Show>
<Show
when={
getRootSessions(group.sessions).length >
previewCount(workspace().id)
}
>
<button
type="button"
class="w-full rounded-[15px] border border-transparent px-3 py-2.5 text-left text-[11px] text-gray-10 transition-colors hover:bg-gray-2/60 hover:text-gray-11"
onClick={() =>
showMoreSessions(
workspace().id,
getRootSessions(group.sessions).length,
)
}
>
{showMoreLabel(
workspace().id,
getRootSessions(group.sessions).length,
)}
</button>
</Show>
</Show>
}
>
<For
each={previewSessions(
workspace().id,
group.sessions,
tree,
forcedExpandedSessionIds,
)}
>
{(row) =>
renderSessionRow(
workspace().id,
row,
tree,
forcedExpandedSessionIds,
)}
</For>
<Show
when={
group.sessions.length === 0 &&
group.status === "ready"
}
>
<button
type="button"
class="group/empty w-full rounded-[15px] border border-transparent px-3 py-2.5 text-left text-[11px] text-gray-10 transition-colors hover:bg-gray-2/60 hover:text-gray-11"
onClick={() =>
props.onCreateTaskInWorkspace(workspace().id)
}
disabled={props.newTaskDisabled}
>
<span class="group-hover/empty:hidden">
{t("workspace.no_tasks")}
</span>
<span class="hidden group-hover/empty:inline font-medium">
{t("workspace.new_task_inline")}
</span>
</button>
</Show>
<Show
when={
getRootSessions(group.sessions).length >
previewCount(workspace().id)
}
>
<button
type="button"
class="w-full rounded-[15px] border border-transparent px-3 py-2.5 text-left text-[11px] text-gray-10 transition-colors hover:bg-gray-2/60 hover:text-gray-11"
onClick={() =>
showMoreSessions(
workspace().id,
getRootSessions(group.sessions).length,
)
}
>
{showMoreLabel(
workspace().id,
getRootSessions(group.sessions).length,
)}
</button>
</Show>
<div class="w-full rounded-[15px] px-3 py-2.5 text-left text-[11px] text-gray-10">
{t("workspace.loading_tasks")}
</div>
</Show>
}
>
<div class="w-full rounded-[15px] px-3 py-2.5 text-left text-[11px] text-gray-10">
{t("workspace.loading_tasks")}
<div class="space-y-2">
<For each={[0, 1, 2]}>
{(idx) => (
<div class="w-full rounded-[15px] border border-dls-border/70 bg-dls-hover/30 px-3 py-2.5">
<div
class="h-2.5 rounded-full bg-dls-hover/80 animate-pulse"
style={{ width: idx === 0 ? "62%" : idx === 1 ? "78%" : "54%" }}
/>
</div>
)}
</For>
</div>
</Show>
</div>

View File

@@ -1118,7 +1118,10 @@ export function createSessionStore(options: {
}
}
async function selectSession(sessionID: string) {
async function selectSession(
sessionID: string,
selectOptions?: { skipHealthCheck?: boolean; source?: string },
) {
const c = options.client();
if (!c) return;
@@ -1159,15 +1162,20 @@ export function createSessionStore(options: {
const run = (async () => {
mark("start");
mark("checking health");
try {
await withTimeout(c.global.health(), 3000, "health");
mark("health ok");
} catch (error) {
mark("health FAILED", {
error: error instanceof Error ? error.message : safeStringify(error),
});
throw new Error(t("app.connection_lost", currentLocale()));
const skipHealthCheck = selectOptions?.skipHealthCheck === true;
if (skipHealthCheck) {
mark("health skipped", { source: selectOptions?.source ?? "unknown" });
} else {
mark("checking health");
try {
await withTimeout(c.global.health(), 3000, "health");
mark("health ok");
} catch (error) {
mark("health FAILED", {
error: error instanceof Error ? error.message : safeStringify(error),
});
throw new Error(t("app.connection_lost", currentLocale()));
}
}
if (abortIfStale("selection changed after health")) return;

View File

@@ -66,6 +66,7 @@ import {
type SandboxDoctorResult,
type WorkspaceInfo,
} from "../lib/tauri";
import type { BootPhase, StartupBranch } from "../lib/startup-boot";
import { waitForHealthy, createClient, type OpencodeAuth } from "../lib/opencode";
import type { OpencodeConnectStatus, ProviderListItem } from "../types";
import { t, currentLocale } from "../../i18n";
@@ -140,7 +141,7 @@ export function createWorkspaceStore(options: {
creatingSession: () => boolean;
readLastSessionByWorkspace?: () => Record<string, string>;
selectedSessionId: () => string | null;
selectSession: (id: string) => Promise<void>;
selectSession: (id: string, options?: { skipHealthCheck?: boolean; source?: string }) => Promise<void>;
setBlueprintSeedMessagesBySessionId: (
updater: (current: Record<string, BlueprintSeedMessage[]>) => Record<string, BlueprintSeedMessage[]>,
) => void;
@@ -164,6 +165,9 @@ export function createWorkspaceStore(options: {
openworkEnvWorkspaceId?: string | null;
setOpencodeConnectStatus?: (status: OpencodeConnectStatus | null) => void;
onEngineStable?: () => void;
onBootPhaseChange?: (phase: BootPhase, detail?: Record<string, unknown>) => void;
onStartupBranch?: (branch: StartupBranch, detail?: Record<string, unknown>) => void;
onStartupTrace?: (event: string, detail?: Record<string, unknown>) => void;
engineRuntime?: () => EngineRuntime;
developerMode: () => boolean;
pendingInitialSessionSelection?: () => { workspaceId: string; title: string | null; readyAt: number } | null;
@@ -747,7 +751,10 @@ export function createWorkspaceStore(options: {
const shouldDeferInitialOpen = Boolean(pending && pending.workspaceId === workspaceId);
if (result.openSessionId && !shouldDeferInitialOpen) {
options.setView("session", result.openSessionId);
await options.selectSession(result.openSessionId);
await options.selectSession(result.openSessionId, {
skipHealthCheck: true,
source: "blueprint-open-session",
});
}
} catch (error) {
const message = error instanceof Error ? error.message : safeStringify(error);
@@ -2053,6 +2060,14 @@ export function createWorkspaceStore(options: {
await options.loadSessions(targetRoot);
connectMetrics.loadSessionsMs = Date.now() - sessionsAt;
wsDebug("connect:loadSessions:done", { ms: Date.now() - sessionsAt });
options.onBootPhaseChange?.("sessionIndexReady", {
source: context?.reason ?? "connectToServer",
targetRoot: targetRoot || null,
});
options.onStartupTrace?.("session-index-ready", {
source: context?.reason ?? "connectToServer",
targetRoot: targetRoot || null,
});
const pendingPermissionsAt = Date.now();
await options.refreshPendingPermissions();
connectMetrics.pendingPermissionsMs = Date.now() - pendingPermissionsAt;
@@ -2221,7 +2236,10 @@ export function createWorkspaceStore(options: {
options.setPendingInitialSessionSelection?.(null);
options.setSelectedSessionId(openSessionId);
options.setView("session", openSessionId);
await options.selectSession(openSessionId);
await options.selectSession(openSessionId, {
skipHealthCheck: true,
source: "create-workspace-open-session",
});
}
}
@@ -3769,31 +3787,49 @@ export function createWorkspaceStore(options: {
if (options.selectedSessionId() === lastSessionId) return;
options.setSelectedSessionId(lastSessionId);
options.setView("session", lastSessionId);
void options.selectSession(lastSessionId);
void options.selectSession(lastSessionId, { skipHealthCheck: true, source: "restore-last-session" });
}
async function bootstrapOnboarding() {
const enterPhase = (phase: BootPhase, detail?: Record<string, unknown>) => {
options.onBootPhaseChange?.(phase, detail);
options.onStartupTrace?.(`phase:${phase}`, detail);
};
const markBranch = (branch: StartupBranch, detail?: Record<string, unknown>) => {
options.onStartupBranch?.(branch, detail);
options.onStartupTrace?.(`branch:${branch}`, detail);
};
const startupPref = readStartupPreference();
let info: EngineInfo | null = null;
if (isTauriRuntime()) {
enterPhase("workspaceBootstrap", { source: "workspace_bootstrap" });
try {
const ws = await workspaceBootstrap();
setWorkspaces(ws.workspaces);
syncSelectedWorkspaceId(pickSelectedWorkspaceId(ws.workspaces, [resolveWorkspaceListSelectedId(ws)], ws));
} catch {
// ignore
} catch (error) {
options.onStartupTrace?.("workspace_bootstrap:error", {
error: error instanceof Error ? error.message : safeStringify(error),
});
}
}
await refreshEngine();
await refreshEngineDoctor();
enterPhase("engineProbe", { source: "ts-probe" });
void refreshEngine().catch(() => undefined);
info = engine();
void refreshEngineDoctor().catch(() => undefined);
if (isTauriRuntime() && workspaces().length === 0) {
markBranch("firstRunNoWorkspace", { startupPref });
options.setStartupPreference("local");
const welcomeFolder = await resolveFirstRunWelcomeFolder();
const ok = await createWorkspaceFlow("starter", welcomeFolder);
if (!ok) {
options.setOnboardingStep("local");
}
enterPhase("ready", { reason: "first-run-no-workspace" });
return;
}
@@ -3826,28 +3862,35 @@ export function createWorkspaceStore(options: {
}
}
const info = engine();
if (info?.baseUrl) {
options.setBaseUrl(info.baseUrl);
const localEngine = info ?? engine();
if (localEngine?.baseUrl) {
options.setBaseUrl(localEngine.baseUrl);
}
const activeWorkspace = selectedWorkspaceInfo();
if (isTauriRuntime() && !info?.baseUrl) {
if (isTauriRuntime() && !localEngine?.baseUrl) {
const firstLocalWorkspace = workspaces().find((workspace) => workspace.workspaceType === "local");
if (firstLocalWorkspace?.path?.trim()) {
enterPhase("engineStartOrConnect", { source: "bootstrap-first-local-host-start" });
await startHost({ workspacePath: firstLocalWorkspace.path.trim(), navigate: false }).catch(() => false);
info = engine();
}
}
if (activeWorkspace?.workspaceType === "remote") {
markBranch("remoteWorkspace", { workspaceId: activeWorkspace.id });
options.setStartupPreference("server");
options.setOnboardingStep("connecting");
enterPhase("engineStartOrConnect", { source: "remote-activate" });
const ok = await activateWorkspace(activeWorkspace.id);
if (!ok) {
options.setOnboardingStep("server");
} else {
enterPhase("sessionIndexReady", { source: "remote-activate" });
restoreLastSession();
enterPhase("firstSessionReady", { source: "restore-last-session" });
}
enterPhase("ready", { reason: "remote-workspace-branch" });
return;
}
@@ -3856,18 +3899,24 @@ export function createWorkspaceStore(options: {
}
if (startupPref === "server") {
markBranch("serverPreference", { startupPref });
options.setOnboardingStep("server");
enterPhase("ready", { reason: "server-preference" });
return;
}
if (selectedWorkspacePath().trim()) {
options.setStartupPreference("local");
if (info?.running && info.baseUrl) {
const bootstrapRoot = selectedWorkspacePath().trim() || info.projectDir?.trim() || "";
if (localEngine?.running && localEngine.baseUrl) {
markBranch("localAttachExisting", {
baseUrl: localEngine.baseUrl,
});
const bootstrapRoot = selectedWorkspacePath().trim() || localEngine.projectDir?.trim() || "";
options.setOnboardingStep("connecting");
enterPhase("engineStartOrConnect", { source: "bootstrap-local-attach" });
const ok = await connectToServer(
info.baseUrl,
localEngine.baseUrl,
bootstrapRoot || undefined,
{ workspaceType: "local", targetRoot: bootstrapRoot, reason: "bootstrap-local" },
engineAuth() ?? undefined,
@@ -3875,28 +3924,42 @@ export function createWorkspaceStore(options: {
if (!ok) {
options.setStartupPreference(null);
options.setOnboardingStep("welcome");
enterPhase("error", { reason: "bootstrap-local-connect-failed" });
return;
}
enterPhase("sessionIndexReady", { source: "bootstrap-local-attach" });
restoreLastSession();
enterPhase("firstSessionReady", { source: "restore-last-session" });
enterPhase("ready", { reason: "bootstrap-local-attach" });
return;
}
markBranch("localHostStart", { workspacePath: selectedWorkspacePath().trim() });
options.setOnboardingStep("connecting");
enterPhase("engineStartOrConnect", { source: "bootstrap-local-host-start" });
const ok = await startHost({ workspacePath: selectedWorkspacePath().trim() });
if (!ok) {
options.setOnboardingStep("local");
enterPhase("error", { reason: "bootstrap-local-host-start-failed" });
return;
}
enterPhase("sessionIndexReady", { source: "bootstrap-local-host-start" });
restoreLastSession();
enterPhase("firstSessionReady", { source: "restore-last-session" });
enterPhase("ready", { reason: "bootstrap-local-host-start" });
return;
}
if (startupPref === "local") {
markBranch("localPreference", { startupPref });
options.setOnboardingStep("local");
enterPhase("ready", { reason: "local-preference" });
return;
}
markBranch("welcome", { startupPref: startupPref ?? null });
options.setOnboardingStep("welcome");
enterPhase("ready", { reason: "default-welcome" });
}
function onSelectStartup(nextPref: StartupPreference) {

View File

@@ -0,0 +1,56 @@
export type BootPhase =
| "nativeInit"
| "workspaceBootstrap"
| "engineProbe"
| "engineStartOrConnect"
| "sessionIndexReady"
| "firstSessionReady"
| "ready"
| "error";
export type StartupBranch =
| "firstRunNoWorkspace"
| "remoteWorkspace"
| "localAttachExisting"
| "localHostStart"
| "serverPreference"
| "localPreference"
| "welcome"
| "unknown";
export type StartupTraceEvent = {
at: number;
phase: BootPhase;
event: string;
detail?: Record<string, unknown>;
};
export function classifyStartupBranch(input: {
workspaceCount: number;
activeWorkspaceType: "local" | "remote" | null;
startupPreference: "local" | "server" | null;
engineHasBaseUrl: boolean;
selectedWorkspacePath: string;
}): StartupBranch {
if (input.workspaceCount === 0) return "firstRunNoWorkspace";
if (input.activeWorkspaceType === "remote") return "remoteWorkspace";
if (input.startupPreference === "server") return "serverPreference";
if (!input.selectedWorkspacePath.trim()) {
if (input.startupPreference === "local") return "localPreference";
return "welcome";
}
return input.engineHasBaseUrl ? "localAttachExisting" : "localHostStart";
}
export function pushStartupTraceEvent(
current: StartupTraceEvent[],
event: StartupTraceEvent,
maxEvents = 100,
): StartupTraceEvent[] {
if (!Number.isFinite(event.at) || !event.phase || !event.event) {
return current;
}
const base = current.length >= maxEvents ? current.slice(current.length - maxEvents + 1) : current.slice();
base.push(event);
return base;
}

View File

@@ -112,6 +112,7 @@ import {
saveSessionDraft,
sessionDraftScopeKey,
} from "../session/draft-store";
import type { BootPhase, StartupBranch, StartupTraceEvent } from "../lib/startup-boot";
import { ReactIsland } from "../../react/island";
import { reactSessionEnabled } from "../../react/feature-flag";
import { SessionSurface } from "../../react/session/session-surface.react";
@@ -152,6 +153,10 @@ export type SessionViewProps = {
orchestratorStatus: OrchestratorStatus | null;
opencodeRouterInfo: OpenCodeRouterInfo | null;
appVersion: string | null;
booting: boolean;
startupPhase: BootPhase;
startupBranch: StartupBranch;
startupTrace: StartupTraceEvent[];
headerStatus: string;
busyHint: string | null;
updateStatus: {
@@ -167,6 +172,7 @@ export type SessionViewProps = {
anyActiveRuns: boolean;
installUpdateAndRestart: () => void;
newTaskDisabled: boolean;
sidebarHydratedFromCache: boolean;
workspaceSessionGroups: WorkspaceSessionGroup[];
openRenameWorkspace: (workspaceId: string) => void;
messages: MessageWithParts[];
@@ -2167,6 +2173,25 @@ export default function SessionView(props: SessionViewProps) {
if (props.messages.length > 0) return false;
return props.sessionLoadingById(sessionId);
});
const showStartupSkeleton = createMemo(() => {
if (props.messages.length > 0) return false;
if (props.clientConnected) return false;
const phase = props.startupPhase;
return phase !== "sessionIndexReady" && phase !== "firstSessionReady" && phase !== "ready";
});
const showSidebarInitialLoading = createMemo(() => {
if (props.workspaceSessionGroups.some((group) => group.sessions.length > 0)) {
return false;
}
if (props.sidebarHydratedFromCache) return false;
const phase = props.startupPhase;
if (phase !== "sessionIndexReady" && phase !== "firstSessionReady" && phase !== "ready") {
return true;
}
return props.workspaceSessionGroups.some(
(group) => group.status === "loading" || group.status === "idle",
);
});
const [showDelayedSessionLoadingState, setShowDelayedSessionLoadingState] = createSignal(false);
const [deferSessionRender, setDeferSessionRender] = createSignal(false);
let deferSessionRenderFrame: number | undefined;
@@ -2968,6 +2993,7 @@ export default function SessionView(props: SessionViewProps) {
selectedWorkspaceId={props.selectedWorkspaceId}
developerMode={props.developerMode}
selectedSessionId={props.selectedSessionId}
showInitialLoading={showSidebarInitialLoading()}
showSessionActions
sessionStatusById={props.sessionStatusById}
connectingWorkspaceId={props.connectingWorkspaceId}
@@ -3227,6 +3253,29 @@ export default function SessionView(props: SessionViewProps) {
chatContentEl = el;
}}
>
<Show when={showStartupSkeleton()}>
<div class="px-6 py-14" role="status" aria-live="polite">
<div class="mx-auto max-w-2xl space-y-6">
<div class="space-y-2">
<div class="h-4 w-32 rounded-full bg-dls-hover/80 animate-pulse" />
<div class="h-3 w-64 rounded-full bg-dls-hover/60 animate-pulse" />
</div>
<div class="space-y-3">
<For each={[0, 1, 2]}>
{(idx) => (
<div class="rounded-2xl border border-dls-border bg-dls-hover/40 p-4">
<div class="mb-3 h-3 rounded-full bg-dls-hover/80 animate-pulse" style={{ width: idx === 0 ? "42%" : idx === 1 ? "56%" : "36%" }} />
<div class="space-y-2">
<div class="h-2.5 rounded-full bg-dls-hover/70 animate-pulse" />
<div class="h-2.5 rounded-full bg-dls-hover/60 animate-pulse" style={{ width: idx === 2 ? "74%" : "88%" }} />
</div>
</div>
)}
</For>
</div>
</div>
</div>
</Show>
<Show when={showDelayedSessionLoadingState()}>
<div class="px-6 py-24">
<div
@@ -3252,6 +3301,7 @@ export default function SessionView(props: SessionViewProps) {
when={
props.messages.length === 0 &&
!showWorkspaceSetupEmptyState() &&
!showStartupSkeleton() &&
!showSessionLoadingState() &&
!deferSessionRender() &&
!showReactSessionSurface()

View File

@@ -73,7 +73,7 @@ export function createSessionActionsStore(options: {
selectedWorkspaceRoot: () => string;
runtimeWorkspaceRoot: () => string;
ensureWorkspaceRuntime: (workspaceId: string) => Promise<boolean>;
selectSession: (id: string) => Promise<void>;
selectSession: (id: string, options?: { skipHealthCheck?: boolean; source?: string }) => Promise<void>;
refreshSidebarWorkspaceSessions: (workspaceId: string) => Promise<void>;
abortRefreshes: () => void;
modelConfig: ReturnType<typeof createModelConfigStore>;
@@ -362,7 +362,7 @@ export function createSessionActionsStore(options: {
options.setBusyLabel("status.loading_session");
mark("session:select:start", { sessionID: session.id });
await options.selectSession(session.id);
await options.selectSession(session.id, { skipHealthCheck: true, source: "create-ready-session" });
mark("session:select:ok", { sessionID: session.id });
options.modelConfig.applyPendingSessionChoice(session.id);

File diff suppressed because one or more lines are too long

View File

@@ -2715,22 +2715,22 @@
"markdownDescription": "Denies the unregister command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
@@ -2751,16 +2751,16 @@
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",

View File

@@ -2715,22 +2715,22 @@
"markdownDescription": "Denies the unregister command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
@@ -2751,16 +2751,16 @@
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",

View File

@@ -114,8 +114,13 @@ pub fn engine_info(
manager: State<EngineManager>,
orchestrator_manager: State<OrchestratorManager>,
) -> EngineInfo {
let mut state = manager.inner.lock().expect("engine mutex poisoned");
let state = manager.inner.lock().expect("engine mutex poisoned");
if state.runtime == EngineRuntime::Orchestrator {
let runtime = state.runtime.clone();
let fallback_project_dir = state.project_dir.clone();
let fallback_username = state.opencode_username.clone();
let fallback_password = state.opencode_password.clone();
drop(state);
let data_dir = orchestrator_manager
.inner
.lock()
@@ -142,18 +147,18 @@ pub fn engine_info(
.as_ref()
.and_then(|active| status.workspaces.iter().find(|ws| &ws.id == active))
.map(|ws| ws.path.clone())
.or_else(|| state.project_dir.clone());
.or(fallback_project_dir.clone());
// The orchestrator can keep running across app relaunches. In that case, the in-memory
// EngineManager state (including opencode basic auth) is lost. Persist a small
// auth snapshot next to openwork-orchestrator-state.json so the UI can reconnect.
let auth_snapshot = orchestrator::read_orchestrator_auth(&data_dir);
let opencode_username = state.opencode_username.clone().or_else(|| {
let opencode_username = fallback_username.or_else(|| {
auth_snapshot
.as_ref()
.and_then(|auth| auth.opencode_username.clone())
});
let opencode_password = state.opencode_password.clone().or_else(|| {
let opencode_password = fallback_password.or_else(|| {
auth_snapshot
.as_ref()
.and_then(|auth| auth.opencode_password.clone())
@@ -161,7 +166,7 @@ pub fn engine_info(
let project_dir = project_dir.or_else(|| auth_snapshot.and_then(|auth| auth.project_dir));
return EngineInfo {
running: status.running,
runtime: state.runtime.clone(),
runtime,
base_url,
project_dir,
hostname: Some("127.0.0.1".to_string()),
@@ -173,6 +178,8 @@ pub fn engine_info(
last_stderr,
};
}
drop(state);
let mut state = manager.inner.lock().expect("engine mutex poisoned");
EngineManager::snapshot_locked(&mut state)
}

View File

@@ -8,12 +8,13 @@ use crate::types::{
};
use crate::workspace::files::ensure_workspace_files;
use crate::workspace::state::{
load_workspace_state, normalize_local_workspace_path, save_workspace_state,
load_workspace_state, load_workspace_state_fast, normalize_local_workspace_path,
save_workspace_state,
stable_workspace_id, stable_workspace_id_for_openwork, stable_workspace_id_for_remote,
};
use crate::workspace::watch::{update_workspace_watch, WorkspaceWatchState};
use serde::Serialize;
use tauri::State;
use tauri::{Manager, State};
use walkdir::WalkDir;
use zip::write::FileOptions;
use zip::{CompressionMethod, ZipArchive, ZipWriter};
@@ -43,6 +44,16 @@ fn update_watched_workspace(
update_workspace_watch(app, watch_state, watched_workspace)
}
fn schedule_watched_workspace_update(app: &tauri::AppHandle, state: crate::types::WorkspaceState) {
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
let watch_state = app_handle.state::<WorkspaceWatchState>();
if let Err(error) = update_watched_workspace(&app_handle, watch_state, &state) {
eprintln!("[workspace] deferred watcher update failed: {error}");
}
});
}
#[tauri::command]
pub fn workspace_bootstrap(
app: tauri::AppHandle,
@@ -72,7 +83,8 @@ pub fn workspace_bootstrap(
}
save_workspace_state(&app, &state)?;
update_watched_workspace(&app, watch_state, &state)?;
let _ = watch_state;
schedule_watched_workspace_update(&app, state.clone());
Ok(build_workspace_list(state))
}
@@ -128,7 +140,7 @@ pub fn workspace_set_selected(
watch_state: State<WorkspaceWatchState>,
) -> Result<WorkspaceList, String> {
println!("[workspace] set_selected request: {workspace_id}");
let mut state = load_workspace_state(&app)?;
let mut state = load_workspace_state_fast(&app)?;
let id = workspace_id.trim();
if id.is_empty() {
@@ -155,7 +167,7 @@ pub fn workspace_set_runtime_active(
watch_state: State<WorkspaceWatchState>,
) -> Result<WorkspaceList, String> {
println!("[workspace] set_runtime_active request: {workspace_id}");
let mut state = load_workspace_state(&app)?;
let mut state = load_workspace_state_fast(&app)?;
let id = workspace_id.trim();
if id.is_empty() {
@@ -168,7 +180,8 @@ pub fn workspace_set_runtime_active(
}
save_workspace_state(&app, &state)?;
update_watched_workspace(&app, watch_state, &state)?;
let _ = watch_state;
schedule_watched_workspace_update(&app, state.clone());
println!(
"[workspace] set_runtime_active complete: {}",
if id.is_empty() { "(cleared)" } else { id }

View File

@@ -46,8 +46,8 @@ use commands::window::set_window_decorations;
use commands::workspace::{
workspace_add_authorized_root, workspace_bootstrap, workspace_create, workspace_create_remote,
workspace_export_config, workspace_forget, workspace_import_config, workspace_openwork_read,
workspace_openwork_write, workspace_set_active, workspace_set_runtime_active,
workspace_set_selected, workspace_update_display_name, workspace_update_remote,
workspace_openwork_write, workspace_set_active, workspace_set_runtime_active, workspace_set_selected,
workspace_update_display_name, workspace_update_remote,
};
use engine::manager::EngineManager;
use opencode_router::manager::OpenCodeRouterManager;

View File

@@ -158,14 +158,46 @@ fn fetch_json<T: DeserializeOwned>(url: &str) -> Result<T, String> {
.map_err(|e| format!("Failed to parse response: {e}"))
}
fn fetch_json_with_timeout<T: DeserializeOwned>(url: &str, timeout_ms: u64) -> Result<T, String> {
let timeout = std::time::Duration::from_millis(timeout_ms.max(50));
let agent = ureq::AgentBuilder::new().timeout(timeout).build();
let response = agent
.get(url)
.set("Accept", "application/json")
.call()
.map_err(|e| format!("{e}"))?;
response
.into_json::<T>()
.map_err(|e| format!("Failed to parse response: {e}"))
}
pub fn fetch_orchestrator_health(base_url: &str) -> Result<OrchestratorHealth, String> {
let url = format!("{}/health", base_url.trim_end_matches('/'));
fetch_json(&url)
}
pub fn fetch_orchestrator_workspaces(base_url: &str) -> Result<OrchestratorWorkspaceList, String> {
fn fetch_orchestrator_health_with_timeout(
base_url: &str,
timeout_ms: u64,
) -> Result<OrchestratorHealth, String> {
let url = format!("{}/health", base_url.trim_end_matches('/'));
fetch_json_with_timeout(&url, timeout_ms)
}
fn fetch_orchestrator_workspaces_with_timeout(
base_url: &str,
timeout_ms: u64,
) -> Result<OrchestratorWorkspaceList, String> {
let url = format!("{}/workspaces", base_url.trim_end_matches('/'));
fetch_json(&url)
fetch_json_with_timeout(&url, timeout_ms)
}
fn resolve_orchestrator_status_timeout_ms() -> u64 {
std::env::var("OPENWORK_ORCHESTRATOR_STATUS_TIMEOUT_MS")
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
.filter(|value| *value >= 50 && *value <= 5_000)
.unwrap_or(250)
}
pub fn wait_for_orchestrator(
@@ -397,9 +429,10 @@ pub fn resolve_orchestrator_status(
return fallback;
};
match fetch_orchestrator_health(&base_url) {
let timeout_ms = resolve_orchestrator_status_timeout_ms();
match fetch_orchestrator_health_with_timeout(&base_url, timeout_ms) {
Ok(health) => {
let workspace_payload = fetch_orchestrator_workspaces(&base_url).ok();
let workspace_payload = fetch_orchestrator_workspaces_with_timeout(&base_url, timeout_ms).ok();
let workspaces = workspace_payload
.as_ref()
.map(|payload| payload.workspaces.clone())

View File

@@ -36,6 +36,28 @@ pub fn normalize_local_workspace_path(path: &str) -> String {
normalized.to_string_lossy().to_string()
}
pub fn normalize_local_workspace_path_fast(path: &str) -> String {
let trimmed = path.trim();
if trimmed.is_empty() {
return String::new();
}
let expanded = if trimmed == "~" {
home_dir().unwrap_or_else(|| PathBuf::from(trimmed))
} else if trimmed.starts_with("~/") || trimmed.starts_with("~\\") {
if let Some(home) = home_dir() {
let suffix = trimmed[2..].trim_start_matches(['/', '\\']);
home.join(suffix)
} else {
PathBuf::from(trimmed)
}
} else {
PathBuf::from(trimmed)
};
expanded.to_string_lossy().to_string()
}
pub fn openwork_state_paths(app: &tauri::AppHandle) -> Result<(PathBuf, PathBuf), String> {
let data_dir = app
.path()
@@ -52,7 +74,16 @@ pub fn repair_workspace_state(state: &mut WorkspaceState) {
for workspace in state.workspaces.iter_mut() {
let next_id = match workspace.workspace_type {
WorkspaceType::Local => {
let normalized = normalize_local_workspace_path(&workspace.path);
// Canonicalize only currently selected/watched entries. Full canonicalization across
// every workspace can block startup/switch paths when mounts are slow.
let canonicalize_for_active_workspace =
workspace.id == old_selected_workspace_id
|| workspace.id == old_watched_workspace_id;
let normalized = if canonicalize_for_active_workspace {
normalize_local_workspace_path(&workspace.path)
} else {
normalize_local_workspace_path_fast(&workspace.path)
};
if !normalized.is_empty() {
workspace.path = normalized;
}
@@ -126,6 +157,17 @@ pub fn load_workspace_state(app: &tauri::AppHandle) -> Result<WorkspaceState, St
Ok(state)
}
pub fn load_workspace_state_fast(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()))
}
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()))?;